지난 분기, 잘못된 고객에게 환불을 해 주는 버그를 배포했다. processRefund 함수는 주문 ID가 와야 할 자리에 사용자 ID를 받았다. 두 문자열은 똑같아 보였고, 테스트는 통과했으며, TypeScript는 아무런 이의도 제기하지 않았다.

두 식별자 모두 string으로 타입이 지정되어 있었다. TypeScript는 둘 사이에 차이를 전혀 느끼지 못한다. userIdorderId를 원하는 매개변수에 전달하고 컴파일러가 어깨를 으쓱하는 모습을 본 적이 있다면, 같은 벽에 부딪힌 것이다.

타입 별칭은 이 문제를 해결하지 못한다. 변수 이름을 신중하게 짓는다고 핸 마찬가지다. 컴파일러는 정확히 당신이 시킨 대로 하고 있으며, 그것이 바로 문제다.

type UserId = string은 거짓말인가

TypeScript는 구조적 타이핑을 사용한다. 두 타입의 구조가 일치하면 호환된다. type UserId = string이라고 적을 때, 새로운 타입을 만든 것이 아니다. 별칭을 만든 것이다. 컴파일 시점에 UserIdstring은 동일하다. UserIdOrderId도 마찬가지다.

type UserId = string;
type OrderId = string;

function fetchOrder(id: OrderId) {
  // ...
}

const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.

컴파일러는 타입 검사 중에 타입 별칭을 제거한다. UserId라는 이름은 사람을 위한 것이다. 타입 검사기는 이를 무시한다. 이것은 보통 장점이다. 별다른 절차 없이 구현을 바꿀 수 있게 해준다. 하지만 절대 교환되어서는 안 되는 식별자에게는 자기 발을 쏘는 총이다.

브랜디드 타입은 똑같은 구조를 서로 호환되지 않게 만든다

해결책은 브랜디드 타입, 때로는 불투명 타입(opaque type)이나 뉴타입(newtype)이라고 불리는 것이다. 기본 타입과 타입 수준에서만 존재하는 고유한 브랜드를 교차시킨다.

런타임에는 값은 여전히 그냥 문자열이다. 컴파일 시점에 브랜드는 이를 다른 모든 문자열과 구분되게 만든다.

고유 심볼을 사용한 패턴은 다음과 같다:

declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };

declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };

unique symbol은 다른 타입이 우연히 이 브랜드를 공유할 수 없음을 보장한다. readonly 속성은 이를 변형하여 없앨 수 없음을 의미한다. 런타임에는 실제 문자열에 심볼 속성이 존재하지 않으므로, 이는 순전히 컴파일 시점의 구조물이다.

값을 만들려면 원시 문자열을 캐스팅하는 생성자 함수가 필요하다:

function UserId(value: string): UserId {
  return value as UserId;
}

function OrderId(value: string): OrderId {
  return value as OrderId;
}

이제 앞서의 버그는 코드를 실행하기 전에 잡힌다:

const uid = UserId("usr_0192");
fetchOrder(uid);
//     ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.

에러 메시지는 명확하다. 브랜드가 다르기 때문에 타입은 구조적으로 다르다. 컴파일러는 둘을 섞지 못하게 막는다.

상용구 코드는 귀찮다. 여기 도우미가 있다.

모든 ID 타입마다 브랜드와 생성자를 작성하는 것은 금세 지겨워진다. 우리는 둘 다 생성하는 작은 유틸리티를 사용한다:

interface Brand<T> {
  readonly __brand: T;
}

type Branded<T, B> = T & Brand<B>;

function makeBrand<T, B>(
  _brand: B
): (value: T) => Branded<T, B> {
  return (value) => value as Branded<T, B>;
}

사용법:

type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");

type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");

이렇게 하면 식별자당 두 줄의 선언으로 끝난다. 더 강력한 충돌 저항을 원한다면 제네릭 헬퍼 안에서 고유 심볼 접근법을 사용할 수도 있다. 어느 쪽이든 목표는 같다: 새로운 브랜디드 타입을 추가하는 비용을 실제로 그렇게 할 만큼 낮추는 것이다.

