본문으로 건너뛰기

TypeScript Template Literal Types와 Branded Types: 런타임 없이 도메인 오류를 타입으로 막는 설계

TypeScript30분 읽기본문 15,016

핵심 요약

이 글에서 확인할 내용

타입이 런타임 오류를 막지 못한다면, 타입 시스템은 절반짜리다 실서비스 TypeScript 코드에서는 개발자들은 종종 착각에 빠집니다. "타입을 붙였으니 안전하다"는 믿음입니다. 그러나 string 타입의 userId 와 string 타입의 orderId 는 TypeScript 컴파일러 눈에 동일하게 보입니다.

  • 1. Template Literal Types 기본
  • 2. 문자열 유니온 자동 생성
  • 3. Infer로 경로 파싱

TypeScript Template Literal Branded Types domain safety

타입이 런타임 오류를 막지 못한다면, 타입 시스템은 절반짜리다

실서비스 TypeScript 코드에서는 개발자들은 종종 착각에 빠집니다. "타입을 붙였으니 안전하다"는 믿음입니다. 그러나 string 타입의 userIdstring 타입의 orderId는 TypeScript 컴파일러 눈에 동일하게 보입니다.

필자가 속한 팀이 결제 서비스와 배송 서비스를 통합하는 프로젝트를 담당했을 때 정확히 이 상황을 겪었습니다. 두 서비스 모두 string 타입의 식별자를 주고받는 API였고, 팀원이 processShipment(userId, orderId) 함수 호출에서 인자 순서를 뒤바꾼 버그는 스테이징 환경의 E2E 테스트를 통과한 뒤 프로덕션에서 터졌습니다.

이 버그가 위험한 이유는 "조용하기 때문"입니다. 잘못된 순서로 넘긴 두 문자열은 여전히 문자열이고, fetch 호출도 정상적으로 나가며, 서버는 존재하지 않는 리소스에 대해 200이 아닌 404나 빈 응답을 돌려줄 뿐입니다. 예외가 던져지지 않으니 스택 트레이스도 없고, 로그에는 그저 "주문을 찾을 수 없음"이라는 평범한 메시지만 남습니다. 이런 버그는 코드 리뷰에서도 잘 잡히지 않습니다. 리뷰어의 눈에도 stringstring은 그저 같은 타입이기 때문입니다. 결국 문제를 발견하는 것은 CS 채널로 들어온 고객 문의이거나, 정산이 맞지 않는다는 재무팀의 연락입니다. 컴파일러가 1초 안에 잡아 줄 수 있었던 오류를 사람이 며칠 뒤에 발견하는 셈입니다.

우리가 이 글에서 다루려는 근본 질문은 이것입니다. 타입 시스템이 이미 "이 값은 문자열이다"까지 알고 있다면, 왜 "이 값은 사용자 식별자다"까지는 강제하지 못하는가? TypeScript는 구조적 타이핑(structural typing)을 채택했기 때문에, 형태가 같은 두 값을 의미가 달라도 호환된다고 판단합니다. 이 설계는 유연성을 주지만, 도메인 의미가 중요한 코드에서는 안전망에 구멍을 남깁니다. Template Literal Types와 Branded Types는 바로 이 구멍을 런타임 페널티 없이, 타입 레이어에서만 메우는 두 가지 핵심 도구입니다. 두 기법 모두 컴파일이 끝나면 흔적도 없이 사라지고, 최종 번들에는 평범한 문자열 연산만 남습니다.


1. Template Literal Types 기본

TypeScript 4.1에 도입된 Template Literal Types는 JavaScript의 템플릿 리터럴 문법을 타입 레이어로 그대로 가져온 기능입니다. 공식 핸드북에 따르면, 이 기능은 문자열 유니온 타입과 결합되었을 때 기하급수적인 타입 조합을 자동 생성하는 강력함을 발휘합니다.

type EventName = `on${string}`;

const onClick: EventName = 'onClick';
const rawString: EventName = 'click';    // Error

type Locale = 'ko' | 'en' | 'ja';
type Direction = 'Left' | 'Right' | 'Top' | 'Bottom';
type LocalizedPadding = `${Locale}-padding-${Direction}`;

type UserId = `usr_${string}`;
type OrderId = `ord_${string}`;

function getUser(id: UserId) {
  return fetch(`/api/users/${id}`);
}

getUser('usr_abc123');          // OK
getUser('ord_xyz789');          // Error

UserIdOrderId는 구조적으로 모두 string이지만, Template Literal Types 덕분에 서로 다른 타입으로 취급됩니다. 런타임에는 여전히 일반 string입니다.

여기서 한 가지 중요한 개념을 분명히 해 둘 필요가 있습니다. `usr_${string}`이 검사하는 것은 접두사의 존재일 뿐, 그 뒤에 오는 값이 실제로 유효한 식별자인지까지 보장하지는 않습니다. 'usr_'라는 빈 껍데기 문자열도 이 타입을 만족하고, 'usr_ 공백이 잔뜩' 같은 값도 통과합니다. 다시 말해 Template Literal Types는 형태(shape) 수준의 계약을 표현하는 데 강하지만, "이메일 형식이 맞는가", "6자 이상인가" 같은 내용(content) 수준의 규칙은 표현하지 못합니다. 뒤에서 다룰 Branded Types와 스마트 생성자가 이 빈틈을 메워 주며, 두 기법은 경쟁 관계가 아니라 서로를 보완하는 층위에 있습니다.

