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는 **비즈니스 로직을 외부 의존성으로부터 보호**하는 아키텍처 패턴입니다. 핵심 원칙은...