Skip to content
Published on

Clean Architecture + DDD 실전 적용 가이드: Spring Boot로 구현하는 도메인 중심 설계

Authors
  • Name
    Twitter

Clean Architecture란?

Robert C. Martin(Uncle Bob)이 제안한 Clean Architecture는 비즈니스 로직을 외부 의존성으로부터 보호하는 아키텍처 패턴입니다. 핵심 원칙은 의존성 규칙(Dependency Rule): 안쪽 레이어는 바깥쪽 레이어를 알지 못합니다.

[Frameworks & Drivers][Interface Adapters][Use Cases][Entities]
       외부                                                    내부(핵심)

DDD와의 관계

개념Clean ArchitectureDDD
핵심의존성 방향 제어도메인 모델링
초점아키텍처 레이어비즈니스 도메인
결과테스트 가능한 코드비즈니스 정합성

두 접근법은 상호 보완적입니다. Clean Architecture가 어떻게 구조화할지 알려주고, DDD가 무엇을 모델링할지 알려줍니다.

프로젝트 구조

패키지 레이아웃

com.example.order/
├── domain/                     # 🟢 엔티티, 밸류 오브젝트, 도메인 서비스
│   ├── model/
│   │   ├── Order.java
│   │   ├── OrderItem.java
│   │   ├── OrderStatus.java    # Enum
│   │   └── Money.java          # Value Object
│   ├── event/
│   │   └── OrderPlacedEvent.java
│   ├── exception/
│   │   └── InsufficientStockException.java
│   └── repository/
│       └── OrderRepository.java  # Interface (Port)
├── application/                # 🔵 유스케이스, 애플리케이션 서비스
│   ├── port/
│   │   ├── in/
│   │   │   ├── PlaceOrderUseCase.java
│   │   │   └── PlaceOrderCommand.java
│   │   └── out/
│   │       ├── LoadProductPort.java
│   │       ├── SaveOrderPort.java
│   │       └── SendNotificationPort.java
│   └── service/
│       └── PlaceOrderService.java
├── adapter/                    # 🟡 어댑터 (인프라)
│   ├── in/
│   │   └── web/
│   │       ├── OrderController.java
│   │       ├── OrderRequest.java
│   │       └── OrderResponse.java
│   └── out/
│       ├── persistence/
│       │   ├── OrderJpaEntity.java
│       │   ├── OrderJpaRepository.java
│       │   └── OrderPersistenceAdapter.java
│       ├── messaging/
│       │   └── KafkaNotificationAdapter.java
│       └── external/
│           └── ProductServiceAdapter.java
└── config/
    └── BeanConfig.java

도메인 레이어 구현

Entity: Order

// domain/model/Order.java
package com.example.order.domain.model;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

public class Order {
    private final String id;
    private final String customerId;
    private final List<OrderItem> items;
    private OrderStatus status;
    private Money totalAmount;
    private final LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // 팩토리 메서드 — 생성 로직을 도메인에 캡슐화
    public static Order create(String customerId, List<OrderItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("주문 항목이 비어있습니다");
        }

        Money total = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);

        return new Order(
            UUID.randomUUID().toString(),
            customerId,
            new ArrayList<>(items),
            OrderStatus.PLACED,
            total,
            LocalDateTime.now(),
            LocalDateTime.now()
        );
    }

    // 비즈니스 로직은 엔티티 안에!
    public void confirm() {
        if (this.status != OrderStatus.PLACED) {
            throw new IllegalStateException(
                "PLACED 상태에서만 확인할 수 있습니다. 현재: " + this.status
            );
        }
        this.status = OrderStatus.CONFIRMED;
        this.updatedAt = LocalDateTime.now();
    }

    public void cancel(String reason) {
        if (this.status == OrderStatus.SHIPPED || this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException(
                "배송 시작 후에는 취소할 수 없습니다"
            );
        }
        this.status = OrderStatus.CANCELLED;
        this.updatedAt = LocalDateTime.now();
    }

    public void addItem(OrderItem item) {
        if (this.status != OrderStatus.PLACED) {
            throw new IllegalStateException("확정 전에만 항목을 추가할 수 있습니다");
        }
        this.items.add(item);
        this.totalAmount = this.totalAmount.add(item.getSubtotal());
        this.updatedAt = LocalDateTime.now();
    }

    public List<OrderItem> getItems() {
        return Collections.unmodifiableList(items);
    }

    // private constructor
    private Order(String id, String customerId, List<OrderItem> items,
                  OrderStatus status, Money totalAmount,
                  LocalDateTime createdAt, LocalDateTime updatedAt) {
        this.id = id;
        this.customerId = customerId;
        this.items = items;
        this.status = status;
        this.totalAmount = totalAmount;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    // getters...
    public String getId() { return id; }
    public String getCustomerId() { return customerId; }
    public OrderStatus getStatus() { return status; }
    public Money getTotalAmount() { return totalAmount; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}

Value Object: Money

// domain/model/Money.java
package com.example.order.domain.model;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

public final class Money {
    public static final Money ZERO = new Money(BigDecimal.ZERO);

    private final BigDecimal amount;

    public Money(BigDecimal amount) {
        if (amount == null) throw new IllegalArgumentException("금액은 null일 수 없습니다");
        this.amount = amount.setScale(2, RoundingMode.HALF_UP);
    }

    public static Money of(long amount) {
        return new Money(BigDecimal.valueOf(amount));
    }

    public static Money of(String amount) {
        return new Money(new BigDecimal(amount));
    }

    public Money add(Money other) {
        return new Money(this.amount.add(other.amount));
    }

    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)));
    }

    public boolean isGreaterThan(Money other) {
        return this.amount.compareTo(other.amount) > 0;
    }

    public BigDecimal getAmount() { return amount; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.compareTo(money.amount) == 0;
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount);
    }

    @Override
    public String toString() {
        return amount.toPlainString() + "원";
    }
}

