구조적 타입 시스템에서 명목적 타이핑을 구현하는 강력한 기법인 TypeScript 브랜디드 타입을 알아보세요. 타입 안정성과 코드 명확성을 향상시키는 방법을 배울 수 있습니다.
TypeScript 브랜디드 타입: 구조적 시스템에서의 명목적 타이핑
TypeScript의 구조적 타입 시스템은 유연성을 제공하지만 때로는 예기치 않은 동작으로 이어질 수 있습니다. 브랜디드 타입(Branded types)은 명목적 타이핑(nominal typing)을 강제하여 타입 안정성과 코드 명확성을 향상시키는 방법을 제공합니다. 이 글에서는 브랜디드 타입을 심층적으로 탐구하고, 구현을 위한 실용적인 예제와 모범 사례를 제공합니다.
구조적 타이핑 vs. 명목적 타이핑 이해하기
브랜디드 타입에 대해 알아보기 전에, 구조적 타이핑과 명목적 타이핑의 차이점을 명확히 짚고 넘어가겠습니다.
구조적 타이핑 (덕 타이핑)
구조적 타입 시스템에서는 두 타입이 동일한 구조(즉, 동일한 속성과 타입을 가짐)를 가질 경우 호환되는 것으로 간주합니다. TypeScript는 구조적 타이핑을 사용합니다. 다음 예제를 살펴보세요:
interface Point {
x: number;
y: number;
}
interface Vector {
x: number;
y: number;
}
const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // TypeScript에서 유효함
console.log(vector.x); // 출력: 10
Point
와 Vector
가 별개의 타입으로 선언되었음에도 불구하고, TypeScript는 두 타입이 동일한 구조를 공유하기 때문에 Point
객체를 Vector
변수에 할당하는 것을 허용합니다. 이는 편리할 수 있지만, 우연히 동일한 형태를 가진 논리적으로 다른 타입들을 구별해야 할 때 오류로 이어질 수 있습니다. 예를 들어, 위도/경도 좌표가 우연히 화면 픽셀 좌표와 일치하는 경우를 생각할 수 있습니다.
명목적 타이핑
명목적 타입 시스템에서는 타입들이 동일한 이름을 가질 경우에만 호환되는 것으로 간주합니다. 두 타입이 동일한 구조를 가지더라도 이름이 다르면 별개의 타입으로 취급됩니다. Java나 C#과 같은 언어들이 명목적 타이핑을 사용합니다.
브랜디드 타입의 필요성
TypeScript의 구조적 타이핑은 값의 구조와 상관없이 특정 타입에 속한다는 것을 보장해야 할 때 문제가 될 수 있습니다. 예를 들어, 통화를 표현하는 경우를 생각해 봅시다. USD와 EUR에 대해 다른 타입을 가질 수 있지만, 둘 다 숫자로 표현될 수 있습니다. 이를 구별할 메커니즘이 없다면, 실수로 잘못된 통화에 대한 연산을 수행할 수 있습니다.
브랜디드 타입은 구조적으로는 유사하지만 타입 시스템에 의해 다르게 취급되는 별개의 타입을 생성할 수 있게 하여 이 문제를 해결합니다. 이는 타입 안정성을 향상시키고 다른 방법으로는 놓칠 수 있는 오류를 방지합니다.
TypeScript에서 브랜디드 타입 구현하기
브랜디드 타입은 교차 타입(intersection types)과 고유한 심볼(symbol) 또는 문자열 리터럴을 사용하여 구현됩니다. 아이디어는 타입에 "브랜드"를 추가하여 동일한 구조를 가진 다른 타입과 구별하는 것입니다.
심볼 사용하기 (권장)
브랜딩에 심볼을 사용하는 것이 일반적으로 선호되는데, 심볼은 고유성이 보장되기 때문입니다.
const USD = Symbol('USD');
type USD = number & { readonly [USD]: unique symbol };
const EUR = Symbol('EUR');
type EUR = number & { readonly [EUR]: unique symbol };
function createUSD(value: number): USD {
return value as USD;
}
function createEUR(value: number): EUR {
return value as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);
const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);
// 다음 줄의 주석을 해제하면 타입 오류가 발생합니다
// const invalidOperation = addUSD(usd1, eur1);
이 예제에서 USD
와 EUR
는 number
타입에 기반한 브랜디드 타입입니다. unique symbol
은 이 타입들이 서로 구별되도록 보장합니다. createUSD
와 createEUR
함수는 이러한 타입의 값을 생성하는 데 사용되며, addUSD
함수는 USD
값만 받습니다. USD
값에 EUR
값을 더하려고 시도하면 타입 오류가 발생합니다.
문자열 리터럴 사용하기
문자열 리터럴을 브랜딩에 사용할 수도 있지만, 문자열 리터럴은 고유성이 보장되지 않기 때문에 심볼을 사용하는 방법보다 덜 견고합니다.
type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };
function createUSD(value: number): USD {
return value as USD;
}
function createEUR(value: number): EUR {
return value as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);
const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);
// 다음 줄의 주석을 해제하면 타입 오류가 발생합니다
// const invalidOperation = addUSD(usd1, eur1);
이 예제는 이전 예제와 동일한 결과를 달성하지만, 심볼 대신 문자열 리터럴을 사용합니다. 더 간단하지만, 브랜딩에 사용되는 문자열 리터럴이 코드베이스 내에서 고유하도록 보장하는 것이 중요합니다.
실용적인 예제 및 사용 사례
브랜디드 타입은 구조적 호환성을 넘어 타입 안정성을 강제해야 하는 다양한 시나리오에 적용될 수 있습니다.
ID
UserID
, ProductID
, OrderID
와 같이 다양한 유형의 ID가 있는 시스템을 생각해 보세요. 이 모든 ID는 숫자나 문자열로 표현될 수 있지만, 서로 다른 ID 유형이 실수로 섞이는 것을 방지하고 싶을 것입니다.
const UserIDBrand = Symbol('UserID');
type UserID = string & { readonly [UserIDBrand]: unique symbol };
const ProductIDBrand = Symbol('ProductID');
type ProductID = string & { readonly [ProductIDBrand]: unique symbol };
function getUser(id: UserID): { name: string } {
// ... 사용자 데이터 가져오기
return { name: "Alice" };
}
function getProduct(id: ProductID): { name: string, price: number } {
// ... 제품 데이터 가져오기
return { name: "Example Product", price: 25 };
}
function createUserID(id: string): UserID {
return id as UserID;
}
function createProductID(id: string): ProductID {
return id as ProductID;
}
const userID = createUserID('user123');
const productID = createProductID('product456');
const user = getUser(userID);
const product = getProduct(productID);
console.log("User:", user);
console.log("Product:", product);
// 다음 줄의 주석을 해제하면 타입 오류가 발생합니다
// const invalidCall = getUser(productID);
이 예제는 브랜디드 타입이 UserID
를 기대하는 함수에 ProductID
를 전달하는 것을 어떻게 방지하여 타입 안정성을 향상시키는지를 보여줍니다.
도메인 특정 값
브랜디드 타입은 제약 조건이 있는 도메인 특정 값을 나타내는 데도 유용할 수 있습니다. 예를 들어, 항상 0과 100 사이여야 하는 백분율 타입을 가질 수 있습니다.
const PercentageBrand = Symbol('Percentage');
type Percentage = number & { readonly [PercentageBrand]: unique symbol };
function createPercentage(value: number): Percentage {
if (value < 0 || value > 100) {
throw new Error('백분율은 0과 100 사이여야 합니다');
}
return value as Percentage;
}
function applyDiscount(price: number, discount: Percentage): number {
return price * (1 - discount / 100);
}
try {
const discount = createPercentage(20);
const discountedPrice = applyDiscount(100, discount);
console.log("할인된 가격:", discountedPrice);
// 다음 줄의 주석을 해제하면 런타임 오류가 발생합니다
// const invalidPercentage = createPercentage(120);
} catch (error) {
console.error(error);
}
이 예제는 런타임에 브랜디드 타입의 값에 대한 제약 조건을 강제하는 방법을 보여줍니다. 타입 시스템이 Percentage
값이 항상 0과 100 사이임을 보장할 수는 없지만, createPercentage
함수는 런타임에 이 제약 조건을 강제할 수 있습니다. io-ts와 같은 라이브러리를 사용하여 브랜디드 타입의 런타임 유효성 검사를 강제할 수도 있습니다.
날짜 및 시간 표현
날짜와 시간 작업은 다양한 형식과 시간대 때문에 까다로울 수 있습니다. 브랜디드 타입은 서로 다른 날짜 및 시간 표현을 구별하는 데 도움이 될 수 있습니다.
const UTCDateBrand = Symbol('UTCDate');
type UTCDate = string & { readonly [UTCDateBrand]: unique symbol };
const LocalDateBrand = Symbol('LocalDate');
type LocalDate = string & { readonly [LocalDateBrand]: unique symbol };
function createUTCDate(dateString: string): UTCDate {
// 날짜 문자열이 UTC 형식인지 확인 (예: Z가 있는 ISO 8601)
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
throw new Error('유효하지 않은 UTC 날짜 형식입니다');
}
return dateString as UTCDate;
}
function createLocalDate(dateString: string): LocalDate {
// 날짜 문자열이 현지 날짜 형식인지 확인 (예: YYYY-MM-DD)
if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
throw new Error('유효하지 않은 현지 날짜 형식입니다');
}
return dateString as LocalDate;
}
function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
// 시간대 변환 수행
const date = new Date(utcDate);
const localDateString = date.toLocaleDateString();
return createLocalDate(localDateString);
}
try {
const utcDate = createUTCDate('2024-01-20T10:00:00.000Z');
const localDate = convertUTCDateToLocalDate(utcDate);
console.log("UTC Date:", utcDate);
console.log("Local Date:", localDate);
} catch (error) {
console.error(error);
}
이 예제는 UTC 날짜와 현지 날짜를 구별하여 애플리케이션의 다른 부분에서 올바른 날짜 및 시간 표현으로 작업하고 있음을 보장합니다. 런타임 유효성 검사를 통해 올바른 형식의 날짜 문자열만 이러한 타입에 할당될 수 있도록 합니다.
브랜디드 타입 사용을 위한 모범 사례
TypeScript에서 브랜디드 타입을 효과적으로 사용하려면 다음 모범 사례를 고려하세요:
- 브랜딩에 심볼 사용: 심볼은 고유성에 대한 가장 강력한 보장을 제공하여 타입 오류의 위험을 줄입니다.
- 헬퍼 함수 생성: 헬퍼 함수를 사용하여 브랜디드 타입의 값을 생성하세요. 이는 유효성 검사를 위한 중앙 지점을 제공하고 일관성을 보장합니다.
- 런타임 유효성 검사 적용: 브랜디드 타입은 타입 안정성을 향상시키지만, 런타임에 잘못된 값이 할당되는 것을 막지는 못합니다. 런타임 유효성 검사를 사용하여 제약 조건을 강제하세요.
- 브랜디드 타입 문서화: 코드 유지보수성을 향상시키기 위해 각 브랜디드 타입의 목적과 제약 조건을 명확하게 문서화하세요.
- 성능 영향 고려: 브랜디드 타입은 교차 타입과 헬퍼 함수의 필요성 때문에 약간의 오버헤드를 유발합니다. 코드의 성능이 중요한 부분에서는 성능 영향을 고려하세요.
브랜디드 타입의 장점
- 향상된 타입 안정성: 구조적으로는 유사하지만 논리적으로 다른 타입이 실수로 섞이는 것을 방지합니다.
- 개선된 코드 명확성: 타입을 명시적으로 구별하여 코드를 더 읽기 쉽고 이해하기 쉽게 만듭니다.
- 오류 감소: 컴파일 시간에 잠재적인 오류를 잡아내어 런타임 버그의 위험을 줄입니다.
- 향상된 유지보수성: 관심사를 명확하게 분리하여 코드 유지보수 및 리팩토링을 더 쉽게 만듭니다.
브랜디드 타입의 단점
- 복잡성 증가: 특히 많은 브랜디드 타입을 다룰 때 코드베이스에 복잡성을 더합니다.
- 런타임 오버헤드: 헬퍼 함수와 런타임 유효성 검사의 필요성으로 인해 약간의 런타임 오버헤드가 발생합니다.
- 보일러플레이트 가능성: 특히 브랜디드 타입을 생성하고 유효성을 검사할 때 보일러플레이트 코드가 발생할 수 있습니다.
브랜디드 타입의 대안
브랜디드 타입은 TypeScript에서 명목적 타이핑을 달성하는 강력한 기법이지만, 고려해 볼 만한 다른 대안들도 있습니다.
불투명 타입(Opaque Types)
불투명 타입은 브랜디드 타입과 유사하지만 기본 타입을 숨기는 더 명시적인 방법을 제공합니다. TypeScript는 불투명 타입을 내장 지원하지 않지만, 모듈과 비공개 심볼을 사용하여 흉내 낼 수 있습니다.
클래스
클래스를 사용하면 별개의 타입을 정의하는 데 더 객체 지향적인 접근 방식을 제공할 수 있습니다. TypeScript에서 클래스는 구조적으로 타이핑되지만, 더 명확한 관심사 분리를 제공하며 메서드를 통해 제약 조건을 강제하는 데 사용될 수 있습니다.
`io-ts` 또는 `zod`와 같은 라이브러리
이러한 라이브러리들은 정교한 런타임 타입 유효성 검사를 제공하며, 브랜디드 타입과 결합하여 컴파일 시간과 런타임 모두의 안정성을 보장할 수 있습니다.
결론
TypeScript 브랜디드 타입은 구조적 타입 시스템에서 타입 안정성과 코드 명확성을 향상시키는 귀중한 도구입니다. 타입에 "브랜드"를 추가함으로써, 명목적 타이핑을 강제하고 구조적으로는 유사하지만 논리적으로 다른 타입이 실수로 섞이는 것을 방지할 수 있습니다. 브랜디드 타입은 약간의 복잡성과 오버헤드를 유발하지만, 향상된 타입 안정성과 코드 유지보수성이라는 이점이 종종 단점을 능가합니다. 값의 구조와 상관없이 특정 타입에 속한다는 것을 보장해야 하는 시나리오에서 브랜디드 타입을 사용하는 것을 고려해 보세요.
구조적 타이핑과 명목적 타이핑의 원리를 이해하고 이 글에서 설명한 모범 사례를 적용함으로써, 브랜디드 타입을 효과적으로 활용하여 더 견고하고 유지보수하기 쉬운 TypeScript 코드를 작성할 수 있습니다. 통화 및 ID 표현부터 도메인 특정 제약 조건 강제에 이르기까지, 브랜디드 타입은 프로젝트의 타입 안정성을 향상시키는 유연하고 강력한 메커니즘을 제공합니다.
TypeScript로 작업하면서 타입 유효성 검사 및 강제를 위해 사용 가능한 다양한 기법과 라이브러리를 탐색해 보세요. 포괄적인 타입 안정성 접근 방식을 달성하기 위해 io-ts
나 zod
와 같은 런타임 유효성 검사 라이브러리와 함께 브랜디드 타입을 사용하는 것을 고려해 보세요.