Template Literal Types와 함께 자주 쓰이는 네 가지 내장 문자열 조작 타입도 기억해 둘 만합니다. Uppercase<T>, Lowercase<T>, Capitalize<T>, Uncapitalize<T>는 컴파일러 내부(intrinsic)에 구현되어 있어 다른 어떤 타입 헬퍼보다 빠르게 평가됩니다. 이 네 가지를 템플릿 리터럴과 조합하면, 예를 들어 이벤트 이름 'click'으로부터 핸들러 프로퍼티 이름 'onClick'을 타입 수준에서 유도하거나, HTTP 헤더 키를 정규화하는 변환 계층을 순수 타입만으로 설계할 수 있습니다. 이런 문자열 변환 유틸리티의 전반적인 활용법은 TypeScript Utility Types 정복하기에서 더 넓게 정리하고 있습니다.


2. 문자열 유니온 자동 생성

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products' | 'payments';

type ApiRoute = `/${ApiVersion}/${Resource}`;

type UserEvent = `user:${'created' | 'updated' | 'deleted' | 'suspended'}`;
type OrderEvent = `order:${'placed' | 'paid' | 'shipped' | 'delivered' | 'cancelled'}`;
type DomainEvent = UserEvent | OrderEvent;

function emit(event: DomainEvent, payload: unknown) {
  console.log(`[Event] ${event}`, payload);
}

emit('user:created', { id: 'usr_001' });    // OK
emit('user:shipped', {});                    // Error

유니온의 멤버 수가 많아질수록 TypeScript 컴파일러의 체크 비용이 증가합니다. Uppercase<T>, Lowercase<T>, Capitalize<T> 같은 내장 유틸리티와 결합하면 API의 camelCase 키를 snake_case로 변환하는 타입 변환 계층을 설계할 수 있습니다.

여기서 반드시 이해해야 할 것은 템플릿 리터럴이 유니온을 만났을 때 곱집합(cartesian product) 으로 전개된다는 사실입니다. `/${ApiVersion}/${Resource}`ApiVersion 2개와 Resource 4개가 곱해져 8개의 리터럴 유니온으로 펼쳐집니다. 이 곱셈은 삽입 지점이 늘어날수록 폭발적으로 커집니다. 슬롯이 세 개이고 각 슬롯의 후보가 10개라면 조합은 1,000개, 네 개라면 10,000개가 됩니다. 조합의 정확성이라는 장점 뒤에는 이런 조합 폭발(combinatorial explosion)이라는 대가가 숨어 있고, 이 문제는 6절에서 성능 관점으로 다시 다룹니다.

이 곱집합 전개는 잘 통제하면 강력한 무기가 됩니다. 대표적으로 국제화(i18n) 키의 타입 안전화가 있습니다. `settings.${'profile' | 'security' | 'billing'}.title` 같은 타입을 정의해 두면, 번역 리소스에 존재하지 않는 키를 참조하는 순간 컴파일 오류가 납니다. CSS-in-JS 라이브러리가 `${number}px``${number}rem`으로 단위를 강제하거나, 상태 관리 라이브러리가 액션 타입 문자열의 오타를 잡아 주는 것도 같은 원리입니다. 문자열 하나하나가 "그냥 문자열"이 아니라 "허용된 값의 집합"으로 승격되는 것, 이것이 Template Literal Types가 실무에서 주는 실질적인 방어력입니다.


3. Infer로 경로 파싱

infer 키워드와 Template Literal Types를 결합하면 문자열 패턴에서 일부를 파싱하는 타입을 만들 수 있습니다.

type ExtractRouteParams<Route extends string> =
  Route extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
    : Route extends `${infer _Start}:${infer Param}`
    ? { [K in Param]: string }
    : {};

type Params2 = ExtractRouteParams<'/users/:userId/orders/:orderId'>;
// 결과: { userId: string; orderId: string }

function createHandler<Route extends string>(
  route: Route,
  handler: (params: ExtractRouteParams<Route>, req: Request) => Response
) {
  return { route, handler };
}

const userOrderHandler = createHandler(
  '/users/:userId/orders/:orderId',
  (params, req) => {
    const { userId, orderId } = params;
    return new Response(`User ${userId}, Order ${orderId}`);
  }
);

이 패턴은 TypeScript GitHub 이슈 #4895에서 명목적(Nominal) 타이핑을 요청한 커뮤니티 논의와 맞닿아 있습니다.

