개발지식

DDD(Domain-Driven Design) 도메인 주도 설계란 무엇인가

JohnnyDeveloper 2025. 12. 29. 13:37

DDD(Domain-Driven Design)
도메인 주도 설계란 무엇인가

복잡한 비즈니스 로직을 다루는 현실적인 설계 방법론

DDD란 무엇인가?

DDD(Domain-Driven Design, 도메인 주도 설계)는 Eric Evans가 2003년 출간한 동명의 책에서 소개한 소프트웨어 설계 방법론입니다. DDD의 핵심은 비즈니스 도메인에 깊이 집중하여 이를 기반으로 소프트웨어를 설계하는 것입니다. 단순히 데이터베이스 테이블을 객체로 매핑하거나 CRUD 작업을 구현하는 것이 아니라, 실제 비즈니스 문제와 규칙을 코드로 표현하는 것이 목표입니다.

전통적인 개발 방식에서는 데이터베이스 스키마를 먼저 설계하고, 그에 맞춰 애플리케이션을 개발하는 경우가 많습니다. 이러한 데이터 중심 접근 방식은 단순한 CRUD 애플리케이션에서는 효과적이지만, 복잡한 비즈니스 로직을 다루기에는 한계가 있습니다. DDD는 이와 달리 비즈니스 도메인을 먼저 이해하고 모델링한 후, 그에 맞는 소프트웨어 구조를 설계합니다.

전통적 접근 방식 vs DDD 접근 방식
flowchart LR subgraph Traditional["전통적 접근 방식"] DB1[데이터베이스
스키마 설계] --> CODE1[코드
작성] CODE1 --> BIZ1[비즈니스 로직
구현] end subgraph DDD["DDD 접근 방식"] DOMAIN[도메인
이해 및 모델링] --> MODEL[도메인 모델
설계] MODEL --> IMPL[구현] end style DB1 fill:#e74c3c,color:#fff style DOMAIN fill:#3498db,color:#fff style MODEL fill:#3498db,color:#fff

도메인이란?

도메인(Domain)은 소프트웨어가 해결하고자 하는 문제 영역을 의미합니다. 예를 들어 전자상거래 시스템이라면 '주문', '결제', '배송', '재고 관리' 등이 도메인이 됩니다. 각 도메인은 고유한 비즈니스 규칙과 용어, 프로세스를 가지고 있으며, DDD에서는 이러한 도메인 지식을 소프트웨어 설계의 중심에 둡니다.

유비쿼터스 언어

DDD의 가장 중요한 개념 중 하나는 유비쿼터스 언어(Ubiquitous Language)입니다. 이는 도메인 전문가와 개발자가 공통으로 사용하는 언어를 의미합니다. 개발자는 기술 용어를, 도메인 전문가는 비즈니스 용어를 사용하면 의사소통에 문제가 생깁니다. 유비쿼터스 언어를 정의하고 사용함으로써 팀 전체가 동일한 이해를 바탕으로 소프트웨어를 개발할 수 있습니다.

유비쿼터스 언어의 예시

나쁜 예: "사용자가 상품 데이터를 장바구니 테이블에 INSERT 합니다"

좋은 예: "고객이 상품을 장바구니에 담습니다"

코드, 문서, 대화 모두에서 동일한 용어를 사용해야 합니다.

왜 DDD가 필요한가

모든 프로젝트에 DDD가 필요한 것은 아닙니다. 단순한 CRUD 애플리케이션이나 데이터 입출력이 주된 목적인 시스템에서는 DDD가 오히려 과도한 복잡성을 가져올 수 있습니다. 하지만 복잡한 비즈니스 로직을 다루는 시스템에서는 DDD가 큰 가치를 제공합니다.

복잡한 비즈니스 로직 관리

금융, 보험, 전자상거래, 물류 등의 도메인은 복잡한 비즈니스 규칙을 가지고 있습니다. 예를 들어 주문 시스템에서는 재고 확인, 할인 적용, 배송비 계산, 결제 처리, 포인트 적립 등 여러 규칙이 얽혀 있습니다. 이러한 복잡성을 체계적으로 관리하지 않으면 코드는 쉽게 스파게티가 됩니다.

// DDD 없이 작성된 주문 서비스 (안티패턴) public class OrderService { public void createOrder(OrderRequest request) { // 비즈니스 로직이 서비스 레이어에 산재 if (request.getTotalPrice() > 50000) { request.setShippingFee(0); } else { request.setShippingFee(3000); } if (request.getCustomerGrade().equals("VIP")) { request.setDiscount(request.getTotalPrice() * 0.1); } // 데이터베이스에 저장 orderRepository.save(request); } } // DDD를 적용한 주문 도메인 public class Order { private OrderId id; private Customer customer; private List<OrderLine> orderLines; private Money totalAmount; private ShippingInfo shippingInfo; // 도메인 로직이 도메인 객체 안에 캡슐화됨 public Money calculateTotalAmount() { Money orderAmount = calculateOrderAmount(); Money shippingFee = calculateShippingFee(orderAmount); Money discount = customer.calculateDiscount(orderAmount); return orderAmount.plus(shippingFee).minus(discount); } private Money calculateShippingFee(Money orderAmount) { return shippingInfo.calculateFee(orderAmount); } private Money calculateOrderAmount() { return orderLines.stream() .map(OrderLine::getAmount) .reduce(Money.ZERO, Money::plus); } }