Domain Event

// domain/event/OrderPlacedEvent.java
package com.example.order.domain.event;

import java.time.LocalDateTime;

public record OrderPlacedEvent(
    String orderId,
    String customerId,
    String totalAmount,
    LocalDateTime occurredAt
) {
    public static OrderPlacedEvent from(String orderId, String customerId, String totalAmount) {
        return new OrderPlacedEvent(orderId, customerId, totalAmount, LocalDateTime.now());
    }
}

애플리케이션 레이어: 유스케이스

Port 정의 (인터페이스)

// application/port/in/PlaceOrderUseCase.java
package com.example.order.application.port.in;

public interface PlaceOrderUseCase {
    String execute(PlaceOrderCommand command);
}

// application/port/in/PlaceOrderCommand.java
public record PlaceOrderCommand(
    String customerId,
    List<OrderItemCommand> items
) {
    public record OrderItemCommand(
        String productId,
        int quantity
    ) {}
}
// application/port/out/SaveOrderPort.java
package com.example.order.application.port.out;

import com.example.order.domain.model.Order;

public interface SaveOrderPort {
    void save(Order order);
}

// application/port/out/LoadProductPort.java
public interface LoadProductPort {
    ProductInfo load(String productId);

    record ProductInfo(String id, String name, Money price, int stock) {}
}

Service 구현

// application/service/PlaceOrderService.java
package com.example.order.application.service;

import com.example.order.application.port.in.*;
import com.example.order.application.port.out.*;
import com.example.order.domain.model.*;
import com.example.order.domain.exception.InsufficientStockException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class PlaceOrderService implements PlaceOrderUseCase {

    private final SaveOrderPort saveOrderPort;
    private final LoadProductPort loadProductPort;
    private final SendNotificationPort sendNotificationPort;

    public PlaceOrderService(
        SaveOrderPort saveOrderPort,
        LoadProductPort loadProductPort,
        SendNotificationPort sendNotificationPort
    ) {
        this.saveOrderPort = saveOrderPort;
        this.loadProductPort = loadProductPort;
        this.sendNotificationPort = sendNotificationPort;
    }

    @Override
    public String execute(PlaceOrderCommand command) {
        // 1. 상품 정보 조회 및 재고 확인
        List<OrderItem> orderItems = command.items().stream()
            .map(item -> {
                var product = loadProductPort.load(item.productId());
                if (product.stock() < item.quantity()) {
                    throw new InsufficientStockException(
                        product.name() + " 재고 부족: " + product.stock() + "개 남음"
                    );
                }
                return new OrderItem(
                    item.productId(),
                    product.name(),
                    product.price(),
                    item.quantity()
                );
            })
            .toList();

        // 2. 주문 생성 (도메인 로직)
        Order order = Order.create(command.customerId(), orderItems);

        // 3. 저장
        saveOrderPort.save(order);

        // 4. 알림 발송
        sendNotificationPort.sendOrderConfirmation(
            command.customerId(), order.getId()
        );

        return order.getId();
    }
}

어댑터 레이어: 외부 연동

Web Adapter (Controller)

// adapter/in/web/OrderController.java
package com.example.order.adapter.in.web;

import com.example.order.application.port.in.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final PlaceOrderUseCase placeOrderUseCase;

    public OrderController(PlaceOrderUseCase placeOrderUseCase) {
        this.placeOrderUseCase = placeOrderUseCase;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
        var command = new PlaceOrderCommand(
            request.customerId(),
            request.items().stream()
                .map(i -> new PlaceOrderCommand.OrderItemCommand(i.productId(), i.quantity()))
                .toList()
        );

        String orderId = placeOrderUseCase.execute(command);

        return ResponseEntity.ok(new OrderResponse(orderId, "주문이 접수되었습니다"));
    }
}

record OrderRequest(String customerId, List<OrderItemRequest> items) {
    record OrderItemRequest(String productId, int quantity) {}
}

record OrderResponse(String orderId, String message) {}

Persistence Adapter