이 코드가 어떻게 동작하는지 한 겹씩 벗겨 보면 재귀 조건부 타입(recursive conditional type)의 진가가 드러납니다. 컴파일러는 `${infer _Start}:${infer Param}/${infer Rest}` 패턴으로 문자열을 : 앞부분, 파라미터 이름, 나머지 세 조각으로 쪼갠 뒤, 나머지 조각을 다시 자기 자신에게 넘겨 반복합니다. 문자열이 콜론을 더 이상 포함하지 않을 때 재귀가 종료되고, 각 단계에서 뽑아낸 파라미터 이름들이 매핑드 타입의 키로 합쳐집니다. 즉 우리는 런타임 파서를 한 줄도 작성하지 않고, 타입 수준에서 동작하는 작은 파서를 조립한 것입니다. 이것이 오늘날 tRPC, Hono, Elysia 같은 라이브러리가 문자열 라우트 하나만으로 파라미터 타입을 완벽하게 추론해 내는 마법의 뼈대입니다.

다만 이런 재귀 타입에는 현실적인 한계가 있습니다. TypeScript는 무한 루프를 방지하기 위해 타입 인스턴스화 깊이에 상한을 두며, 너무 깊게 재귀하면 TS2589: Type instantiation is excessively deep and possibly infinite 오류를 냅니다. TypeScript 4.5부터는 꼬리 재귀(tail-recursive) 형태의 조건부 타입에 대해 최적화가 도입되어 훨씬 긴 문자열도 처리할 수 있게 되었지만, 그렇다고 임의로 긴 입력을 안전하게 파싱할 수 있다고 가정해서는 안 됩니다. 타입 파서는 어디까지나 개발 시점의 편의를 위한 것이며, 실제 사용자 입력을 신뢰할 수 있게 검증하는 일은 여전히 런타임 코드의 몫입니다. 이 경계에 대한 감각은 뒤에서 다룰 "파싱하되 검증만 하지 말라"는 원칙과 직결됩니다.


4. Branded(Nominal) Types 개념

TypeScript는 구조적 타이핑(Structural Typing) 언어입니다. 두 타입의 구조가 호환되면, 이름이 달라도 같은 타입으로 취급합니다.

declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };

type UserId  = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function makeUserId(raw: string): UserId {
  return raw as UserId;
}

function getOrder(userId: UserId, orderId: OrderId) {
  return fetch(`/api/users/${userId}/orders/${orderId}`);
}

const uid = makeUserId('usr_001');
const oid = makeOrderId('ord_abc');

getOrder(uid, oid);   // OK
getOrder(oid, uid);   // Error

__brand 프로퍼티는 declare const로 선언된 unique symbol이므로 실제로는 존재하지 않습니다. 런타임에는 순수한 string입니다.

구조적 타이핑과 명목적 타이핑의 차이를 조금 더 파고들어 봅시다. Java나 C#, Rust는 명목적(nominal) 타입 시스템이라 이름이 다르면 구조가 같아도 서로 호환되지 않습니다. 반면 TypeScript와 Go는 구조가 같으면 이름과 무관하게 호환되는 구조적 시스템입니다. 구조적 타이핑은 오리 타이핑(duck typing)의 정적 버전이라 유연하지만, "값의 형태는 같지만 의미는 절대 섞이면 안 되는" 도메인 개념 앞에서는 오히려 독이 됩니다. Branded Types는 여기에 실재하지 않는 팬텀(phantom) 프로퍼티를 교차 타입으로 붙여, 구조적으로 서로 다른 타입인 척 만드는 기법입니다. 브랜드를 담는 그릇으로 unique symbol을 쓰는 이유는, 문자열 리터럴로 브랜드를 표현할 때 발생할 수 있는 우연한 충돌을 원천 차단하기 위해서입니다. 서로 다른 두 심볼은 이름이 같아도 절대 같아질 수 없으므로, 모듈 경계를 넘나드는 대규모 코드베이스에서도 브랜드가 뒤섞일 위험이 없습니다.

여기서 반드시 짚어야 할 트레이드오프가 있습니다. raw as UserId라는 타입 단언(assertion)은 컴파일러에게 "내가 책임질 테니 믿어라"라고 말하는 것과 같습니다. 즉 브랜딩의 안전성은 컴파일러가 보장해 주는 것이 아니라, 팩토리 함수 작성자가 보장하기로 약속한 것입니다. 그래서 이 as 캐스팅이 코드베이스 여기저기 흩어져 있으면 브랜드는 종잇장처럼 얇아집니다. 캐스팅을 팩토리 함수 한 곳에 가둬 두고, 그 함수가 반드시 검증을 통과한 값만 브랜딩하도록 강제할 때에야 비로소 브랜드가 진짜 계약이 됩니다. 이 원칙은 다음 절과 안티패턴 절에서 반복해서 등장합니다. 명목적 타이핑을 포함한 TypeScript 고급 타입 시스템 전반의 지형은 TypeScript 타입 시스템 마스터하기에서 더 폭넓게 다룹니다.


5. 브랜딩으로 UserId vs OrderId 혼동 방지

Branded Types를 프로젝트에 본격 도입할 때 우리 팀이 따른 설계 원칙은 세 가지입니다.

첫째, 생성 함수는 유효성 검증 책임을 가집니다.
둘째, Branded Type은 경계를 통과할 때만 해제합니다.
셋째, 하나의 as 캐스팅은 하나의 팩토리 함수 안에만 있습니다.

declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };

type UserId  = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function parseUserId(raw: unknown): UserId {
  if (typeof raw !== 'string' || !raw.startsWith('usr_') || raw.length < 6) {
    throw new Error(`Invalid UserId format: ${String(raw)}`);
  }
  return raw as UserId;
}

async function fetchOrderFromAPI(rawUserId: string, rawOrderId: string) {
  const userId  = parseUserId(rawUserId);
  const orderId = parseOrderId(rawOrderId);

  return orderService.getOrder(userId, orderId);
}

function processShipment(userId: UserId, orderId: OrderId) { /* ... */ }
processShipment(oid, uid); // Error

이 세 원칙의 밑바탕에는 함수형 커뮤니티에서 널리 인용되는 "파싱하되 검증만 하지 말라(parse, don't validate)" 라는 철학이 깔려 있습니다. 흔한 코드에서는 isValidUserId(raw)처럼 참·거짓만 돌려주는 검증 함수를 호출한 뒤, 그 아래 코드에서 여전히 원본 string을 그대로 씁니다. 문제는 검증했다는 사실이 타입에 남지 않는다는 것입니다. 몇 줄만 지나면 "이 값은 이미 검증됐던가?"라는 질문이 다시 떠오르고, 불안한 개발자는 같은 검증을 곳곳에서 중복으로 수행합니다. 반면 parseUserId처럼 검증에 성공한 값만 새로운 타입으로 승격시켜 반환하면, 검증 사실이 값의 타입에 각인됩니다. 이후 코드는 UserId를 받는 순간 "이 값은 이미 형식 검증을 통과했다"는 사실을 타입만 보고 신뢰할 수 있고, 재검증이 사라집니다. 검증 로직을 시스템의 가장 바깥 경계 한 곳으로 밀어 넣고, 그 안쪽은 이미 정제된 타입만 다루게 하는 이 구조를 흔히 "불법 상태를 표현 불가능하게 만든다(make illegal states unrepresentable)"고 표현합니다.

검증에 실패했을 때 예외를 던질지 결과 객체를 반환할지는 별개의 설계 결정입니다. 위 예시는 예외를 던지지만, 사용자 입력처럼 실패가 정상 흐름의 일부인 경계에서는 Result<UserId, ValidationError> 형태로 실패를 값으로 다루는 편이 흐름 제어에 유리합니다. 이 선택 기준은 TypeScript 에러 처리 설계: try/catch, Result 패턴, 도메인 오류를 구분하는 실무 기준에서 더 자세히 정리합니다. 스마트 생성자를 팩토리 클래스나 정적 메서드로 감싸는 구조적 선택지는 TypeScript로 배우는 GoF 디자인 패턴의 생성 패턴과도 자연스럽게 이어집니다.


도메인 원시값을 안전하게: 이메일, 통화, 단위

식별자만이 브랜딩의 대상은 아닙니다. 실무에서 조용한 사고를 가장 많이 일으키는 것은 오히려 의미가 다른데 형태가 같은 원시값들입니다. 대표적인 세 가지를 봅시다.

첫째, 이메일과 정규화된 이메일입니다. 사용자가 입력한 원본 문자열, 공백을 제거하고 소문자로 정규화한 문자열, 형식 검증을 통과한 문자열은 개념적으로 서로 다른 단계의 값이지만 모두 string입니다. Email을 브랜디드 타입으로 두고 parseEmail을 통과해야만 그 타입을 얻도록 하면, "검증 안 된 사용자 입력을 그대로 DB 유니크 조회에 쓰는" 실수를 컴파일 단계에서 막을 수 있습니다.

둘째, 통화 금액입니다. 금융 도메인에서 두 종류의 사고가 반복됩니다. 하나는 통화를 섞는 것(원화 금액과 달러 금액을 그대로 더함), 다른 하나는 단위를 섞는 것(원 단위 금액과 센트/전 단위 금액을 혼동함)입니다. Amount<'KRW'>Amount<'USD'>를 브랜드 파라미터로 구분하면 서로 다른 통화의 덧셈이 타입 오류가 되고, 환전 함수를 통과해야만 통화가 바뀌도록 강제할 수 있습니다. 금액 계산에서 부동소수점 오차를 피하려면 정수 최소 단위로 저장하는 것이 정석인데, 이 "최소 단위 정수"라는 규약 자체도 MinorUnits라는 브랜드로 못 박아 두면 팀원이 실수로 소수점 금액을 섞어 넣는 것을 방지할 수 있습니다.

셋째, 물리 단위입니다. 1999년 화성 기후 궤도선(Mars Climate Orbiter)이 대기권에서 소실된 원인은 한 팀은 파운드-포스 초 단위로, 다른 팀은 뉴턴 초 단위로 추력 값을 주고받았기 때문이었습니다. 두 값 모두 그저 숫자였고, 어떤 타입 시스템도 이를 구분해 주지 않았습니다. MetersFeet, MillisecondsSeconds를 브랜디드 넘버로 구분하면, 단위가 다른 값을 무심코 연산하는 순간 컴파일러가 제동을 겁니다. 우주선까지 갈 것도 없이, 만료 시각을 초로 다루는 코드와 밀리초로 다루는 코드가 한 함수에서 만나는 순간은 어느 백엔드 코드베이스에나 존재합니다. NonEmptyString, PositiveInt, Percentage처럼 값의 범위 규약을 담은 브랜드도 같은 방식으로 무한히 확장할 수 있습니다.

