DDD
Updated:
AI 시대에는 단순히 코드를 짜는 능력보다, 비즈니스를 이해하고 구조를 설계하는 능력이 더 중요해자고 있다. DDD는 단순한 설계 패턴이 아니라, 복잡한 비즈니스 요구사항을 구조화하고 코드로 표현하는 사고방식이다.
지금까지 팀프로젝트를 진행하면 다음과 같은 상황이 자주 발생하였다.
- Entity 하나가 모든 것의 중심이 된다.
- Service 코드에 Repository가 잔뜩 있다.
- 하나의 메서드에 여러 기능이 들어가 있다.
- 도메인 경계가 모호해서 수정 시 연쇄 영향이 방생한다.
이런 문제는 결국 강결합, 스파게티 의존성, 확장 불가능한 구조로 이어진다.
ERD 중심 설계의 한계
보통 개발은 요구사항 분석, ERD 설계, FK 설정, 엔티티 구현, 서비스 로직 구현순서로 진행된다. 이 흐름은 DB의 JOIN 관계를 객체의 참조 관계와 동일하게 생각하게 만든다는 점이다. 관계형 DB에서는 JOIN이 자유롭지만, 객체에서는 연관관계가 많아질 수록 추적이 어려워지고, JPA 연관관계도 점점 복잡해진다.
트랜잭션 스크립트의 한계
@Entity
@Table(name = "p_user")
public class User extends BaseEntity {
@Id @UuidGenerator
private UUID id;
private String loginId;
private String password;
private String email;
private String name;
private String phone;
@Enumerated(EnumType.STRING)
private UserStatus status = UserStatus.ACTIVE;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<UserRole> userRoles = new ArrayList<>();
// 여기까지는 괜찮아 보이지만...
// 만약 주문, 리뷰, 장바구니, 배송지까지 User가 직접 들고 있다면?
@OneToMany(mappedBy = "user")
private List<Order> orders = new ArrayList<>();
@OneToMany(mappedBy = "user")
private List<Review> reviews = new ArrayList<>();
@OneToMany(mappedBy = "user")
private List<Cart> carts = new ArrayList<>();
@OneToMany(mappedBy = "user")
private List<UserAddress> addresses = new ArrayList<>();
@OneToMany(mappedBy = "user")
private List<Store> stores = new ArrayList<>(); // OWNER인 경우
// getter, setter 수십 개...
}
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
private final StoreRepository storeRepository;
private final ProductRepository productRepository;
private final UserAddressRepository userAddressRepository;
private final PaymentService paymentService;
private final OrderPaymentProcessor orderPaymentProcessor;
// ...
}
@Override
@Transactional
public CreateOrderResponse createOrder(UUID userId, CreateOrderRequest request) {
// 1. 유저 조회
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND"));
// 2. 가게 존재 확인
Store store = storeRepository.findById(request.getStoreId())
.orElseThrow(() -> new IllegalArgumentException("STORE_NOT_FOUND"));
// 3. 주문번호 생성
String orderNo = "ORD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
// 4. 총 주문 금액 계산 — 옵션 가격까지 직접 합산
int totalAmount = request.getItems().stream()
.mapToInt(item -> {
int optionSum = 0;
if (item.getOptions() != null) {
optionSum = item.getOptions().stream()
.mapToInt(opt -> opt.getExtraPrice() == null ? 0 : opt.getExtraPrice())
.sum();
}
return (item.getUnitPrice() + optionSum) * item.getQuantity();
})
.sum();
// 5. 배송지 확인
UserAddress deliveryAddress = null;
if (request.getDeliveryAddressId() != null) {
deliveryAddress = userAddressRepository
.findByIdAndUserId(request.getDeliveryAddressId(), userId)
.orElseThrow(() -> new IllegalArgumentException("DELIVERY_ADDRESS_NOT_FOUND"));
}
// 6. 주문 생성
Order order = new Order(
user, store, deliveryAddress,
orderNo, totalAmount, request.getRequestMemo()
);
// 7. 주문 항목 생성 — 상품 조회 + 옵션 매핑 + 연관관계 설정
for (CreateOrderRequest.CreateOrderItemRequest itemReq : request.getItems()) {
Product product = productRepository.findById(itemReq.getProductId())
.orElseThrow(() -> new IllegalArgumentException("PRODUCT_NOT_FOUND"));
OrderItem orderItem = new OrderItem(
product, itemReq.getProductName(),
itemReq.getUnitPrice(), itemReq.getQuantity()
);
if (itemReq.getOptions() != null) {
for (CreateOrderRequest.CreateOrderItemOptionRequest optReq : itemReq.getOptions()) {
OrderItemOption option = new OrderItemOption(
optReq.getOptionName(), optReq.getOptionItemName(),
optReq.getExtraPrice()
);
orderItem.addOption(option);
}
}
order.addItem(orderItem);
}
Order savedOrder = orderRepository.save(order);
// 8. 결제 엔티티 생성
paymentService.createPayment(
CreatePaymentCommand.of(savedOrder, savedOrder.getTotalAmount())
);
return CreateOrderResponse.from(savedOrder);
}
위와 같은 코드는 “트랜잭션 스크립트” 패턴이라고 한다. 하나의 요청을 처리하는 절차를 하나의 메서드에 스크립트처럼 순서대로 나열하는 방식이다. 이는 직관적이지만, 비즈니스가 복잡해지는 순간 한계에 부딪힌다.
위 코드에서 “최소 주문 금액 검증 추가해 주세요”, “주문 거절 시 결제 환불해 주세요”, “주문 금액 미리보기 만들어 주세요”와 같이 요청을 받게 되면 createOrder가 더 길어지거나 비슷한 코드를 복사해 붙여넣게된다.
이는 트랜잭션 스크립트의 근본적인 문제이다. 비즈니스 규칙이 “코드의 절차” 안에 녹아 있어서, 규칙만 따로 꺼내 쓸 수가 없다.
DDD
바운디드 컨텍스트, 애그리거트
DDD는 시스템을 한 덩어리로 보지 않고, 의미 있는 비즈니스 경계 단위로 나눈다. 이를 바운디드 컨텍스트라고 한다. 서로 관련 있는 모델끼리 경계선을 긋고, 그 경계를 넘는 통신은 직접 참조가 아니라 API나 이벤트로 하도록 만드는 것이다.
바운디드 컨텍스트가 큰 경계라면, 애그리거트는 그 안에서 함께 변경되고 일관성을 함께 지켜야 하는 객체 묶음이다.