객체나 숫자는 어떤가?

브랜디드 타입은 문자열뿐 아니라 모든 원시 타입에 작동한다. 숫자 ID도 같은 방식으로 브랜딩한다:

type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");

객체에도 브랜드를 붙일 수 있다. 모양은 같지만 절대 바꿔 쓰면 안 되는 두 설정이 있다면, 브랜드를 붙여라:

type ApiConfig = Branded<{
  endpoint: string;
  timeout: number;
}, "ApiConfig">;

하지만 객체 브랜딩은 주의가 필요하다. 런타임 객체에는 브랜드 속성이 없으므로 JSON.stringify와 전개 연산은 정상적으로 작동한다. 보통 이것이 원하는 것이다. 하지만 깊은 동등성 검사를 하거나, 런타임에 타입을 검사하는 라이브러리에 값을 전달할 때는 브랜드가 도움이 되지 않는다. 이는 엄격히 컴파일 시점의 보호 장치다.

트레이드오프는 실재하지만, 작다

브랜디드 타입은 마찰을 추가한다. 이제 모든 ID는 생성자 호출이 필요하다. 브랜디드 타입을 기대하는 함수에 원시 문자열을 인라인으로 넣을 수 없다. 그것이 핵심이지만, 더 많은 코드를 의미한다.

직렬화는 또 다른 함정이다. 브랜디드 문자열을 JSON.stringify하면 원시 문자열이 돌아온다. 나중에 파싱하면 브랜드는 사라진다. 시스템 경계에서 생성자를 다시 적용해야 한다. 우리는 보통 API 응답 파서와 데이터베이스 로우 매퍼에서 이를 처리한다.

const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));

브랜디드 타입은 런타임 검증에도 도움이 되지 않는다. 문자열이 잘못된 형식이라면 브랜드는 이를 잡아내지 못한다. 여전히 시스템의 가장자리에서 zod, valibot, 또는 수동 검증이 필요하다. 브랜드는 타입의 구분을 보장할 뿐, 데이터의 정확성은 보장하지 않는다.

언제 이것이 가치가 있고, 언제 소음인가

우리는 모듈 경계를 넘나드는 식별자에 브랜드를 붙인다: 사용자 ID, 조직 ID, 추적 ID, 스팬 ID. 이것들이 가장 많은 코드를 거쳐 이동하며, 잘못된 순서로 전달될 가능성이 가장 높은 값들이다.

우리는 내부 루프 카운터, 지역 임시 변수, 또는 함수보다 작은 범위를 가진 것에는 브랜드를 붙이지 않는다. 태어난 곳을 떠나지 않는 값들에게는 오버헤드가 그만한 가치가 없다.

코드베이스에 연속된 세 개의 문자열 매개변수를 가진 함수 시그니처가 있다면, 그것은 강력한 신호다. 브랜디드 타입은 그 호출 지점을 추측 게임에서 컴파일러가 대신 검사해 주는 것으로 바꾼다.

하나의 실용적인 출발점

내일 당장 코드베이스의 모든 문자열에 브랜드를 붙일 필요는 없다. 실제 버그를 일으킨 식별자를 골라라. 그것들에 브랜드를 붙여라. 다음 리팩토링 중에 컴파일러가 혼동을 잡아내는 모습을 지켜봐라. 빌드 오류가 프로덕션 버그를 막는 그 단 한 순간, 추가적인 생성자 호출은 싸게 느껴지기 시작한다.

하나의 모듈 경계부터 시작하라. UserId 브랜드를 추가하라. OrderId 브랜드를 추가하라. 데이터베이스 레이어를 업데이트하여 로우가 들어올 때 생성자를 적용하라. 느낌이 어떤지 살펴봐라. 코드 리뷰에서 잘못된 인수 하나를 잡아낸다면, 그것으로 본전은 뽑은 것이다.