이 패턴은 데이터가 시스템 경계를 넘을 때 특히 빛을 발합니다. 예를 들어 메시지 큐 파이프라인에서 스키마로 검증된 페이로드를 소비하는 컨슈머라면, 역직렬화 직후에 브랜딩을 부여해 이후 처리 단계 전체가 "검증된 값"만 다루도록 만들 수 있습니다. 검증을 통과한 값만 안쪽으로 흘려보내는 이 구조는, 경계에서 한 번 걸러 낸 신뢰를 시스템 깊숙한 곳까지 타입으로 실어 나르는 일이라고 볼 수 있습니다.


컴파일타임 상태머신: 팬텀 타입 파라미터

브랜딩을 한 단계 더 밀고 나가면, 값이 지금 어떤 상태에 있는지를 타입으로 추적할 수 있습니다. 브랜드에 들어가는 이름 자리에 상태를 나타내는 문자열을 넣고, 상태 전이 함수가 새로운 상태의 타입을 반환하도록 설계하는 것입니다. 예를 들어 커넥션을 Connection<'Open'>Connection<'Closed'>로 나누면, 이미 닫힌 커넥션에 send()를 호출하는 코드가 타입 오류가 됩니다. send 함수의 시그니처가 Connection<'Open'>만 받도록 되어 있기 때문입니다.

같은 아이디어가 인증 세션에도 적용됩니다. Session<'Anonymous'>Session<'Authenticated'>를 구분하면, 인증이 필요한 핸들러가 익명 세션을 받는 순간 컴파일이 실패합니다. "로그인 검사를 깜빡한 API 핸들러" 같은 흔한 보안 결함을 타입으로 예방하는 셈입니다. 인증 흐름 자체의 설계는 패스키(Passkey)와 WebAuthn 도입 가이드에서 더 깊이 다루지만, 그 흐름을 코드로 옮길 때 상태 브랜딩은 "인증 전 상태에서 인증 후에만 가능한 동작을 호출"하는 실수를 구조적으로 막아 줍니다.

빌더 패턴에서도 같은 기법이 유용합니다. 필수 필드가 모두 채워졌는지를 타입 파라미터로 추적하다가, 모든 필수 필드가 설정된 상태에서만 build()를 노출하면, 필수 값을 빠뜨린 채 객체를 만들려는 시도가 컴파일 단계에서 걸립니다. 이 모든 사례의 공통점은 명확합니다. 런타임 if 검사로 방어하던 규칙을, 애초에 잘못된 호출을 작성할 수 없게 만드는 타입 계약으로 끌어올리는 것입니다. 규칙이 타입에 새겨지면, 그 규칙은 테스트를 돌리기 전에, 코드를 실행하기 전에, 심지어 코드를 저장하기도 전에 에디터에서 즉시 강제됩니다. 다만 상태 조합이 늘어날수록 타입 파라미터가 복잡해지므로, 상태 머신이 세 개 이상의 축으로 얽히기 시작하면 타입 대신 명시적 런타임 검증으로 내려오는 편이 유지보수에 낫다는 균형 감각도 함께 필요합니다.


본문 중간 핵심 흐름을 시각화한 TypeScript 일러스트

6. 퍼포먼스 영향과 컴파일 시간

기법런타임 오버헤드컴파일 타임 비용코드 가독성
단순 string 타입없음최소낮음
Template Literal Type없음유니온 크기의 제곱에 비례중간
Branded Type (phantom)없음매우 낮음높음
class 래퍼 객체객체 생성 비용낮음높음

Template Literal Types에서 주의해야 할 점은 유니온 폭발입니다. TypeScript 팀은 단일 유니온이 100,000개를 넘으면 경고를 발생시키도록 제한을 두고 있습니다.

이 제한이 왜 중요한지는 실제 증상을 보면 체감됩니다. 유니온이 수만 개 규모로 커지면 컴파일 시간이 늘어나는 것은 물론, 개발 경험 자체가 무너집니다. 에디터의 자동완성이 몇 초씩 멈추고, 타입 위에 마우스를 올렸을 때 뜨는 툴팁이 ...으로 잘리며, tsserver 프로세스의 메모리 사용량이 가파르게 치솟습니다. 팀 전체의 IntelliSense가 굼떠지는 원인을 추적해 보면 누군가 무심코 정의한 거대한 템플릿 리터럴 유니온인 경우가 적지 않습니다. 이런 유니온은 종종 "타입만으로 모든 URL 조합을 표현하겠다"는 과욕에서 나옵니다.