위 그림을 보면, 허브 컨텍스트, 상품 컨텍스트, 업체 컨텍스트, 주문 컨텍스트, 배송 컨텍스트, 사용자 컨텍스트로 바운디드 컨텍스트를 나눌 수 있고, 그 안에 각각의 애그리거트가 들어가 있는 것을 확인할 수 있다. 이때 외부에서 직접 접근하는 대표 객체가 애그리거트 루트이다.
DDD 방식으로 바뀐 주문 설계
앞에서 본 주문 생성 기능을 DDD 관점에서 설계하면 어떻게 달라지는지 알아본다.
// Product는 자신의 상태(품절, 숨김)를 관리한다
public class Product extends BaseEntity {
private UUID id;
private Store store;
private String name;
private Integer price;
private boolean isSoldOut;
private boolean isHidden;
// 상태 조회 — Product는 자신의 상태를 외부에 알려줄 뿐이다
public boolean isSoldOut() {
return this.isSoldOut;
}
public boolean isHidden() {
return this.isHidden;
}
// 상태 변경 — Product가 스스로 품절/품절 해제를 관리한다
public void markSoldOut() {
this.isSoldOut = true;
}
public void markAvailable() {
this.isSoldOut = false;
}
public void hide() {
this.isHidden = true;
}
public void unhide() {
this.isHidden = false;
}
}
// Order는 생성 시점에 OrderItem을 함께 받아 불변식을 보장한다 (Aggregate Root)
// "주문 항목 없는 주문"은 존재할 수 없다
public class Order extends BaseEntity {
private UUID id;
private User user;
private Store store;
private UserAddress deliveryAddress;
private String orderNo;
private OrderStatus status;
private String requestMemo;
private String canceledReason;
private Integer totalAmount;
private Set<OrderItem> items = new LinkedHashSet<>();
// ==================== 생성 ====================
// 정적 팩토리 메서드 — 주문 항목과 함께 생성해야만 한다
public static Order create(User user, Store store,
UserAddress deliveryAddress,
String requestMemo,
List<OrderItem> items) {
return new Order(user, store, deliveryAddress, requestMemo, items);
}
private Order(User user, Store store, UserAddress deliveryAddress,
String requestMemo, List<OrderItem> items) {
validateAtLeastOneItem(items);
this.user = user;
this.store = store;
this.deliveryAddress = deliveryAddress;
this.orderNo = generateOrderNo();
this.requestMemo = requestMemo;
this.status = OrderStatus.PENDING_PAYMENT;
bindItems(items);
this.totalAmount = calculateTotal();
}
private void validateAtLeastOneItem(List<OrderItem> items) {
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException(
"주문에는 최소 1개의 상품이 필요합니다"
);
}
}
private static String generateOrderNo() {
return "ORD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
private void bindItems(List<OrderItem> items) {
items.forEach(item -> item.bindOrder(this));
this.items.addAll(items);
}
private Integer calculateTotal() {
return items.stream()
.mapToInt(OrderItem::getLineTotalAmount)
.sum();
}
// ==================== 상태 전이 ====================
public void markPaid() {
validateStatus(OrderStatus.PENDING_PAYMENT);
this.status = OrderStatus.PAID;
}
public void cancel(String reason) {
validateStatus(
OrderStatus.PENDING_PAYMENT,
OrderStatus.PAID,
OrderStatus.ACCEPTED
);
this.status = OrderStatus.CANCELED;
this.canceledReason = reason;
}
private void validateStatus(OrderStatus... allowed) {
for (OrderStatus s : allowed) {
if (this.status == s) return;
}
throw new IllegalStateException(
"허용되지 않은 주문 상태 변경입니다. current=" + this.status
);
}
}
위 코드를 보면, Product는 자신의 상태를 관리하고, Order는 생성 시점에 불변식을 보장하며 상태 전이 규칙을 스스로 관리한다. 이는 기존과 다르게 Service에서 검증을 대신해 줄 필요가 없다.
// Store가 스스로 주문 가능 여부와 배달비를 관리한다
public class Store extends BaseEntity {
private UUID id;
private String name;
private StoreStatus status;
private LocalTime openTime;
private LocalTime closeTime;
private Integer deliveryFee;
private Integer minimumOrderAmount;
public void validateOrderable(Integer orderAmount) {
if (this.status != StoreStatus.ACTIVE) {
throw new StoreNotOperatingException();
}
if (orderAmount < this.minimumOrderAmount) {
throw new InvalidMinimumOrderAmountException();
}
}
public boolean isOpen() {
LocalTime now = LocalTime.now();
return !now.isBefore(openTime) && !now.isAfter(closeTime);
}
// 리뷰 등록 시 평점 갱신 — Store가 스스로 관리
public void addReviewRating(int newRating) {
this.totalRatingSum += newRating;
this.reviewCount += 1;
this.averageRating = (double) this.totalRatingSum / this.reviewCount;
}
}
또한 가게 운영 조건이 바뀌면 Store 클래스만 수정하면 된다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final StoreService storeService; // Repository가 아닌 Service
private final ProductService productService; // Repository가 아닌 Service
private final PaymentService paymentService; // Repository가 아닌 Service
@Transactional
public OrderResult createOrder(CreateOrderCommand command) {
// ↑ Controller의 Request DTO가 아닌, Service 계층 소유의 Command를 받는다
// ↑ 반환도 Controller의 Response DTO가 아닌, Service 계층 소유의 Result를 반환한다
// 1. 가게 검증 — StoreService에 위임
Store store = storeService.getStore(command.storeId());
// 2. 상품 목록을 한 번에 조회 — 단건 조회 N번 대신 IN 쿼리 1번
List<UUID> productIds = command.items().stream()
.map(CreateOrderCommand.Item::productId)
.toList();
Map<UUID, Product> productMap = productService.getProductsByIds(productIds);
// 3. 주문 항목 준비 — 조회한 Map에서 꺼내 검증
List<OrderItem> orderItems = command.items().stream()
.map(item -> {
Product product = productMap.get(item.productId());
if (product == null) {
throw new IllegalArgumentException("상품을 찾을 수 없습니다: " + item.productId());
}
if (product.isSoldOut()) {
throw new IllegalStateException("품절된 상품입니다: " + product.getName());
}
if (product.isHidden()) {
throw new IllegalStateException("주문할 수 없는 상품입니다: " + product.getName());
}
return OrderItem.create(product, item.quantity());
})
.toList();
// 4. 주문 생성 — 항목과 함께 한 번에 생성 (불변식 보장)
Order order = Order.create(
command.userId(), store, command.requestMemo(), orderItems
);
// 5. 가게 최소 주문 금액 검증 — Store가 스스로 검증
store.validateOrderable(order.getTotalAmount());
orderRepository.save(order);
// 6. 결제 생성 — PaymentService에 위임
paymentService.createPayment(
CreatePaymentCommand.of(order, order.getTotalAmount())
);
return OrderResult.from(order); // Entity → Service 계층의 Result로 변환
}
}
또한 다른 도메인의 데이터가 필요할 때, 그 도메인의 Repository를 직접 주입하는 게 아니라 해당 도메인의 Service를 통해 요청하면 된다.
위 과정을 보면 기존에 복잡했던 메서드들이 훨씬 간결해졌다. 더 중요한 것은 각 줄이 무엇을 하는지 코드만 읽어도 이해된다는 점이다.
애그리거트 경계를 나누는 기준
애그리거트 경계를 나누는 기준은 다음과 같다.
- 함께 변경되는가
- 주문 수량을 바꾸면 총액도 같이 바뀐다(Order와 OrderItem은 같은 애그리거트).
- 불변식이 있는가
- 총액 = Σ(수량 × 단가) 같은 규칙을 함께 지켜야 한다(같은 애그리거트).
- 생명주기가 독립적인가
- 회원이 탈퇴해도 주문 기록은 남아야 한다(User와 Order는 다른 애그리거트).
- Cascade를 걸 수 있는가
- Order 삭제 시 OrderItem 삭제는 가능하지만, Product까지 같이 삭제되면 안 된다(Order와 Product는 다른 애그리거트).
그래서 DDD에서는 OrderItem 와 Product를 직접 객체 참조로 강하게 묶기보다, productId처럼 ID 참조로 두는 방향을 선호한다.
MSA
MSA에서는 주문 서비스, 상품 서비스, 결제 서비스가 각각 별도의 애플리케이션으로 분리된다. 같은 프로세스 안에 없기 때문에 직접 주입해서 호출할 수 없다. 그래서 두 가지 방식으로 도메인 간 통신이 이루어 진다.
동기 통신
다른 서비스의 REST API는 FeignClient나 WebClient를 사용하면 된다.
// 주문 서비스에서 상품 서비스의 API를 호출하는 FeignClient
@FeignClient(name = "product-service")
public interface ProductClient {
@GetMapping("/api/products/{productId}")
ProductInfo getProduct(@PathVariable UUID productId);
@PostMapping("/api/products/batch")
Map<UUID, ProductInfo> getProductsByIds(@RequestBody List<UUID> productIds);
}
// MSA 환경의 OrderService — FeignClient로 타 도메인과 통신
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductClient productClient; // 상품 서비스 API 호출
private final StoreClient storeClient; // 가게 서비스 API 호출
@Transactional
public OrderResult createOrder(CreateOrderCommand command) {
// 가게 서비스에 API 호출 (HTTP 통신)
StoreInfo store = storeClient.getStore(command.storeId());
// 상품 목록을 한 번에 조회 — 단건 API 호출 N번 대신 배치 API 1번
List<UUID> productIds = command.items().stream()
.map(CreateOrderCommand.Item::productId)
.toList();
Map<UUID, ProductInfo> productMap = productClient.getProductsByIds(productIds);
// 주문 항목 준비
List<OrderItem> orderItems = command.items().stream()
.map(item -> {
ProductInfo product = productMap.get(item.productId());
if (product == null) {
throw new IllegalArgumentException("상품을 찾을 수 없습니다: " + item.productId());
}
return OrderItem.create(product, item.quantity());
})
.toList();
// 주문 생성 — 항목과 함께 한 번에 (불변식 보장)
Order order = Order.create(
command.userId(), store.id(), command.requestMemo(), orderItems
);
// 가게 서비스에 최소 주문 금액 검증 요청
storeClient.validateMinimumOrder(store.id(), order.getTotalAmount());
orderRepository.save(order);
return OrderResult.from(order);
}
}
이 방식은 직관적이지만, 상품 서비스가 죽으면 주문도 실패하는 강한 결합 문제가 있다.
비동기 통신
다음과 같이 주문 서비스는 “주문이 생성되었다”는 이벤트만 발행하고, 다른 서비스들이 그 이벤트를 구독해서 각자의 로직을 처리하면 된다.
// 1. 도메인 이벤트 정의 — "주문이 생성되었다"는 사실을 담는 객체
public record OrderCreatedEvent(
UUID orderId,
UUID userId,
UUID storeId,
List<OrderItemDto> orderItems,
Integer totalAmount,
String requestMemo
) {}
// 2. 주문 서비스 — 주문을 생성하고 이벤트를 발행한다
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher; // 이벤트 발행기
@Transactional
public OrderResult createOrder(CreateOrderCommand command) {
// 주문 항목 준비
List<OrderItem> orderItems = command.items().stream()
.map(item -> OrderItem.create(item.productId(), item.quantity()))
.toList();
// 주문 생성 — 항목과 함께 한 번에 (불변식 보장)
Order order = Order.create(
command.userId(), command.storeId(),
command.requestMemo(), orderItems
);
orderRepository.save(order);
// "주문이 생성되었다"는 이벤트를 발행 — 나머지는 알아서 처리된다
eventPublisher.publishEvent(new OrderCreatedEvent(
order.getId(),
command.userId(),
order.getStoreId(),
order.toOrderItemDtos(),
order.getTotalAmount(),
order.getRequestMemo()
));
return OrderResult.from(order);
}
}
// 3. 결제 서비스 — 주문 생성 이벤트를 듣고 결제를 생성한다
@Service
@RequiredArgsConstructor
public class PaymentEventHandler {
private final PaymentRepository paymentRepository;
@EventListener // Kafka라면 @KafkaListener
public void handleOrderCreated(OrderCreatedEvent event) {
Payment payment = Payment.createPending(
event.orderId(), event.totalAmount()
);
paymentRepository.save(payment);
}
}
// 4. 가게 서비스 — 주문 생성 이벤트를 듣고 사장님에게 알림을 보낸다
@Service
@RequiredArgsConstructor
public class StoreNotificationHandler {
private final StoreRepository storeRepository;
private final NotificationService notificationService;
@EventListener // Kafka라면 @KafkaListener
public void handleOrderCreated(OrderCreatedEvent event) {
Store store = storeRepository.findById(event.storeId())
.orElseThrow();
notificationService.notifyOwner(
store.getUser().getId(), "새 주문이 들어왔습니다!"
);
}
}
이벤트 방식에서 주목할 점은, 주문 서비스는 결제 서비스나 가게 서비스의 존재를 모른다는 점이다. 이는 새로운 기능이 추가되더라도 OrderService를 수정할 필요 없이 PointEventHander만 새로 만들면 된다는 말이다.
이제 호출 방식을 비교해보면 다음과 같다.

DDD의 핵심은 다음 네 가지이다.
- 바운디드 컨텍스트
- 전체 시스템을 의미 있는 비즈니스 경계로 나누는 것
- 애그리거트
- 함께 변경되고 일관성을 함께 지켜야 하는 객체 묶음
- 애그리거트 루트
- 외부에서 접근 가능한 유일한 진입점
- 도메인 관점 설계
- ERD보다 먼저 비즈니스 개념과 규칙을 모델링하는 것
결국 DDD는 복잡한 비즈니스 요구사항을 코드에 자연스럽게 녹여내고, 도메인 경계를 명확히 하며, 장기적으로 유지보수 가능한 구조를 만드는 방법이다.
댓글남기기