타입 시스템으로 개발 생산성 높이기 — 도메인 모델링편
서론. 타입으로 도메인 룰 표현하기
이전 글에서 타입 안전성있는 개발 환경을 구축하는 방법에 대해 이야기했다.
이번에는 도메인 룰을 타입 시스템으로 검증하는 방법을 알아보자.
도메인 룰이 뭘까? 도메인 룰은 비즈니스에서 반드시 지켜져야 하는 핵심 규칙들이다.
- 상품의 재고는 음수가 될 수 없다
- 계좌 잔고는 마이너스가 될 수 없다 (당좌대출 계좌 제외)
- 주문 취소는 배송 시작 전에만 가능하다
그리고 이렇게 특정 비즈니스 영역(도메인)의 주요 개념, 규칙, 관계를 표현하는 추상화된 모델을 도메인 모델이라고 부른다.
도메인 모델을 정의하는 과정에서 도메인 룰을 타입 수준으로 표현한다면 컴파일 단계에서 많은 오류를 방지할 수 있을 것이다.
도메인 규칙을 어떻게 타입 수준으로 표현할까? 여기에는 ADT, Brand Type같은 전략이 있다.
ADT, Algebraic Data Type
대수적 타입(ADT)은 union type과 object type을 조합한 것이다.
예시를 함께 보자.
Product Type (Intersection Type)
곱타입을 사용하면 일관성있는 규칙을 재사용할 수 있다.
export type OrderStatus = "PENDING" | "PROCESSING" | "SHIPPED" | "DELIVERED";
interface OrderBase<T extends OrderStatus> {
id: string;
status: T;
}
interface OrderPending extends OrderBase<"PENDING"> {}
interface OrderProcessing extends OrderBase<"PROCESSING"> {
startedAt: Date;
}
interface OrderShipped extends OrderBase<"SHIPPED"> {
trackingNumber: string;
}
interface OrderDelivered extends OrderBase<"DELIVERED"> {
deliveredAt: Date;
}
Sum Type (Union Type)
합타입을 사용하면 여러 규칙을 하나의 규칙으로 묶을 수 있다.
다음 예시에서는 주문 정보의 상태별 규칙을 하나의 규칙으로 묶고 있다.
// 합타입을 사용하지 않은 모습
interface Order extends OrderBase<OrderStatus> {
startedAt?: Date;
trackingNumber?: string;
deliveredAt?: Date;
}
// 합타입을 사용한 모습
type Order = OrderPending | OrderProcessing | OrderShipped | OrderDelivered;
합타입을 사용하지 않으면 주문 상태와 필수 데이터가 불일치하는 상황이 발생할 수 있다. 예를 들어 배달 완료 주문에 완료 일자가 없는 경우다.
status가 “DELIVERED”이면 배달 완료 상태의 주문 정보다. 하지만 타입 수준에서는 배달 완료 일자 정보가 optional하게 표시되어 있다.
다음의 예시는 합타입을 사용한 경우다. 타입 좁히기(type narrowing)을 통해서 order가 OrderDelivered 타입으로 좁혀진다. 배달 완료 주문 정보에 실제로 존재하는 데이터에 대해서만 타입이 추론되고 있다.
이번에는 “주문 취소 기능”을 생각해보자.
// 주문 취소는 PENDING 상태일 때만 가능
const cancelOrder1 = (order: Order) => {
if (order.status !== "PENDING") {
throw new Error("Cannot cancel non-pending order");
}
// 취소 로직
};
// 타입 수준에서 pending 상태는 제외시킴
const cancelOrder2 = (order: Exclude<Order, OrderPending>) => {
// 취소 로직
};
cancelOrder1은 “주문 취소가 가능한 상태인지 확인”과 “실제 취소 처리”라는 두 가지 책임을 가진다. 따라서 상태 검증과 취소 처리 각각에 대한 테스트가 필요하다.
반면 cancelOrder2는 타입 시스템이 취소 가능한 주문만 받도록 제한하므로, “취소 처리” 자체에 대한 테스트만 필요하다.
Brand Type
Brand Type은 Primitive 타입에 태그를 추가해 새로운 타입으로 구분한다.
type Brand<K, T> = K & { __brand: T };
이렇게 brand type을 사용하면 Primitive 타입에도 추가적인 규칙을 적용할 수 있다. e.g. Email 형식, 양수 데이터
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
간단한 사용 예시를 보자.
이메일, user id는 모두 단순한 문자열 타입이지만, brand type을 사용해서 고유한 규칙이 적용된 타입으로 구분하고 있다.
function sendEmail(email: Email, content: string) {
// 이메일 전송 로직
}
sendEmail("test@example.com", "Hello"); // 컴파일 에러: 일반 문자열은 Email 타입에 할당할 수 없음
const email = "test@example.com" as Email;
sendEmail(email, "Hello");
function getUser2(id: UserId) {}
getUser2(email); // 컴파일 에러: Email 타입은 UserId 타입에 할당할 수 없습니다.
다만 TS에서 brand type을 사용할 때 유의할 점이 있다.
- 실제 런타임 데이터에는 태그가 없으므로 타입 정보와 런타임 데이터가 불일치한 상황이다.
- primitive type을 brand type으로 변환하는 작업이 필요하다.
- brand type이 많아지면 오히려 관리가 어려울 수 있다.
정리
도메인 룰을 잘 설명하는 도메인 모델을 정의하는 것은 매우 중요하다.
게다가 도메인 룰이 타입 수준으로 표현된다면 타입 시스템의 지원을 받을 수 있다.
다만 ADT, BrandType을 무분별하게 남용하면 오히려 도메인 모델이 복잡해질 수 있고, 타입에 대한 관리 비용이 늘어날 수 있다. 따라서 상황에 따라 가장 적절한 방법을 판단하고 적용해야 한다.