Featured image of post 야 너두 할 수 있어 ! ㄷㄷㄷ

야 너두 할 수 있어 ! ㄷㄷㄷ

DDD(Domain-Driven Design)의 핵심 개념과 도메인 경계 나누는 법을 정리.

DDD 라는 단어 개발자라면 익숙하면서도 직접 사용해보지 않은 나와 같은 분들이 많을거라 생각한다.

그런데 막상 핵심만 잡아보면 DDD 는 이렇다.

비즈니스를 잘 이해하고 설계하는 것. 기술 중심이 아닌 문제를 해결하는 참여자 모두가 도메인을 깊게 이해하고 이를 바탕으로 설계를 진행하는 것이다.

즉 DDD의 관심사는 어떤 프레임워크를 쓸까? 보다 우리가 풀고 있는 문제가 정확히 뭐지? 에 더 가깝다.


DDD는 왜 나왔을까?

소프트웨어는 시간이 지날수록 복잡해진다.

처음에는 단순하다.
컨트롤러에서 요청 받고, 서비스에서 처리하고, DB에 저장하면 된다.

그런데 기능이 늘어나면 점점 이런 일이 생긴다.

  • 주문 서비스에서 쿠폰 정책을 판단한다.
  • 결제 서비스에서 회원 등급을 확인한다.
  • 배송 로직이 주문 상태를 직접 바꾼다.
  • User, Member, Customer가 비슷한 의미로 여기저기 등장한다.
  • “이 조건문 왜 있는 거예요?“라고 물어보면 다들 잠깐 먼 산을 본다.

이쯤 되면 코드는 단순히 길어진 게 아니다.
비즈니스 지식이 여기저기 흩어진 것이다.

Eric Evans는 2003년 Domain-Driven Design: Tackling Complexity in the Heart of Software에서 이런 복잡성을 다루는 방법으로 DDD를 소개했다. 핵심은 기술이 아니라 도메인이다.

여기서 도메인은 소프트웨어가 해결하려는 비즈니스 문제 영역이다. 쇼핑몰이라면 주문, 결제, 재고, 배송, 정산 같은 것이 도메인이 된다.

DDD는 이 도메인을 깊게 이해하고, 그 이해를 코드의 이름과 구조, 책임에 반영하려는 접근이다.


DDD를 세 덩어리로 보면

DDD를 처음 공부하면 용어가 많이 나와서 머리가 좀 아파진다.
이럴 때는 크게 세 덩어리로 나눠보면 편하다.

1. 도메인 탐색

먼저 도메인을 알아가는 단계다.

도메인 전문가와 개발자가 함께 업무 흐름을 살펴보면서 어떤 일이 일어나고, 어떤 정책이 있고, 어디서 문제가 생기는지 찾는다.

이때 EventStorming 같은 방법을 사용할 수 있다.
예를 들어 “주문 생성됨”, “결제 승인됨”, “재고 차감됨”, “배송 시작됨” 같은 이벤트를 쭉 펼쳐놓고 업무 흐름을 보는 것이다.

2. 전략적 설계

전략적 설계는 큰 경계를 나누는 일이다.

전체 시스템을 하나의 거대한 모델로 만들지 않고, 의미가 일관되는 영역으로 나눈다. 이때 중요한 개념이 Bounded Context다.

예를 들어 커머스에서는 다음처럼 나눠볼 수 있다.

  • Order: 주문 생성, 주문 상태, 주문 취소
  • Payment: 결제 승인, 결제 취소, PG 연동
  • Inventory: 재고 예약, 차감, 복구
  • Delivery: 배송 요청, 송장, 배송 상태

중요한 건 이 경계가 단순히 폴더나 테이블 기준이 아니라는 점이다.
경계는 언어, 규칙, 책임, 변경 이유를 기준으로 나누는 것이 좋다.

3. 전술적 설계

전술적 설계는 Bounded Context 내부를 코드로 표현하는 방법이다.

여기서 Entity, Value Object, Aggregate, Repository, Domain Service, Domain Event, Factory 같은 패턴이 등장한다.

다만 이 패턴들을 쓴다고 자동으로 DDD가 되는 것은 아니다.

