도메인 주도 설계(DDD) 핵심
1. 도메인 주도 설계란 무엇인가?
1-1. 복잡한 소프트웨어를 다루는 새로운 접근법
도메인 주도 설계(Domain-Driven Design, DDD)는 단순한 설계 기법이 아니라, 복잡한 비즈니스 요구사항을 명확히 모델링하고 지속적으로 진화시켜가는 방법론이다. 전통적인 시스템 설계는 기능 중심이었지만, DDD는 "비즈니스 개념을 코드에 반영한다"는 철학을 중심으로 한다.
DDD는 에릭 에반스(Eric Evans)의 저서 『Domain-Driven Design』에서 처음 정립되었으며, 복잡한 도메인 로직을 개발자와 도메인 전문가가 공통 언어(Ubiquitous Language) 로 공유하며 점진적으로 모델링하는 방식이다. 즉, 비즈니스 언어를 모델로 승화시키는 과정이다.
이 철학은 단순히 객체지향을 더 잘 활용하는 방법이 아니다. 실제 도메인 문제를 중심으로 코드를 구조화하고, 모델과 언어, 책임을 일치시켜 시스템을 설계하는 궁극적인 목적지라고 할 수 있다.
1-2. 핵심 개념은 '도메인'과 '모델'
DDD에서 말하는 도메인이란, 단순한 “업무 분야”가 아니라 소프트웨어가 해결해야 하는 문제 공간 그 자체를 뜻한다. 예를 들어 금융 시스템이라면 대출, 이자, 계좌, 정산 등이 도메인에 속하고, 각각의 규칙과 제약이 비즈니스의 핵심 복잡도를 형성한다.
이러한 복잡한 도메인을 정제하고 코드로 표현한 것이 모델(Model)이다. 모델은 현실 세계를 반영하되, 소프트웨어에서 해결 가능한 수준으로 추상화된 비즈니스 개념의 집합이다. 즉, 모델은 시스템 내부에 존재하는 ‘비즈니스 로직의 지적 자산’이라고 볼 수 있다.
도메인과 모델의 차이를 명확히 구분해야 한다. 도메인은 외부 세계의 실제이며, 모델은 그것을 시스템 안에서 구현한 구조다. DDD의 본질은 모델을 끊임없이 리팩토링하며 도메인에 근접하도록 정제하는 데 있다.
1-3. 개발자는 비즈니스 전문가가 되어야 한다
DDD에서는 개발자가 단순히 기술적 구현자에 머물지 않고, 도메인 전문가와 함께 비즈니스 개념을 이해하고 설계해야 한다. 이는 개발자가 ‘어떻게 만들 것인가’뿐 아니라 ‘무엇을 만들 것인가’를 결정하는 데 관여하게 된다는 의미다.
이를 위해 도입된 개념이 바로 유비쿼터스 언어(Ubiquitous Language) 다. 유비쿼터스 언어란, 개발자, 기획자, 운영자, 사용자 등 모든 관계자가 동일한 용어 체계를 공유하며 도메인을 논의하는 방식이다. 이를 통해 커뮤니케이션 오류를 줄이고, 구현과 요구사항 간 불일치를 해소할 수 있다.
DDD를 도입하면 비즈니스 담당자와 개발자 간의 벽이 허물어지며, 시스템의 진화가 비즈니스의 변화와 함께 자연스럽게 동기화된다. 이는 코드가 실제 도메인을 설명하는 문서 역할을 하도록 만든다.
1-4. DDD는 설계 철학이자 전략이다
도메인 주도 설계는 단순히 객체지향을 잘 활용하는 기술이 아니라, 시스템이 왜 이렇게 생겼는가에 대한 설계 철학을 담은 전략이다. 복잡성을 분석하고 나누고, 명확한 언어와 모델을 기준으로 팀 내 지식을 정제하는 과정 전체가 DDD의 실천 대상이다. DDD는 기능 명세를 잘게 쪼개는 구조적 접근과는 다르다. 오히려 비즈니스 개념 단위로 시스템을 바라보며, 각 영역이 독립된 사고와 책임을 가지도록 경계(Bounded Context) 를 설정한다. 그리고 각 컨텍스트 간의 관계와 통합 전략을 통해 전체 아키텍처를 구성한다. 따라서 DDD는 단순히 Value Object, Entity, Aggregate 같은 용어를 적용하는 것이 아니라, 복잡성을 다루는 방식 자체를 바꾸는 접근이라고 이해해야 한다.
2. 유비쿼터스 언어와 팀 커뮤니케이션
2-1. 유비쿼터스 언어란 무엇인가?
유비쿼터스 언어(Ubiquitous Language)는 도메인 주도 설계에서 가장 중요한 개념 중 하나로, 모든 구성원이 공유하는 공통된 언어 체계를 의미한다. 개발자, 기획자, 도메인 전문가, 운영 담당자, 심지어 고객까지도 동일한 단어와 의미를 사용함으로써, 시스템에 대한 이해와 설계 방향이 일치하게 만든다.
예를 들어 쇼핑몰 시스템에서 “장바구니”를 어떤 팀은 cart, 다른 팀은 basket, 또 다른 곳은 orderBuffer라고 부른다면,
서로 말이 통하지 않을 뿐 아니라 설계된 모델도 달라질 수밖에 없다. 유비쿼터스 언어는 이러한 용어 불일치에서 오는 의사소통 오류를 사전에 차단하고, 시스템의 언어를 명확하게 통일해주는 역할을 한다.
중요한 점은 이 언어가 문서에만 존재해서는 안 된다는 것이다. 코드, 문서, 회의, 설계서, 사용자 인터페이스 모두에 반영되어야 한다. 즉, 단지 용어 정리 수준이 아니라 비즈니스 지식을 시스템에 녹여내는 방법론이자, 도메인 모델을 올바르게 구현하기 위한 토대다.
2-2. 언어 정립이 가져오는 실질적 이점
유비쿼터스 언어를 제대로 실천하면 가장 먼저 개선되는 것은 커뮤니케이션 효율성이다. 모호한 용어나 애매한 표현 없이 팀원 간에 명확한 지식 전달이 가능해지고, 기획 변경이나 요구사항 논의도 빠르게 본질을 파악할 수 있다.
두 번째로는 모델링 정밀도 향상이다. 의미가 불명확하거나 겹치는 단어가 사라지고, 도메인 전문가가 실제로 사용하는 말이 코드로 옮겨진다. 이 덕분에 개발자는 "이게 무슨 기능이지?"가 아니라 "이건 이 개념의 구현이구나"라는 관점으로 코드를 이해하게 된다. 또한 이는 설계 변경 비용을 줄이고, 유지보수를 쉽게 만들어주는 요소가 된다. 코드를 보는 사람은 설계를 읽는 것처럼 비즈니스 개념을 해석할 수 있고, 모델이 잘 정립되어 있을수록 신규 기능 도입도 수월하다. 결국 유비쿼터스 언어는 코드의 품질뿐 아니라, 설계 및 운영 전반의 효율성을 높이는 강력한 도구가 된다.
2-3. 도메인 전문가와의 협업 방식 변화
DDD에서는 도메인 전문가(업무 담당자)가 단순한 요구사항 제공자가 아니다. 이들은 도메인에 대한 깊은 지식을 가진 모델링의 실질적인 파트너다. 유비쿼터스 언어는 이런 전문가들과 개발자가 같은 말로 소통할 수 있도록 중간 언어를 없애주는 다리 역할을 한다.
실무에서는 모델 워크숍, 사용자 시나리오 작성, 화이트보드 세션 등을 통해 실제 사용되는 언어를 추출하고, 이를 모델링에 바로 반영하는 과정을 거친다. 여기서 중요한 것은 개발자가 전문가의 말을 기술 용어로 해석하지 않고, 그 용어 자체를 시스템의 핵심 언어로 반영하는 자세다. 이런 협업 구조는 개발자 스스로가 비즈니스 분석가처럼 성장하는 계기가 된다. 도메인을 이해하고 모델링하는 능력은 기술 숙련도와 별개로, 시스템 설계자에게 꼭 필요한 자질이며, DDD는 그 훈련 과정을 유비쿼터스 언어를 통해 제공한다.
2-4. 유비쿼터스 언어의 도입 전략
현장에서 유비쿼터스 언어를 도입할 때는 몇 가지 전략적 접근이 필요하다. 먼저, 문서나 노션 같은 도구를 통해 팀 내에서 쓰이는 핵심 개념, 엔터티, 상태, 행동 등의 용어 목록을 시각화해야 한다. 그리고 모든 신규 기능은 이 목록에서 파생된 언어를 바탕으로 기획하고 개발되어야 한다.
두 번째는 코드와 문서, 인터페이스에 같은 언어를 반영하는 실천이다. 예를 들어 도메인 모델 클래스 이름, 필드 이름, 메서드 명 등은 도메인 전문가가 사용하는 표현과 동일해야 한다. “구매 확정”을 finalizePurchase()가 아닌 confirmReceipt()처럼 표현하면 혼란이 생기므로, 업무 용어에 맞는 명칭이 그대로 코드에 쓰이도록 한다.
마지막으로는 언어 변경에 대한 민감도다. 비즈니스 개념이 바뀌면 시스템 언어도 반드시 바뀌어야 한다. 이런 원칙을 지키기 위해서는 언어의 변경이 코드 리팩토링으로 이어지는 과정을 팀 전체가 자연스럽게 받아들이는 문화가 필요하다. 결국 유비쿼터스 언어는 단순한 사전이 아니라, 팀의 사고 체계 자체를 바꾸는 문화적 도구다.
3. 엔터티와 값 객체의 차이와 사용 전략
3-1. 엔터티(Entity)란 무엇인가?
엔터티(Entity)는 고유한 식별자를 기반으로 생명주기를 가지는 도메인 객체다. 즉, 시간이 지나도 그 정체성이 유지되어야 하며, 식별자(ID)를 기준으로 동일성을 판단한다. 예를 들어 주문(Order), 사용자(User), 상품(Product)은 모두 "언제 생성되었는가", "어떤 상태인가"와 무관하게 식별자를 기준으로 하나의 개체로 추적되어야 할 대상이다.
엔터티는 보통 데이터베이스의 테이블에 대응되며, 각 인스턴스는 고유한 ID 값을 가진다. 이 ID는 시스템 내부에서 비즈니스 로직의 정합성을 유지하는 데 사용되며, 도메인 이벤트나 변경 이력 관리에도 중요한 기준이 된다. 예를 들어 사용자의 주소가 바뀌었다고 해서 그 사용자가 다른 사람으로 취급되진 않는다. 이는 엔터티의 정체성이 식별자에 의해 정의된다는 의미다.
엔터티를 설계할 때 중요한 점은 불변이 아닌 객체라는 것이다. 즉, 상태가 변경될 수 있으며, 이 상태 변경은 비즈니스 규칙에 따라 명확하게 정의되어야 한다. 이러한 "상태 변화와 정체성의 분리"가 엔터티의 핵심 설계 기준이다.
3-2. 값 객체(Value Object)란 무엇인가?
값 객체는 고유한 식별자가 없고, 속성 값만으로 동일성을 판단하는 객체다. 즉, 두 객체의 속성이 완전히 같으면 동일한 것으로 간주되며, 변경보다는 교체를 지향하는 구조다. 대표적으로 주소(Address), 좌표(Point), 통화(Money), 기간(DateRange) 같은 객체가 여기에 해당된다. 값 객체는 기본적으로 불변성(immutability) 을 추구한다. 일단 생성된 후에는 그 상태를 바꿀 수 없으며, 값이 변경되면 새로 만들어서 대체해야 한다.
예를 들어 사용자 주소가 바뀌었다면 기존 Address 객체를 수정하는 것이 아니라, 새로운 Address 객체를 만들어 덮어씌운다. 값 객체는 동일성과 상태 변경을 따질 필요 없는 모델을 표현할 때 매우 효과적이다. 또한 테스트가 용이하고, 객체 간 의존도가 낮아지며, 코드 가독성도 좋아진다. 비즈니스 로직에서 값 자체로 판단이 가능한 데이터는 값 객체로 다루는 것이 이상적이다.
3-3. 엔터티와 값 객체의 경계 판단 기준
엔터티와 값 객체를 구분하는 가장 중요한 기준은 식별 필요성과 상태 지속성이다. 해당 객체가 시스템 내에서 "이 개체는 누구인가?"를 추적할 필요가 있다면 엔터티, 그렇지 않고 "값이 어떤가?"만 중요하다면 값 객체로 설계하는 것이 바람직하다. 또한 "변경이 필요한가"가 아닌 "변경을 추적해야 하는가"를 기준으로 판단해야 한다.
예를 들어 전화번호는 변경될 수 있지만, 변경 이력을 추적하거나 식별에 쓰지 않는다면 값 객체로 다루는 것이 자연스럽다. 반대로, 사용자의 이메일은 로그인이나 인증 식별자로 사용될 수 있다면 엔터티 성격을 띨 수도 있다. 경계가 모호한 경우도 많기 때문에 처음에는 값 객체로 시작해서, 도메인의 진화에 따라 엔터티로 확장할 수 있는 유연한 구조를 택해야 한다. 핵심은 이 구분이 도메인 모델을 얼마나 정확하게 표현하고, 유지 가능한 구조로 만들 수 있느냐에 있다.
3-4. 실무에서의 활용 전략과 리팩토링 힌트
실무에서는 값 객체를 적극적으로 활용하는 것이 유지보수에 유리하다. 비즈니스에서 동일성을 중요시하지 않는 구조는 가급적 값 객체로 설계하면, 로직 단순화, 테스트 용이성, 불변성의 이점이 따라온다. 특히 Java나 Kotlin 같은 언어에서 record, data class 등을 활용하면 직관적이고 명확한 코드 구성이 가능하다.
반면, 엔터티는 관계 설정, 생명주기 관리, 상태 변경 로직 등이 필요한 만큼 설계 시점에서 책임과 행위를 명확히 나누는 것이 중요하다. 엔터티가 지나치게 많은 로직을 가지거나 외부에 상태를 노출한다면 도메인 서비스로 책임을 분리하거나,
행위 기반 설계로 리팩토링해야 할 신호일 수 있다.
또한 값 객체와 엔터티는 함께 조합되어 더 강력한 모델링 구조를 만든다. 예를 들어 Order 엔터티가 Address 값을 포함하거나, Customer 엔터티가 Money 타입의 creditLimit 값을 가질 수 있다. 이러한 구조는 시스템의 의도를 코드로 더욱 명확히 표현하게 해주며, 도메인 지식이 객체에 녹아들게 만든다.
4. 애그리거트와 일관성 경계
4-1. 애그리거트란 무엇인가?
애그리거트(Aggregate)는 도메인 주도 설계에서 도메인 객체들의 군집이자, 일관성을 유지하기 위한 경계 단위를 말한다. 하나의 애그리거트는 루트 엔터티(Aggregate Root)를 중심으로 여러 엔터티와 값 객체를 포함하며, 외부에서는 오직 루트를 통해서만 접근하도록 제한한다.
예를 들어 주문(Order)이라는 애그리거트는 하나의 Order 엔터티를 루트로 가지고, 그 안에 여러 개의 OrderLine, 배송지(Address), 결제 정보(Payment) 등이 내부 구성 요소로 존재할 수 있다. 하지만 외부에서는 절대로 OrderLine에 직접 접근하지 않으며, 반드시 Order를 통해 모든 조작이 이뤄진다.
이 구조는 단순한 캡슐화를 넘어서, 도메인 규칙의 무결성과 상태의 정합성을 하나의 단위로 보장하게 만든다. 즉, 시스템은 "Order 전체"를 일관된 단위로 보며, 그 내부 상태가 전부 유효해야만 변경을 수용한다. 이것이 바로 애그리거트가 갖는 전략적 가치다.
4-2. 일관성 경계(Consistency Boundary)의 중요성
복잡한 시스템에서 모든 데이터를 항상 동기적으로 일관되게 유지하는 것은 사실상 불가능하다. 따라서 DDD에서는 일관성을 유지해야 할 단위를 명확히 정의하고, 그 경계 안에서만 즉각적인 정합성을 보장하는 구조를 지향한다. 이 경계가 바로 애그리거트다.
애그리거트는 변경 트랜잭션의 단위이기도 하다. 즉, 데이터베이스에서 하나의 애그리거트 단위로 트랜잭션을 묶고, 경계를 넘는 데이터 변경은 비동기 이벤트나 도메인 서비스를 통해 처리하는 것이 권장된다. 이는 성능, 확장성, 복잡성 측면에서 더 나은 설계를 가능하게 한다.
이런 방식은 "전체 데이터는 Eventually Consistent(최종 일관성)을 보장하고, 중요한 핵심 객체는 즉각 일관성을 유지"하는 식으로 시스템을 구성할 수 있게 해준다. 즉, 부분적 일관성 전략을 애그리거트를 통해 구현하는 것이다.
4-3. 애그리거트 루트의 책임과 제약
애그리거트 루트는 단순히 외부 접근을 중재하는 게 아니라, 애그리거트 내부 전체의 상태 무결성을 보장하는 책임자다. 모든 변경은 루트를 통해 발생해야 하며, 루트는 내부 객체에 대한 유효성 검증과 도메인 규칙을 철저히 수호해야 한다.
이 때문에 루트는 무조건 "작고 단순하게" 설계되어야 한다. 지나치게 많은 상태나 로직을 가진 루트는 이해도와 유지보수성을 떨어뜨리고, 결국 도메인 모델을 또다시 레거시화시킬 위험이 있다. 실무에서는 Order.changeShippingAddress()처럼 명확한 의도를 표현하는 메서드들로 루트의 행위를 추상화한다.
또한 루트는 외부에서 참조되지만, 내부 객체는 외부에 노출되지 않아야 한다. 예를 들어 OrderLine을 삭제하려면 Order.removeLineItem(orderLineId) 같은 방식으로 루트를 통해 접근해야 하며, 외부에서 order.orderLines.remove() 같은 직접 조작은 금지된다. 이 규칙이 바로 모델의 구조적 안전성을 보장한다.
4-4. 실무 설계 시 주의할 점
실제 프로젝트에서 애그리거트를 설계할 때 흔히 발생하는 실수는 루트 엔터티에 모든 기능과 상태를 몰아넣는 것이다. 이럴 경우 루트가 비대해지며, 단일 책임 원칙(SRP)을 위반하게 된다. 가장 이상적인 애그리거트는 핵심 규칙만 포함하고, 부수적인 기능은 외부 서비스로 위임해야 한다.
또한 하나의 트랜잭션에서 여러 애그리거트를 동시에 수정하는 구조도 지양해야 한다. 이 경우 트랜잭션 실패 시 일관성 보장이 어렵고, CQRS나 이벤트 소싱 기반 구조에서는 불가능한 설계가 되어버린다. 대신 애그리거트 간 상호 작용은 도메인 이벤트 발행 → 비동기 이벤트 처리 구조로 해소해야 한다.
마지막으로, 애그리거트는 도메인 전문가와의 논의로부터 도출되는 것이 이상적이다. 실무에서는 ERD나 테이블 스키마가 아니라, 업무 용어와 책임의 단위를 기준으로 설계하는 것이 바람직하다. 이는 설계자가 “데이터 저장소가 아닌, 도메인 모델”을 중심으로 시스템을 구성해야 한다는 DDD 철학과도 일치한다.
5. 도메인 서비스와 애플리케이션 서비스의 역할 분리
5-1. 도메인 서비스란 무엇인가?
도메인 서비스는 도메인 주도 설계에서 엔터티나 값 객체만으로 표현하기 어려운 도메인 로직을 담당하는 객체다. 즉, 복수의 엔터티에 걸쳐 있는 비즈니스 규칙이나, 특정 엔터티에 귀속시키기 애매한 계산 로직을 별도의 클래스로 추출해놓은 것이다.
이러한 서비스는 상태를 가지지 않고, 주로 도메인 개념에 대한 의미 있는 행동을 캡슐화하는 역할을 한다. 예를 들어 송금 기능에서 계좌(Account) 간의 금액 이체는 단순히 A에서 B로 값을 옮기는 것이 아니다. 계좌 A의 출금 규칙, 계좌 B의 입금 처리, 통화 단위 변환, 수수료 계산 등의 여러 비즈니스 규칙이 함께 얽힌다.
이러한 로직은 어느 한 엔터티에 속하지 않으므로, TransferMoneyService 같은 도메인 서비스로 분리하는 것이 적절하다.
도메인 서비스는 도메인 레이어에 존재하며, 도메인 모델의 행위를 조율하는 책임을 가진다. 그렇기 때문에 외부 인프라(DB, 네트워크 등)와의 직접적인 상호작용은 갖지 않고, 오직 비즈니스 로직과 규칙에 집중하는 순수한 객체로 구성된다.
5-2. 애플리케이션 서비스란 무엇인가?
애플리케이션 서비스는 도메인 모델을 이용해 작업 흐름을 제어하고, 외부 시스템과의 연결을 조율하는 계층이다. 주로 API 요청을 처리하거나, DB 저장, 메시지 큐 발행, 트랜잭션 경계 설정 등의 역할을 수행하며, 비즈니스 규칙 자체보다는 프로세스 흐름을 관리하는 역할에 초점이 맞춰져 있다. 예를 들어 사용자 주문 요청이 들어왔을 때, 애플리케이션 서비스는
- 요청 파라미터를 검증하고,
- 도메인 모델을 통해 주문을 생성하고,
- 이를 리포지토리에 저장하고,
- 주문 성공 메시지를 이벤트로 발행한다.
이 전체 흐름은 애플리케이션 서비스가 담당하고, 핵심 로직인 “주문 생성”은 도메인 모델 또는 도메인 서비스가 수행한다.
중요한 점은 애플리케이션 서비스는 상태를 가지지 않고, 하나의 API 혹은 유즈케이스 단위를 구성하는 프로시저적 흐름의 최상단으로서 행동한다는 것이다. 테스트 시 외부 시스템을 쉽게 Mocking하거나, 흐름 중심의 시나리오 테스트를 설계하는 데 유리한 구조이기도 하다.
5-3. 두 레이어의 구분이 왜 중요한가?
도메인 서비스와 애플리케이션 서비스를 구분하는 이유는 책임의 모호함을 방지하고, 관심사를 분리하여 설계를 명확하게 만들기 위해서다. 도메인 서비스는 비즈니스의 복잡한 규칙을 표현하는 데 집중해야 하며, 외부 의존성을 가지면 테스트가 어렵고 설계가 뭉개지기 쉽다.
반면 애플리케이션 서비스는 외부 시스템과의 연계를 책임지고, 도메인 규칙은 위임해야 한다. 두 레이어가 뒤섞이면 테스트 코드도 복잡해지고, 도메인 로직이 외부 기술에 오염되며, 로직이 어디에 들어가야 할지 판단하기 어려운 구조가 된다.
이는 궁극적으로 도메인 모델의 퇴화를 초래하고, 시스템이 점점 기능 중심의 구조로 회귀하게 만든다.
따라서 도메인 로직이 명확한 책임과 경계 안에서 존재하도록 “모델은 모델 안에서, 흐름은 서비스에서”라는 설계 원칙을 철저히 지켜야 한다. 이 경계를 유지할수록 시스템은 유연하고 테스트 가능하며, 변화에 강한 구조로 진화할 수 있다.
5-4. 실무 적용 시의 조언
실무에서는 도메인 서비스와 애플리케이션 서비스를 명칭과 위치로 구분하는 것부터 시작하는 것이 좋다. 예를 들어 CreateOrderService가 도메인 레이어에 있으면 혼란스럽지만, OrderService는 도메인, OrderApplicationService는 애플리케이션 서비스라는 식으로 네이밍 컨벤션을 명확히 구분하는 방식이 유용하다. 또한 팀 내 컨벤션으로 “도메인 서비스는 도메인에 대한 행위를 수행하지만 외부와 직접 연결되지 않는다”, “애플리케이션 서비스는 외부 요청에 반응하여 도메인을 호출하고 결과를 전달한다”는 원칙을 명시적으로 합의하는 것이 중요하다.
초기에 설계 기준이 불명확하면 나중에 리팩토링 비용이 매우 커지기 때문이다. 마지막으로는 도메인 서비스가 많아질 경우 서비스 간 협업을 어떻게 추상화할 것인지 고민해야 한다. 경우에 따라 도메인 서비스 간 의존성으로 인해 로직이 중첩될 수 있으므로, 적절한 도메인 이벤트, 명령 핸들러 구조 등을 함께 활용하는 것이 바람직하다. 이러한 분리 원칙을 실천할수록 도메인 중심의 강건한 시스템 아키텍처가 가능해진다.