TypeScript Branded Types로 타입 안전성 높이기

모든 ID가 string 타입이라 인자 순서를 바꿔도 컴파일 에러가 안 났습니다. Branded Types 패턴으로 런타임 오버헤드 없이 타입 안전성을 확보한 방법을 공유합니다.

1. 문제 상황

1.1 타입 안전성의 함정

TypeScript를 사용하면 타입 안전성이 보장된다고 생각하기 쉽습니다. 하지만 다음 코드에서 버그를 발견할 수 있나요?

// API 호출 함수들
async function getItem(itemId: string) {
  return fetch(`/api/items/${itemId}`);
}

async function getGroup(groupId: string) {
  return fetch(`/api/groups/${groupId}`);
}

// 사용 코드
const itemId = "clx1234567890";   // 항목 ID
const groupId = "cly0987654321";  // 그룹 ID

// 버그! itemId를 groupId 위치에 사용
const group = await getGroup(itemId);  // 타입 에러 없음!

문제: itemIdgroupId 모두 string 타입이라 TypeScript가 실수를 잡지 못합니다.

1.2 실제 발생한 버그

// 기록 삭제 API 호출
async function deleteRecord(recordId: string, itemId: string) {
  return fetch(`/api/records/${recordId}`, {
    method: 'DELETE',
    body: JSON.stringify({ itemId }),
  });
}

// 사용 코드
const { recordId, itemId } = selectedCell;

// 버그! 인자 순서를 바꿔서 호출
await deleteRecord(itemId, recordId);  // 컴파일 통과!

결과: API가 잘못된 ID로 호출되어 404 에러 또는 엉뚱한 데이터 삭제

1.3 왜 발생하는가?

문제 원인
구조적 타이핑 TypeScript는 구조가 같으면 같은 타입으로 취급
string 남용 ID, 날짜, 시간 등 모든 것을 string으로 표현
런타임 발견 버그가 테스트/프로덕션에서만 발견됨

2. 해결 방법: 브랜디드 타입

2.1 브랜디드 타입이란?

**브랜디드 타입(Branded Types)**은 구조적으로 동일한 타입에 "브랜드(태그)"를 추가하여 구분하는 패턴입니다.

기존: string === string (구분 불가)
브랜디드: Brand<string, "ItemId"> !== Brand<string, "GroupId"> (구분 가능)

핵심 원리:

  • 컴파일 타임에만 존재하는 가상의 속성 추가
  • 런타임에는 일반 string과 동일하게 동작 (오버헤드 없음)
  • TypeScript 컴파일러가 서로 다른 브랜드를 다른 타입으로 인식

2.2 기본 구현

// 브랜드 심볼 (컴파일 타임에만 존재)
declare const __brand: unique symbol;

// 브랜디드 타입 유틸리티
type Brand<T, B extends string> = T & { readonly [__brand]: B };

동작 원리:

Brand<string, "ItemId">
  = string & { readonly [__brand]: "ItemId" }

Brand<string, "GroupId">
  = string & { readonly [__brand]: "GroupId" }

→ __brand 속성의 값이 다르므로 서로 다른 타입!

2.3 ID 타입 정의

// types/branded.ts

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

// ID 타입들
export type ItemId = Brand<string, "ItemId">;
export type GroupId = Brand<string, "GroupId">;
export type UserId = Brand<string, "UserId">;
export type RecordId = Brand<string, "RecordId">;

// 날짜/시간 타입들
export type DateString = Brand<string, "DateString">;   // YYYY-MM-DD
export type TimeString = Brand<string, "TimeString">;   // HH:MM

3. 타입 캐스팅과 검증

3.1 캐스팅 헬퍼 함수

브랜디드 타입을 사용하려면 일반 string을 캐스팅해야 합니다:

// 단순 캐스팅 (런타임 검증 없음)
export const asItemId = (id: string): ItemId => id as ItemId;
export const asGroupId = (id: string): GroupId => id as GroupId;
export const asUserId = (id: string): UserId => id as UserId;
export const asRecordId = (id: string): RecordId => id as RecordId;

