
주문 서비스에 검증, 재고 확인, 가격 계산, 쿠폰 적용, 알림 발송 로직이 한 곳에 뒤섞였다고 생각해 보자.
공포스럽다.
기능이 추가될 때마다 로직이 쌓이고, 결국 누구도 손대기 두려운 코드가 된다. 이 문제를 해결하기 위해 DDD 방법론을 고려할 수 있는데 패턴들을 암기하기보다는 "비즈니스 로직을 어느 객체에 위치시킬 것인가"라는 질문부터 출발하면 된다.
실무에서 제품 개발을 하면서 까다로운 것 중 하나가 도메인 용어를 맞추는 일이다. 현업의 언어와 코드의 언어가 일치하지 않으면 소통과 개발이 매끄럽지가 않은데, 보통 다음과 같다. 기획자는 "주문을 취소한다"라고 표현하는데 코드에서는 다르게 표현된 경우를 생각하면 된다.
이를 유비쿼터스 언어를 통해 해결할 수 있다.
유비쿼터스 언어란 도메인 주도 설계(DDD)의 핵심 개념으로, 개발자와 비즈니스 전문가(기획자, PM 등)가 프로젝트의 모든 과정에서 사용하는 단 하나로 통일된 공통 언어를 의미한다.
쉽게 말해서 "기획서의 말과 메서드 이름을 일치시킨다"는 규칙을 코드에 녹여내면 된다.
예제 쇼핑몰의 흐름(주문 → 결제 → 배송)에 따라 패키지 구조를 구성해 보자.
패키지는 다음과 같이 각 비즈니스 영역별로 분리한다.

의존성 원칙은 간단하다. 의존성의 화살표는 항상 안쪽(domain)을 향해야 한다. domain 레이어가 application이나 infrastructure 레이어를 인지하는 순간, 도메인의 순수성이 깨지고 특정 기술 프레임워크에 종속된다.
📌 경험
프로젝트에서도 동일하게 domain → application → infrastructure → presentation 레이어 구조를 채택했다.
"의존성은 안쪽으로 향한다"는 규칙을 개발자의 자율에 맡기지 않고 ArchUnit 룰로 강제했다. 꼭 한 명씩 안 지킨다.
같은 "스키마" 데이터라도 도메인이 바라보는 형태가 다르기 때문에, 두 컨텍스트의 패키지와 데이터 저장소를 완전히 격리하여 관리하고 있다.
1. 경계부터 나누자
컨텍스트를 나누고 패키지로 격리하기
동일한 상품이라도 주문 도메인이 바라보는 상품과 배송 도메인이 바라보는 상품은 다르다.
주문에는 SKU(상품 식별 코드)와 단가 정보만 있으면 충분하지만, 배송에는 무게, 부피, 송장 번호가 필요하다. 하나의 클래스에 모든 필드를 관리하면, 나중에 주문 로직을 수정할 때 배송 필드가 오염되거나 그 반대의 부작용이 발생한다.
이를 해결하기 위해 모델이 유효한 범위를 명확히 제한하는데, 이를 바운디드 컨텍스트라고 한다. 패키지를 ordering.Sku와 shipping.Sku로 분리하면 글자만 같을 뿐 서로 영향을 주지 않는 독립된 타입이 된다.
경계를 넘어 데이터를 주고받을 때는 모델을 직접 노출하지 않고 인터페이스를 통해 주고받는다.
도메인의 순수성을 빌드 단계에서 강제하기
도메인 패키지에는 순수한 비즈니스 규칙만 존재해야 한다.
"외부 라이브러리나 다른 레이어 코드를 import 하지 말자"는 코드 리뷰 단계의 약속은 일정과 리소스에 쫓기다 보면 깨지기 쉽다. 따라서 이를 빌드 실패로 이어지는 테스트 코드로 강제해야 하면 좋은데, 아래는 ArchUnit을 활용해 도메인 레이어의 방향성을 검증하는 예시다.