모든 비즈니스 로직이 여전히 OrderService 하나에 몰려 있고, 도메인 객체가 getter/setter만 가진 빈 껍데기라면 패키지 이름이 domain이어도 DDD라고 보기 어렵다.


난 아직 Bounded Context 가 뭔지 잘 모르겠다? (몰루?)

DDD에서 제일 중요한 개념을 하나만 고르라면 나는 Bounded Context 인 것 같다.

Bounded Context는 특정 모델과 용어가 일관되게 사용되는 경계다.

예를 들어 “상품"이라는 단어를 생각해보자.

재고팀에게 상품은 입고 수량, 출고 가능 수량이 중요하다.
정산팀에게 상품은 판매가, 공급가, 수수료율이 중요하다.

다 같은 상품인데 보고 있는 시각이 다르다.

이걸 하나의 Product 모델에 전부 넣으면 어떻게 될까?

처음엔 든든 국밥이지만 나중엔 아무도 건드리고 싶지 않은 거대한 객체가 된다.

즉, 여기서 중요한 점은 같은 단어라도 의미가 다르면 경계를 나눠라. 이다.


도메인 경계는 어떻게 나눌까?

DDD 를 공부해보면서 도메인을 어떻게 나눠야 할지 기준을 잡는것이 어려웠다. 아래 4가지 기준을 가지고 나누어보면 그럴싸하게 잘 나눠지는 것 같다.

같은 단어가 다른 의미로 쓰이는가?

회원, 상품, 주문, 정산 같은 단어가 팀마다 다르게 쓰이면 경계 후보가 된다.

회원도 인증 컨텍스트에서는 로그인 주체이고, 주문 컨텍스트에서는 주문자 정보이며, 마케팅 컨텍스트에서는 캠페인 대상자일 수 있다.

같은 단어라고 무조건 같은 모델로 만들면 안 된다.

이 규칙의 주인은 누구인가?

결제 승인 규칙은 결제 도메인이 알아야 한다.
재고 차감 규칙은 재고 도메인이 알아야 한다.
쿠폰 사용 가능 여부는 쿠폰 도메인이 알아야 한다.

다른 도메인이 남의 규칙을 직접 수행하기 시작하면 검증이 빠지고, 이력이 누락되고, 사이드 이펙트가 생긴다.

실무 DDD 적용 사례에서도 이 부분을 중요하게 본다. 예를 들어 한도 잔액 변경 같은 기능은 반드시 한도 도메인을 통해서만 수행되어야 한다. 그래야 검증, 이력, 정책이 한곳에서 지켜진다.

반드시 한 트랜잭션으로 묶여야 하는가?

Aggregate는 관련 있어 보이는 객체를 다 모아둔 상자가 아니다.
일관성을 즉시 지켜야 하는 최소 단위에 가깝다.

주문 금액과 주문 항목 합계는 즉시 일치해야 할 수 있다.
하지만 주문 완료 후 알림 발송이나 통계 반영은 나중에 처리되어도 괜찮을 수 있다.

모든 걸 한 트랜잭션으로 묶으면 모델이 너무 커진다.
반대로 모든 걸 이벤트로만 처리하면 흐름을 따라가기 어려워진다.

중요한 건 "무엇을 즉시 지켜야 하고 무엇은 나중에 맞아도 되는가?" 를 구분하는 것이다.

함께 바뀌는가, 따로 바뀌는가?

자주 함께 바뀌는 것들은 같은 경계 안에 있을 가능성이 높다.
서로 다른 이유로 바뀌는 것들은 분리 후보가 된다.

프로모션 정책은 마케팅 이벤트 때문에 바뀐다.
배송 연동은 택배사 API나 물류 정책 때문에 바뀐다.
정산은 회계, 계약, 수수료 정책 때문에 바뀐다.

변경 이유가 다르면 같은 모델 안에 묶었을 때 서로를 계속 괴롭힌다.


전술적 패턴은 이렇게 이해하면 된다

  • Entity: 식별자가 중요한 객체. 예를 들어 주문, 회원
  • Value Object: 값 자체가 중요한 객체. 예를 들어 돈, 주소, 기간
  • Aggregate: 일관성을 지켜야 하는 변경 단위
  • Repository: 저장하고 조회하는 통로
  • Domain Service: 특정 객체 하나에 넣기 애매한 도메인 규칙
  • Domain Event: 도메인에서 발생한 의미 있는 사건
  • Factory: 복잡한 객체 생성을 책임지는 객체