비즈니스와 코드의 일치

DDD를 적용하면 비즈니스 요구사항과 코드가 긴밀하게 일치합니다. 도메인 전문가가 말하는 용어가 그대로 클래스 이름, 메서드 이름으로 사용되므로 코드를 읽는 것만으로도 비즈니스 로직을 이해할 수 있습니다. 이는 유지보수성을 크게 향상시키며, 새로운 팀원이 코드베이스를 이해하는 시간을 단축시킵니다.

변경에 강한 구조

비즈니스 요구사항은 끊임없이 변화합니다. DDD는 도메인 모델을 중심으로 설계하기 때문에 비즈니스 규칙이 변경되어도 해당 도메인 객체만 수정하면 됩니다. 데이터베이스 스키마나 외부 시스템과의 결합도가 낮아 변경의 영향 범위를 최소화할 수 있습니다.

DDD가 적합한 프로젝트

복잡한 비즈니스 규칙: 단순 CRUD를 넘어서는 복잡한 로직이 있는 경우

장기 프로젝트: 지속적으로 유지보수하고 확장할 시스템

도메인 전문가와 협업: 도메인 지식이 중요하고 전문가와 긴밀히 협업하는 경우

마이크로서비스: 서비스 경계를 명확히 정의해야 하는 분산 시스템

전략적 설계

DDD는 크게 전략적 설계(Strategic Design)와 전술적 설계(Tactical Design)로 나뉩니다. 전략적 설계는 큰 그림을 그리는 단계로, 시스템을 어떻게 나눌 것인지, 각 부분이 어떻게 협력할 것인지를 정의합니다. 전술적 설계는 각 도메인 내부를 어떻게 구현할 것인지에 대한 구체적인 설계입니다.

Bounded Context

Bounded Context(경계 지어진 컨텍스트)는 DDD의 가장 중요한 전략적 패턴입니다. 하나의 큰 시스템을 명확한 경계를 가진 여러 컨텍스트로 나누는 것입니다. 각 컨텍스트는 독립적인 모델을 가지며, 같은 용어라도 컨텍스트에 따라 다른 의미를 가질 수 있습니다.

예를 들어 '고객(Customer)'이라는 개념은 주문 컨텍스트에서는 '주문자', 배송 컨텍스트에서는 '수취인', 마케팅 컨텍스트에서는 '타겟 세그먼트'로 다르게 모델링될 수 있습니다. 각 컨텍스트에서 필요한 속성과 행위만 가지므로 불필요한 복잡성을 제거할 수 있습니다.

전자상거래 시스템의 Bounded Context
graph TB subgraph OrderContext["주문 컨텍스트"] Order[Order
주문 처리] Customer1[Customer
주문자 정보] end subgraph PaymentContext["결제 컨텍스트"] Payment[Payment
결제 처리] Customer2[Customer
결제자 정보] end subgraph ShippingContext["배송 컨텍스트"] Shipping[Shipping
배송 처리] Customer3[Customer
수취인 정보] end subgraph InventoryContext["재고 컨텍스트"] Inventory[Inventory
재고 관리] Product[Product
상품 정보] end Order -.->|주문 생성 이벤트| Payment Payment -.->|결제 완료 이벤트| Shipping Order -.->|재고 차감 요청| Inventory style OrderContext fill:#3498db,color:#fff style PaymentContext fill:#2ecc71,color:#fff style ShippingContext fill:#e74c3c,color:#fff style InventoryContext fill:#f39c12,color:#fff

Context Mapping

여러 Bounded Context가 있을 때, 이들 간의 관계를 정의하는 것이 Context Mapping입니다. 각 컨텍스트가 완전히 독립적일 수는 없으며, 필연적으로 상호작용이 발생합니다. DDD에서는 이러한 관계를 명시적으로 정의하는 여러 패턴을 제공합니다.

패턴 설명 사용 시나리오
Shared Kernel 두 팀이 공통 모델을 공유 긴밀히 협력하는 팀 간 핵심 도메인 공유
Customer-Supplier 상류(Supplier)와 하류(Customer) 관계 한쪽이 API를 제공하고 다른 쪽이 소비
Conformist 상류 팀의 모델을 그대로 따름 외부 시스템 통합 시 변경 불가능한 경우
Anti-Corruption Layer 외부 모델의 영향을 차단하는 변환 계층 레거시 시스템 통합 시 도메인 보호
Published Language 통합을 위한 공통 언어 정의 여러 시스템 간 표준 통신 포맷

