불변식을 타입으로 DDD와 대수적 타입

소프트웨어 개발의 핵심은 "복잡성 관리"입니다.
그중에서도 DDD는 비즈니스 로직을 어떻게 안전하게 보호할 것인가를 고민하며,
대수적 타입은 그 로직을 '런타임 에러'가 아닌 '컴파일 타임의 구조'로 강제하는 강력한 도구를 제공합니다.

오늘은 이 두 개념을 연결하여 "말이 안 되는 상태"를 원천 차단하는 설계 기법을 알아보겠습니다.


1. 도메인의 구성 요소와 '불변식'

도메인을 설계할 때 우리는 흔히 축구 경기에 비유하곤 합니다.

  • 엔티티: 손흥민, 이강인 (식별자가 중요한 객체)
  • 값 객체: 점수, 골대 크기 (상태 그 자체가 중요한 객체)
  • 비즈니스 로직: 드리블, 패스, 슈팅 (행위)
  • 불변식: "손으로 공을 건드리면 안 된다", "라인을 넘어야 골이다"

여기서 가장 중요한 것이 불변식입니다.
불변식은 "언제나 참이어야 하는 조건"입니다.
이 불변식을 유지하기 위해 우리는 관련 객체들을 애그리게이트라는 컨테이너에 가둡니다.

설계 팁: 엔티티인가 VO인가?

  • 데이터가 무한히 늘어나는가? → 엔티티
  • 동시에 여러 곳에서 수정되는가? → 엔티티
  • 정합성이 깨지면 시스템이 망하는가? → VO로 묶어서 원자적으로 처리

2. 대수적 곱연산과 합연산 타입

타입은 수학적으로 집합입니다.
타입을 어떻게 조합하느냐에 따라 우리가 관리해야 할 '경우의 수'가 결정됩니다.

곱타입 - AND

클래스나 인터페이스처럼 속성을 나열하는 방식입니다.

  • class User(name: String, active: Boolean)
  • 경우의 수: String × Boolean = 무한
  • 속성이 늘어날수록 경우의 수가 기하급수적으로 폭발하며, "잘못된 상태"가 끼어들 틈이 많아집니다.

합타입 - OR

enum이나 Union Type처럼 "이것 아니면 저것"인 방식입니다.

  • type Result = Success | Failure
  • 경우의 수: Success + Failure = 2
  • 경우의 수가 산술적으로만 늘어나므로 상태 제어가 매우 쉽습니다.

3. 결제 모델링

잘못된 곱타입 설계와 올바른 합타입 설계를 비교해 봅시다.

곱타입 위주 나쁜 설계

class Payment {
  status: 'paid' | 'pending';
  paidDate?: Date; // 결제 전에는 null, 결제 후에는 Date
}
  • 카디널리티: 2 * (Date + 1)
  • 문제점: pending이면서 paidDate가 존재하는 "불가능한 상태"가 표현 가능합니다. 이를 막기 위해 런타임에 if 문으로 체크하는 코드가 도처에 깔리게 됩니다.

합타입으로 불변식 강제 좋은 설계

type Payment = PendingPayment | PaidPayment;

interface PendingPayment {
  status: 'pending';
}

interface PaidPayment {
  status: 'paid';
  paidDate: Date; // 이제 date는 필수입니다.
}
  • 카디널리티: 1 + Date
  • 장점: Pending 상태일 때는 아예 paidDate에 접근조차 할 수 없습니다. 불변식이 구조적으로 보장됩니다.

4. TypeScript의 브랜드 타입 기교

타입스크립트는 '구조적 타이핑'을 따릅니다. 즉, number 타입은 어디서든 number입니다. 하지만 UserIdOrderId가 같은 number라고 해서 서로 섞여도 될까요?

이를 방지하기 위해 브랜드 타입 기법을 사용합니다.

// 공용 브랜드 유틸리티
declare const __brand: unique symbol;
type Brand<T, ID> = T & { readonly [__brand]: ID };

type OrderId = Brand<number, "OrderId">;
type UserId = Brand<number, "UserId">;

// 컴파일 타임에만 존재하는 캐스팅 함수
function createOrderId(id: number): OrderId {
  return id as OrderId;
}

const oid = createOrderId(123);
const uid: UserId = 123; // Error! 

이 기법을 사용하면 원시 타입에도 비즈니스적 의미를 부여하고, 컴파일 타임에 실수를 완벽히 차단할 수 있습니다.


5. 불변식 처리기의 위치와 구현

모든 불변식을 타입으로 다 표현할 수는 없습니다. 이때는 애그리게이트 내부에서 명시적으로 체크해야 합니다.

상시 불변식 vs 특정 시점 불변식

  • 상시 불변식: 애그리게이트 내에서 트랜잭션 단위로 즉시 보호.
    예를 들어서 주문 금액은 0보다 커야 한다
  • 특정 시점 불변식: 이벤트나 도메인 서비스를 통해 사후에 검증.
    예를들어서 마케팅 수신 동의 시 쿠폰 발송이 있다

Kotlin 스타일 불변식 처리 일반화 코드 (비교 설명)

abstract class Aggregate<T : Aggregate<T>> {
    protected var invariants = listOf<(T) -> Boolean>()
    
    protected fun checkInvariants() {
        if (!invariants.all { it(this as T) }) throw IllegalStateException("불변식 위반!")
    }

    abstract fun clone(): T
}

class Order(val status: Status, val price: Long) : Aggregate<Order>() {
    init {
        invariants = listOf({ it.price > 0 }) // 불변식 정의
        checkInvariants()
    }

    fun applyDiscount(amount: Long): Order {
        val next = this.clone(price = this.price - amount)
        next.checkInvariants() // 상태 변경 후 즉시 검증
        return next
    }
}

마무리하며

도메인 설계의 핵심은 "불변식을 어디에 가둘 것인가"이며, 대수적 타입은 그 불변식을 "런타임이 아닌 컴파일 타임으로 끌어올리는 도구"입니다.

  1. 최대한 VO와 합타입을 사용하여 상태 폭발을 막아야 합니다.
  2. 도저히 타입으로 안 되는 것만 애그리게이트의 불변식 체크로 보호해야하기때문에 반드시 사용해야 합니다.
  3. 타입스크립트의 브랜드 타입 같은 기교를 활용해 기본형 타입의 안정성을 높여야 합니다.

이러한 습관이 모여 테스트 코드가 줄어들고, 배포가 두렵지 않은 견고한 시스템을 만듭니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글