그동안 라미 사파리만 써왔던 사람이라 넓고 깊은 만년필의 세계 속에 너무 소소한 사용기를 쓰는 것 같아 괜스럽게 부끄러운 기분이 든다... 여튼.

꽤 오랜 시간 라미 사파리를 썼다. 호주에서 저렴하게 구입해서 손에 익을 정도로 오래 썼다. 특히 사용할 때 무게감이 좋아서 자주 썼었다. 그러다가 수첩을 몰스킨으로 정착하면서부터 라미 사파리와의 관계가 조금씩 틀어지기 시작했다. 문구류는 쉽게 정착하는 편이기도 하고 왠만해서는 불편함을 잘 느끼지 않는 스타일이다. 그런데도 유독 거슬리는 조합이 바로 라미 사파리 만년필과 몰스킨 수첩이다. 뒷면에 비치는 것도 심하고 마르기 전에 덮으면 반대쪽에 뭍는 경우도 꽤 잦다. 그래도 꾹 참고서 꽤 오랜 시간 사용하다가 어느 순간부터 라미 사파리를 잘 들고 다니지 않게 되었다.

요즘은 더 좋은 수첩 브랜드도 많아서 다른 수첩도 사용해보고 싶은 마음이 있는데 아무래도 수첩은 시작하면 마음에 안든다고 한 두 장 쓰고 그만 쓸 수가 없으니까. 펜보다는 수첩을 결정하는 것이 더 큰 결정인 게 확실하다. 몰스킨도 사실 엄청 좋은 질의 수첩은 아닌데 이미 어느 정도 품질인지 알고 있다는 부분에서 계속 사용하게 되는 것 같다. 그나마 펜은 좀 더 가벼운 마음으로 시도해볼 수 있다는 점에서, 몰스킨은 남고 새로운 펜을 찾게 되는데.

  1. 리필 카트리지 대신에 직접 잉크를 충전할 수 있을 것
  2. 얇은 펜촉으로 번지지 않도록
  3. 저렴할수록 좋고 플라스틱도 문제 없음

그렇게 한참 둘러보다가 프레피 플레티넘을 구입하게 되었다.

프레피 플레티넘 만년필

단점부터 얘기하면 좀 내구성이 부실한 소재다. 라미 사파리는 차가 밟아도 부서질 것 같지 않은 그런 느낌1이라면 이건 너무 꽉 닫으면 부서질까 조심히 돌려야 한다. 라미 사파리처럼 대충 가방에 던져 놓거나 몰스킨 사이에 꽂아두고 사용하면 금방 부서져서 가방을 잉크바다로 만들 것 같은 불안함이 있다. 그래서 더 조심조심 사용하게 되었으니 이건 장점으로 봐야 할까.

그 외에는 다 마음에 든다. EF 02 촉인데 일단 몰스킨에는 번짐 없이 사용할 수 있다. 무게도 적당하며 모든 부분을 다 분리해서 청소 가능한 것도 마음에 든다. 카트리지 대신 컨버터를 구입해서 잉크를 충전해서 쓰는 경험도 전혀 불편함이 없다. 구입한지거의 반년 정도 되었고 학교 노트를 다 이 펜으로 했는데 사용할수록 만족스럽다.

학교 다닐 땐 매일 쓰면서도 좋네 마네 쓸 시간도 없었는데 학교가 끝나니 수첩을 펼 시간이 없이 바쁘다. 글도 쓰면서 심정 여유 찾는 시간 챙기기로.

Footnotes

  1. 실제로는 꽤 잘 부서진다는데 복불복인가보다.

The PHP Foundationd에서 게시한 State of Generics and Collections를 번역했습니다.

Table of Contents

제네릭과 컬렉션 현황

제네릭은 오랜 기간 동안 많은 PHP 개발자가 원했던 기능 중 하나입니다. 이 주제는 매번 Q&A 세션에서 언급되기도 합니다. 이 주제에 관해서 현재 상황과 함께 다양한 접근 방식에 대해 논의해보려고 합니다.

완전히 구체화된 제네릭

제네릭이 있다면 클래스를 선언할 때 프로퍼티와 메소드에 플레이스홀더를 활용할 수 있습니다. 이렇게 선언된 제네릭은 클래스가 인스턴스로 생성될 떄 타입이 결정되게 됩니다. 이 방식은 코드 재사용성을 높이고 여러 데이터 타입에서 타입 안정성을 제공하게 됩니다. "구체화된" 제네릭은 제네릭 타입에 대한 정보가 클래스 정의에 사용될 뿐만 아니라 제네릭 요구사항을 런타임에서도 강제하게 됩니다.

PHP의 문법으로 보면 이렇습니다.

class Entry<KeyType, ValueType>
{
  public function __construct(protected KeyType $key, protected ValueType $value)
  {
  }

  public function getKey(): KeyType
  {
    return $this->key;
  }

  public function getValue(): ValueType
  {
    return $this->value;
  }
}

new Entry<int, BlogPost>(123, new BlogPost());

클래스로 인스턴스를 생성하면 다음과 같이 제네릭 타입으로 선언한 KeyTypeint로, ValueTypeBlogPost로 결정되어 해당 개체는 다음 클래스 정의와 같이 동작하게 됩니다.

class IntBlogPostEntry
{
  public function __construct(protected int $key, protected BlogPost $value)
  {
  }

  public function getKey(): int
  {
    return $this->key;
  }

  public function getValue(): BlogPost
  {
    return $this->value;
  }
}

그동안 이 기능을 추가하기 위한 여러 번의 시도가 있었습니다. 2020/2021년에는 Nikita Popov의 가장 포괄적인 실험 구현이 있었고 2016년의 RFC 초안, 그리고 이 주제에서 남아있는 과제를 정리한 레딧 포스트 등에서 그 시도를 확인해볼 수 있습니다.

2024년에 PHP 파운데이션의 지원 아래, Arnaud Le Blanc이 Nikita Popov의 구현을 출발점으로 이 작업을 다시 시작했습니다. 비록 많은 기술적인 이슈가 해결되긴 했지만 여전히 많은 부분이 풀리지 않은 상태입니다.

가장 큰 도전 과제는 타입 추론입니다. 제네릭을 활용하는데 있어서 코드가 장황해지는 경향이 있는데 매번 제네릭 타입이 참조될 때마다 타입 인자를 필요로 하기 때문입니다. 다음 예시를 보면 명확합니다.

funciton f(List<Entry<int,BlogPost>> $entries): Map<int, BlogPost>
{
  return new Map<int, BlogPost>($entries);
}

function g(List<BlogPostId> $ids): List<BlogPost>
{
  return map<int, BlogostId, BlogPost>($ids, $repository->find(...));
}

타입 추론은 이처럼 장황한 부분을 컴파일러에서 적절한 타입을 자동으로 적용하는 방식으로 해결할 수 있습니다. 위 에시에서는 컴파일러가 반환 값인 new Map()map()을 보고서 알맞은 반환 타입을 자동으로 정할 수 있습니다. 다만 이런 접근 방식은 PHP에서 어렵습니다. Nikita에 따르면 PHP의 컴파일러는 주로 한번에 파일을 하나씩만 보는 등 아주 제한적으로 코드베이스를 읽기 때문에 쉽지 않습니다.

다음 예시를 고려해봅니다.

class Box<T>
{
  public function __construct(public T $value) {}
}

new Box(getValue());

이 경우에는 getValue() 표현이 런타임에서 실제로 함수가 호출되기 전까지는 어떤 타입인지 확인할 수 없기 때문에 new Box(...)T를 컴파일 단계에서 추론하기 어렵습니다.

T를 런타임 기준으로 함수의 반환값을 사용할 수는 있겠지만 결과적으로 안정적이지 못한 타입 선언이 됩니다. 앞서 예시에서는 new Box()getValue()의 반환값 구현에 의존적인 상태가 되는데요. 의도와 다르게 불변적인 형태가 되어서 실제 코드에서는 그다지 유용하지 못한 형태가 될 수 있습니다.