Core Domain과 Supporting Domain

모든 도메인이 동일한 중요도를 가지는 것은 아닙니다. Core Domain은 비즈니스의 핵심 경쟁력을 제공하는 도메인으로, 가장 많은 투자와 관심이 필요합니다. Supporting Domain은 핵심 도메인을 지원하지만 경쟁 우위를 제공하지는 않는 도메인입니다. Generic Subdomain은 일반적인 기능으로 외부 솔루션을 사용할 수 있는 영역입니다.

// 전자상거래 시스템의 도메인 분류 예시 Core Domain (핵심 도메인) - 주문 처리 시스템 - 가격 및 프로모션 엔진 - 추천 알고리즘 → 경쟁 우위를 제공, 가장 많은 리소스 투입 Supporting Domain (지원 도메인) - 재고 관리 - 배송 추적 - 고객 관리 → 필요하지만 차별화 요소는 아님 Generic Subdomain (일반 하위 도메인) - 인증/인가 - 이메일 발송 - 결제 처리 (PG 연동) → 외부 서비스 활용 가능

전술적 설계

전술적 설계는 Bounded Context 내부를 어떻게 구현할 것인지에 대한 구체적인 패턴과 기법을 제공합니다. 이는 실제 코드 작성과 직접적으로 연관되는 부분으로, Entity, Value Object, Aggregate, Repository 등의 빌딩 블록을 사용합니다.

Layered Architecture

DDD는 일반적으로 계층화된 아키텍처를 사용합니다. 도메인 레이어를 중심에 두고, 다른 레이어들이 도메인을 지원하는 구조입니다. 이를 통해 비즈니스 로직을 인프라스트럭처 관심사로부터 분리할 수 있습니다.

DDD의 계층 구조
flowchart TB UI[Presentation Layer
사용자 인터페이스] APP[Application Layer
애플리케이션 서비스] DOMAIN[Domain Layer
도메인 모델
비즈니스 로직] INFRA[Infrastructure Layer
데이터베이스, 외부 API] UI --> APP APP --> DOMAIN DOMAIN -.-> INFRA APP -.-> INFRA style DOMAIN fill:#3498db,color:#fff style APP fill:#2ecc71,color:#fff style UI fill:#9b59b6,color:#fff style INFRA fill:#95a5a6,color:#fff

각 레이어의 역할은 명확히 구분됩니다. Presentation Layer는 사용자 인터페이스를 담당하고, Application Layer는 유스케이스를 조율하며, Domain Layer는 비즈니스 규칙을 구현하고, Infrastructure Layer는 기술적 세부사항을 처리합니다.

Domain Layer의 구성

Domain Layer는 DDD의 핵심으로, 비즈니스 로직이 집중되는 곳입니다. 이 레이어는 데이터베이스, 프레임워크, UI 등 외부 기술에 의존하지 않으며, 순수한 비즈니스 규칙만을 표현합니다. 이를 통해 비즈니스 로직의 테스트가 용이하고, 기술 스택 변경에도 영향을 받지 않습니다.

핵심 구성 요소

DDD의 전술적 설계에서는 도메인을 표현하기 위한 여러 빌딩 블록을 제공합니다. 이들을 올바르게 이해하고 사용하는 것이 DDD 구현의 핵심입니다.

Entity

Entity는 고유한 식별자를 가지며, 생명 주기 동안 속성이 변경되어도 동일성을 유지하는 객체입니다. 예를 들어 고객(Customer)은 이름이나 주소가 변경되어도 고객 ID로 식별되는 동일한 고객입니다.

// Entity 예시 public class Order { // 식별자 private OrderId id; // 속성 private Customer customer; private OrderStatus status; private List<OrderLine> orderLines; private LocalDateTime orderedAt; // Entity는 식별자로 동등성을 판단 @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Order)) return false; Order order = (Order) o; return Objects.equals(id, order.id); } @Override public int hashCode() { return Objects.hash(id); } // 비즈니스 로직 public void cancel() { if (this.status == OrderStatus.SHIPPED) { throw new IllegalStateException("배송 시작된 주문은 취소할 수 없습니다"); } this.status = OrderStatus.CANCELLED; } public void complete() { if (this.status != OrderStatus.PAID) { throw new IllegalStateException("결제되지 않은 주문은 완료할 수 없습니다"); } this.status = OrderStatus.COMPLETED; } }

Value Object

Value Object는 식별자가 없고, 속성값으로만 동등성을 판단하는 불변 객체입니다. 주소(Address), 금액(Money), 날짜 범위(DateRange) 등이 대표적인 예입니다. Value Object를 사용하면 도메인 개념을 명확히 표현하고, 유효성 검증을 캡슐화할 수 있습니다.