사용 예시:

// 데이터베이스에서 가져온 ID (신뢰할 수 있는 소스)
const itemId = asItemId(item.id);
const groupId = asGroupId(group.id);

// 타입 에러!
const wrong: GroupId = itemId;
// Error: Type 'ItemId' is not assignable to type 'GroupId'

3.2 검증 함수 (날짜/시간용)

형식 검증이 필요한 타입에는 검증 함수를 제공합니다:

/** DateString 형식 검증 (YYYY-MM-DD) */
export function isValidDateString(value: string): value is DateString {
  return /^\d{4}-\d{2}-\d{2}$/.test(value);
}

/** TimeString 형식 검증 (HH:MM, 00:00~23:59) */
export function isValidTimeString(value: string): value is TimeString {
  return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
}

/**
 * 문자열을 DateString으로 변환 (검증 포함)
 * @throws 형식이 올바르지 않으면 에러
 */
export function toDateString(value: string): DateString {
  if (!isValidDateString(value)) {
    throw new Error(`Invalid date format: ${value}. Expected YYYY-MM-DD`);
  }
  return value;
}

/**
 * 문자열을 TimeString으로 변환 (검증 포함)
 * @throws 형식이 올바르지 않으면 에러
 */
export function toTimeString(value: string): TimeString {
  if (!isValidTimeString(value)) {
    throw new Error(`Invalid time format: ${value}. Expected HH:MM`);
  }
  return value;
}

3.3 Type Guard 활용

// 타입 가드로 안전하게 변환
function processDate(input: string) {
  if (isValidDateString(input)) {
    // input은 이제 DateString 타입
    saveRecord(input);  // 타입 안전!
  } else {
    throw new Error('Invalid date format');
  }
}

// 또는 toDateString으로 변환 (에러 발생 가능)
function processDateUnsafe(input: string) {
  const date = toDateString(input);  // 잘못된 형식이면 에러
  saveRecord(date);
}

4. 실제 적용 사례

4.1 기록 타입 정의

// types/record.ts
import type { DateString, TimeString } from './branded';

export interface Record {
  id: string;
  itemId: string;
  date: DateString;              // 브랜디드 타입!
  statusType: StatusType;
  startTime: TimeString | null;  // 브랜디드 타입!
  endTime: TimeString | null;    // 브랜디드 타입!
  breakStart: TimeString | null;
  breakEnd: TimeString | null;
  note: string | null;
}

export interface RecordCell {
  itemId: string;
  date: DateString;              // 브랜디드 타입!
  statusType: StatusType | null;
  hasNote: boolean;
  hasTimeDetails: boolean;
  recordId?: string;
}

export interface RecordFormData {
  itemId: string;
  date: DateString;              // 브랜디드 타입!
  statusType: StatusType;
  startTime?: TimeString;        // 브랜디드 타입!
  endTime?: TimeString;
  breakStart?: TimeString;
  breakEnd?: TimeString;
  note?: string;
}

4.2 API 응답 처리

// API에서 받은 데이터 변환
const recordMap: Record<string, Record<string, RecordCell>> = {};

itemsWithRecords.forEach((item) => {
  recordMap[item.id] = {};

  item.records.forEach((rec) => {
    // formatLocalDate는 DateString을 반환
    const dateStr = formatLocalDate(rec.date) as DateString;

    recordMap[item.id][dateStr] = {
      itemId: item.id,
      date: dateStr,  // DateString 타입
      statusType: rec.statusType,
      hasNote: !!rec.note,
      hasTimeDetails: !!(rec.startTime || rec.endTime),
      recordId: rec.id,
    };
  });
});

4.3 낙관적 업데이트에서 사용

// hooks/useOptimisticData.ts
import type { DateString } from '@/types/branded';

const optimisticDelete = useCallback(
  async (
    itemId: string,
    date: DateString,        // 브랜디드 타입으로 타입 안전성 확보
    recordId: string
  ): Promise<boolean> => {
    // ...
  },
  [/* deps */]
);

