TypeScript 브랜디드 타입(Branded Type) 심화: Zod와 템플릿 리터럴 활용법
지난 포스팅에서 **브랜디드 타입(Branded Type)**의 기초와 숫자 데이터를 안전하게 격리하는 방법을 살펴봤습니다. 하지만 실무에서는 매번 as를 써서 타입을 강제하거나, 수동으로 팩토리 함수를 만드는 것이 번거로울 수 있습니다.
오늘은 이 패턴을 한 단계 더 발전시켜, **런타임 라이브러리(Zod)**와 **최신 TypeScript 문법(Template Literals)**을 결합하여 더 우아하게 타입을 방어하는 방법을 알아보겠습니다.
목차
- Zod를 이용한 선언적 브랜딩: 런타임과 컴파일 타임의 결합
- 템플릿 리터럴 타입으로 문자열 브랜딩 고도화하기
- 실무 적용: 도메인 주도 설계(DDD)와 타입 시스템
- 주의사항: 브랜디드 타입의 한계와 트레이드오프
- 자주 묻는 질문(FAQ)
Zod를 이용한 선언적 브랜딩: 런타임과 컴파일 타임의 결합
매번 수동으로 validateAmount 같은 함수를 만드는 대신, 스키마 검증 라이브러리인 Zod를 사용하면 검증과 브랜딩을 한 번에 처리할 수 있습니다.
import { z } from "zod";
// 1. 브랜드 타입 정의
type UserId = number & z.BRAND<"UserId">;
// 2. Zod 스키마 정의 시 .brand() 메서드 사용
const UserIdSchema = z.number().positive().brand<"UserId">();
// 3. 사용 예시
const rawData = 123;
const result = UserIdSchema.safeParse(rawData);
if (result.success) {
const userId: UserId = result.data; // 안전하게 브랜딩된 타입 확보
console.log("검증된 ID:", userId);
} else {
console.error("유효하지 않은 ID 형식입니다.");
}
왜 Zod인가요? Zod의 .brand()를 사용하면 내부적으로 우리가 앞서 배운 교차 타입(&)을 생성해 줍니다. 개발자는 비즈니스 규칙(양수여야 함, 특정 길이어야 함 등)만 기술하면, 검증 성공 시 자동으로 브랜드가 찍힌 타입이 반환됩니다.
템플릿 리터럴 타입으로 문자열 브랜딩 고도화하기
숫자만큼이나 위험한 것이 문자열입니다. 특히 ID-123 같은 특정 패턴을 가진 문자열을 일반 string으로 다루면 오타에 취약해집니다. TypeScript의 **템플릿 리터럴 타입(Template Literal Types)**을 쓰면 문자열의 “형식” 자체를 브랜드화할 수 있습니다.
// 특정 접두사로 시작하는 유저 ID 브랜드
type UserPrefix = `user_${string}`;
type BrandedUserTag = string & { __brand: "UserTag" };
// 브랜드와 템플릿 리터럴의 조합
type ValidUserTag = UserPrefix & BrandedUserTag;
function createTag(input: string): ValidUserTag | null {
if (input.startsWith("user_")) {
return input as ValidUserTag;
}
return null;
}
const myTag = createTag("user_admin"); // OK
// const wrongTag: ValidUserTag = "admin_user"; // Error! 패턴 불일치
이 방식은 단순히 “무엇이다”라고 선언하는 것을 넘어, 문자열의 내부 패턴까지 타입 시스템이 검사하게 만듭니다. CSS의 hex color나 ISO Date 형식을 다룰 때 매우 강력합니다.
실무 적용: 도메인 주도 설계(DDD)와 타입 시스템
브랜디드 타입은 값 객체(Value Object) 패턴을 구현할 때 핵심적인 역할을 합니다.
💡 개발자의 팁: 코드 가독성과 보안성
결제 시스템에서
Amount와Tax를 구분하지 않고 계산하면 사고가 납니다. 브랜디드 타입을 적용하면 함수 시그니처가 다음과 같이 바뀝니다.
calculateTotal(price: Price, tax: TaxRatio): TotalAmount이제 누군가 실수로
tax자리에price를 넣으면 컴파일러가 즉시 차단합니다. 이는 주석보다 강력한 살아있는 문서가 됩니다.
주의사항: 브랜디드 타입의 한계와 트레이드오프
- 직렬화(Serialization) 이슈: JSON으로 데이터를 주고받을 때 브랜드 정보는 사라집니다. API 응답을 받은 직후 다시 브랜딩(Zod 등으로 재검증)을 거쳐야 합니다.
- 수학 연산의 취약점:
number기반 브랜디드 타입끼리 더하기 연산을 하면 결과값은 일반number가 됩니다. 다시 브랜딩을 해줘야 하는 번거로움이 있습니다. - 학습 곡선: 팀원 모두가 이 기법을 이해하지 못하면 코드 베이스에 불필요한 복잡성을 더할 수 있습니다.
자주 묻는 질문(FAQ)
Q. Zod의 .brand()와 수동 브랜딩 중 무엇이 더 좋나요?
프로젝트에서 이미 Zod를 사용 중이라면 .brand()가 훨씬 생산적입니다. 라이브러리 의존성을 줄이고 싶거나 아주 가벼운 체크만 필요하다면 수동 패턴을 권장합니다.
Q. 템플릿 리터럴 타입은 런타임 성능에 영향이 없나요?
네, 브랜디드 타입과 마찬가지로 템플릿 리터럴 타입 역시 컴파일 타임에만 존재합니다. 실제 실행 시에는 일반 문자열로 동작하므로 성능 걱정은 하지 않으셔도 됩니다.
Q. 모든 숫자에 브랜딩을 해야 할까요?
아니요. “의미가 혼용될 여지가 있는” 경우에만 적용하세요. 예를 들어 반복문의 i 같은 인덱스에 브랜딩을 하는 것은 과도한 엔지니어링(Over-engineering)일 수 있습니다.
더 깊이 있는 타입 설계가 궁금하시다면 TypeScript 공식 문서: Template Literal Types를 정독해 보시는 것을 추천합니다.
댓글을 불러오는 중...