본문으로 건너뛰기
Leo Jung
뒤로 가기

[DDD Intro #2] 모델은 현실의 복사본이 아니다

페이지 수정

DDD를 이해하려면 먼저 “모델”이라는 말을 다시 생각해야 한다. 많은 사람이 모델을 현실을 최대한 정확하게 옮긴 것이라고 생각한다. 현실에 회원이 있으니 Member를 만들고, 주문이 있으니 Order를 만들고, 결제가 있으니 Payment를 만든다. 현실에 있는 속성은 되도록 빠짐없이 담으려 한다.

하지만 도메인 모델은 현실의 복사본이 아니다.

모델은 어떤 목적을 위해 현실을 선택적으로 해석한 것이다. 같은 현실이라도 어떤 문제를 풀고 있는지에 따라 전혀 다른 모델이 나올 수 있다. 좋은 모델은 현실을 많이 담은 모델이 아니라, 지금 해결하려는 문제를 잘 설명하고 다루게 해주는 모델이다.

지도는 땅이 아니다

모델을 설명할 때 지도 비유는 유용하다. 지도는 실제 땅이 아니다. 지도는 현실을 축소하고, 생략하고, 강조한다.

지하철 노선도는 실제 지리적 거리와 방향을 정확히 반영하지 않는다. 하지만 지하철을 갈아타는 데에는 매우 좋은 모델이다. 등산 지도는 고도와 등산로를 강조한다. 운전용 내비게이션 지도는 도로와 교통 정보를 강조한다. 같은 지역을 표현하지만 목적에 따라 모델은 달라진다.

도메인 모델도 마찬가지다.

구독 서비스에서 “회원”이라는 현실의 사람을 생각해보자. 한 사람은 이메일 주소를 갖고 있고, 이름과 전화번호도 있고, 결제 수단도 있고, 구독 이력도 있고, 고객센터 문의 이력도 있다. 현실을 그대로 복사하려 한다면 거대한 User 객체가 만들어질 것이다.

User
- id
- name
- email
- phoneNumber
- password
- paymentMethods
- subscriptions
- loginHistory
- coupons
- customerSupportTickets
- marketingAgreements
- notificationSettings
- ...

처음에는 편해 보인다. 모든 정보가 한 곳에 있으니 재사용하기 좋아 보인다. 하지만 시간이 지나면 이 객체는 아무도 정확히 이해하지 못하는 거대한 덩어리가 된다.

왜 그럴까? 하나의 현실을 하나의 모델로 복사하려 했기 때문이다.

목적이 다르면 모델도 달라진다

같은 사람이라도 맥락에 따라 중요하게 보는 것이 다르다.

인증 컨텍스트에서는 이 사람은 로그인 가능한 계정이다. 중요한 것은 이메일, 비밀번호, 인증 수단, 잠금 상태다.

구독 컨텍스트에서는 이 사람은 구독자다. 중요한 것은 현재 구독 중인지, 어떤 요금제를 사용 중인지, 갱신일이 언제인지, 이용 권한이 있는지다.

결제 컨텍스트에서는 이 사람은 결제 주체다. 중요한 것은 결제 수단, 청구 정보, 실패한 결제 이력, 환불 가능 여부다.

고객센터 컨텍스트에서는 이 사람은 문의자다. 중요한 것은 문의 이력, 처리 상태, 상담 메모, 보상 정책이다.

이 모든 맥락에서 같은 User라는 모델을 쓰려 하면 문제가 생긴다. 어떤 팀은 로그인 잠금 상태를 바꾸기 위해 User를 수정하고, 어떤 팀은 구독 갱신 정책을 추가하기 위해 User를 수정하고, 어떤 팀은 고객센터 메모를 추가하기 위해 User를 수정한다.

결국 User는 모두의 것이면서 아무의 것도 아닌 객체가 된다.

DDD에서는 이런 상황을 피하기 위해 모델이 유효한 경계를 중요하게 본다. 모델은 특정한 문제를 해결하는 맥락 안에서 의미를 갖는다. 현실에 한 사람이 있다고 해서 소프트웨어 안에서도 반드시 하나의 모델이어야 하는 것은 아니다.

좋은 모델은 과감히 버린다

모델링은 현실을 많이 담는 일이 아니라, 무엇을 버릴지 결정하는 일에 가깝다.

구독 갱신을 다루는 모델에서 사용자의 프로필 사진은 필요하지 않다. 고객센터 문의를 다루는 모델에서 사용자의 비밀번호 해시값은 필요하지 않다. 결제 실패 재시도를 다루는 모델에서 마케팅 수신 동의 여부는 핵심 정보가 아니다.

필요 없는 것을 버려야 중요한 것이 보인다.

예를 들어 구독 컨텍스트에서 중요한 모델은 다음처럼 단순할 수 있다.

public class Subscription {
    private SubscriptionId id;
    private SubscriberId subscriberId;
    private PlanId planId;
    private SubscriptionStatus status;
    private Period currentPeriod;
    private RenewalPolicy renewalPolicy;

    public void renew(PaymentResult paymentResult) {
        // 갱신 규칙
    }

    public void cancel(CancelReason reason) {
        // 해지 규칙
    }

    public boolean allowsAccess(LocalDateTime at) {
        // 이용 권한 판단
    }
}

이 모델은 현실의 사용자를 모두 설명하지 않는다. 하지만 구독이라는 문제를 다루기에는 더 좋다. 구독 상태, 이용 기간, 갱신 정책, 해지 규칙이 모델 안에 드러난다.

반대로 모든 사용자 정보를 가진 User 모델은 현실을 더 많이 담고 있을지 모르지만, 구독 문제를 설명하는 데에는 오히려 방해가 될 수 있다.

데이터 모델과 도메인 모델은 다르다

모델을 현실의 복사본으로 오해하는 또 다른 이유는 데이터베이스 중심의 사고 때문이다. 우리는 자주 테이블을 먼저 설계하고, 그 테이블을 객체로 매핑한다.

users
subscriptions
payments
plans

테이블은 중요하다. 시스템은 결국 데이터를 저장해야 한다. 하지만 테이블 구조가 곧 도메인 모델은 아니다.

데이터 모델은 저장과 조회에 관심이 있다. 정규화, 인덱스, 외래 키, 성능, 쿼리 패턴이 중요하다. 도메인 모델은 의미와 행위에 관심이 있다. 규칙, 상태 변화, 불변식, 책임이 중요하다.

둘이 완전히 분리되어야 한다는 뜻은 아니다. 현실적으로 ORM을 사용하면 도메인 객체와 영속성 모델이 어느 정도 연결된다. 다만 둘을 같은 것으로 착각하면 도메인 모델은 쉽게 빈 데이터 객체가 된다.

예를 들어 다음 코드는 데이터 중심 모델에 가깝다.

subscription.setStatus(SubscriptionStatus.CANCELED);
subscription.setCanceledAt(now);
subscription.setCancelReason(reason);

반면 다음 코드는 도메인 행위를 표현한다.

subscription.cancel(reason, now);

두 코드는 비슷한 데이터를 저장할 수 있다. 하지만 의미는 다르다. 첫 번째 코드는 필드를 바꾸는 절차를 외부에 노출한다. 두 번째 코드는 구독에게 해지라는 행위를 요청한다. 해지 가능한지, 해지 시 어떤 시간이 기록되어야 하는지, 어떤 이벤트가 발생해야 하는지는 Subscription이 책임질 수 있다.

도메인 모델은 데이터를 담는 그릇이 아니라, 도메인 규칙을 표현하는 언어다.

모델은 코드와 분리되어 있지 않다

가끔 모델링을 문서나 다이어그램 작업으로만 생각하기도 한다. 회의실에서 도메인 전문가와 이야기하며 용어를 정리하고, 화이트보드에 박스와 화살표를 그린다. 이 과정은 매우 중요하다.

하지만 DDD에서 모델은 문서에서 끝나지 않는다. 모델은 코드에 반영되어야 한다. 문서에는 구독 해지라고 되어 있는데 코드에는 updateStatus(CANCELED)만 있다면, 모델은 코드에 살아 있지 않은 것이다.

좋은 모델은 대화와 코드 사이를 오갈 수 있어야 한다.

도메인 전문가가 “유료 구독자는 갱신일 전에 해지해도 남은 기간 동안은 이용할 수 있어요”라고 말한다면, 코드에서도 그 개념이 드러나야 한다.

subscription.cancelAtPeriodEnd(reason);

또는 다음처럼 정책 객체가 드러날 수도 있다.

AccessPeriod accessPeriod = subscription.cancel(policy, reason);

정답은 하나가 아니다. 중요한 것은 도메인에서 중요한 개념이 코드에서도 이름을 갖고 책임을 갖는다는 점이다.

모델은 발견되는 것이다

처음부터 좋은 모델을 만들 수 있다고 기대하면 DDD는 부담스럽다. 현실에서는 처음 만든 모델이 대부분 부족하다. 도메인 이해가 얕기 때문이다.

처음에는 Subscription에 모든 것을 넣었다가, 시간이 지나면서 BillingAgreement, Entitlement, Renewal, RefundPolicy 같은 개념이 분리될 수 있다. 처음에는 cancel() 하나면 충분해 보였지만, 나중에는 cancelImmediately(), cancelAtPeriodEnd(), suspendDueToPaymentFailure()처럼 구분될 수 있다.

이 변화는 실패가 아니다. 오히려 모델이 도메인 이해를 따라 진화하고 있다는 신호다.

문제는 처음 만든 모델을 고정된 설계로 믿는 것이다. 도메인은 계속 드러난다. 운영 이슈, 예외 케이스, 정책 변경, 도메인 전문가와의 대화를 통해 우리가 몰랐던 개념이 나타난다. 모델은 그때마다 정제되어야 한다.

좋은 모델링은 한 번에 완성하는 작업이 아니라, 계속 더 나은 이름과 경계를 찾아가는 과정이다.

현실을 닮은 모델보다 문제를 푸는 모델

현실을 그대로 복사하려는 모델은 처음에는 직관적이다. 하지만 소프트웨어는 현실 전체를 담기 위해 존재하지 않는다. 소프트웨어는 특정한 문제를 해결하기 위해 존재한다.

따라서 좋은 도메인 모델을 판단할 때는 이런 질문을 던져야 한다.

이 모델은 지금 해결하려는 문제를 잘 설명하는가?
도메인 전문가와 대화할 때 쓰는 말이 코드에도 드러나는가?
중요한 규칙이 모델 안에 모여 있는가?
불필요한 정보 때문에 핵심 개념이 흐려지지는 않는가?
이 모델이 유효한 경계는 어디인가?

현실에 더 가까운 모델이 항상 좋은 모델은 아니다. 때로는 현실보다 단순해야 한다. 때로는 현실과 다른 방식으로 나누어야 한다. 때로는 같은 현실을 여러 모델로 표현해야 한다.

구독 서비스의 한 사용자는 인증 컨텍스트에서는 Account이고, 구독 컨텍스트에서는 Subscriber이며, 결제 컨텍스트에서는 Payer일 수 있다. 이것은 중복이 아니라 목적에 맞는 모델링이다.

마치며

모델은 현실의 복사본이 아니다. 모델은 도메인을 이해하고, 특정한 문제를 해결하기 위해 현실을 선택적으로 해석한 결과다.

그래서 모델링은 단순히 명사를 찾아 클래스로 만드는 일이 아니다. 모델링은 무엇을 중요하게 볼지, 무엇을 버릴지, 어떤 이름을 붙일지, 어떤 경계 안에서 일관성을 유지할지 결정하는 일이다.

DDD에서 좋은 모델은 문서 속에만 있지 않다. 좋은 모델은 코드 속에서 도메인 언어로 살아 움직인다. 도메인 전문가가 말하는 규칙이 객체의 행위와 이름으로 드러나고, 중요한 개념이 우연한 필드가 아니라 명시적인 타입과 책임으로 표현된다.

다음 글에서는 이 모델을 가능하게 하는 가장 중요한 기반인 유비쿼터스 언어를 다룬다. 좋은 설계는 좋은 언어에서 시작된다.


페이지 수정
공유하기:

이전 글
[DDD Intro #3] 유비쿼터스 언어: 좋은 설계는 좋은 언어에서 시작된다
다음 글
[DDD Intro #1] DDD는 기술이 아니라 관점이다