// Value Object 예시 public class Money { private final BigDecimal amount; private final Currency currency; // 생성자에서 유효성 검증 public Money(BigDecimal amount, Currency currency) { if (amount == null) { throw new IllegalArgumentException("금액은 null일 수 없습니다"); } if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("금액은 음수일 수 없습니다"); } if (currency == null) { throw new IllegalArgumentException("통화는 null일 수 없습니다"); } this.amount = amount; this.currency = currency; } // 불변성 유지 - 새로운 객체를 반환 public Money plus(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("통화가 다른 금액은 더할 수 없습니다"); } return new Money( this.amount.add(other.amount), this.currency ); } public Money minus(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("통화가 다른 금액은 뺄 수 없습니다"); } return new Money( this.amount.subtract(other.amount), this.currency ); } // Value Object는 속성으로 동등성 판단 @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Money)) return false; Money money = (Money) o; return Objects.equals(amount, money.amount) && currency == money.currency; } @Override public int hashCode() { return Objects.hash(amount, currency); } } // Address Value Object public class Address { private final String zipCode; private final String address1; private final String address2; public Address(String zipCode, String address1, String address2) { // 유효성 검증 if (zipCode == null || !zipCode.matches("\\d{5}")) { throw new IllegalArgumentException("우편번호 형식이 올바르지 않습니다"); } this.zipCode = zipCode; this.address1 = address1; this.address2 = address2; } // getter만 제공 (불변) public String getZipCode() { return zipCode; } public String getAddress1() { return address1; } public String getAddress2() { return address2; } }

Aggregate

Aggregate는 관련된 Entity와 Value Object를 하나의 단위로 묶은 것입니다. Aggregate Root는 Aggregate의 대표 Entity로, 외부에서는 반드시 Aggregate Root를 통해서만 Aggregate 내부에 접근할 수 있습니다. 이를 통해 일관성 경계를 명확히 하고, 불변 조건을 보장할 수 있습니다.

Aggregate 구조
graph TB subgraph OrderAggregate["Order Aggregate"] OrderRoot[Order
Aggregate Root] OrderLine1[OrderLine] OrderLine2[OrderLine] ShippingInfo[ShippingInfo
Value Object] OrderRoot --> OrderLine1 OrderRoot --> OrderLine2 OrderRoot --> ShippingInfo end External[외부] -.->|직접 접근 불가| OrderLine1 External -->|Aggregate Root를 통해 접근| OrderRoot style OrderRoot fill:#3498db,color:#fff style OrderLine1 fill:#95a5a6,color:#fff style OrderLine2 fill:#95a5a6,color:#fff style ShippingInfo fill:#95a5a6,color:#fff
// Aggregate 예시 public class Order { // Aggregate Root private OrderId id; private Customer customer; private List<OrderLine> orderLines; private ShippingInfo shippingInfo; private OrderStatus status; // Aggregate 내부 엔티티/밸류는 Aggregate Root를 통해서만 수정 public void addOrderLine(Product product, int quantity) { validateOrderStatus(); OrderLine orderLine = new OrderLine(product, quantity); this.orderLines.add(orderLine); } public void removeOrderLine(int index) { validateOrderStatus(); if (index < 0 || index >= orderLines.size()) { throw new IllegalArgumentException("잘못된 주문 항목 인덱스"); } this.orderLines.remove(index); } public void changeShippingInfo(ShippingInfo newShippingInfo) { validateOrderStatus(); this.shippingInfo = newShippingInfo; } private void validateOrderStatus() { if (status != OrderStatus.PENDING) { throw new IllegalStateException( "주문 대기 상태에서만 수정할 수 있습니다" ); } } // OrderLine에 직접 접근하는 메서드는 제공하지 않음 // 대신 필요한 비즈니스 로직을 Aggregate Root에 구현 public Money calculateTotalAmount() { return orderLines.stream() .map(OrderLine::getAmount) .reduce(Money.ZERO, Money::plus); } } // OrderLine은 Order Aggregate 내부에만 존재 class OrderLine { private Product product; private int quantity; private Money price; OrderLine(Product product, int quantity) { if (quantity <= 0) { throw new IllegalArgumentException("수량은 1 이상이어야 합니다"); } this.product = product; this.quantity = quantity; this.price = product.getPrice(); } Money getAmount() { return price.multiply(quantity); } }

Repository

Repository는 Aggregate의 영속성을 담당하는 인터페이스입니다. 도메인 레이어에서는 Repository 인터페이스만 정의하고, 실제 구현은 Infrastructure 레이어에서 수행합니다. 이를 통해 도메인 로직을 데이터베이스 기술로부터 분리할 수 있습니다.