interface ValueInterface {]
class A implements ValueInterface {}
class B implements ValueInterface {}

function getValue(): ValueInterface
{
  return new A();
}

function doSomething(Box<ValueInterface> $box)
{
}

$box = new Box(getValue()); // 런타임: Box<A>, 정적: Box<ValueInterface>
doSomething($box); // Box<A>가 아닌 Box<ValueInterface>가 필요

타입은 컴파일 단계에서 구현에 의존하지 않은 정적 정보를 제공할 때 가장 유용합니다.

참고: 이 예제에서 Box는 불변이며 제네릭 클래스의 형태로 자주 구현됩니다. XY 타입이 어떤 관계이든지 간에 Box<X>Box<Y>의 서브타입도, 수퍼타입도 아니라는 의미인데, 위 예시에서 Box<A>Box<ValueInterface>의 서브타입도 아니고 doSomething()Box<A>를 파라미터로 받을 수도 없다는 뜻입니다.

제네릭 클래스는 타입 플레이스홀더가 읽기(반환 타입 등)과 쓰기(파라미터 타입 등)에 함께 사용되면 불변이라고 합니다. 프로퍼티 타입은 읽기와 쓰기 모두에 위치할 수 있습니다.

다음 예시를 보면 좀 더 명확합니다.

function changeValue(Box<ValueInterface> $box)
{
  $box->value = new B();
}

changeValue() 함수는 Box<ValueInterface>를 파라미터로 받기 때문에 ValueInterface의 어떤 서브타입이든 $box->value의 타입으로 배정될 수 있어야 합니다. 하지만 Box<A>를 전달한 후에 (AValueInterface의 서브타입) ValueInterface지만 A가 아닌 타입을 전달하게 되면 이 계약 관계가 준수되질 않습니다.

다른 제네릭 언어에서의 일반적인 해결 방법은 타입 파라미터에 직접 어떤 변성(variant)인지 직접 지정하는 방식으로 해결합니다. 일반적으로 in 또는 out등 단방향으로만 움직이도록, 파라미터나 반환 타입에 지정하는 방식을 활용합니다. 이런 방식으로 반공변성이나 공변성을 명시적으로 지정할 수 있습니다.

타입 추론의 하이브리드 접근 방식

이런 문제를 해결하기 위해서는 하이브리드 접근 방식이 필요한데, 즉 모든 정보가 가능하지 않은 컴파일 타임에 제네릭 파라미터에 대한 정적 타입 추론을 구현할 수 있어야 합니다. 다시 말하면 컴파일 타임에서 알 수 없는 타입을 심볼로만 표현하는 방식입니다 예를 들어 getValue()fcall<getValue> 식으로 표현할 수 있습니다. 심볼릭 타입은 런타임에서 함수와 클래스가 모두 불려온 이후에 해석되며 런타임에서의 전체 분석을 필요로 하기 때문에 일정량의 실행 비용을 소비하게 됩니다. 물론 이 동작은 상속이 캐시되는 것처럼 요청을 처리하는 동안에는 캐시를 통해 처리될 수 있습니다.

개념 증명은 이미 구현되었고 제네릭 타입 파라미터에서 데이터 흐름 기반, 지역적, 또는 단방향의 타입 추론은 PHPStan/Psalm의 동작 방식처럼 동일하게 작동합니다. 이 접근 방식이라면 다른 타입 추론도 실험해볼 수 있게 됩니다.

성능 고려사항

제네릭에 있어 다른 고민거리는 바로 성능에 미치는 영향입니다. 벤치마크를 관찰한 결과,

  • 제네릭 유무가 제네릭이 없는 코드에서 성능 영향을 미치지 않음
  • 단순한 제네릭 코드는 특수 코드와 비교해서 1~2% 정도의 크지 않은 성능 저하가 발생

하지만 이후에 얘기하게 될 union과 같은 복합 타입의 경우는 타입 체크에 초선형(superlinear) 시간 복잡도를 보이기 때문에 잠재적으로 상당한 성능 감소를 야기할 수 있습니다. 예를 들면 A|BB를 받을 수 있는지 확인하는 것은 선형적이지만 Box<A|B>()Box<A|B>()와 확인하게 되면 O(nm)이 됩니다.

초선형 복잡도는 복합 타입을 합치는 중에 심볼릭 타입을 확인하려고 해도 발생할 수 있습니다.

이후 방향

구체화된 제네릭은 다음과 같은 연구 과제가 남아있습니다.

  • 복합 타입, 극단적인 경우 어떤 영향이 있는지 평가 필요
  • 인라인 캐시에서 타입 체크를 구현하고 복합 타입을 처리하는 더 똑똑한 알고리즘이 있는지 연구
  • 즉시 오토로딩(eager-autoloading) 또는 상속 캐시와 같은 방식으로 심볼릭 타입의 양을 줄이는 방법을 탐구

컬렉션

제네릭의 주된 사용 케이스로 자주 언급되는 부분은 타입 배열입니다. PHP에서는 스위스 군용칼 같은 배열 타입이 사용 또는 과용되는 데는 많은 이유가 있습니다. 하지만 현재는 배열에 키 또는 값에 타입을 강제할 수 있는 방법은 존재하지 않습니다.

병렬 프로젝트에서는 전용 컬랙션 문법을 사용하는 방식으로 완전한 제네릭보다는 부족하지만 그래도 도움이 될 수 있습니다.

컬랙션은 집합, 목록, 사전 등의 형식으로 주로 활용됩니다. 집합과 목록의 경우에는 값에 대한 타입이 정의되며 사전 형식은 키와 값 모두에 타입이 지정됩니다. 다음 같은 식의 문법을 활용 할 수 있겠습니다.

class Article
{
  public function __construct(public string $subject) {}
}

collection(Seq) Articles<Article>
{
}

collection(Dict) YearBooks<int => Book>
{
}

다음처럼 목록을 인스턴스로 만들어서 일반 클래스처럼 사용할 수 있게 됩니다.

$a1 = new Articles();
$b1 = new YearBooks();

목록과 사전 형식은 자동으로 많은 메소드가 정의되며 PHP에서 array_* 함수처럼 제공되었던 것들이 기본적인 기능으로 제공됩니다. 컬렉션에 정의된 메소드를 사용해 개체를 추가하거나 수정하려 한다면 컬렉션의 정의된 바에 따라 키와 값의 타입을 맞춰야 합니다.

위 예시에서 YearBooks 사전에 add() 메소드를 사용한다면 키는 int 타입만 사용할 수 있고 값은 Book 타입 인스턴스만 가능합니다. 주요 조작 메소드 (add, get, unset, isset)와 ArrayAccess 스타일의 오버로드 동작도 여전히 사용 가능하며 연산자 오버로드도 적용 가능할 수 있습니다.

이 방식의 단점은 컬랙션을 직접 선언해야 한다는 점입니다. 다음 예시에서 볼 수 있는 것처럼 단일 라인 선언이 별도의 파일에 각각 컬렉션을 위해 존재해야 합니다.

다른 우려 사항은 잠재적으로 메모리 사용량이 높다는 점인데 각 클래스 PHP가 모든 연관 메소드 목록을 포함한 해당 클래스 항목을 계속 들고 있어야 한다는 점입니다.

세번째 우려할 만한 부분은 instanceof/is-a 관계가 호환 가능한 유형의 컬렉션 사이에서 존재하지 않는다는 점입니다.

class A {}
class B extends A {}

seq As<A> {}
seq Bs<B> {}

new B() instanceof A // true
new Bs() instanceof As // false

또는

namespace Foo;
seq As<A> {}

namespace Bar;
seq As<A> {}

namespace;
new Foo\As instanceof Bar\As; // false