핵심은 표현력과 비용의 균형입니다. 브랜디드 타입은 팬텀 프로퍼티를 교차하는 것뿐이라 컴파일러가 거의 공짜로 처리하지만, 템플릿 리터럴 유니온은 슬롯과 후보가 늘어날 때마다 곱셈으로 비싸집니다. 그래서 우리는 "가능한 모든 문자열 조합을 타입으로 열거"하기보다, `usr_${string}`처럼 패턴만 좁히고 나머지는 브랜딩으로 의미를 부여하는 조합을 선호합니다. 조합이 정말로 필요한 곳(제한된 이벤트 이름, 소수의 API 버전)에만 유니온 전개를 쓰고, 무한히 열린 값에는 패턴 가드와 브랜드를 씁니다. 또한 라이브러리 저자라면 재귀 조건부 타입이 사용자 코드에서 TS2589를 유발하지 않도록 재귀 깊이를 방어적으로 설계해야 합니다. 타입 수준 연산도 결국 컴파일러가 실행하는 프로그램이며, 공짜가 아니라는 감각을 잃지 않는 것이 대규모 코드베이스에서 특히 중요합니다.


7. Zod와 결합한 런타임 브랜딩

Zod의 .brand() 메서드(공식 문서)는 스키마 파싱 결과에 Branded Type을 자동으로 부여합니다.

import { z } from 'zod';

const UserIdSchema = z
  .string()
  .startsWith('usr_')
  .min(6)
  .brand<'UserId'>();

const OrderIdSchema = z
  .string()
  .startsWith('ord_')
  .min(6)
  .brand<'OrderId'>();

type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;

const CreateOrderResponseSchema = z.object({
  orderId:  OrderIdSchema,
  userId:   UserIdSchema,
  status:   z.enum(['pending', 'paid', 'failed']),
});

async function createOrder(userId: UserId): Promise<z.infer<typeof CreateOrderResponseSchema>> {
  const raw = await fetch('/api/orders', {
    method: 'POST',
    body: JSON.stringify({ userId }),
  }).then(r => r.json());

  return CreateOrderResponseSchema.parse(raw);
}

타입 시스템의 더 넓은 맥락은 TypeScript 고급 타입 시스템 총정리에서 다루고 있습니다.

Zod 방식이 앞서 손으로 짠 팩토리 함수보다 나은 지점은, 검증 규칙과 브랜드가 한 곳에 응집된다는 것입니다. .startsWith('usr_').min(6).brand<'UserId'>()라는 선언 하나가 형식 검증과 타입 승격을 동시에 표현하므로, "검증 따로, 브랜딩 따로"가 어긋날 여지가 없습니다. 이것이 안티패턴 절에서 지적할 "브랜딩과 유효성 검증 분리" 문제를 구조적으로 예방합니다. 실무에서는 parse가 실패 시 예외를 던지는 반면 safeParse는 성공/실패를 담은 결과 객체를 돌려준다는 차이를 기억해 두면 좋습니다. 신뢰할 수 없는 외부 입력(API 응답, 폼 데이터, 웹훅 페이로드)을 다루는 경계에서는 safeParse로 실패를 값으로 다루고, 내부 불변식을 재확인하는 자리에서는 parse로 빠르게 실패시키는 식으로 나눠 쓰면 흐름이 깔끔해집니다. 다만 Zod는 런타임 코드이므로 매 요청마다 파싱 비용이 실제로 발생한다는 점을, 순수 타입 레벨 브랜딩과 구분해서 인지해야 합니다.


8. opaque 타입 라이브러리: ts-brand 도입 가이드

ts-brand는 TypeScript 커뮤니티에서 가장 널리 사용되는 Branded Type 헬퍼 라이브러리입니다.

import { Brand, make } from 'ts-brand';

type UserId  = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

const makeUserId  = make<UserId>();
const makeOrderId = make<OrderId>();

const uid = makeUserId('usr_001');
const oid = makeOrderId('ord_abc');

function getOrder(userId: UserId, orderId: OrderId) {
  return fetch(`/api/users/${userId}/orders/${orderId}`);
}

getOrder(uid, oid);  // OK
getOrder(oid, uid);  // Error

ts-brand 같은 라이브러리를 도입할지, 아니면 프로젝트 안에 type Brand<T, B> = ... 한 줄을 직접 두고 쓸지는 규모의 문제입니다. 브랜드 정의 자체는 워낙 짧아서, 의존성을 하나 더 늘리기 싫다면 src/types/branded.ts 같은 파일에 자체 헬퍼를 두는 편이 오히려 낫습니다. 반대로 팀 규모가 크고 브랜딩 관례를 표준화하고 싶다면, 검증된 라이브러리를 채택해 makeBrand의 사용법을 문서화하는 편이 신규 합류자에게 친절합니다. 어느 쪽을 택하든 변하지 않는 원칙은 하나입니다. 브랜드를 만드는 통로를 좁게 유지하는 것. make<UserId>()가 반환하는 함수든 손으로 짠 parseUserId든, 브랜드를 발급하는 지점이 한 파일에 모여 있어야 나중에 검증 규칙을 강화하거나 로깅을 끼워 넣을 때 한 곳만 고치면 됩니다.


9. 실무 적용 사례

레거시 코드베이스에서 결제 도메인의 모든 식별자가 string으로 선언되어 있었고, userId, orderId, paymentId, invoiceId, merchantId가 함수 간에 자유롭게 섞여 전달되는 상황이었습니다.