const optimisticQuickInput = useCallback(
  async (
    itemId: string,
    date: DateString,        // 일반 string이 아닌 DateString
    statusType: StatusType | null,
    recordId?: string
  ): Promise<boolean> => {
    // ...
  },
  [/* deps */]
);

5. Before/After 비교

5.1 함수 시그니처

// ❌ Before: 모든 것이 string
async function deleteRecord(
  recordId: string,
  itemId: string,
  date: string
)

// ✅ After: 브랜디드 타입으로 구분
async function deleteRecord(
  recordId: RecordId,
  itemId: ItemId,
  date: DateString
)

5.2 잘못된 사용 감지

// ❌ Before: 컴파일 통과 (런타임 버그)
const itemId = "item-123";
const groupId = "group-456";
await getGroup(itemId);  // 버그지만 타입 에러 없음

// ✅ After: 컴파일 에러
const itemId: ItemId = asItemId("item-123");
const groupId: GroupId = asGroupId("group-456");
await getGroup(itemId);
// Error: Argument of type 'ItemId' is not assignable to parameter of type 'GroupId'

5.3 인터페이스 정의

// ❌ Before: 주석으로만 형식 표시
interface Record {
  date: string;       // YYYY-MM-DD (주석에 의존)
  startTime: string;  // HH:MM (주석에 의존)
}

// ✅ After: 타입으로 형식 보장
interface Record {
  date: DateString;       // 타입 자체가 형식을 표현
  startTime: TimeString;  // 컴파일러가 형식을 강제
}

6. 핵심 개념 정리

6.1 런타임 vs 컴파일 타임

구분 런타임 검증 컴파일 타임 검증 (브랜디드 타입)
검증 시점 코드 실행 시 빌드 시
오버헤드 있음 (검증 함수 실행) 없음 (타입만 존재)
버그 발견 테스트/프로덕션에서 개발 중에
에러 유형 런타임 에러 컴파일 에러

6.2 브랜디드 타입 선택 기준

✅ 브랜디드 타입이 적합한 경우:
- ID 타입들 (ItemId, GroupId 등)
- 특정 형식의 문자열 (날짜, 시간, 이메일 등)
- 단위가 있는 숫자 (Meters, Kilograms 등)
- 서로 다른 도메인의 동일한 기본 타입

❌ 브랜디드 타입이 과한 경우:
- 이미 구분되는 타입 (number vs string)
- 한 곳에서만 사용되는 타입
- 외부 라이브러리와 호환이 필요한 타입

6.3 브랜디드 타입 사용 패턴

// 1. 신뢰할 수 있는 소스: as 캐스팅
const id = asItemId(dbResult.id);

// 2. 사용자 입력: 검증 후 캐스팅
if (isValidDateString(userInput)) {
  const date = userInput;  // 타입 가드로 자동 캐스팅
}

// 3. 변환 필요 시: to* 함수 사용
const date = toDateString(formData.date);  // 검증 포함

// 4. 타입 간 변환: 명시적 재캐스팅
function formatDate(date: DateString): string {
  return date;  // 그냥 사용 가능 (string의 상위 타입)
}

7. 베스트 프랙티스

7.1 브랜디드 타입 체크리스트

□ ID 타입들을 브랜디드 타입으로 정의했는가?
□ 캐스팅 헬퍼 함수를 제공하는가?
□ 날짜/시간 등 형식 검증이 필요한 타입에 검증 함수가 있는가?
□ 인터페이스에서 브랜디드 타입을 사용하는가?
□ API 경계에서 올바른 타입을 사용하는가?

7.2 캐스팅 위치

// ✅ Good: 데이터 경계에서 한 번만 캐스팅
function fetchItem(id: string): Promise<Item> {
  return fetch(`/api/items/${id}`).then(r => {
    const data = r.json();
    return {
      ...data,
      id: asItemId(data.id),
      groupId: asGroupId(data.groupId),
    };
  });
}