여기서 핵심은 패턴 이름이 아니다.
비즈니스 규칙이 코드 안에서 잘 드러나는지가 중요하다.

예를 들어 Price를 그냥 Long으로 들고 다닐 수도 있다.
하지만 금액은 음수가 되면 안 된다거나, 통화 단위가 필요하다거나, 계산 규칙이 있다면 Price라는 Value Object로 표현하는 편이 더 명확할 수 있다.

코드는 컴퓨터만 읽지 않는다.

미래의 나도 읽는다. 그리고 미래의 나는 화가나있다. (잘 짜자…)


DDD는 마이크로서비스랑 같은건가?

아니다.

Bounded Context는 설계 경계이지, 반드시 배포 경계는 아니다.

DDD를 한다고 처음부터 서비스를 다 찢을 필요는 없다. 오히려 많은 경우에는 모듈러 모놀리스가 좋은 출발점이 될 수 있다.

하나의 애플리케이션 안에서 패키지, 모듈, 의존성 규칙으로 경계를 먼저 지켜보고, 정말 독립 배포가 필요해졌을 때 서비스로 분리해도 늦지 않다.

DDD를 한다고 갑자기 Kafka, Event Sourcing, CQRS, MSA 풀세트를 장착할 필요는 없다.

그건 DDD라기보다 장비 욕심에 가깝다.


Anti-Corruption Layer도 살짝만

외부 시스템이나 레거시 시스템과 연동하다 보면 모델이 다를 수밖에 없다.

이때 외부 모델을 내부 도메인에 그대로 들여오면 내부 모델이 점점 오염된다.
예를 들어 외부 PG사의 결제 상태값이 내부 결제 도메인 곳곳에 그대로 퍼지면, 나중에 PG사를 바꿀 때 코드 전체가 같이 흔들릴 수 있다.

Anti-Corruption Layer는 이런 상황에서 중간 번역 계층 역할을 한다.

외부 시스템의 언어를 내부 도메인의 언어로 바꿔주는 보호막이라고 보면 된다.

한 가지 쉬운 예시로 맥북 Air 에 HDMI 가 안 꽂히기 때문에 USB-C 허브 를 이용하는 걸 생각하면 될 것 같다. (맥북 <-> USB-C 허브 <-> HDMI)

// ACL 예시

class LegacyOrderTranslator {

    fun translate(legacyStatus: String): OrderStatus =
        when (legacyStatus) {
            "A" -> OrderStatus.PAID
            "B" -> OrderStatus.SHIPPED
            else -> throw IllegalArgumentException("Unknown status: $legacyStatus")
        }
}

내 생각 정리

DDD를 잘 사용하면 좋은 점은 분명하다.

  1. 비즈니스 규칙이 코드에서 잘 보인다.
    어떤 정책이 어디에 있는지 찾기 쉬워지고, 변경할 때 영향 범위를 예측하기 좋아진다.

  2. 팀의 대화가 코드와 가까워진다.
    기획자나 도메인 전문가가 말하는 용어가 코드에도 비슷하게 나타나면, 요구사항을 이해하고 구현하는 과정에서 오해가 줄어든다.

  3. 복잡한 시스템을 한 번에 이해하려 하지 않아도 된다.
    Bounded Context로 경계를 나누면 각 영역을 독립적으로 바라볼 수 있다. 거대한 시스템을 통째로 외우지 않아도 되는 것이다.

다만 DDD를 모든 곳에 적용해야 한다고 생각하진 않는다.
단순 CRUD나 정책이 거의 없는 기능에 DDD를 과하게 넣으면 오히려 복잡해질 수 있을 것 같다는 생각이 든다.

결국 DDD는 복잡한 도메인을 다룰 때 빛이 날 것 같다. 규칙이 많고, 예외가 많고, 변경이 잦고, 여러 팀이 같은 단어를 다르게 쓰기 시작한다면 그때 DDD를 꺼내볼 만하다.

정리하면 DDD는 이런 것 같다다.

비즈니스와 코드를 가깝게게 하자.


야, 너두 이제 ㄷㄷㄷ 할 수 있어.