// Domain Layer - Repository 인터페이스 public interface OrderRepository { void save(Order order); Optional<Order> findById(OrderId orderId); List<Order> findByCustomer(Customer customer); void delete(Order order); } // Infrastructure Layer - Repository 구현 @Repository public class JpaOrderRepository implements OrderRepository { @PersistenceContext private EntityManager entityManager; @Override public void save(Order order) { if (order.getId() == null) { entityManager.persist(order); } else { entityManager.merge(order); } } @Override public Optional<Order> findById(OrderId orderId) { Order order = entityManager.find(Order.class, orderId); return Optional.ofNullable(order); } @Override public List<Order> findByCustomer(Customer customer) { return entityManager .createQuery( "SELECT o FROM Order o WHERE o.customer = :customer", Order.class ) .setParameter("customer", customer) .getResultList(); } @Override public void delete(Order order) { entityManager.remove(order); } }
Aggregate 설계 시 주의사항

크기를 작게 유지: Aggregate가 너무 크면 성능 문제와 동시성 이슈가 발생합니다.

ID로만 참조: 다른 Aggregate를 참조할 때는 객체 참조가 아닌 ID를 사용합니다.

트랜잭션 경계: 하나의 트랜잭션에서는 하나의 Aggregate만 수정합니다.

Domain Service

어떤 로직은 특정 Entity나 Value Object에 속하지 않습니다. 이러한 경우 Domain Service를 사용합니다. 예를 들어 송금(Transfer)은 두 개의 계좌(Account)를 모두 필요로 하므로, 어느 한쪽의 메서드로 구현하기 어렵습니다.

// Domain Service 예시 public class TransferService { public void transfer( Account fromAccount, Account toAccount, Money amount ) { // 출금 계좌에서 금액 차감 fromAccount.withdraw(amount); // 입금 계좌에 금액 추가 toAccount.deposit(amount); // 거래 내역 생성 Transaction transaction = new Transaction( fromAccount.getId(), toAccount.getId(), amount, TransactionType.TRANSFER ); } } // Application Service에서 Domain Service 사용 @Service public class AccountApplicationService { private final AccountRepository accountRepository; private final TransferService transferService; @Transactional public void transferMoney( AccountId fromAccountId, AccountId toAccountId, Money amount ) { Account fromAccount = accountRepository .findById(fromAccountId) .orElseThrow(() -> new AccountNotFoundException(fromAccountId) ); Account toAccount = accountRepository .findById(toAccountId) .orElseThrow(() -> new AccountNotFoundException(toAccountId) ); // Domain Service 호출 transferService.transfer(fromAccount, toAccount, amount); // Repository에 저장 accountRepository.save(fromAccount); accountRepository.save(toAccount); } }

실전 구현 예제

실제 주문 시스템을 DDD 방식으로 구현해보겠습니다. 이 예제는 Spring Boot와 JPA를 사용하지만, DDD의 핵심 원칙은 프레임워크와 무관하게 적용할 수 있습니다.

프로젝트 구조

com.example.shop ├── domain │ ├── order │ │ ├── Order.java │ │ ├── OrderId.java │ │ ├── OrderLine.java │ │ ├── OrderStatus.java │ │ ├── OrderRepository.java │ │ └── OrderService.java │ ├── customer │ │ ├── Customer.java │ │ ├── CustomerId.java │ │ └── CustomerRepository.java │ ├── product │ │ ├── Product.java │ │ ├── ProductId.java │ │ └── ProductRepository.java │ └── shared │ ├── Money.java │ ├── Address.java │ └── ShippingInfo.java ├── application │ ├── OrderApplicationService.java │ ├── CustomerApplicationService.java │ └── dto │ ├── OrderRequest.java │ └── OrderResponse.java ├── infrastructure │ ├── persistence │ │ ├── JpaOrderRepository.java │ │ ├── JpaCustomerRepository.java │ │ └── JpaProductRepository.java │ └── messaging │ └── OrderEventPublisher.java └── presentation └── OrderController.java

도메인 모델 구현