컬렉션은 제네릭에 비해서는 부족한 면이 있으며 훨씬 복잡도를 높히는 경향이 있지만 제네릭의 사용 케이스 대부분을 대체할 수 있습니다. 다만 이 구현은 제네릭에 비해 훨씬 간단하며 이 실험 브랜치에서 사용해볼 수 있습니다. 하지만 완전한 제네릭을 구현할 수 있다면 이런 컬렉션 구현 방식보다 제네릭을 활용하는 것이 훨씬 선택할 만한 방향입니다.

Larry Garfield는 다른 언어에서 컬렉션 API가 얼마나 광범위한지 연구를 수행하기도 했습니다. 아직 대략적이긴 하지만 "모든 것을 포함"하는 방향으로 합의되었고 아마도 여러 개의 개별 인터페이스로 나뉘어질 예정입니다. 앞으로의 대략적인 방향은 문서 끝에서 제시하는 방식을 따라갈 것 같습니다.

컬렉션 패치는 https://github.com/php/php-src/pull/15429에서 찾을 수 있습니다.

다른 대안

정적 분석

근래 들어 정적 분석기가 부상하고 있습니다. PHPStanPsalm 모두 제네릭을 지원하며 많은 오픈소스 라이브러리와 개별 프로젝트에서 활용되고 있습니다.

다음은 일반적인 Dict 클래스를 PHPStan과 Psalm에서 지원하는 방식대로 작성한 예시입니다.

/**
 * @template Key
 * @template Value
 */
class Dict
{
  /**
   * @param array<Key,Value> $entries
   */
  public function __construct(private array $entries) {}

  /**
   * @param Key $key
   * @param Value $value
   */
  publci function set($key, $value): self
  {
    $this->entries[$key] = $value;
    return $this;
  }
}

/** @param Dict<string,string> $dict */
function f($dict) {}

$dict = new Dict([1 => 'foo']);
$dict->set('foo', 'bar'); // 정적 분석에서 오류 발생
$dict->set(1, 'bar');     // 통과
f($dict);                 // 정적 분석에서 오류 발생

template 이라는 docblock 어노테이션이 사용된 점에는 역사적인 이유가 있지만 제네릭에 실제적 구현에서는 자바의 제네릭 타입과 유사합니다. 제네릭 타입은 정적 분석 단계에서만 제네릭을 확인하지 실제 런타임에서는 보이지 않습니다.

이 방식은 제네릭의 장점인 타입 안전을 제공하긴 하지만 다음과 같은 아쉬움이 있습니다.

  • docblock은 장황하기 쉬움
  • 타입 체크가 별도의 도구를 통해서만 이루어짐 (PHPStan, 또는 Psalm)
  • 제네릭 타입 정보가 런타임에서는 활용 불가능
  • 제네릭 타입 정보가 런타임에서 강제되지 않음 (즉 코드 실행 전에 정적 분석을 수행하지 않으면 아무런 의미가 없게 됨)

소거된 제네릭 타입 선언

PHP 코어에서 구체화된 제네릭 구현의 어려움이 있기 때문에 문법 수준에서만 지원하고 타입 검사 자체는 정적 분석기를 활용하자는 제안도 있습니다.

이 대안에서는 PHP 문법에서 타입, 클래스, 함수 정의에서 제네릭 문법을 허용하지만 PHP 엔진 자체에서는 타입 체크를 수행하지 않는 것입니다.

이 방식을 "소거된" 타입 선언이라고 부르는 이유는 엔진이 단순히 런타임에서 무시해버리기 때문에 그렇습니다. 이 대안은 다양한 방법을 구현할 수 있습니다.

  • php-src의 일부분으로
  • 확장으로
  • 오토로더 수준에서
  • 그 외

앞서 본 Dict 클래스는 다음처럼 작성 가능합니다.

class Dict<Key,Value>
{
    public function __construct(private array<Key,Value> $entries) {}

    public function set(Key $key, Value $value): self
    {
        $this->entries[$key] = $value;
        return $this;
    }
}

function f(Dict<string,string> $dict) {}

$dict = new Dict([1 => 'foo']);
$dict->set('foo', 'bar'); // 정적 분석에서 오류 발생
$dict->set(1, 'bar');     // 통과
f($dict);                 // 정적 분석에서 오류 발생

이 방식은 정적 분석기에서 docblock이 장황해지던 문제를 해결하긴 하지만 일관성이 부족한 문제가 있습니다. 일반적인 타입 선언은 자동 형 변환(Type coercion)이 가능하지만 소거된 제네릭 타입 선언은 그렇지 않습니다.

다음 예시를 보면 알 수 있습니다.

class StringList
{
  public function add(string $value)
  {
    $this->values[] = $value;
  }
}

class List<T>
{
  public function add(T $value)
  {
    $this->values[] = $value;
  }
}

$list = new StringList();
$list->add(123); // 문자열로 형변환이 됨

$list = new List<string>();
$list->add(123); // 문자열로 형변환 되지 않음

이 시나리오에서 첫 add() 호출은 형변환이 되어 인자가 문자열로 전환되었지만 두번째 경우는 그렇지 않습니다.

자바의 경우에는 소거된 제네릭이 전통적인 타입 시스템 위에 구현되어 있어서 컴파일러가 타입 체크를 수행하기 때문에 위와 같은 문제는 발생하지 않습니다. 하지만 PHP의 경우는 이 문제를 피할 수 없는 상황입니다.

소거된 제네릭 방식의 다른 단점은 런타임 단계에서 제네릭이 보이지 않는다는 점입니다. 이는 패턴 매칭과 같이 제네릭 타입 인자를 봐야 하는 상황 등에서 한계를 보입니다.

완전히 소거된 타입 선언

소거된 제네릭의 비일관성을 해결하는 방법 중 하나는 모든 타입 선언을 제거해버리는 방식입니다. declare()를 사용해서 선택적으로 적용할 수 있습니다.

declare(types=erased);

이 대안에서는 엔진이 런타임에서 타입 체크를 더이상 수행하지 않게 됩니다. 즉 add()를 호출하던 앞서 예시에서 두 경우 모두 자동 형변환을 수행하지 않습니다. 즉 사용자가 직접 분석기를 통해 타입을 확인해야 합니다.

주류 인터프리터 언어에는 이런 접근 방식이 그렇게 새로운 것은 아닙니다. 타입스크립트를 통한 자바스크립트, 파이썬, 루비 등 여러 언어에서 완전히 소거된 타입 선언을 활용하고 있습니다.

사용자가 완전히 소거된 타입과 제네릭을 파일 단위로 선택적 적용을 할 수 있게 하는 방식으로 PHPStan/Psalm의 장황한 제네릭을 덜 복잡하게 활용할 수 있게 됩니다. 이 접근 방식은 다음과 같은 장점도 있습니다.

  • 단기적으로는 선택적으로 런타임 타입 체크를 끄기 때문에 성능 향상이 있을 수 있음
  • 잠재적으로 더 고수준의 타입 시스템으로 확장해서 non-empty-string, list, int, class-string, 조건부 타입 등과 같은 고급 타입을 지원할 수 있음

하지만 다음과 같은 큰 단점도 존재합니다.

  • 리플렉션이나 리플렉션에 의존하고 있는 라이브러리가 이 완전히 소거된 타입에 어떤 영향을 받게 될지 명확하지 않음
  • 타입을 강제하는 것이 개발자가 적극적으로 정적 분석을 사용해야만 달성할 수 있게 되는데 이는 현재 대부분의 PHP 생태계에서는 흔하지 않음
  • 현재 강타입과 약타입 두 가지에서도 개발자가 고려해야 할 부분이 많은 편인데 3번쨰 "타입 모드"를 만드는 것이 맞는 방향인지 의문 (거기에 더해 사용자가 유사 타입이 타입 강제 모드에서는 호환도 되지 않음)
  • 이 접근 방식이 "어떤 타입은 강제되지만 다른 것을 그렇지 않은" 문제를 해결하지 못함. 제네릭을 사용하면서도 완전히 소거된 타입을 원하지 않는 사람이라면 여전히 부분적인 타입 강제 수준에 머물게 됨.
  • PHP는 주요 스크립트 언어 중 타입을 강제하는 유일한 언어. 이를 잃으면 시장에서의 장점도 잃을 수 있음.