아키텍처 오염을 막기 위해 검증 규칙을 촘촘하게 세우면 좋다.
나의 경우에는 다음과 같은 룰셋을 프로젝트에 적용하고 있다.
| Spring 프레임워크 컴포넌트 사용 금지 | 도메인 패키지 내부 클래스에는 @Service, @Component, @Transactional, @Autowired 사용을 금지한다. |
| Public Setter 금지 | @Data나 @Setter 같은 롬복 애너테이션은 컴파일 시점에 바이트코드로 생성되므로 ArchUnit으로 추적이 가능하다. 캡슐화를 깨뜨리는 수정 메서드 생성을 원천 차단한다. |
| 비결정적 API 호출 금지 | 도메인 내부에서 Instant.now()나 UUID.randomUUID()를 직접 호출하면 테스트 환경에 따라 결과가 달라진다. 시간이나 식별자 생성은 반드시 호출자(Application 레이어)가 주입하도록 유도한다. |
| 도메인 서비스의 무상태 유지 | @DomainService 객체의 모든 인스턴스 필드는 final로 선언되어야 한다. |
| 엔티티 내, 원시 타입 제한 | 단순 문자열은 비즈니스 의미를 담은 값 객체로 전환하도록 유도하여 원시 타입 집착을 방지한다. |
영속성은 도메인의 관심사가 아니다
도메인 레이어는 "주문 데이터를 저장하고 조회한다"는 추상적인 의도만 알면 된다. 내부 저장소가 MySQL인지, MongoDB인지, JPA를 쓰는지 JDBC를 쓰는지는 중요하지 않다. 이를 위해 영속성 계층의 인터페이스(Repository)는 도메인이 소유하고, 구체적인 구현체는 인프라(Infrastructure) 레이어에 둔다.
의존성 역전 원칙(DIP)의 핵심은 인터페이스를 어떤 패키지에 위치시킬 것인가에 있다.

구현체인 JpaOrderRepository는 인프라 레이어에서 이 인터페이스를 상속받아 구현한다. 덕분에 응용 서비스와 도메인은 JPA의 존재를 의식하지 않으며, 향후 데이터베이스 기술이 변경되더라도 인프라 영역만 수정하면 된다.
외부로 나갈 땐 정해진 규격으로, 들어올 땐 내부용으로 변환한다
바운디드 컨텍스트 간 연동 시에는 관계성과 주도권을 명확히 해야 한다. 타 시스템에 인터페이스를 제공하는 Upstream 시스템이라면 공개 호스트 서비스 형태로 외부 전용 DTO와 API 계약을 노출하고 내부 도메인 모델은 숨긴다.
반대로 통제할 수 없는 외부 시스템(예: PG사 API)을 연동하는 Downstream 시스템이라면 부패 방지 계층(ACL, Anti-Corruption Layer)을 두어 외부 모델이 우리 도메인 영역을 오염시키지 않도록 진입 단계에서 격리한다.

2. 응용 서비스는 얇아야 한다
응용 서비스(Application Service)의 명확한 역할
응용 서비스의 역할은 비즈니스 흐름 조립에 국한되어야 한다. 서비스는 스스로 비즈니스 판단을 내리지 않고 흐름만 제어하는 얇은 레이어여야 한다.

서비스 코드 내에 가격 계산이나 상태 유효성 검증 로직이 보이지 않는 이유는, 해당 책임이 도메인 메서드 내부로 캡슐화되었기 때문이다.
비즈니스 로직이 서비스 레이어로 유출될 때의 문제점
대표적인 안티 패턴이다.

이러한 구조는 당장은 동작할지 몰라도, 관리자 어드민, 배치 프로세스, 외부 API 등 동일한 도메인 행위를 호출하는 경로가 늘어날 때 치명적이다. 비즈니스 로직이 여러 서비스로 복사 및 붙여 넣기 되며, 정책이 변경될 때 일부 누락될 수 있다. 검증, 정책, 계산, 상태 전이는 도메인 엔티티 내부에 응집해야 한다.
도메인 분리 시 의존성 방향 주의하기
엔티티에 넣기 모호한 비즈니스 로직을 처리하기 위해 도메인 서비스를 활용할 때도 주의가 필요하다. 도메인 서비스의 메서드가 응용 계층의 DTO를 파라미터로 받는 순간, domain 패키지가 상위의 application 패키지를 참조하게 되며 아키텍처 규칙이 붕괴됩니다.