// Order Aggregate Root @Entity @Table(name = "orders") public class Order { @EmbeddedId private OrderId id; @Embedded private CustomerId customerId; @ElementCollection @CollectionTable(name = "order_lines") private List<OrderLine> orderLines = new ArrayList<>(); @Embedded private ShippingInfo shippingInfo; @Enumerated(EnumType.STRING) private OrderStatus status; @Column(name = "ordered_at") private LocalDateTime orderedAt; // JPA를 위한 기본 생성자 protected Order() {} // 도메인 로직을 통한 생성 public static Order create( CustomerId customerId, List<OrderLine> orderLines, ShippingInfo shippingInfo ) { Order order = new Order(); order.id = OrderId.generate(); order.customerId = customerId; order.orderLines = new ArrayList<>(orderLines); order.shippingInfo = shippingInfo; order.status = OrderStatus.PENDING; order.orderedAt = LocalDateTime.now(); // 비즈니스 규칙 검증 order.validate(); return order; } private void validate() { if (orderLines.isEmpty()) { throw new IllegalArgumentException("주문 항목이 없습니다"); } if (shippingInfo == null) { throw new IllegalArgumentException("배송 정보가 없습니다"); } } // 비즈니스 로직 public void place() { if (status != OrderStatus.PENDING) { throw new IllegalStateException( "대기 중인 주문만 확정할 수 있습니다" ); } this.status = OrderStatus.PLACED; } public void pay() { if (status != OrderStatus.PLACED) { throw new IllegalStateException( "확정된 주문만 결제할 수 있습니다" ); } this.status = OrderStatus.PAID; } public void cancel() { if (status == OrderStatus.SHIPPED || status == OrderStatus.COMPLETED) { throw new IllegalStateException( "배송 시작된 주문은 취소할 수 없습니다" ); } this.status = OrderStatus.CANCELLED; } public Money calculateTotalAmount() { Money total = Money.ZERO; for (OrderLine line : orderLines) { total = total.plus(line.getAmount()); } Money shippingFee = calculateShippingFee(total); return total.plus(shippingFee); } private Money calculateShippingFee(Money orderAmount) { // 5만원 이상 무료 배송 if (orderAmount.isGreaterThanOrEqual(Money.won(50000))) { return Money.ZERO; } return Money.won(3000); } // Getter public OrderId getId() { return id; } public OrderStatus getStatus() { return status; } public Money getTotalAmount() { return calculateTotalAmount(); } } // OrderLine (Aggregate 내부 Entity) @Embeddable public class OrderLine { @Embedded @AttributeOverride( name = "value", column = @Column(name = "product_id") ) private ProductId productId; @Column(name = "product_name") private String productName; @Embedded @AttributeOverrides({ @AttributeOverride( name = "amount", column = @Column(name = "price") ), @AttributeOverride( name = "currency", column = @Column(name = "currency") ) }) private Money price; @Column(name = "quantity") private int quantity; protected OrderLine() {} public OrderLine( ProductId productId, String productName, Money price, int quantity ) { if (quantity <= 0) { throw new IllegalArgumentException( "수량은 1 이상이어야 합니다" ); } this.productId = productId; this.productName = productName; this.price = price; this.quantity = quantity; } public Money getAmount() { return price.multiply(quantity); } }

Application Service 구현

// Application Service @Service @Transactional public class OrderApplicationService { private final OrderRepository orderRepository; private final CustomerRepository customerRepository; private final ProductRepository productRepository; private final OrderEventPublisher eventPublisher; public OrderApplicationService( OrderRepository orderRepository, CustomerRepository customerRepository, ProductRepository productRepository, OrderEventPublisher eventPublisher ) { this.orderRepository = orderRepository; this.customerRepository = customerRepository; this.productRepository = productRepository; this.eventPublisher = eventPublisher; } public OrderResponse createOrder(OrderRequest request) { // 1. 필요한 Aggregate 조회 CustomerId customerId = new CustomerId(request.getCustomerId()); Customer customer = customerRepository .findById(customerId) .orElseThrow(() -> new CustomerNotFoundException(customerId) ); // 2. 주문 항목 생성 List<OrderLine> orderLines = request.getOrderItems() .stream() .map(item -> { ProductId productId = new ProductId(item.getProductId()); Product product = productRepository .findById(productId) .orElseThrow(() -> new ProductNotFoundException(productId) ); return new OrderLine( product.getId(), product.getName(), product.getPrice(), item.getQuantity() ); }) .collect(Collectors.toList()); // 3. 배송 정보 생성 ShippingInfo shippingInfo = new ShippingInfo( request.getReceiverName(), request.getReceiverPhone(), new Address( request.getZipCode(), request.getAddress1(), request.getAddress2() ) ); // 4. Order Aggregate 생성 Order order = Order.create( customerId, orderLines, shippingInfo ); // 5. 저장 orderRepository.save(order); // 6. 이벤트 발행 eventPublisher.publish( new OrderCreatedEvent(order.getId()) ); // 7. 응답 DTO 반환 return OrderResponse.from(order); } public void placeOrder(String orderId) { Order order = orderRepository .findById(new OrderId(orderId)) .orElseThrow(() -> new OrderNotFoundException(orderId) ); // 도메인 로직 실행 order.place(); // 저장 (더티 체킹) orderRepository.save(order); // 이벤트 발행 eventPublisher.publish( new OrderPlacedEvent(order.getId()) ); } public void cancelOrder(String orderId) { Order order = orderRepository .findById(new OrderId(orderId)) .orElseThrow(() -> new OrderNotFoundException(orderId) ); order.cancel(); orderRepository.save(order); eventPublisher.publish( new OrderCancelledEvent(order.getId()) ); } }

Presentation Layer 구현