// ❌ Bad: 매번 캐스팅
function doSomething(id: string) {
  const itemId = asItemId(id);
  // ...
  const sameId = asItemId(id);  // 중복 캐스팅
}

7.3 Zod와 함께 사용

import { z } from 'zod';

// Zod 스키마에서 브랜디드 타입으로 변환
const RecordSchema = z.object({
  itemId: z.string().transform(asItemId),
  date: z.string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .transform(val => val as DateString),
  startTime: z.string()
    .regex(/^([01]\d|2[0-3]):([0-5]\d)$/)
    .optional()
    .nullable()
    .transform(val => val as TimeString | null),
});

// 사용
const result = RecordSchema.parse(rawData);
// result.date는 DateString 타입!

7.4 주의사항

// 1. 런타임에는 일반 string처럼 동작
const id: ItemId = asItemId("item-123");
console.log(typeof id);  // "string" (브랜드는 없어짐)

// 2. JSON 직렬화 시 브랜드 정보 손실
const json = JSON.stringify({ id });
const parsed = JSON.parse(json);
// parsed.id는 string 타입 (ItemId 아님)
// → API 응답 처리 시 재캐스팅 필요

// 3. as 캐스팅은 검증하지 않음
const invalid = asItemId("");  // 빈 문자열도 캐스팅됨
// → 사용자 입력에는 검증 함수 사용

8. 확장 패턴

8.1 숫자 브랜디드 타입

// 단위가 있는 숫자
type Meters = Brand<number, "Meters">;
type Kilograms = Brand<number, "Kilograms">;
type Hours = Brand<number, "Hours">;

const distance: Meters = 100 as Meters;
const weight: Kilograms = 50 as Kilograms;

// 타입 에러: 다른 단위 혼용 방지
const total: Meters = distance + weight;  // Error!

8.2 복합 브랜디드 타입

// 여러 검증 조건을 결합
type NonEmptyString = Brand<string, "NonEmpty">;
type Email = Brand<string, "Email">;
type ValidatedEmail = NonEmptyString & Email;

function isNonEmpty(s: string): s is NonEmptyString {
  return s.length > 0;
}

function isEmail(s: string): s is Email {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}

function validateEmail(s: string): ValidatedEmail | null {
  if (isNonEmpty(s) && isEmail(s)) {
    return s as ValidatedEmail;
  }
  return null;
}

9. 참고 자료


10. 다음 단계

브랜디드 타입으로 컴파일 타임 안전성을 확보했다면, 타임존 안전한 날짜 처리도 적용해보세요.

시리즈 목차:

  1. TypeScript Branded Types로 타입 안전성 높이기 ← 현재 글
  2. JavaScript 타임존 함정 피하기: UTC vs Local 날짜 처리

11. FAQ (자주 묻는 질문)

Q: 브랜디드 타입은 런타임에 오버헤드가 있나요?

A: 아니요. 브랜디드 타입은 컴파일 타임에만 존재하며, 런타임에는 일반 string과 완전히 동일하게 동작합니다. JavaScript로 컴파일되면 타입 정보는 사라집니다.

Q: API 응답에서 브랜디드 타입을 어떻게 사용하나요?

A: API 응답을 받는 경계에서 한 번만 캐스팅하면 됩니다. 예: asItemId(response.id). 이후에는 해당 타입이 전파됩니다.

Q: Prisma나 다른 ORM과 함께 사용할 수 있나요?

A: 네, 가능합니다. DB에서 가져온 데이터를 변환할 때 캐스팅하면 됩니다. 단, 쿼리 작성 시에는 일반 string으로 다시 변환해야 할 수 있습니다.

Q: 기존 프로젝트에 점진적으로 도입할 수 있나요?

A: 가능합니다. 먼저 타입만 정의하고, 새로 작성하는 코드부터 적용하면 됩니다. 기존 코드는 as 캐스팅으로 호환성을 유지할 수 있습니다.