제네릭 배열

이 문서에서 제네릭 개체에 대한 얘기를 하고 있으니 제네릭 배열에 대한 얘기도 언급하고자 합니다.

유동적 배열

배열은 작성할 때 복사하게 됩니다. 수정하게 되면 새로운 사본을 만들고 (다른 곳에 사본이 존재한다면), 그리고 사본을 수정하게 됩니다 (복사시점 변경, copy-on-write). 이 접근 방식으로 배열을 다른 곳으로 보내고도 함수가 해당 배열을 수정하는 것에 대한 걱정을 할 필요가 없게 됩니다. (참조로 보내지 않는 한에는 말입니다.)

타입 과점에서 봤을 떄는 배열은 언제나 내부에 있는 내용을 기준으로 타입이 정해지고 배열을 수정했을 떄는 새로운 배열이 생성되기 때문에 타입이 변경되지 않습니다.

제네릭 관점에서 봤을 때는 아주 편리한 특성인데 배열이 가변적이라는 의미이기 떄문입니다. 즉 배열은 상위 타입과 하위 타입을 모두 포함할 수 있습니다. 즉 다음 코드도 타입 안전성을 보장합니다.

class A {}
class B extends A {}

function f(array $a) {}
function g(array<A> $a) {}
function h(array<B> $a) {}

$array = [new B()];

f($array);
g($array);
h($array);

일반적으로 제네릭 컨테이너는 비가변적인데 타입 플레이스홀더가 읽기와 쓰기 모두에 사용되기 때문입니다. 여기서의 경우는 문법적으로 불변이며 복사시점 변경을 수행하기 때문에 문제가 되지 않습니다.

그래서 자연스럽게 제네릭 배열을 구현하는 것이 가능합니다.

$a = [1];         // array<int>
$b = [new A()];   // array<A>
$c = $b;          // array<A>
$c[] = new B();   // array<A|B>
$b;               // array<A>

이 방식은 API 경계 즉 함수에 인자로 전달할 때나 값을 반환할 때, 개체를 업데이트 하는 등의 상황에서 타입을 확인하기 때문에 타입 안전성을 제공합니다.

function f(array<int> $a) {}
$a = [1];
f($a); // ok

$b = [new A()];
f($b); // error

증명 구현은 이미 되었지만 아직 성능에 어떤 영향을 주는지는 잘 평가되지 않았습니다. 다른 문제도 있는데 이 방식에서는 참조나 타입 프로퍼티를 지원하는 것은 어려울 수 있습니다.

정적 배열

유동적 배열의 대안은 인스턴스화에서 타입을 지정하는 방식입니다.

$a = array<int>(1); // array<int>
$a[] = new A();     // error

하지만 이 대안은 현재 PHP에서 배열이 어떻게 사용되고 있는지와 정면으로 충돌합니다. 또한 이 접근 방식은 배열을 반변적으로 만듭니다.

function f(array<int> $a) {}
function g(array $a) {}

$a = [1];
f($a); // ok
g($a); // error

g($a)에 오류가 발생하느냐 하면 제네릭의 반변성을 참고하세요. g()array (array<mixed>)를 인자로 받는데 어떤 타입의 개체든 추가할 수 있는 배열이란 얘기입니다. 하지만 array<int>를 여기에 전달했기 때문에 이 계약이 깨지게 됩니다. 그래서 arrayarray<int>를 받을 수 없습니다.

불변성은 배열에 제네릭을 적용하기 어렵게 합니다. 라이브러리가 제네릭 배열에 타입 힌트를 추가하면 사용자 코드를 깨뜨리게 될 것이고 반대로 사용자는 제네릭 배열을 라이브러리에 전달하려면 타입 선언에 제네릭 배열을 쓰지 않고서는 라이브러리를 사용하지 못하게 됩니다.

이런 문제로 개체 기반 컬렉션을 사용할 수 밖에 없습니다. 대다수 현대적인 언어처럼 컬렉션을 선언하는데 커스텀 문법을 사용하거나 더 확실한 제네릭 문법을 활용해야 할 것입니다. 물론 이 두 방식은 서로 상호적으로 호환이 가능할 겁니다.

결론

이 글에서 PHP에 제네릭을 구현한다는 것이 어떤 의미인지, 그리고 어떤 선택지가 있는지, 제네릭 개체와 컬렉션, 그리고 여러 연관된 기능에 대해 살펴봤습니다. 앞으로도 더 많은 작업이 필요하고 이런 작업은 게속 진행될 예정이며 어떤 기능이 가장 필요하며 가능한 방법인지 계속 논의될 예정입니다.

앞으로의 방향은 이렇습니다.

  • 구체화된 제네릭을 위한 타입 추론에 대해 조사를 지속할 예정이며 이해할 수 있는 수준의 트레이드오프가 있는 방안이 가장 알맞은 방향으로 판단되면 컬렉션은 그 방식으로 구현될 예정.
  • 소거된 제네릭이 여기에 논의된 것 외의 단점으로 실현이 불가능한 방식인지 파악
  • 완전히 소거된 제네릭 타입이 여기에 논의된 것 외의 단점으로 실현이 불가능한 방식인지 파악
  • 컬렉션을 위한 기능을 최적화하고 전용 문법이나 제네릭네서 사용될 수 있는지 확인
  • 컬렉션에서 더 나은 성능과 단순함을 위해 해시맵 (배열) 대신 사용할 수 있는 내부 자료형이 있는지 연구 (이런 이유에서 컬렉션은 사용자 공간에서 구현되지 않을 가능성이 높음)
  • 타입 배열은 배열 동작의 복잡도, 구현 이후의 이득을 고려했을 때 큰 가치가 없는 것으로 판단되어 타입 배열에 대한 연구는 중단

현재는 다음 질의에 대한 피드백을 구하는 것에 집중하고 있습니다.

  • 만약 구체화된 제네릭이 불가능한 방식으로 판명되면 소거된 제네릭 방식이 맞는 접근법이 될지, 아니면 계속 사용자 공간에서의 도구로 남겨둬야 할지
  • 어떤 제네릭 기능이 구현에 포함되고 포함되지 않아야 하는지? (예를 들면 합 타입에 제네릭을 허용하지 않는다, 합 제네릭이 느리게 동작해도 상관하지 않는다, in/out 변성 마커를 지원할 필요 없다 등)
  • 만약 소거된 제네릭이 포함된다면, 타입을 검증하기 위한 공식 린터를 만들 필요가 있을지 아니면 계속 사용자 공간의 도구를 활용할지
  • 만약 구체화된 제네릭이 불가능한 방식으로 판명되면 여기서 보여준 컬렉션 문법이 괜찮은지
  • 소거된 제네릭을 먼저 적용한 후에 구체화된 제네릭을 적용하는 것이 가능하다면 이 전략을 채택하는 것이 맞는지

논의

Structure Over Chaos | How to Self-Learn Like a PhD Student을 보고 나서 메모.

체계적으로 독학하는 방법

목표 정하기

  • 학습의 목표는 무엇인가요?
    • 에세이, 발제, 도구 만들기, 논문 쓰기...
    • 학습의 결과를 적용할 수 있어야 적극적으로 학습 가능
  • 단기적, 장기적인 목표. 중간 목표도 있으면 도움
  • 동기를 적어둘 것 ~ 왜 이 학습을 시작했나요?
    • 커리어, 개인적인 호기심, 회사 차리려고...