// REST Controller @RestController @RequestMapping("/api/orders") public class OrderController { private final OrderApplicationService orderService; public OrderController(OrderApplicationService orderService) { this.orderService = orderService; } @PostMapping public ResponseEntity<OrderResponse> createOrder( @RequestBody @Valid OrderRequest request ) { OrderResponse response = orderService.createOrder(request); return ResponseEntity .status(HttpStatus.CREATED) .body(response); } @PostMapping("/{orderId}/place") public ResponseEntity<Void> placeOrder( @PathVariable String orderId ) { orderService.placeOrder(orderId); return ResponseEntity.ok().build(); } @PostMapping("/{orderId}/cancel") public ResponseEntity<Void> cancelOrder( @PathVariable String orderId ) { orderService.cancelOrder(orderId); return ResponseEntity.ok().build(); } @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity<ErrorResponse> handleOrderNotFound( OrderNotFoundException ex ) { return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(new ErrorResponse(ex.getMessage())); } @ExceptionHandler(IllegalStateException.class) public ResponseEntity<ErrorResponse> handleIllegalState( IllegalStateException ex ) { return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(new ErrorResponse(ex.getMessage())); } }

실무 적용 사례

DDD는 이론만큼이나 실무에서의 적용 경험이 중요합니다. 여러 기업들이 DDD를 도입하며 얻은 교훈과 성과를 살펴보겠습니다.

카카오페이 여신코어 시스템

카카오페이는 여신코어 시스템 리팩토링에 DDD를 적용했습니다. 복잡한 금융 도메인 로직을 효과적으로 관리하기 위해 Bounded Context를 명확히 구분하고, Aggregate를 통해 일관성을 보장했습니다. 특히 대출 심사, 한도 관리, 상환 처리 등 각 도메인을 독립적으로 발전시킬 수 있게 되었습니다.

카카오페이의 DDD 도입 효과

비즈니스 로직 명확화: 복잡한 금융 규칙이 도메인 객체에 응집되어 이해가 쉬워짐

테스트 용이성: 도메인 로직을 독립적으로 테스트할 수 있어 품질 향상

변경 영향도 최소화: Bounded Context 분리로 한 영역의 변경이 다른 영역에 영향을 주지 않음

팀 간 협업 개선: 유비쿼터스 언어로 개발자와 도메인 전문가 간 소통 원활

MSA 전환에서의 DDD

많은 기업들이 모놀리식 시스템을 마이크로서비스로 전환할 때 DDD를 활용합니다. Bounded Context는 마이크로서비스의 경계를 정의하는 데 매우 유용한 지침을 제공합니다. 각 Bounded Context를 독립적인 마이크로서비스로 구현하면, 높은 응집도와 낮은 결합도를 달성할 수 있습니다.

적용 영역 도입 전 문제 DDD 적용 후
주문 시스템 비즈니스 로직이 서비스 레이어에 산재 Order Aggregate에 로직 응집
결제 시스템 상태 전이 관리 복잡 Payment Entity에 상태 머신 구현
재고 관리 동시성 문제로 재고 오류 발생 Aggregate 트랜잭션 경계로 일관성 보장
프로모션 엔진 할인 규칙 변경 시 전체 영향 전략 패턴과 DDD로 유연한 구조

DDD의 장단점

DDD는 강력한 방법론이지만 만능은 아닙니다. 장단점을 명확히 이해하고 프로젝트에 맞게 적용해야 합니다.

장점

  • 비즈니스 로직의 명확한 표현: 도메인 개념이 코드에 직접 반영되어 이해하기 쉬움
  • 유지보수성 향상: 비즈니스 규칙이 한 곳에 모여 있어 변경이 용이
  • 테스트 용이성: 도메인 로직을 독립적으로 테스트 가능
  • 확장성: Bounded Context로 시스템을 나눠 독립적으로 확장 가능
  • 팀 간 협업 개선: 유비쿼터스 언어로 의사소통 원활
  • 변경에 강한 구조: 도메인 중심 설계로 기술 변경에 영향 최소화

단점

DDD의 한계와 주의점

높은 학습 곡선: DDD 개념과 패턴을 이해하는 데 시간이 필요합니다.

초기 개발 속도: 도메인 모델링에 시간이 소요되어 초기 개발이 느릴 수 있습니다.

과도한 복잡성: 단순한 시스템에 DDD를 적용하면 오히려 복잡도가 증가합니다.

도메인 전문가 필요: 효과적인 모델링을 위해서는 도메인 전문가의 참여가 필수입니다.

팀 전체의 이해 필요: 팀원 모두가 DDD를 이해하지 못하면 일관성이 떨어집니다.

도입 시 고려사항

DDD를 성공적으로 도입하기 위해서는 신중한 검토와 준비가 필요합니다.

DDD가 적합한 경우

✓ DDD 도입을 고려해야 하는 경우 1. 복잡한 비즈니스 로직 - 단순 CRUD를 넘어서는 비즈니스 규칙 - 도메인 전문가의 지식이 필요한 영역 2. 장기 프로젝트 - 지속적으로 유지보수하고 확장할 시스템 - 비즈니스 요구사항이 자주 변경되는 경우 3. 충분한 리소스 - 도메인 모델링에 투자할 시간 - DDD를 학습할 여유 4. 협업 환경 - 도메인 전문가와 긴밀히 협력 가능 - 팀 전체가 DDD에 동의 ✗ DDD를 피해야 하는 경우 1. 단순 CRUD - 복잡한 비즈니스 로직이 거의 없음 - 데이터 입출력이 주된 기능 2. 빠른 프로토타입 - 신속한 MVP 개발이 우선 - 비즈니스 검증이 필요한 초기 단계 3. 짧은 수명 - 일회성 또는 단기 프로젝트 - 유지보수 계획이 없는 경우 4. 팀의 역량 부족 - DDD 학습에 투자할 여유 없음 - 도메인 전문가 없음

