Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

Clean Architecture란?

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

[Frameworks & Drivers] → [Interface Adapters] → [Use Cases] → [Entities]

외부 내부(핵심)

DDD와의 관계

| 개념 | Clean Architecture | DDD |

| ---- | ------------------ | --------------- |

| 핵심 | 의존성 방향 제어 | 도메인 모델링 |

| 초점 | 아키텍처 레이어 | 비즈니스 도메인 |

| 결과 | 테스트 가능한 코드 | 비즈니스 정합성 |

두 접근법은 상호 보완적입니다. 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;

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;

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;

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;

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;

@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;

@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;

@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명 이하)

**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, 소규모 프로젝트 등 비즈니스 로직이 단순한 경우에는 오버엔지니어링이 됩니다.

현재 단락 (1/385)

Robert C. Martin(Uncle Bob)이 제안한 Clean Architecture는 **비즈니스 로직을 외부 의존성으로부터 보호**하는 아키텍처 패턴입니다. 핵심 원칙은...

작성 글자: 0원문 글자: 10,746작성 단락: 0/385