유스케이스 입력은 명확한 '명령(Command)' 객체로 수신하기
컨트롤러(Web) 영역의 DTO와 응용 서비스(Application) 영역의 Command가 어떻게 분리되는지, 주문(Order) 도메인을 기준으로 예시 코드를 살펴보자.
1. Web 레이어: 프레임워크 기술이 섞인 DTO
HTTP 요청을 받는 객체다. 여기에는 Spring Web Validation(@NotNull, @Min)이나 JSON 직렬화 어노테이션 등 웹 프레임워크 기술이 잔뜩 묻어 있다.

2. Application 레이어: 순수하고 불변인 Command
응용 서비스로 넘어오는 명령 객체다. 웹 프레임워크 어노테이션이 전혀 없는 순수 자바 record다.

3. 흐름 조립 (Controller ➡️ Service)
컨트롤러는 HTTP 요청을 받아 Command로 변환한 뒤, 서비스 계층으로 넘깁니다.

응용 서비스 파라미터에 @Valid나 @RequestBody의 잔재가 묻은 Request 객체가 들어오는 것을 차단한다. 서비스는 오직 비즈니스 의도가 담긴 불변 Command 레코드만 수신하므로, 입력 데이터의 오염을 막고 웹 프레임워크에 대한 종속성을 끊어낼 수 있다.
3. 애그리거트: 작게 쪼개고, 한 번에 하나만 수정하기
진입점은 오직 애그리거트 루트로 한정하기
함께 변경되어야 하는 일관성 있는 객체들의 묶음을 애그리거트라고 부른다. 주문(Order)과 주문 하위 항목(OrderLine)이 대표적이다. 외부 객체는 오직 애그리거트의 대표인 루트(Order)를 통해서만 내부 모델에 접근할 수 있어야 한다.
외부에 도메인 내부 컬렉션을 날것으로 노출하거나 바깥에서 하위 객체를 직접 수정하는 행위는 차단한다.

"주문 총액은 각 항목 합계의 총합이어야 한다"는 비즈니스 불변식을 지키기 위함이다. 진입 경로를 addLine 메서드 하나로 제한하면, 데이터 정합성이 깨질 수 있는 범위도 이 메서드 내부로 좁혀진다.
애그리거트 크기 최소화하기
애그리거트는 비즈니스 불변식을 유지하는 데 필요한 최소한의 객체만 포함해야 한다. 애그리거트 덩어리가 커질수록 JPA 데이터 로딩 성능이 저하되고, DB 트랜잭션 잠금 범위가 넓어져 동시성 충돌이 빈번하게 발생한다. 객체를 애그리거트에 포함시키기 전, 아래처럼 기준들을 검토해 보자.
| 객체 후보 | 관계성 분석 | 설계 결론 |
| 배송지 정보 | 주문과 생명주기가 완전히 일치하며, 독자적으로 식별할 필요가 없음 | 값 객체로 변환하여 Order에 포함 |
| 결제 정보 | 고유한 상태 흐름을 가지며 별도 생명주기로 움직임 | 독립된 애그리거트로 분리, ID 참조로 연동 |
| 회원 정보 | 주문의 생성/소멸과 무관하게 존재함 | 독립된 애그리거트로 분리, ID 참조로 연동 |
무상태 엔티티 지양하기
Getter와 Setter만 존재하고 비즈니스 행위 메서드가 없는 엔티티를 빈약한 도메인 모델이라 부른다. 이 경우 "현재 상태에서 취소가 가능한가?" 같은 비즈니스 규칙이 엔티티 외부의 서비스 계층으로 조각조각 흩어지게 된다.
데이터와 규칙을 동일한 객체 내부에 응집시키면 코드가 명확해진다.

