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); // 타입 에러 없음!
문제: itemId와 groupId 모두 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. 참고 자료
- TypeScript - Type Branding (관련 개념)
- TypeScript - Type Guards
- ts-brand - 브랜디드 타입 라이브러리
- io-ts - 런타임 타입 검증
- zod - 스키마 검증 (브랜디드 타입과 함께 사용)
10. 다음 단계
브랜디드 타입으로 컴파일 타임 안전성을 확보했다면, 타임존 안전한 날짜 처리도 적용해보세요.
시리즈 목차:
- TypeScript Branded Types로 타입 안전성 높이기 ← 현재 글
- 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 캐스팅으로 호환성을 유지할 수 있습니다.