// adapter/out/persistence/OrderPersistenceAdapter.java
package com.example.order.adapter.out.persistence;

import com.example.order.application.port.out.SaveOrderPort;
import com.example.order.domain.model.Order;
import org.springframework.stereotype.Component;

@Component
public class OrderPersistenceAdapter implements SaveOrderPort {

    private final OrderJpaRepository jpaRepository;

    public OrderPersistenceAdapter(OrderJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public void save(Order order) {
        OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
        jpaRepository.save(entity);
    }
}

// adapter/out/persistence/OrderJpaEntity.java
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
    @Id
    private String id;

    @Column(name = "customer_id", nullable = false)
    private String customerId;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(name = "total_amount", nullable = false)
    private BigDecimal totalAmount;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "order")
    private List<OrderItemJpaEntity> items;

    // 도메인 → JPA 변환
    public static OrderJpaEntity fromDomain(Order order) {
        var entity = new OrderJpaEntity();
        entity.id = order.getId();
        entity.customerId = order.getCustomerId();
        entity.status = order.getStatus();
        entity.totalAmount = order.getTotalAmount().getAmount();
        entity.items = order.getItems().stream()
            .map(item -> OrderItemJpaEntity.fromDomain(item, entity))
            .toList();
        return entity;
    }

    // JPA → 도메인 변환
    public Order toDomain() {
        // Mapper로 도메인 객체 복원
        // ...
    }
}

테스트 전략

도메인 단위 테스트 (외부 의존성 없음!)

@Test
void 주문_생성_성공() {
    var items = List.of(
        new OrderItem("P001", "노트북", Money.of(1500000), 1),
        new OrderItem("P002", "마우스", Money.of(50000), 2)
    );

    Order order = Order.create("C001", items);

    assertThat(order.getStatus()).isEqualTo(OrderStatus.PLACED);
    assertThat(order.getTotalAmount()).isEqualTo(Money.of(1600000));
    assertThat(order.getItems()).hasSize(2);
}

@Test
void 배송_후_취소_불가() {
    Order order = createShippedOrder();

    assertThatThrownBy(() -> order.cancel("변심"))
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("배송 시작 후에는 취소할 수 없습니다");
}

유스케이스 테스트 (Mocking)

@ExtendWith(MockitoExtension.class)
class PlaceOrderServiceTest {

    @Mock SaveOrderPort saveOrderPort;
    @Mock LoadProductPort loadProductPort;
    @Mock SendNotificationPort sendNotificationPort;
    @InjectMocks PlaceOrderService service;

    @Test
    void 정상_주문_처리() {
        given(loadProductPort.load("P001"))
            .willReturn(new LoadProductPort.ProductInfo(
                "P001", "노트북", Money.of(1500000), 10
            ));

        var command = new PlaceOrderCommand("C001",
            List.of(new PlaceOrderCommand.OrderItemCommand("P001", 1))
        );

        String orderId = service.execute(command);

        assertThat(orderId).isNotNull();
        then(saveOrderPort).should().save(any(Order.class));
        then(sendNotificationPort).should().sendOrderConfirmation(eq("C001"), any());
    }
}

장단점과 적용 기준

적합한 경우

  • 비즈니스 로직이 복잡한 도메인
  • 장기적으로 유지보수할 프로젝트
  • 여러 외부 시스템과 연동이 필요한 경우
  • 테스트 커버리지가 중요한 프로젝트

과도한 경우

  • 단순 CRUD 애플리케이션
  • 프로토타입 / PoC
  • 소규모 프로젝트 (2~3명 이하)

📝 확인 퀴즈 (6문제)

Q1. Clean Architecture의 의존성 규칙(Dependency Rule)이란?

안쪽 레이어는 바깥쪽 레이어를 알지 못합니다. 의존성은 항상 바깥에서 안쪽으로 향합니다.

Q2. Port와 Adapter의 역할 차이는?

Port는 인터페이스(계약)이고, Adapter는 그 인터페이스의 구체적인 구현체입니다. Port는 애플리케이션 레이어에, Adapter는 인프라 레이어에 위치합니다.

Q3. Value Object의 특징은?

불변(immutable), 동등성은 값으로 비교(equals), 비즈니스 규칙 캡슐화. 예: Money, Address, Email

Q4. 도메인 레이어에 Spring 어노테이션을 사용하면 안 되는 이유는?

도메인 레이어는 프레임워크에 독립적이어야 합니다. Spring에 의존하면 도메인 로직이 인프라에 결합됩니다.

Q5. JPA Entity와 Domain Entity를 분리하는 이유는?

JPA Entity는 ORM 프레임워크의 제약을 받지만, Domain Entity는 순수한 비즈니스 로직만 포함해야 합니다. 분리하면 도메인이 영속성 기술에 독립적입니다.

Q6. Clean Architecture + DDD가 과도한 경우는?

단순 CRUD, 프로토타입/PoC, 소규모 프로젝트 등 비즈니스 로직이 단순한 경우에는 오버엔지니어링이 됩니다.