점진적 도입 전략

DDD를 한 번에 전체 시스템에 적용하기보다는 점진적으로 도입하는 것이 현실적입니다. 먼저 가장 복잡한 Core Domain부터 시작하여 효과를 검증한 후, 다른 영역으로 확장하는 방식을 권장합니다.

DDD 점진적 도입 로드맵
graph LR Phase1[1단계
학습 및 이해] --> Phase2[2단계
Core Domain
적용] Phase2 --> Phase3[3단계
Supporting Domain
확장] Phase3 --> Phase4[4단계
전체 시스템
리팩토링] style Phase1 fill:#3498db,color:#fff style Phase2 fill:#2ecc71,color:#fff style Phase3 fill:#f39c12,color:#fff style Phase4 fill:#9b59b6,color:#fff

실패를 피하는 방법

DDD 도입 실패 사례를 분석하면 몇 가지 공통된 패턴이 있습니다. 이를 미리 인지하고 대비하는 것이 중요합니다.

// 일반적인 DDD 안티패턴 ❌ 안티패턴 1: 빈약한 도메인 모델 (Anemic Domain Model) public class Order { private String id; private List<OrderLine> lines; // getter/setter만 존재, 비즈니스 로직 없음 } public class OrderService { public void calculateTotal(Order order) { // 비즈니스 로직이 서비스에 존재 } } ✅ 올바른 방법: 풍성한 도메인 모델 public class Order { private OrderId id; private List<OrderLine> lines; // 도메인 로직이 객체 내부에 존재 public Money calculateTotal() { return lines.stream() .map(OrderLine::getAmount) .reduce(Money.ZERO, Money::plus); } } ❌ 안티패턴 2: 너무 큰 Aggregate public class Order { private List<OrderLine> lines; private Customer customer; // 전체 Customer 정보 private List<Payment> payments; // 모든 결제 내역 private ShippingHistory history; // 전체 배송 히스토리 // 너무 많은 데이터를 한 번에 로드 } ✅ 올바른 방법: 적절한 크기의 Aggregate public class Order { private OrderId id; private CustomerId customerId; // ID만 참조 private List<OrderLine> lines; private ShippingInfo currentShipping; } ❌ 안티패턴 3: 기술 중심 패키지 구조 com.example.shop ├── controller ├── service ├── repository └── entity ✅ 올바른 방법: 도메인 중심 패키지 구조 com.example.shop ├── domain │ ├── order │ ├── customer │ └── product ├── application └── infrastructure

결론 및 학습 로드맵

DDD는 복잡한 비즈니스 로직을 다루는 강력한 도구입니다. 하지만 만능이 아니며, 프로젝트의 특성과 팀의 상황에 맞게 적용해야 합니다. 핵심은 비즈니스 도메인에 대한 깊은 이해와 이를 코드로 명확히 표현하는 것입니다.

DDD 학습 로드맵

DDD 마스터를 위한 학습 경로 1단계: 기초 개념 이해 (2-3주) ├── 유비쿼터스 언어 ├── Bounded Context ├── Entity vs Value Object └── Aggregate 기본 개념 2단계: 전략적 설계 (3-4주) ├── Context Mapping ├── Core Domain 식별 ├── 이벤트 스토밍 └── 도메인 모델링 실습 3단계: 전술적 설계 (4-5주) ├── Aggregate 상세 설계 ├── Repository 패턴 ├── Domain Service ├── Domain Event └── 실제 프로젝트 적용 4단계: 고급 패턴 (지속적) ├── CQRS ├── Event Sourcing ├── Saga 패턴 └── 분산 시스템에서의 DDD 실습 프로젝트 추천 1. 간단한 블로그 시스템 2. 전자상거래 주문 시스템 3. 예약 관리 시스템 4. 금융 계좌 관리 시스템

핵심 원칙 정리

기억해야 할 DDD 핵심 원칙

1. 비즈니스에 집중하라: 기술보다 비즈니스 로직이 우선입니다.

2. 유비쿼터스 언어를 사용하라: 코드, 문서, 대화에서 동일한 용어를 사용합니다.

3. Bounded Context를 명확히 하라: 시스템을 적절한 크기로 나눕니다.

4. Aggregate를 작게 유지하라: 일관성 경계를 최소화합니다.

5. 도메인 로직을 도메인 객체에 두라: 빈약한 도메인 모델을 피합니다.

6. 완벽을 추구하지 마라: 점진적으로 개선해나갑니다.