전환 작업은 세 단계로 나뉘었습니다.

1단계: Branded Type 인프라 구축. src/types/branded.ts 파일 하나에 모든 도메인 식별자 타입과 팩토리 함수를 정의했습니다.

2단계: 경계면부터 교체. API 응답을 파싱하는 레이어부터 Zod 스키마 + .brand()를 적용했습니다.

3단계: 내부 함수 시그니처 교체. 컴파일 에러가 발생하는 함수들의 파라미터 타입을 순서대로 교체했습니다.

이 전환에서 우리가 얻은 가장 큰 교훈은 순서가 곧 전략이라는 점이었습니다. 안쪽 함수부터 타입을 바꾸면 경계에서 아직 string으로 들어온 값과 충돌해 컴파일 오류가 사방에서 터지고, 어디부터 손대야 할지 막막해집니다. 반대로 데이터가 시스템에 처음 들어오는 경계(API 파싱 레이어)부터 브랜딩을 부여하면, 브랜디드 타입이 자연스럽게 안쪽으로 전파되면서 컴파일러가 "아직 안 바꾼 곳"을 하나씩 친절하게 짚어 줍니다. 우리는 이 컴파일 오류 목록을 사실상 마이그레이션 체크리스트처럼 활용했습니다. 오류가 0개가 되는 순간이 곧 전환 완료 지점이었습니다. 대규모 코드베이스라면 이 과정을 도메인별로 쪼개, 결제 도메인 먼저·배송 도메인 다음 식으로 점진적으로 굴리는 것이 안전합니다.

전환 후 결과: 코드 리뷰에서 "이 파라미터 순서 맞나요?"라는 주석이 완전히 사라졌고, 식별자 혼용으로 인한 런타임 오류 보고가 관련 도메인에서 발생하지 않고 있습니다. 부수적으로 얻은 이득도 있었습니다. 함수 시그니처만 봐도 어떤 종류의 식별자를 기대하는지 한눈에 드러나면서, 타입이 곧 문서 역할을 하게 된 것입니다. TypeScript 오류 처리에 대한 패턴은 Result 패턴으로 TypeScript 에러 처리하기에서 이어집니다.


10. 안티패턴 정리

안티패턴 1: 내부에서 as 캐스팅 남용.
안티패턴 2: 모든 string에 Branded Type 적용.
안티패턴 3: Template Literal Types에서 유니온 무한 확장.
안티패턴 4: 브랜딩과 유효성 검증 분리.

// 위험한 패턴: 검증 없이 브랜딩
function asUserId(raw: string): UserId {
  return raw as UserId;
}

// 올바른 패턴: 검증 후 브랜딩
function parseUserId(raw: string): UserId {
  if (!raw.startsWith('usr_') || raw.length < 10) {
    throw new TypeError(`"${raw}"는 유효한 UserId 형식이 아닙니다.`);
  }
  return raw as UserId;
}

각 안티패턴이 왜 위험한지 조금 더 풀어 보겠습니다. 첫 번째, 팩토리 밖에서 as UserId 캐스팅을 남발하면 브랜드가 주는 보장이 통째로 무너집니다. 캐스팅은 컴파일러의 검사를 끄는 행위이므로, 그 지점만큼은 안전망이 사라진 구멍이 됩니다. 캐스팅은 반드시 검증을 동반한 팩토리 함수 안에 격리되어야 합니다. 두 번째, 세상의 모든 문자열에 브랜드를 붙이면 코드가 캐스팅과 팩토리 호출로 뒤덮여 오히려 읽기 어려워집니다. 브랜딩은 "의미가 다른데 형태가 같아 섞일 위험이 실재하는" 값에만 선별적으로 적용해야 비용 대비 효과가 납니다. 로그 메시지나 임시 표시 문자열까지 브랜딩하는 것은 과잉입니다. 세 번째, 앞서 성능 절에서 다뤘듯 유니온을 무한히 키우면 컴파일러와 에디터가 함께 느려집니다. 네 번째, 검증과 브랜딩을 서로 다른 함수로 분리해 두면, 검증을 건너뛰고 브랜딩만 하는 우회로가 반드시 생깁니다. 이 둘은 한 함수 안에서 원자적으로 일어나야 하며, Zod의 .brand()가 이를 강제하는 좋은 예입니다.


언제 쓰지 말아야 하나

강력한 도구일수록 적용 범위를 아는 것이 중요합니다. 브랜디드 타입과 템플릿 리터럴 타입을 피하거나 미뤄야 할 상황도 분명히 존재합니다.

혼동 위험이 실재하지 않는 곳에는 쓰지 않습니다. 프로젝트에 식별자가 단 한 종류뿐이거나, 타입 시스템이 이미 구조로 충분히 구분해 주는 경우라면 브랜딩은 순수한 오버헤드입니다. 값이 함수 사이를 넘나들며 섞일 실제 경로가 없다면 굳이 팬텀 타입을 도입할 이유가 없습니다. 아주 짧게 쓰고 버리는 스크립트나 프로토타입에서도 마찬가지입니다. 브랜딩이 주는 이득은 코드가 오래 살아남아 여러 사람이 손댈 때 복리로 커지므로, 수명이 짧은 코드에서는 그 이득을 회수할 시간이 없습니다.

