DDD를 공부하고 나면 설계가 더 좋아질 것처럼 느껴진다. Entity, Value Object, Aggregate, Repository, Domain Event 같은 개념을 익히고 나면 복잡한 비즈니스 로직도 깔끔하게 정리될 것 같다.
하지만 현실에서는 반대의 일도 자주 일어난다. DDD를 도입했는데 코드가 더 복잡해진다. 클래스는 많아졌고, 패키지도 나뉘었고, 인터페이스도 늘어났지만 정작 도메인 로직은 여전히 Service 클래스에 흩어져 있다. Aggregate는 너무 커져서 수정하기 어렵고, Repository는 DAO와 다르지 않으며, 유비쿼터스 언어는 문서에만 남아 있다.
그러다 팀은 말한다.
DDD는 너무 어렵다.
DDD는 우리에게 맞지 않는다.
DDD를 하니까 생산성이 떨어졌다.
그럴 수 있다. DDD는 모든 문제에 어울리는 방법이 아니다. 하지만 많은 실패는 DDD 자체의 실패라기보다, DDD를 적용하는 방식의 실패에 가깝다.
DDD가 실패하는 가장 흔한 이유는 복잡성을 다루기 위한 사고방식을 패턴 적용법으로 오해하기 때문이다.
1. 패턴부터 시작한다
DDD 도입이 실패하는 가장 흔한 출발점은 패턴부터 시작하는 것이다.
Entity는 어떻게 만들지?
Value Object는 어디까지 분리하지?
Aggregate Root는 무엇으로 정하지?
Repository 인터페이스는 어디에 두지?
Domain Event는 언제 발행하지?
이 질문들은 중요하다. 하지만 첫 질문이 되어서는 안 된다.
DDD의 첫 질문은 이런 것이어야 한다.
우리는 어떤 도메인 문제를 풀고 있는가?
이 도메인에서 가장 중요한 규칙은 무엇인가?
도메인 전문가들은 이 문제를 어떤 언어로 설명하는가?
같은 단어가 서로 다른 의미로 쓰이는 곳은 어디인가?
어디에 가장 많은 설계 에너지를 써야 하는가?
패턴부터 시작하면 DDD는 객체지향 설계 규칙 모음처럼 변한다. 그러면 코드에는 AggregateRoot, ValueObject, Repository라는 이름이 생기지만, 도메인 지식은 여전히 코드 바깥에 남는다.
전술적 패턴은 도메인 이해의 결과여야 한다. 도메인을 충분히 이해하지 못한 상태에서 패턴을 먼저 적용하면, 멋진 이름을 가진 빈 껍데기가 만들어진다.
2. 도메인 전문가 없이 개발자끼리 모델링한다
DDD는 도메인 전문가와 개발자가 함께 모델을 만들어가는 방식이다. 그런데 실무에서는 개발자들끼리 모여 모델링하는 경우가 많다.
개발자 입장에서는 구독 상태가 이렇게 보일 수 있다.
ACTIVE
CANCELED
EXPIRED
하지만 도메인 전문가와 이야기하면 더 많은 차이가 드러난다.
무료 체험 중
유료 구독 중
해지 예약
결제 실패로 인한 유예 상태
결제 실패로 인한 일시 중지
기간 만료
관리자 직권 종료
환불 처리 중
이 차이는 단순히 상태 값이 많다는 뜻이 아니다. 각 상태마다 가능한 행위, 정책, 예외 처리가 다를 수 있다.
무료 체험 중 해지는 즉시 이용 권한이 사라질 수 있다. 유료 구독 해지는 남은 기간 동안 이용 권한이 유지될 수 있다. 결제 실패로 인한 일시 중지는 결제 수단을 갱신하면 복구될 수 있다. 환불 처리 중인 구독은 추가 변경이 제한될 수 있다.
도메인 전문가 없이 만든 모델은 처음에는 단순하고 깔끔해 보인다. 하지만 운영 규칙이 들어오는 순간 조건문과 예외 처리로 무너진다.
도메인 전문가 없는 DDD는 도메인 주도 설계가 아니라 개발자 주도 추측이 되기 쉽다.
3. 유비쿼터스 언어가 코드에 도달하지 못한다
DDD를 도입한다고 하면서 용어집을 만드는 팀은 많다. 하지만 유비쿼터스 언어가 문서에만 있고 코드에는 없다면 효과는 제한적이다.
문서에는 “구독 갱신”, “해지 예약”, “결제 실패로 인한 일시 중지”라고 적혀 있는데, 코드에는 이런 이름만 있다면 어떨까?
subscription.updateStatus(2);
subscription.process();
subscription.change("C");
이 코드는 도메인 언어를 보존하지 않는다. 코드를 읽는 사람은 숫자와 플래그를 다시 도메인 의미로 번역해야 한다.
도메인 언어가 코드에 반영되면 달라진다.
subscription.renew(paymentResult);
subscription.cancelAtPeriodEnd(reason);
subscription.suspendDueToPaymentFailure();
메서드 이름만 바꾼다고 모든 문제가 해결되는 것은 아니다. 하지만 이름은 중요하다. 이름은 도메인을 어떻게 이해했는지 드러낸다.
유비쿼터스 언어는 용어집이 아니다. 회의, 문서, 코드, 테스트에서 함께 쓰이는 살아 있는 언어다. 이 언어가 코드에 도달하지 못하면 DDD는 껍데기만 남는다.
4. Bounded Context 없이 하나의 거대한 모델을 만든다
DDD가 실패하는 또 다른 이유는 경계를 나누지 않는 것이다. 하나의 통합 모델로 모든 문제를 해결하려고 한다.
예를 들어 Subscription이라는 모델 하나를 모든 팀이 공유한다고 해보자.
결제팀은 정기 결제 주기와 실패 횟수가 필요하다. 이용 권한 팀은 접근 가능한 기간과 콘텐츠 범위가 필요하다. 고객센터는 보상 이력과 직권 조정 사유가 필요하다. 마케팅팀은 무료 체험 여부와 캠페인 반응 정보가 필요하다.
이 모든 관심사가 하나의 Subscription에 들어가면 모델은 점점 비대해진다.
Subscription
- 결제 정보
- 이용 권한 정보
- 고객센터 보상 정보
- 마케팅 캠페인 정보
- 분석용 플래그
- 운영용 메모
- 각종 상태 코드
처음에는 재사용처럼 보인다. 하지만 시간이 지나면 어느 팀도 이 모델을 안전하게 수정하지 못한다. 하나의 필드를 바꾸면 예상치 못한 곳에서 문제가 생긴다. 같은 단어가 여러 의미로 쓰이면서 코드도 점점 모호해진다.
모델은 경계 안에서만 일관된다. 하나의 단어가 항상 하나의 모델을 의미하지 않는다. Bounded Context 없이 DDD를 적용하면 전술적 패턴을 아무리 잘 써도 모델은 서로 오염된다.
5. Aggregate를 객체 그래프로 이해한다
Aggregate는 DDD 구현에서 가장 많이 실패하는 지점이다. 흔한 오해는 Aggregate를 관련 객체들의 묶음으로 이해하는 것이다.
구독에는 결제가 있고, 결제에는 영수증이 있고, 구독에는 이용 권한이 있고, 이용 권한에는 콘텐츠 목록이 있고, 고객센터 이력도 구독과 관련 있으니 전부 하나의 Aggregate에 넣는다.
이렇게 하면 Subscription Aggregate는 너무 커진다. 조회는 무거워지고, 변경은 어려워지고, 동시성 충돌은 많아진다. 무엇보다 어떤 불변식을 지키기 위한 경계인지 알 수 없어진다.
Aggregate는 관련 있는 객체를 모아두는 단위가 아니다. Aggregate는 일관성을 지키는 경계다.
질문은 이렇게 바뀌어야 한다.
무엇이 반드시 함께 변경되어야 하는가?
어떤 규칙이 한 트랜잭션 안에서 지켜져야 하는가?
무엇은 이벤트를 통해 나중에 반영되어도 되는가?
다른 Aggregate를 객체 참조가 아니라 ID로 참조해도 되는가?
구독과 결제는 관련이 깊다. 하지만 항상 같은 Aggregate여야 하는 것은 아니다. 결제 실패나 환불 이력은 별도의 Aggregate나 다른 컨텍스트에서 다루는 편이 나을 수 있다. 중요한 것은 관계가 아니라 불변식이다.
6. Application Service에 모든 비즈니스 로직이 쌓인다
DDD를 한다고 하면서 실제로는 모든 비즈니스 로직이 Application Service에 들어가는 경우가 많다.
public void cancelSubscription(SubscriptionId id) {
Subscription subscription = repository.findById(id);
if (subscription.getStatus() == EXPIRED) {
throw new IllegalStateException("이미 만료된 구독입니다.");
}
if (subscription.isPaid()) {
refundService.requestRefund(subscription.getPaymentId());
}
subscription.setStatus(CANCELED);
repository.save(subscription);
}
이 코드는 동작할 수 있다. 하지만 도메인 규칙이 Application Service에 들어가 있다. 구독이 어떤 상태에서 해지 가능한지, 유료 구독의 해지는 어떤 의미인지, 환불과 해지는 어떤 관계인지가 도메인 모델 안에 없다.
DDD다운 흐름은 도메인 객체에게 행위를 요청하는 것이다.
subscription.cancelAtPeriodEnd(reason);
또는 정책에 따라 즉시 해지와 기간 종료 해지를 구분할 수 있다.
subscription.cancelImmediately(reason);
subscription.cancelAtPeriodEnd(reason);
Application Service는 유스케이스를 조율한다. Aggregate를 조회하고, 도메인 행위를 호출하고, 저장하고, 필요한 이벤트를 발행한다. 하지만 핵심 도메인 규칙은 도메인 모델 안에 있어야 한다.
Application Service가 모든 판단을 하면 도메인 모델은 빈 데이터 객체가 된다.
7. Repository가 도메인 행위를 우회한다
Repository는 Aggregate의 생명주기를 다루는 추상화다. 하지만 실무에서는 Repository가 DAO처럼 사용되면서 도메인 행위를 우회하는 경우가 많다.
subscriptionRepository.updateStatus(id, CANCELED);
subscriptionRepository.extendPeriod(id, nextEndDate);
subscriptionRepository.incrementPaymentFailureCount(id);
이 방식은 빠르고 편해 보인다. 하지만 위험하다. 도메인 객체가 지켜야 할 규칙을 거치지 않고 데이터가 직접 변경된다. 그러면 불변식은 쉽게 깨지고, 규칙은 여러 쿼리와 서비스 메서드에 흩어진다.
DDD에서 더 자연스러운 흐름은 이렇다.
Subscription subscription = repository.findById(id);
subscription.suspendDueToPaymentFailure();
repository.save(subscription);
첫 번째 방식은 데이터를 수정한다. 두 번째 방식은 도메인 객체에게 행위를 요청한다.
Repository가 도메인 행위를 우회하기 시작하면, 도메인 모델은 점점 신뢰할 수 없는 객체가 된다.
8. 모든 곳에 DDD를 적용한다
DDD는 강력하지만 비용이 큰 접근이다. 모든 기능에 DDD를 적용하려 하면 오히려 시스템이 불필요하게 복잡해질 수 있다.
단순한 관리자 CRUD, 코드성 데이터 관리, 변경 규칙이 거의 없는 기능에는 깊은 도메인 모델이 필요하지 않을 수 있다.
공지사항 관리
배너 관리
FAQ 관리
단순 카테고리 관리
약관 문구 관리
이런 기능에 Aggregate, Domain Event, Repository, Domain Service를 모두 적용하면 설계가 문제보다 커진다.
DDD는 복잡한 도메인을 다루기 위한 방법이다. 복잡성이 낮은 곳에는 단순한 구조가 더 낫다. 반대로 복잡성이 높은 핵심 도메인에는 설계 에너지를 집중해야 한다.
DDD를 잘한다는 것은 모든 곳에 DDD 패턴을 쓰는 것이 아니다. 어디에 깊은 모델링이 필요한지 판단하는 것이다.
9. 마이크로서비스 분리를 DDD라고 착각한다
DDD와 마이크로서비스는 자주 함께 언급된다. Bounded Context가 마이크로서비스 경계를 찾는 데 도움을 주는 것은 사실이다. 하지만 둘은 같은 개념이 아니다.
Bounded Context는 모델의 경계다. Microservice는 배포의 경계다.
서비스를 나누었다고 DDD를 한 것은 아니다. 오히려 모델의 경계를 이해하지 못한 채 서비스를 나누면 분산 모놀리스가 될 수 있다.
서비스는 나뉘었지만 같은 데이터베이스를 공유한다.
모든 서비스가 같은 DTO를 사용한다.
한 서비스의 상태 코드 변경이 다른 서비스들을 깨뜨린다.
서비스 간 호출이 지나치게 많다.
트랜잭션 경계가 불분명하다.
이런 구조는 배포 단위만 나뉘었을 뿐 모델은 여전히 얽혀 있다.
서비스를 나누기 전에 먼저 모델의 경계를 찾아야 한다. 같은 단어가 어디까지 같은 의미를 갖는지, 어떤 컨텍스트가 독립적으로 발전해야 하는지, 어떤 관계를 이벤트나 API로 명시할지 고민해야 한다.
마이크로서비스는 DDD의 결과일 수 있지만, DDD 자체는 아니다.
10. 모델을 계속 정제하지 않는다
DDD에서 모델은 한 번 만들고 끝나는 산출물이 아니다. 도메인 이해가 깊어지면 모델도 바뀌어야 한다.
처음에는 Subscription 하나면 충분해 보일 수 있다. 하지만 시간이 지나면서 BillingAgreement, Entitlement, Refund, RenewalPolicy, CancellationPolicy 같은 개념이 드러날 수 있다.
처음에는 cancel() 하나였던 행위가 나중에는 여러 개로 나뉠 수 있다.
subscription.cancelImmediately(reason);
subscription.cancelAtPeriodEnd(reason);
subscription.expire(now);
subscription.terminateByAdmin(reason);
이 변화는 실패가 아니다. 도메인을 더 잘 이해하게 되었다는 신호다.
문제는 처음 만든 모델을 계속 고집하는 것이다. 모델을 바꾸지 않으면 새로운 요구사항은 예외 조건과 플래그로만 쌓인다. 어느 순간 모델은 도메인을 설명하지 못하고, 과거의 추측을 보존하는 구조가 된다.
좋은 모델은 계속 정제된다. DDD는 완벽한 모델을 한 번에 찾는 방법이 아니라, 도메인 이해를 코드에 반복해서 반영하는 과정이다.
실패하지 않으려면 무엇부터 해야 할까
DDD를 실패하지 않기 위해 가장 먼저 해야 할 일은 패턴을 잠시 내려놓는 것이다. 그리고 도메인 질문으로 돌아가야 한다.
이 도메인에서 가장 중요한 비즈니스 규칙은 무엇인가?
도메인 전문가가 반복해서 쓰는 단어는 무엇인가?
그 단어들은 모두 같은 의미로 쓰이는가?
어떤 상태 변화가 특히 중요한가?
어떤 규칙은 반드시 즉시 일관성을 지켜야 하는가?
어떤 영역은 서로 다른 모델로 분리해야 하는가?
그다음 작은 범위에서 시작하는 것이 좋다. 전체 시스템을 한 번에 DDD로 바꾸려 하지 말고, 복잡성이 높은 핵심 도메인 하나를 고른다. 그 안에서 언어를 정리하고, 모델을 만들고, Aggregate 경계를 실험하고, 테스트로 규칙을 표현한다.
처음부터 완벽한 모델을 만들겠다는 기대도 버려야 한다. 좋은 모델은 대화를 통해 발견되고, 코드를 통해 검증되고, 운영 경험을 통해 수정된다.
마치며
DDD가 실패하는 이유는 DDD가 어려워서만은 아니다. DDD를 패턴 적용법으로 오해할 때, 도메인 전문가 없이 개발자끼리 추측할 때, 언어가 코드에 도달하지 못할 때, 모델의 경계를 나누지 않을 때 DDD는 실패한다.
Entity, Value Object, Aggregate, Repository가 있다고 해서 DDD를 하고 있는 것은 아니다. 도메인의 언어와 규칙이 코드 안에 살아 있어야 DDD다.
DDD는 복잡한 코드를 만들기 위한 방법이 아니다. 복잡한 도메인을 정직하게 바라보고, 그 복잡성이 아무 곳에나 흩어지지 않도록 이름과 경계와 책임을 부여하는 방법이다.
결국 DDD의 실패는 패턴의 실패라기보다 대화의 실패에 가깝다. 좋은 DDD는 좋은 클래스 구조에서 시작하지 않는다. 좋은 질문, 좋은 언어, 좋은 경계에서 시작한다.