맞는 학습 자료 찾기

  • 이미 많은 사람이 학습해서 유명한 학습 자료, 책, 강의가 있나요? vs. 흔하지 않은 분야인가요?
  • 근 5년 이내에 좋은 리뷰가 있거나 10건 이상의 리뷰가 있는 자료
    • 자료에 문헌이나 참조로 목록을 확장
    • 문헌이나 참조 목록에서 재귀적으로 목록을 확장
    • 문헌이나 참조 목록의 저자를 확인, 최근 저작 활동이나 강연, 강의를 찾아본다
  • 체계적인 강의(MOOC)나 교재가 있는지 검색
  • 대학 학과나 전공이 존재하는 분야라면 대학 실라버스를 확인
    • 전체적인 그림을 그리고 계획하는데 도움
  • 학습을 위한 읽기 목록을 작성
    • 딱딱한 글에 한정하지 않고 교양 과학서 등도 추가할 수 있음

자신에게 맞는 일정 짜기

  • 주간 또는 일간 시간 블럭을 설정해서 학습
  • 비슷한 관심사의 사람들을 찾아서 함께 학습
  • 학습에 맞는 환경 찾기: 도서관, 카페, 집 등

지속하는 팁

  • 학습하는 내용, 참조 등을 잘 정리하기
  • 학습에 참여 유도하기
    • 스터디 그룹이나 북클럽에 가입
    • 도전 과제를 만들어 수행하기 (예시: The Writers' Hour)
  • 학습과 적용 사이 균형을 유지하기
    • 5개 글을 읽은 후에는 500자 글로 정리해보기
  • 무슨일이 있어도 항상성을 유지하기
    • 데드라인을 정한다든지
  • 진행도를 추적하기
    • 주제, 하이라이트, 핵심 정리 등

Jeffrey MorganBuild Bigger With Small AI: Running Small Models Locally을 보고 정리했다. 항상 큰 모델에 대한 얘기만 강조되다 보니 작은 모델로는 무엇이 가능한가 싶었었는데 이 발표가 이해에 많은 도움이 되었다.

  • (발표자는 Docker에서 근무하다가 현재는 Ollama를 만들고 있음)
    • 다른 도메인 같지만 여러 모델을 운용한다는 점에서 컨테이너처럼 문제를 해결
  • 작은 모델
    • 대형 클라우드 모델과 유사한 아이디어와 구조로 구현
    • 0.5B - 70B 파라미터
    • 적은 용량 (몇 GB 정도)
    • 일반 하드웨어서도 충분히 구동 가능 (적은 용량)
    • 무료 & 자유롭게 사용 가능
  • 작은 모델의 장점
    • 로컬에서 구동되기 때문에 낮은 지연 달성 가능
    • 적은 파라미터로 연산이 적어져서 높은 출력량, 즉각적인 응답을 받을 수 있음
    • 데이터 프라이버시, 보안에 유리
    • 비용이 상대적으로 적음 (이미 있는 컴퓨팅 자원 활용, 통합 비용 등)
    • 다양한 선택지 (Llama, Gemma, Phi, ... 다양한 전문성을 가진 모델을 사용 가능)
  • 모델과 데이터
    • 검색 증강 생성 (Retrieval Augmented Generation, RAG)
      • 데이터를 모델이 이해할 수 있는 형태로 변환해서 모델에 전달
      • 데이터는 벡터 스토어에 저장 (키워드: 벡터 스토어, 임베딩, 도큐먼트)
    • 도구 호출 (Tool calling)
      • 모델이 직접 코드를 구동할 수 있게 함 (예시: DuckDB의 쿼리 도구)
      • 별도의 전처리 과정이 필요 없음
      • 최근 모델에서 지원
  • 적용
    • 외부에 노출되는 서비스보다 내부에서 활용하기 유리 (적은 리소스)
    • 지식 베이스, 헬프데스크, 코드 리뷰, 이슈 배정, 데이터 엔지니어링, 리포팅, 보안, 컴플라이언스 등
    • 큰 모델과 작은 모델 함께 사용도 충분히 가능
  • 데모
    • 일반 예시: gemma2:2b, 간단한 대화 프롬프트 시연
    • RAG 예시: gemma2:2b, llama_index로 텍스트 파일을 documents로 변환한 후 DuckDB VectorStore를 활용
    • Tool Calling 예시: qwen2.5-coder, 질문에 대해 SQL를 생성한 후 duckDB에서 답을 찾아 반환
  • 레퍼런스

RAG 예시 코드

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.core.node_parser import TokenTextSplitter
from llama_index.vector_stores.duckdb import DuckDBVectorStore
from llama_index.core import StorageContext
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.ollama import OllamaEmbedding

# models
Settings.embed_model = OllamaEmbedding(model_name="all-minilm")
Settings.llm = Olama (model="gemma2:2b", temperature=0, request_timeout=360.0)

# load documents into a vector store (DuckDB)
documents = SimpleDirectoryReader(input_files=["facts.txt"]).\
              load_data(show_progress=True)
splitter = TokenTextSplitter(separator="\n", chunk_size=64, chunk_overlap=0)
vector_store = DuckDBVectorStore()
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex(splitter.get_nodes_from_documents(documents), \
  storage_context=storage_context, show_progress=True)
query_engine = index.as_query_engine()

try:
    while True:
        user_query = input (">>> ")
        response = query_ engine. query (user_query)
        print (response)
except KeyboardInterrupt:
    exit()

Tool Calling 예시 코드

import duckdb
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langchain_core.messages import HumanMessage, ToolMessage

con = duckdb.connect(database="ducks.duckdb")
schema = con.execute(f"DESCRIBE ducks"). fetchdf()
schema_str = schema.to_string(index=False)

@tool
def query(query: str) -> str:
    """Queries the database for information and returns the result.
    Args:
    query: The query to run against the database.
    """
    return str(con.execute(query).fetchone()[0])

llm = ChatOllama (model="qwen2.5-coder") .bind_tools([query])
try:
    while True:
        user_query = input(">>> ")
        messages = [HumanMessage(f"You are provided You are given a DuckDB   \\
         schema for table 'ducks': \n\n{schema_strschema_str}\n\n.\n\nAnswer \\
          the user query: '{user_query}' in a single sentence.")]
        ai_msg = llm.invoke(messages)
        messages.append(ai_msg)

        for tool_call in ai_msg.tool_calls:
            print('>>> tool_call:', tool_call)
            selected_tool = {"query": query}[tool_call["name"].lower()]
            tool_output = selected_tool.invoke(tool_call["args"])
            print('>>> tool_output:', tool_output)
            messages.append(ToolMessage(tool_output, tool_call_id=tool_call["id"]))

        response = llm.invoke(messages)
        print(response.content)
except KeyboardInterrupt:
    exit()

요즘 맥북으로 PDF를 읽는 일이 많아졌다. 새하얀 PDF를 한 쪽에 열어두고 어두컴컴한 터미널을 나머지 공간에 띄워 정리하려니 금방 눈이 피곤해지는 기분이다. 그나마 대안이라고 크롬 브라우저에 내장된 PDF 뷰어와 Dark Reader 크롬 확장으로 버텼다. 다만 뷰어 전체를 흑백 반전해주는 정도라서 PDF 이외 부분은 애매한 회색으로 표시되는데 그게 정말 마음에 안드는 색이었다. 오늘 해야 할 일은 뒤로 미뤄두고 PDF 뷰어를 찾아 돌아다녔는데 지난 번에 크롬북 용도로 만들어둔 PDF 뷰어를 그냥 쓰면 되는 것이었다!

다크모드 활성화하기

웹은 역시 최고의 발명임을 상기하며...

크롬북에선 PWA로 설치하는 것이 유일한 앱 설치 방법인데 당연히 맥에서도 전혀 문제 없이 설치 가능했다. pdf.js 기반이고 서비스워커로 오프라인 접속도 지원한다. File System Access API를 사용하면 파일을 열 때마다 권한 확인을 하는 번거로움이 있어서 대신 Origin private file system (OPFS) 공간에 파일을 저장하는 식으로 구성했다. 덕분에 pdf.js의 어노테이션 같은 것도 문제 없이 사용할 수 있고 변경된 PDF를 다시 받는 것도 가능하다.

크롬북에서 부족한 부분이 많아 잔잔하게 만들어 쓰던 것들은 사실 웹브라우저 있는 어느 환경에서나 다 사용 가능하다는 것은 정말 큰 장점이다. 빠르게 새로운 웹 기능을 사용해볼 수 있는 환경이라서 정말 좋아하는 OS인데 요 근래 크롬에 관한 좋지 않은 뉴스가 자꾸 나와서 아쉬울 따름이다.

잉크펜을 사용하는 멋진 어른이 되고 싶지만 여전히 지우고 싶은 것들이 많이 있어서.

쿠루토가 샤프 펜슬은 매번 지면에서 떨어질 때마다 샤프심이 돌아가는 방식으로 심 끝이 골고루 마모되어 항상 선명한 글씨로 글을 쓸 수 있는 특징이 있다. 4, 5년 정도 쿠루토가를 사용하고 있는데 글씨는 선명해서 보기 좋지만 빠르게 쓰다보면 아무래도 돌림힘(토크)가 있어 손이 피곤한 기분도 들고 다른 펜을 썼을 때 필기감이 좀 엉망이 된다는 단점이 있다. 펜을 자주 오가면서 쓴다면 꽤 적응 기간이 필요하다. 그래도 새 샤프심을 끼워서 첫 글자를 쓸 때 느낌을 좋아한다면 이 샤프 펜슬이 제격이다.

몇 번 떨어진 적도 있지만 그다지 험하게 쓰진 않았는지 고장이나 이상 없이 사용하고 있었다. 다만 이제 새학기도 시작인데다 손잡이에 젤이 있는 모델이 있길래 장시간 사용에 더 도움이 될까 싶어서 새 샤프 펜슬을 구입해봤다.

유니 알파겔 스위치

유니 알파겔 스위치는 2021년에 출시한 모델로 기존 사용하던 쿠루토가와 차이점은 그립부 재질이 젤리이고 모드 전환이 지원된다는 점이다.

  • 이런 두께감 있는 젤리 소재는 제브라 에어피트 밖에 떠오르지 않는데 그보다는 말랑하고 얕은 느낌이 있다. 그래도 꽤 부드러운 소재를 사용했다.
  • 쿠루토가 모드와 홀드 모드가 있는데 말 그대로 샤프심이 매번 돌지 않도록 끄는 모드가 추가되었다. 자주 사용할 지 모르지만 옵션이 있으면 좋으니까.

이번 학기엔 다시 수업 노트를 수첩과 펜으로 하기로 했다. 다들 아이패드랑 랩탑으로 하던데 지난 두 학기를 그렇게 해봤더니 도저히 나랑은 맞지 않은 것 같다. 검색이 가능하고 많은 노트를 들고 다니지 않아도 되는 건 장점이긴 하지만 머리에 잘 들어오지 않는 기분에다가 후다닥 스킴해서 본다거나 하는 것은 너무 번거롭다. 특히 몇 페이지 오른쪽 아래에 있다 이런 멘탈 모델이 잘 안생겨서 리뷰에 더 시간이 많이 드는 기분도 들고. 지난 학기엔 안그래도 많은 일이 있었는데 너무 많은 변화를 한번에 추구했던 것은 아닌가 싶다.

새로운 샤프 펜슬 사는 것에 또 지나치게 의미부여 하고 있는 나. 이번 학기도 즐겁게 해보자.

올해 초 장인어른께서 야속하게도 소천하셨다.

장인어른은 정말 평생 일만 하셨다. 차량정비를 하셨는데, 주6일 출근하시고도 주말엔 교회 이웃들 차를 봐주셨다. 덕분에 주말엔 교회처럼 붐볐고 장인어른의 유일한 휴일도 출근한 날과 다르지 않았다. 그렇게 수 십 년 일하셨으니까, 은퇴 후에는 좀 편히 쉬고 즐겁게 시간 보내시길 온가족이 바랐다. 여행도 다니시고, 맛있는 것 찾아 드시고, 은퇴하고 시간을 그렇게 보내는 주변 사람들을 보며 그런 은퇴를 꿈꿨다.

은퇴 직후에 암 진단을 받으셨었다. 장모님도 암으로 오래 투병하셨지만 이제 일상생활에 지장이 없을 정도로 잘 지내고 계시니까, 우리도 모두 소망을 갖고서 치료를 이어갔다. 항암치료 후엔 경과가 좋을 때도 있고 하루 종일 누워계실 때도 있었다. 장기를 떼어 낸 이후에 투석도 시작했다. 점점 더 힘들어 하셨다. 음식도 도통 드시지 못했다.

우리 삶의 우선 순위도 당연히 달라졌다. 왕복 세 시간 거리를 매주 한 두 차례 다녀왔다. 나도 모든 걸 다 붙잡고 있을 수 없었다. 회사도 정리했고, 마지막 순간에는 학업도 잠시 미뤘다. 마음이 복잡했다. 내 일상을 잠시 미루는 것이 다시 건강해질 거라는 믿음을 놓는 기분이 들어서.

조금이라도 나아질 기미가 보일 때마다 모두가 기뻐했다. 잠시 나아졌다, 나빠졌다를 반복했다. 그러다 병원에 입원하셨고, 기쁜 날보다 눈물 고이는 날이 점점 많아지다가, 더이상 할 수 있는 부분이 없어 집으로 모셨다. 그러고 얼마 지난 후에 집에서 눈을 감으셨다.

추모예배는 장모님 다니시던 교회에서 해주셨다. 장모님은 본당에서 하면 큰 공간에 너무 빈 자리가 많을까 걱정하셨는데, 걱정이 무색하게도 많은 분들이 함께 해주셨다. 아픔 없는 하늘나라 가셨으니까, 우리도 다시 만날 날 기약하자는 말씀이 유난히 모난 돌처럼 느껴졌다. 신앙인으로 당연한 이야기를 들으면서도 계실 때 잘해드리지 못한 순간들이 왈칵 쏟아졌다.

장인어른은 처제네가 있는 텍사스로 모셨다. 미국식이라서, 하관 전에 마지막으로 얼굴을 보는 시간이 있었다. 한동안 아프고 힘든 모습만 봐서 그런지 평온한 모습이 낯설었다. 처제네 친정과 함께 말씀과 기도를 나누며 하관식을 마무리했다. 그러고서 모두 밥먹으러 근처 순두부집을 갔다. 모든 게 끝나고 나니 뭐가 그리 급하셨나 화도 나고, 본인이 뭘 어떻게 할 수 있는 것도 아닌데 나는 뭘 원망하나, 하는 앞뒤 없이 복잡한 생각 속에서 하얀 순두부를 떠 먹었다.

나조차도 문득문득 생각나는 장인어른 모습에 가슴이 답답했다. 울다가 자는 날도 많았다. 아내나 처제나 장모님은 어느 정도일지 짐작도 할 수가 없었다. 시간이 흐르면 좀 괜찮아지겠지, 그렇게 생각하는 것 말고는 감정을 추스릴 방법이 없었다. 몇 달이 지났고 조금은 나아졌을까, 아직도 잘 모르겠다. 여전히 가슴이 죄어오는 기분이 들지만, 괜찮아지겠지. 민경씨는 회사에 바빴고 나는 다시 학교로 돌아갔다. 장모님은 처제네와 우리집을 오가며 계시다가 처제네 둘째 출산으로 당분간은 거기서 지내시기로 했다.

이 어려운 순간에도 고마운 손길이 많았다. 힘든 시간 위로해주신 분들께 너무나도 감사하고. 이웃과 공동체를 돌보는 일이 얼마나 대단한 일인지. 아직도 우리의 일상으로 돌아가는 날은 멀거나 아니면 다시는 예전같아 질 수 없을거란 생각이 들지만, 그래도 언젠가는 괜찮을 거란 용기를 얻어간다. 우린 서로가 있고 서로에게 위로와 힘이 되어 줄 수 있으니까. 이웃이든 가족이든.

Father’s day라서 장인어른 보러 가는 길이다. 매년 숯불에 갈비 구웠었는데, 거기서도 좋아하시는 것 잘 드시고 계셨으면 좋겠다.

"마트가서 우유 하나 사고 아보카도 있으면 6개 사와" 요즘 숏폼으로도 많이 돌아다니길래 재미삼아서. 가장 먼저 코드를 작성하기 전에 요구사항을 잘 읽는다.

  • 마트가서: 가야 할 장소
  • 우유 하나 사고: 품목과 수량, 수행해야 할 작업
    • 사고: AND
  • 아보카도: 품목
    • 있으면: 조건 (있으면 사고 없으면 안사도 되는)
    • 6개 사와: 수량과 수행해야 할 작업

정리하면

  • 구입할 물건과 수량: 우유 1개, 아보카도 6개
  • 구입해야 하는 장소: 마트
  • 조건: 아보카도는 있으면 구입

명시되지 않은 상황과 조건은 다시 확인이 필요하다.

  • 우유는 없고 아보카도만 있으면 아보카도만이라도 사올지
  • 마트 간 곳에 우유가 없으면 다른 마트라도 가서 우유 사와야 하는지
// 마트가서 우유 하나 사고 아보카도 있으면 6개 사와

function okJob1(person, place) {
    person.purchase("milk", 1, place)
    if (place.has("avocado")) {
        person.purchase("avocado", 6, place)
    }
}

function okJob2(person, place) {
    person.purchase("milk", 1, place)
    place.has("avocado") && person.purchase("avocado", 6, place)
}

function buggyJob(person, place) {
    // 아보카도가 있으면 우유 6개 사온다는 설정은
    // 코드로 봐도 좀 이상한 결정인 것 같은데
    // 세상은 넓고 요구사항은 다양하니까...
    person.purchase("milk", place.has("avocado") ? 6 : 1, place)
}

대략 이런 구현을 사용해서 일을 잘 정리했는지 테스트해본다.

class Location {
    constructor(name, inventory) { this.name = name; this.inventory = inventory; }
    has(item) { return this.inventory.includes(item); }
}

class Person {
    constructor(name) { this.name = name; }
    purchase(item, count, location) {
        console.log(`${this.name} purchased ${count} ${item} from ${location.name}.`)
    }
}

const memberOfHousehold = new Person("Spouse");
const marketWithAvocado = new Location("market", ["milk", "avocado"]);
const marketWithoutAvocado = new Location("market", ["milk"]);
okJob1(memberOfHousehold, marketWithAvocado);
// Spouse purchased 1 milk from market.
// Spouse purchased 6 avocado from market.

okJob1(memberOfHousehold, marketWithoutAvocado);
// Spouse purchased 1 milk from market.

okJob2(memberOfHousehold, marketWithAvocado);
// Spouse purchased 1 milk from market.
// Spouse purchased 6 avocado from market.

okJob2(memberOfHousehold, marketWithoutAvocado);
// Spouse purchased 1 milk from market.

buggyJob(memberOfHousehold, marketWithAvocado);
// Spouse purchased 6 milk from market.

buggyJob(memberOfHousehold, marketWithoutAvocado);
// Spouse purchased 1 milk from market.

간 김에 이것저것 장을 많이 봐서 올 것 같은데. 내일 아침은 아보카도 토스트 해야겠다.

애플워치를 한동안 사용했지만 도무지 매일 충전하는 일이 익숙해지지 않았다. 온갖 알림 덕분에 모든 것을 놓치지 않고 살게 하지만 눈 앞에 있는 일에 좀 소홀해지는 기분도 들어서, 결국엔 시계 기능만 잘하는 카시오 시계를 한동안 차고 다녔다. 그러다 미밴드에 대해 우연히 듣고는 이 시계는 좀 괜찮지 않을까 싶어서 구입했다.

워치페이스 진짜 다양하다

  • 가격이 애플워치에 비하면 정말 저렴한 편이다. 케이스랑 시계줄을 서드파티로 구입했는데 악세서리 가격이 시계 가격이랑 같았다.
  • 가볍다. 애플워치는 항상 찰 때마다 거추장스러운 느낌이 있었는데 그냥 고무밴드 끼고 있는 기분이다.
  • 배터리가 오래 간다. 대부분의 기능을 켜고 끌 수 있어서 중요하지 않은 기능을 끄면 정말 오래 사용할 수 있다. 게다가 충전도 꽤 빠른 편이라서 샤워하고 오는 사이에 완전 충전이 가능하다. 일주일에 한 번 정도 충전하는 걸로 충분하다.
  • 워치페이스가 다양하다. 안드로이드를 사용하면 직접 워치페이스를 만들어서 넣는 것도 가능하다는데 그러지 않아도 충분히 이것저것 많다.
  • 측정 정확도는 엄청 정확하진 않다는데 의료장비가 아니니까 그런 기대는 크게 안하고. 그래도 대략적으로 얼마나 잤나, 얼마나 걸었나는 정도는 적절하게 측정한다.
    • 애플 건강앱에 데이터도 잘 연동된다.
  • 알람은 진동으로 동작한다. 진동 크기는 밴드를 얼마나 꽉 끼냐에 따라서 편차가 꽤 큰 것 같다.

알림 메시지가 가끔 잘 안온다는 얘기가 있던데 문자든 전화든 아무 알림도 안오게 설정하고 사용하고 있어서 그건 확인을 못해봤다. 조용한 웨어러블 기기로 쓴다면 전혀 부족하지 않아 만족스럽다.

3월 27일부터 30일까지 3박 4일 (+1 레드아이) 일정으로 뉴욕 여행을 다녀왔다. 그동안 가정사에 큼직한 일이 줄줄이 있어서 어딜 가지 못하다가 조금은 갑작스럽게 리프레시 여행을 결정하게 됐다. 뉴욕은 민경 씨와 처음 만난 곳이라서 더욱 추억할 거리가 많았다.

오랜만에 하는 여행이라서 기대하는 마음보다도 얼떨떨함에 걱정이 조금 앞서기도 했었다. 둘 다 체력도 조금 유리인데다 잘 체하기도 해서 꽉 체운 일정을 우리가 잘 지킬 수 있을까 했는데 잘 짜인 계획 덕분에 유연한 대처도 가능했던 것 같다.

생각보다 사진을 많이 못찍었는데, 다음 스마트폰으로 교체하고 나면 더이상 카메라를 들고 다니지 않을 것 같기도 하다. 카메라 즐겁긴 하지만 점점 번거롭게 느껴지는게 아쉽다. 예전에 비해 또 사진에 대한 생각도 많이 달라지기도 했고.

계획할 때는 이게 마지막 뉴욕 여행이 될 것이란 얘길 하면서 일정을 짰는데 여행 후에는 우리 결국 못 본 곳들 많아서 적어도 다시 한 번은 와야겠다는 얘기를 했다.

Apartment Building from the TV Show Friends

커피

큰 기대 없던 곳까지도 커피가 맛있다니, 집에 와서도 뉴욕서 마신 커피 얘기한다.

  • %Arabica NY 30 Rock: 이곳저곳에 매장이 많길래 늘 궁금했는데 숙소 바로 옆이라서 들렸다. 레드아이 직후에 마신 커피라서 기억이 좀 가물가물한데 무난한 편이었다.
  • WatchHouse 5th Ave: 여기도 숙소 근처여서 MoMA 갔다가 들렸는데 아마 여행 중 간 곳 중에 가장 기억에 남는 곳이다. 가격대가 높긴 했지만, 브루커피 마저도 산미가 좋아 만족스러웠다.
  • Pavé NYC: 숙소 근처였는데 아침 식사 겸 들렸다. 페이스트리도 꽤 괜찮고 커피는 산미가 강하진 않았지만 제공되는 음식과 잘 어울리는 곳이었다. 말차 까눌레 처음이었는데 정말 좋았다.
  • Variety Coffee Roasters Upper east side: 이곳저곳 있는데 구겐하임 갔다가 들렸다. 고소함 정말 강하고 끝에 좋은 산미가 있었다. 춥다 못해 비가 온 날이었는데 카페인으로 센트럴 파크를 씩씩하게 걸었다.
  • Bar Pisellino: 인스타그램에서 이뻐 보이길래 간단하게 아침 먹을 겸 들렸다. 이탈리아풍으로 꾸며둔, 조그맣고 귀여운 공간이었다. 점심 전부터 칵테일 마시는 사람도 꽤 있었지만 우린 크로아상이랑 샌드위치, 커피 다 깔끔하고 좋았다. 우린 언제 이탈리아 가보지 얘기 하면서.
  • OSLO Coffee West Village: 사실 웨스트 빌리지에서 Pisellino에 갈 지 이 곳에 갈 지 고민하다가 지나가는 길 근처라서. 조그맣고 귀여웠다. 바디감 있는 쪽이긴 했지만 균형있는 맛이 좋았다.
  • Drip Coffee Makers in Clark Street Station: 덤보에서 브루클린 다리 구경하고 동네 구경 삼아 걸어간 곳이다. 정말 지하철 역사 안에 있는 카페였다. 푸어 오버 해달라고 하니 몇 가지 선택지도 있었고, 역 오가는 사람들 사이에서 커피 내리는 걸 기다렸다. 뉴욕 지하철 답게 너저분한 분위기인데 이런 곳에서 카페를 한다니 너무나도 신기했고, 커피 내리는 사이에도 에스프레소 사가는 사람도 있고. 그렇게 내린 커피는 여행 내내 다시 얘기할 정도로 맛있었다.
  • Birch Coffee Upper East: 메트로폴리탄 보고 나와서 마지막으로 마신 커피. 고소하고도 베리류 산미가 있어서 너무 좋았다. 게다가 카페에 레밍턴 케이크를 팔고 있어서 너무 신기했다.

음식

  • Blue Willow: 맛있게 매운 사천 음식점. 예약 없이 갔다가 자리가 없길래 주문하고 픽업해서 숙소에서 먹었다. 우리 매운 음식 잘 먹지도 못하면서 너무 많이 시킨 거 아닌가 했는데 세상 깔끔하게 먹었다. 사천식 오이샐러드, 마파두부 등등 다 맛있었다!
  • Gallaghers Steakhouse: 그래도 뉴욕이니까 스테이크하우스를 가봐야지 싶어서 유명하다는 곳으로 예약했는데... 분위기는 매우 좋았다.
  • Magnolia Bakery: 민경 씨가 지난 번 왔을 때 분명 먹었다고 하는 바나나 푸딩인데 난 먹은 기억이 없다고 맨날 투닥거리던 곳. 그래서 이제 제대로 먹고 제대로 기억하기로 했다. 맛있었다! 푸딩이란 게 젤로 같은거 생각했는데 그건 또 아니구나 신기했다.
  • Agi's Counter: 가까운 줄 알았는데 생각보다 멀어서 놀랐다. 브루클린 어딘가 있는데. 메뉴 너무 다 생소하고도 맛있었다. 멸치 얹은 데빌드 에그도 맛있었고 보보 치킨, 알감자 튀김에 올리브유 얹은 블루베리 치즈케익도. 모든 음식이 낯설 정도로 달랐는데 요즘도 자기 전에 이때 먹은 것 얘기한다.
  • Corrado Bread & Pastry on Lex 90th: 메트로폴리탄 가기 전에 아침으로 먹었는데, 뉴욕집밥(?) 분위기로 오가는 사람들이 많았다.

전시/공연

  • MoMA: 첫 뉴욕 방문 때에도 왔었는데, 그때보다도 훨씬 많은 인파가 있었다. 어느 공간에나 사람이 많아서 좀 정신 없이 구경하고 나왔다. 대신 스토어에서 이것저것 구경하고 책 살펴보는데 더 시간을 썼다.
  • Guggenheim: 예전부터 꼭 가보고 싶었는데 상설 전시가 아닌 특별전을 하고 있어서 조금 아쉬웠다. 그래도 잘 보지 못하던 작품도 많아서 인상적이었다.
  • The Metropolitan Museum of Art: ... 정말 방대했다. 이번이 마지막 뉴욕 여행이다 했는데 메트로폴리탄 때문에 또 와야겠다고 얘기했다.
  • Wicked - Gershwin Theatre: 브로드웨이에서 뮤지컬 보는 게 지난 뉴욕 여행 때 마음에 걸렸다고 해서. 공연 본다고 줄 서 있는데 투덜거리던 사람들이 많아서 덩달아 짜증났는데 막상 들어가서 너무 즐겁게 봤다. 정말 모든게 초록초록했다! 초록색 덕후인 민경 씨는 에메랄드 시티로 이사가자며 초록색 꿈을 꿨다.
  • Mahler Chamber Orchestra: Mitsuko Uchida - Carnegie Hall: 오케는 정말 오랜만이라서, 예전에 관악 부지런히 하던 것도 생각이 나고. 가슴뛰는 공연이었고 신선하고 재밌었다.

이곳저곳 이것저것

  • Three Lives & Company Bookstore: 우연히 들어간 책방인데 사고 싶은 책을 또 잔뜩 살펴보다 왔다. 언제 다시 책 읽는 삶으로 돌아가지? 그것보다도 이것저것 사서 꽂아둘 공간을 갖고 싶은 마음도 컸다. 조그마한 책방인데 마음에 드는 책은 어찌도 그리 많았는지.
  • Apartment Building from the TV Show Friends: 정말 프렌즈를 촬영하거나 한 것은 아니지만 매 에피소드마다 나오던 그 건물을 보고 왔다. 우리 말고도 사람 많았고 사진 찍는 사람도 많더라. 웨스트 빌리지 귀여웠고 정말 프렌즈 주인공들이 살고 있을 것 같은 동네였다.
  • Brooklyn Bridge, Dumbo, Jane's Carousel: Past Lives를 재밌게 보기도 했어서 다녀왔다. 사람이 정말 많았지만 영화 내용도 또 새록새록 나고. 가기 전에 지하철을 반대 방향으로 타서 한 번 낼 돈을 두 번 내는 에피소드도, 나중에 생각하면 웃길 거야, 했는데.
  • LEGO Store at Rockefeller center: 둘이 처음 뉴욕 왔을 때 들렸던 겸 해서 다녀왔다. 디자인만 하면 피겨 블럭에 출력해주는 서비스는 신기하고 재밌었다.
  • MUJI: 산타모니카에 갈 때마다 종종 들렸는데 아쉽게도 폐점되어서 추억 삼아 숙소 근처에 있던 무지에 다녀왔다. 소소하게 잠옷과 이것 저것 구입. 그러고 나오니 뉴욕의 찍찍이를 잔뜩 봤다.
  • Mure + Grand™: 귀엽고 핑크했던 스토어.
  • Bounce: 짐 맡겨주는 서비스인데 덕분에 숙소로 다시 돌아가는 동선을 줄일 수 있었다. 우리 숙소에 불만이 많았던 탓에 짜증이 많이 나 있던 상태였는데. 여행 중 최고 유용했던 서비스.

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.