타 애그리거트 참조 시 객체가 아닌 고유 ID 타입 활용하기
Order 엔티티가 Customer 엔티티 객체를 통째로 인스턴스 필드로 참조하면, 무의식적으로 한 트랜잭션 내에서 두 애그리거트를 동시에 수정하려는 유혹에 빠지기 쉽고 객체 그래프 탐색에 따른 과도한 조인 조회가 발생한다. 따라서 식별자(ID)만 남기고 의존성을 끊는 게 좋다. 상세 정보가 필요하다면 응용 서비스 계층에서 각각 조회해 조립하자.
이때 식별자는 Long이나 UUID 같은 자바 기본 원시 타입이 아닌, CustomerId, OrderId 같은 전용 값 타입(Typed ID)으로 감싸는 것을 권장한다. 타입이 명확하면 placeOrder(orderId, customerId)의 파라미터 순서를 실수로 바꿔 넣는 오류를 컴파일 단계에서 차단할 수 있다.
하나의 트랜잭션에서는 단 하나의 애그리거트만 수정하기
이는 시스템의 확장성과 결합도를 낮추기 위한 권장 수칙이다.
복수의 애그리거트를 한 트랜잭션 안에서 묶어 갱신하면 락 범위가 결합되어 성능 병목이 발생한다. 실시간 동기화가 반드시 필요한 핵심 불변식만 단일 애그리거트로 묶고, 그 외의 영역은 트랜잭션을 분리하여 최종 일관성 모델로 전환하는 것이 바람직하다.
유효하지 않은 도메인 객체의 생성 차단하기
생성 규칙이 누락되어 필수 필드가 비어있거나 금액 조건이 깨진 엔티티가 메모리에 인스턴스화되는 것 자체가 잠재적 버그의 원인이다. 생성 단계에서 정합성을 강제할 수 있도록, 무분별한 public 생성자 대신 의도가 명확한 정적 팩토리 메서드 내부에 유효성 검증을 밀집시키자.

4. 모델로 표현하기
도메인 개념을 명확한 값 객체로 구체화하기
금액 데이터를 원시 타입으로 관리하면 통화 개념이 누락되고 음수 값이 입력되는 것을 막지 못하며, 원화와 달러화가 아무런 제약 없이 합산되는 심각한 논리 오류가 발생할 수 있다.
비즈니스적 의미를 가지는 개념은 내부에 제약 조건을 품는 고유한 값 객체로 격리해야 한다.

계산용 값 객체에는 record가 최고지만, DB에 저장할 땐 조심해야 한다.
DB 저장 없이 메모리에서 계산용으로만 쓰는 값 객체라면 자바의 record를 쓰는 것이 가장 깔끔하다. 하지만 이 record를 엔티티에 포함시키려고 @Embeddable을 붙였더니 빌드 단계에서 에러가 터졌다. 범인은 JPA(Hibernate)가 아니라 QueryDSL이었다.
최신 하이버네이트는 record 임베디드를 지원하지만, 프로젝트에서 사용하는 QueryDSL 5.1.0의 코드 생성기(APT)가 record 기반의 Q클래스를 만들어내지 못하는 기술적 한계가 있기 때문이다.
따라서 DB에 저장해야 하고 QueryDSL 검색 조건으로도 쓰이는 값 객체라면, 당분간은 record 대신 기존의 불변 클래스 형태로 우회해서 구현해야 한다.


기술적 동작이 아닌 비즈니스 행위로 메서드 명명하기
도메인 모델의 메서드 이름은 단순 상태 변경 코드처럼 보이지 않고 비즈니스 시나리오가 그대로 읽혀야 한다.
| 변경 전 (데이터 중심 설계) | 변경 후 (도메인 행위 주도 설계) |
| order.setStatus(OrderStatus.CANCELLED) | order.cancel(reason) |
| payment.setSuccess(true) | payment.capture() |
| OrderManager (절차적 매니저 클래스) | Order, PlaceOrderService (역할 분담) |
유효한 상태 전이 규칙을 캡슐화하기
주문 상태 변화 흐름(PLACED → PAID → SHIPPED) 중 '배송이 시작된 상품은 이전 단계로 되돌아갈 수 없다' 혹은 '취소할 수 없다'와 같은 상태 제약 규칙은 서비스 레이어가 아닌 Enum 클래스 혹은 엔티티 내부에서 방어해야 한다.

상태 제약 조건을 엔티티 내부 규칙(OrderStatus.isCancellable())에 묶어두면, 외부 어떤 경로를 통해 상태 변경이 호출되더라도 한 곳에서 예외를 발생시키며 오염을 방지한다.
외부에서 인입되는 데이터는 번역해서 들인다
결제 PG사 API가 던져주는 "DONE", "CANCELED" 같은 날것의 응답 문자열을 핵심 비즈니스 로직(도메인)까지 그대로 끌고 들어오면 위험하다. 훗날 PG사를 교체하거나 PG사 측에서 응답값을 바꾸기라도 하면 비즈니스 로직이 연쇄적으로 터지기 때문이다.
따라서 외부 데이터가 우리 시스템으로 들어오는 가장 바깥쪽 관문(인프라 어댑터)에서, 우리 도메인이 이해할 수 있는 안전한 내부 전용 타입(Enum이나 VO)으로 변환하여 넘겨주어야 한다. 이를 통해 외부 스펙의 변경이 도메인 코드를 오염시키는 것을 막아낼 수 있다.
1. 도메인 영역
도메인은 외부 PG사의 존재나 응답 스펙을 전혀 모른다. 오직 우리 비즈니스의 상태값만 정의한다.