팀의 TypeScript 숙련도도 고려해야 합니다. 팬텀 타입, 재귀 조건부 타입, infer 파싱은 처음 보는 사람에게 낯설고, 컴파일 오류 메시지도 난해해지기 쉽습니다. 팀이 아직 준비되지 않았다면 브랜딩의 사용처를 식별자 몇 개로 좁게 시작하고, 재귀 타입 파싱 같은 고급 기법은 라이브러리 경계 안쪽에 감추는 편이 낫습니다. 마지막으로, 무한히 열린 값을 타입 유니온으로 전부 열거하려는 시도는 처음부터 하지 않는 것이 좋습니다. 그것은 성능 절에서 본 유니온 폭발로 직행하는 길입니다. 규칙은 단순합니다. 타입이 주는 안전이 그 타입을 이해하고 유지하는 비용을 넘어설 때만 쓴다.


자주 묻는 질문

Q. 브랜딩은 런타임 성능에 영향을 주나요?
전혀 주지 않습니다. unique symbol 팬텀 프로퍼티와 교차 타입은 순수하게 타입 레벨 구성물이라, tsc가 타입을 지우고 나면 최종 JavaScript에는 흔적조차 남지 않습니다. UserId는 런타임에 그냥 문자열입니다. 반면 Zod의 .parse()는 명백히 런타임 코드이므로, 순수 브랜딩과 스키마 검증의 비용을 구분해서 인지해야 합니다.

Q. 브랜디드 값을 JSON으로 직렬화하면 브랜드는 어떻게 되나요?
브랜드는 애초에 런타임에 존재하지 않으므로 JSON.stringify는 그냥 원본 문자열이나 숫자를 그대로 뱉습니다. 문제는 반대 방향입니다. JSON.parse로 되살린 값은 브랜드가 벗겨진 평범한 원시값이므로, 시스템 경계에서 다시 파싱·검증을 거쳐 브랜딩을 부여해야 합니다. 이것이 "경계에서 파싱한다"는 원칙이 반복해서 강조되는 이유입니다.

Q. 브랜디드 타입끼리 문자열 연산을 하면 어떻게 되나요?
UserId 두 개를 템플릿 리터럴로 이어 붙이거나 .slice() 같은 문자열 메서드를 호출하면 결과는 브랜드가 벗겨진 일반 string이 됩니다. 브랜드는 원본 값에만 붙어 있고 파생 값으로 자동 전파되지 않기 때문입니다. 이는 버그가 아니라 의도된 동작입니다. 가공된 값은 더 이상 원래의 의미론적 계약을 만족한다고 보장할 수 없으므로, 필요하면 다시 팩토리를 거치도록 강제하는 것이 안전합니다.

Q. enum으로 대신할 수는 없나요?
enum은 유한하고 미리 정해진 값의 집합을 다루는 데 적합하지만, 식별자처럼 무한히 열린 문자열 공간에는 맞지 않습니다. 반대로 상태처럼 값이 유한하다면 문자열 리터럴 유니온이나 상태 브랜딩이 enum보다 가볍고 트리 셰이킹에도 유리한 경우가 많습니다. 도구 선택은 값의 개수가 유한한지 무한한지를 먼저 따진 뒤 결정하는 것이 좋습니다.


정리

  • 경계면 식별: 외부 데이터가 Branded Type으로 승격되는 지점이 명확하게 정해져 있는가?
  • 캐스팅 집중: as UserId 같은 직접 타입 단언이 팩토리 함수 본문에만 존재하는가?
  • 유니온 크기 관리: Template Literal Types로 생성되는 유니온의 멤버 수가 수백 개를 넘지 않는가?
  • 적용 범위 선택: Branded Type을 적용한 대상이 실제로 도메인 의미 혼용의 위험이 있는 식별자인가?
  • 팩토리 함수 내 검증: 브랜딩 팩토리 함수가 단순 캐스팅이 아닌 도메인 규칙 검증을 포함하고 있는가?

타입 시스템이 강제하는 규칙은 코드 리뷰 없이도, 테스트 없이도, 24시간 작동합니다. 우리가 이 글에서 반복해 확인한 원칙을 한 문장으로 압축하면 이렇습니다. 검증은 시스템의 가장 바깥 경계 한 곳에서 하고, 그 결과를 타입에 각인시켜, 안쪽 코드가 다시는 의심하지 않게 만들어라. Template Literal Types가 값의 형태를 좁히고, Branded Types가 값의 의미를 못 박고, 스마트 생성자가 그 둘을 검증과 함께 묶어 낼 때, 우리는 런타임 비용을 한 푼도 지불하지 않고 도메인 오류의 상당 부분을 컴파일 단계로 끌어올릴 수 있습니다. 가장 좋은 버그는 잡는 버그가 아니라 애초에 작성할 수 없는 버그입니다.


인접 주제 더 보기