처음 백엔드를 배울 때를 떠올려보면, 항상 ERD부터 그렸다. 요구사항을 받으면 "어떻게 저장할까"를 먼저 고민하고, 테이블을 만들고, FK를 지정하고, 그걸 그대로 엔티티로 옮기는 방식이었다. 당시엔 그게 자연스러운 흐름이라고 생각했는데, 프로젝트가 조금만 커져도 코드가 무섭게 얽히기 시작했다.
post.getUser().getOrders().get(0).getProduct().getStore()...
이런 코드를 본 적 있는가. 어디서 무슨 데이터를 가져오는지 추적이 안 되고, 고치면 예상치 못한 곳에서 에러가 터진다. DDD(Domain-Driven Design, 도메인 주도 설계)는 바로 이 문제를 해결하기 위한 사고방식이다.
도메인(비즈니스)을 중심으로 소프트웨어를 설계하는 방법론이다.
집을 지을 때를 생각해보자. 기존 방식은 벽돌부터 쌓고 나중에 설계도를 맞추는 식이다. 반면 DDD는 거주자의 생활 패턴을 먼저 분석한 뒤 설계에 들어간다. "어떻게 저장할까"가 아니라 "무엇을 해결할까" 를 먼저 고민하는 것이다.
핵심 포인트는 세 가지다.
DDD는 단순한 설계 패턴이 아니다. 비즈니스를 깊이 이해하고, 그것을 코드로 표현하는 사고방식이다.
요구사항 분석 → ERD 설계(FK 설정) → 엔티티 구현(연관 관계 매핑) → 서비스 로직 작성
DB에 FK가 있으니 엔티티에도 연관 관계를 맺어야 한다고 생각하게 된다. 그런데 여기서 근본적인 문제가 하나 있다.
관계형 DB의 JOIN과 객체의 참조는 완전히 다른 개념이다.
orderItem.getOrder().getUser().getCart()... 끝없는 객체 탐색 발생, JPA N+1 문제 폭발| 문제 | 설명 |
|---|---|
| 스파게티 의존성 | 꼬리에 꼬리를 무는 참조, 어디서 데이터를 가져오는지 추적 불가 |
| 일관성 없는 코드 | A 개발자는 연관 관계로, B 개발자는 Repository로 직접 호출 |
| 도메인 경계 모호 | OrderService 안에 StoreRepository, UserRepository 등이 뒤섞임 |
| 확장 불가능한 구조 | 프로젝트가 커질수록 모든 게 엮여 MSA(마이크로서비스 아키텍처) 전환 시 처음부터 재작성 |
비즈니스 영역의 경계선이다. 큰 시스템을 의미 있는 단위로 나누는 방법이다.
회사의 '부서'를 떠올리면 이해가 쉽다.
각 부서는 독립적으로 운영되지만, 필요할 때는 공식 채널로 소통한다. 같은 단어라도 컨텍스트를 넘어가면 의미가 달라진다. 인사부의 '직원'과 재무부의 '직원'이 바라보는 관심사는 전혀 다른 것처럼.
유저 컨텍스트 | User, UserAddress
상품 컨텍스트 | Product
주문 컨텍스트 | Order, OrderItem
결제 컨텍스트 | Payment
장바구니 컨텍스트 | Cart, CartItem
가게 컨텍스트 | Store, StoreAddress, StoreCategory
각 컨텍스트 안에서는 고유한 비즈니스 규칙을 갖고, 다른 컨텍스트와는 인터페이스로만 소통한다.
강결합 (기존 방식)
User ←→ Order ←→ Product ←→ Payment
모두가 서로를 직접 참조
→ 하나 바꾸면 전부 영향
느슨한 결합 (DDD 방식)
[User 컨텍스트] [Order 컨텍스트] [Product 컨텍스트] [Payment 컨텍스트]
각 도메인이 독립적
→ API / 이벤트로만 소통
방법 1 — Facade 패턴 (모놀리식 아키텍처)
다른 도메인의 Repository를 직접 쓰지 않고, Facade(퍼사드 — 복잡한 내부 시스템을 단순한 인터페이스로 감싸는 디자인 패턴)를 통해 접근한다.
// 주문 서비스에서 유저 정보가 필요할 때
// userRepository.findById()를 직접 주입하지 않고, Facade를 경유한다
// → OrderService가 UserRepository를 알 필요가 없어진다
UserDto user = userFacade.getUserById(userId);
// 상품 정보가 필요할 때도 마찬가지
ProductDto product = productFacade.getProductById(productId);
방법 2 — API / 이벤트 (MSA 아키텍처)
// REST API로 다른 서비스 조회
// FeignClient(선언적 HTTP 클라이언트)나 RestClient를 활용한다
UserDto user = restClient.get()
.uri("/api/users/{userId}", userId)
.retrieve()
.body(UserDto.class);
// 또는 Kafka 이벤트 발행으로 느슨하게 연결
// 주문 서비스가 결제 서비스를 직접 호출하지 않고, 이벤트만 발행한다
kafkaTemplate.send("order-topic", orderEvent);
쿠팡 주문서를 생각해보자. 주문(Order)에는 항상 이것들이 함께 따라온다.
Order (애그리거트 루트)
├── OrderItem (주문 상품 목록)
├── ShippingInfo (배송 정보)
└── Orderer (주문자 정보)
OrderItem, ShippingInfo, Orderer는 주문서 없이 혼자 존재할 수 없다. 수정하려면 반드시 Order를 통해야 한다.
만약 OrderItem을 직접 수정해버리면? 총 결제 금액은 그대로인데 수량만 바뀌는 데이터 불일치 버그가 발생한다.
| 문제 | 상황 |
|---|---|
| 데이터 불일치 | orderItem.setQuantity(5) → Order.totalPrice는 그대로 |
| 비즈니스 규칙 무시 | 이미 출고된 주문의 배송지가 상태 체크 없이 변경됨 |
| Repository 남발 | OrderItemRepository를 따로 만들어 직접 CRUD → 데이터 정합성 붕괴 |
| 사이드 이펙트 추적 불가 | 여러 Service에서 OrderItem을 수정 → 어디서 바뀌었는지 불명 |
// ❌ BEFORE — 루트를 무시한 경우
// OrderItemRepository가 별도로 존재한다는 것 자체가 설계 문제다
OrderItem item = orderItemRepository.findById(itemId);
item.setQuantity(5); // Order.totalPrice는 그대로 → 데이터 불일치
item.setPrice(5000); // 비즈니스 규칙 전부 무시
// ✅ AFTER — 루트(Order)를 통해서만 변경
// OrderRepository 하나만 존재한다. OrderItem은 Order를 통해서만 접근한다.
Order order = orderRepository.findById(orderId);
order.changeQuantity(itemId, 5);
// 내부에서 자동으로:
// 1. totalPrice 재계산
// 2. 재고 검증 로직 실행
// 3. 불변식(Invariant — "총액 = Σ(수량×단가)"가 항상 참) 보장
루트만 접근 가능하고, 내부는 캡슐화된다. 이것이 곧 객체지향의 핵심 원칙인 캡슐화다.
public class Order {
private static final int MAX_ORDER_ITEMS = 10;
private List<OrderItem> orderItems;
private int totalPrice;
// 생성자에서 검증 — Order는 반드시 OrderItem과 함께 생성된다
public Order(List<OrderItem> orderItems) {
validateOrderItems(orderItems); // 빈 목록이거나 10개 초과면 예외 발생
this.orderItems = orderItems;
this.totalPrice = calculateTotal(orderItems);
}
// 수량 변경 — 외부에서 직접 item.setQuantity() 대신 이 메서드를 호출한다
public void changeQuantity(Long itemId, int quantity) {
OrderItem item = findItemById(itemId); // 루트 내부에서 탐색
item.updateQuantity(quantity); // 내부 객체 수정
this.totalPrice = calculateTotal(orderItems); // 불변식 보장
}
// 검증 로직은 루트가 책임진다
private void validateOrderItems(List<OrderItem> orderItems) {
if (orderItems == null || orderItems.isEmpty()) {
throw new IllegalArgumentException("주문 항목은 비어 있을 수 없습니다.");
}
if (orderItems.size() > MAX_ORDER_ITEMS) {
throw new IllegalArgumentException(
"주문 상품 수가 " + MAX_ORDER_ITEMS + "개를 초과하였습니다."
);
}
}
}
| 질문 | 예시 |
|---|---|
| 함께 변경되는가? | 수량 바꾸면 총액도 바뀜 → Order, OrderItem은 같은 애그리거트 |
| 불변식이 있는가? | "총액 = Σ(수량×단가)"가 항상 참이어야 함 → 같은 애그리거트 |
| 독립 생명주기인가? | 회원 탈퇴해도 주문 기록은 남음 → User, Order는 다른 애그리거트 |
| Cascade 걸 수 있는가? | Order 삭제 시 OrderItem도 삭제 → 같은 애그리거트 |
OrderItem과 Product는 서로 독립적인 관계다. Product 가격이 나중에 바뀌어도, 주문 당시의 가격은 그대로 보존되어야 한다. 이것이 진짜 비즈니스 규칙이다.
// ❌ 기존 방식 — 다른 애그리거트를 직접 참조
// Product가 변경되면 OrderItem도 영향을 받는다
@ManyToOne
@JoinColumn(name = "product_id")
private Product product;
// ✅ DDD 방식 — ID로만 간접 참조
// 주문 당시의 상품명, 가격은 OrderItem에 직접 스냅샷으로 저장한다
private Long productId; // 다른 애그리거트는 ID로만 참조
private String productName; // 주문 당시 상품명 스냅샷
private int price; // 주문 당시 가격 스냅샷 (이후 Product 가격이 바뀌어도 무관)
| 규칙 | 내용 |
|---|---|
| Repository는 루트만 | OrderRepository는 있지만 OrderItemRepository는 만들지 않는다 |
| 불변식은 루트가 보장 | "총액 = Σ(수량×단가)" 규칙은 루트 메서드 안에서만 검증한다 |
| 다른 애그리거트는 ID 참조 | 객체 직접 참조 대신 ID만 저장, 결합도를 낮추고 MSA 전환을 대비한다 |
기존 흐름과 DDD 흐름을 비교해보면 이렇다.
# 기존 (데이터 중심)
요구사항 → ERD → 엔티티(FK 기반 연관 관계) → 서비스 → Repository 마구 주입
결과: 강결합, 도메인 경계 모호
# DDD (도메인 중심)
요구사항 → 도메인 모델 → 바운디드 컨텍스트 → 애그리거트 → 코드
결과: 느슨한 결합, 명확한 경계, MSA 전환 대비 가능
"이 프로젝트에서 어떤 비즈니스 영역이 존재하는가?"를 먼저 묻는다.
유저 / 상품 / 주문 / 결제 / 장바구니 / 가게 / 리뷰 / 카테고리
draw.io 같은 도구로 화이트보드처럼 펼쳐두고, 팀원들과 함께 식별하는 것이 좋다.
유저 (대표 도메인)
└─ 배송지, 역할(권한) (하위 도메인)
주문 (대표 도메인)
└─ 주문 아이템 (하위 도메인)
장바구니 (대표 도메인)
└─ 장바구니 아이템 (하위 도메인)
이것이 곧 애그리거트의 기반이 된다. 대표 도메인이 루트가 되고, 하위 도메인이 루트에 종속되는 구조다.
사용자 컨텍스트 → User (루트), UserAddress, UserRole
가게 컨텍스트 → Store (루트), StoreAddress
상품 컨텍스트 → Product (루트)
주문 컨텍스트 → Order (루트), OrderItem
결제 컨텍스트 → Payment (루트), PaymentGateway
장바구니 컨텍스트 → Cart (루트), CartItem
리뷰 컨텍스트 → Review (루트), Comment
com.example
├── user/
│ ├── presentation/ (UserController)
│ ├── application/ (UserService)
│ ├── domain/ (User, UserAddress, UserRepository)
│ └── infrastructure/ (UserJpaRepository)
│
├── order/
│ ├── presentation/ (OrderController)
│ ├── application/ (OrderService)
│ ├── domain/ (Order, OrderItem, OrderRepository)
│ │ // OrderItemRepository는 없다
│ └── infrastructure/ (OrderJpaRepository)
│
└── product/
├── presentation/ (ProductController)
├── application/ (ProductService)
├── domain/ (Product, ProductRepository)
└── infrastructure/ (ProductJpaRepository)
핵심:
OrderItemRepository는 존재하지 않는다.Order가 루트이므로,OrderRepository하나로OrderItem까지Cascade(영속성 전이 — 루트 저장 시 하위 객체도 함께 저장/삭제)를 통해 함께 관리한다.
| 개념 | 비유 | 목적 |
|---|---|---|
| 바운디드 컨텍스트 | 회사의 인사부, 재무부, 영업부 | 서로 다른 업무 규칙이 섞이지 않도록 큰 울타리를 치는 것 |
| 애그리거트 | 재무부 안의 '급여 정산 묶음' | 데이터 일관성(불변식)을 지키기 위한 최소 단위의 보호막 |
바운디드 컨텍스트가 "어느 부서의 일인가"를 구분한다면, 애그리거트는 "그 부서 안에서 절대 깨지면 안 되는 데이터 묶음"을 정의하는 것이다.
DDD가 모든 프로젝트에 적합하지는 않다.
DDD가 적합한 경우 (도메인 모델 패턴)
DDD보다 단순한 트랜잭션 스크립트 패턴이 맞는 경우
현실적인 이야기를 하자면, 이미 레거시 코드베이스로 구축된 환경이 훨씬 많다. 이 경우 전면적인 DDD 전환보다는 핵심 도메인부터 점진적으로 경계를 도입하는 전략이 현실적이다. "완벽한 DDD"를 처음부터 달성하려다 오히려 오버엔지니어링(over-engineering — 필요 이상으로 복잡하게 설계하는 것)에 빠지는 경우도 많다.
DDD를 공부하다 보면 자연스럽게 만나게 되는 개념들이 있다. 간단히 소개한다.
도메인 로직을 외부(DB, 프레임워크, UI)로부터 완전히 격리하는 구조다. DDD의 "도메인이 중심"이라는 사상과 자연스럽게 맞닿아 있다. 현재 Spring Boot와 함께 가장 많이 언급되는 아키텍처 패턴 중 하나다.
[외부 세계] ↔ [Port(인터페이스)] ↔ [Domain(핵심 로직)] ↔ [Port] ↔ [Adapter(DB, API, ...)]
명령(Command — 데이터를 변경하는 요청)과 조회(Query — 데이터를 읽는 요청)를 분리하는 패턴이다. 쓰기 모델과 읽기 모델을 분리해 성능과 유지보수성을 동시에 잡는다.
쓰기: OrderService.placeOrder() → 비즈니스 로직 수행, 이벤트 발행
읽기: OrderQueryService.getOrderDetail() → 복잡한 JOIN, 별도 최적화 가능
기획자, 도메인 전문가, 개발자가 함께 포스트잇으로 비즈니스 이벤트를 시각화하는 워크숍(협업 세션) 기법이다. 바운디드 컨텍스트를 발견하는 데 매우 효과적이며, DDD 도입 초기에 많이 활용한다.
MSA처럼 바운디드 컨텍스트로 모듈을 명확히 분리하되, 하나의 배포 단위(모놀리식)를 유지하는 구조다. MSA의 분산 시스템 복잡도 없이 DDD의 이점을 누릴 수 있어, 2025~2026년 기준 중소 규모 팀에서 현실적인 선택지로 주목받고 있다.
DDD를 처음 접했을 때 "설계 이야기가 이렇게 깊어질 줄 몰랐다"는 느낌이 든다. ERD 그리고 엔티티 만들면 끝이라고 생각했는데, 사실 그 이전에 "무엇을 만들고 있는가"를 제대로 이해하는 과정이 있었던 것이다.
AI가 단순 CRUD 코드를 대신 짜주는 시대가 이미 왔다. 그 흐름 속에서 개발자의 진짜 경쟁력은 복잡한 도메인을 분석하고 경계를 나누는 설계 역량에 있다고 생각한다. DDD는 그 역량을 키우는 훈련이다.
채용공고 우대사항에 "DDD, MSA 설계 경험"이 등장하는 건 우연이 아니다.