2. 인프라 영역 (번환을 수행하는 어댑터)
외부 연동을 담당하는 어댑터가 부패 방지 계층(ACL, Anti-Corruption Layer) 역할을 수행하여, 외부의 날것을 내부의 안전한 타입으로 변환한다.

요구사항 변화 속에서 아키텍처가 무너지는 징후는 대동소이하다.
위 내용들을 전부 지키기 어렵다면 아래 다섯 가지 징후를 추적하자.
1. 도메인 비즈니스 정책이 서비스 레이어에 노출되어 있는가?
- 해당 로직을 엔티티 내부나 무상태 도메인 서비스로 격리하고 서비스 레이어는 단순 흐름 제어만 담당하도록 수정한다.
2. 화폐 금액, 데이터 수량, 엔티티 식별자가 여전히 자바 원시 타입(long, String, UUID)으로 유출되고 있는가?
- 도메인 제약 조건을 스스로 검증하는 전용 값 객체(VO)와 타입 지정 ID(Typed ID)로 감싸 캡슐화한다.
3. 엔티티 클래스가 행위 메서드 없이 Getter/Setter로만 채워져 있는가?
- Setter 사용을 전면 금지하고 비즈니스 의도가 반영된 행위 중심 명명 메서드로 대체한다.
4. 도메인 레이어 패키지 내부에 Spring 프레임워크나 환경 의존적 컴포넌트가 침투했는가?
- 인프라 종속성을 수동으로 검증하는 대신 ArchUnit 테스트 자동화 스크립트를 빌드 파이프라인에 이식하여 원천 격리한다.
5. 하나의 DB 트랜잭션 범위 안에서 서로 다른 다수의 애그리거트 루트들을 동시에 수정하고 있는가?
- 애그리거트 간 결합도를 낮추기 위해 유스케이스 단위별로 트랜잭션을 분할하거나 최종 일관성 설계를 적용한다.
정리를 하다 보니 DDD는 결국 한 문장으로 수렴한다.
"비즈니스 정책과 규칙은 도메인 객체 내부에 응집시키고, 기술 프레임워크는 도메인 경계 바깥에 위치시킨다."
출처
https://github.com/HongJungWan/opinionated-harness-template
GitHub - HongJungWan/opinionated-harness-template: DDD 원칙을 강제하는 하네스 엔지니어링 ⚙️
DDD 원칙을 강제하는 하네스 엔지니어링 ⚙️. Contribute to HongJungWan/opinionated-harness-template development by creating an account on GitHub.
github.com
https://github.com/HongJungWan/knowledge-search
GitHub - HongJungWan/knowledge-search: 사내 지식 검색 시스템 (RAG) 📚
사내 지식 검색 시스템 (RAG) 📚. Contribute to HongJungWan/knowledge-search development by creating an account on GitHub.
github.com
https://github.com/HongJungWan/metadata-ontology
GitHub - HongJungWan/metadata-ontology: 온톨로지 기반 메타데이터 🌐
온톨로지 기반 메타데이터 🌐. Contribute to HongJungWan/metadata-ontology development by creating an account on GitHub.
github.com
'Dev' 카테고리의 다른 글
| PostgreSQL WAL (Write-Ahead Logging) 데이터 구조 (0) | 2026.05.20 |
|---|---|
| 코빗은 왜 체결 엔진에 Rust를 선택했을까? (feat. 추리) (1) | 2026.04.22 |
| Kafka 간 맞추기 (1) | 2026.04.20 |
| 샤갈!! 저 Temporal Workflow 엔진 도입했어요!! (0) | 2026.04.12 |
| 와 CDC(Change Data Capture)! Debezium 로그 기반 아시는구나! (0) | 2026.04.08 |