클린 아키텍처의 계층마다 어떤 패턴을 배치할 것인가
Entity·Use Case·Interface Adapter·Frameworks 계층마다 Aggregate·Interactor·Repository·Adapter 패턴을 매핑하고 Spring Boot 주문 생성 예제로 확인한다
2026-08-13 · 최초 발행 2025-10-14
기능 하나를 추가했을 뿐인데 화면 코드부터 DB 스키마까지 손대야 한다면, 계층 사이의 의존성이 어느 방향으로도 뚫려 있다는 신호다. 클린 아키텍처는 이 의존성의 방향을 강제로 통제해 변경 비용을 낮추는 접근이고, 그 안에서 디자인 패턴은 각 계층의 책임에 맞는 검증된 템플릿을 제공한다.
계층을 나누는 이유: 변경의 방향을 통제한다
클린 아키텍처는 엔터티(Entity), 유스케이스(Use Case), 인터페이스 어댑터(Interface Adapters), 프레임워크·드라이버(Frameworks & Drivers)로 구성되는 계층형 아키텍처다. 의존성 역전 원칙(DIP)에 따라 바깥에서 안쪽으로만 의존이 허용된다. 디자인 패턴은 반복 발생하는 문제에 대한 검증된 구조·행위 템플릿을 제공하는 역할이고, 계층별 책임에 맞는 패턴을 고르면 결합도는 낮아지고 응집도는 높아진다. 패턴을 계층에 매핑해두는 목적은 "무엇을 어디에 둘 것인가"에 일관된 기준을 세우는 것이고, 변경 방향과 트랜잭션 경계를 명시해두면 기능을 추가할 때 영향 범위가 최소화된다.
계층 분리가 강제하는 의존성 규칙
유스케이스는 엔터티에만 의존하고 어댑터는 유스케이스 인터페이스에 의존하며, 프레임워크는 가장 바깥에 위치해 교체 가능성을 확보한다. 인풋·아웃풋 DTO와 Repository·Gateway 인터페이스로 경계를 계약화해두면, 테스트할 때 In-memory 대체 구현으로 바꿔치기해 빠른 피드백 루프를 만들 수 있다. 트랜잭션과 락은 유스케이스 경계에서 관리하고, 엔터티는 순수한 도메인 로직만 유지해 단위 테스트가 쉬워진다.
Entity에는 무엇을 두는가
Entity 계층의 목적은 도메인 규칙, 상태 불변성, 연산 캡슐화다. Aggregate, Domain Service, Specification 패턴이 여기 어울린다. I/O 접근과 트랜잭션 제어는 금지되며, 불변 값 객체(Value Object)를 적극 활용해야 한다.
Use Case가 트랜잭션 경계를 정한다
Use Case(Application) 계층의 목적은 작업 시나리오를 오케스트레이션하고 트랜잭션 경계를 설정하며 정책을 적용하는 것이다. Command/Query, Interactor, Unit of Work, 분산 트랜잭션이 필요하면 Saga가 권장 패턴이다. 입력·출력 DTO를 명확히 하고 도메인 이벤트를 퍼블리시하며 idempotency를 고려해야 한다.
Interface Adapters가 형식을 변환한다
Interface Adapters 계층의 목적은 외부 형식과 내부 모델을 변환하고 입출력을 바인딩하는 것이다. Adapter, Presenter/ViewModel, Mapper(예: MapStruct), Repository 구현이 여기 속한다. 변환·검증 책임을 분리하고, 예외는 유스케이스 친화적인 오류로 매핑해야 한다.
Frameworks & Drivers는 기술 스택을 연결한다
Frameworks & Drivers 계층의 목적은 웹·메시징·DB·파일·클라우드 등 기술 스택을 연결하는 것이다. Facade, Gateway, Proxy, 드라이버 선택에는 Strategy가 어울린다. 프레임워크 의존성이 안쪽 계층으로 누수되지 않게 차단하고, 구성(설정)과 코드를 분리해야 한다.
요청이 계층을 오가는 흐름
계층별 성능·확장성·일관성 비교
| 계층 | 성능 | 확장성 | 일관성 | 안정성 | 운영 편의 |
|---|---|---|---|---|---|
| Entity | 메모리 내 계산, 매우 빠름 | 규칙 확장 용이 | 불변/불변성 패턴으로 논리 일관성 확보 | 프레임워크 무관, 회귀 위험 낮음 | 독립 단위 테스트 용이 |
| Use Case | I/O 최소화로 지연 관리 | 새로운 시나리오 추가 용이 | 트랜잭션 경계로 강한 일관성 제공 | 예외/리트라이 정책으로 복원력 | 인메모리 대역으로 빠른 테스트 |
| Interface Adapters | 직렬화/매핑 비용 존재 | 어댑터 추가로 수평 확장 | 변환 규칙 일관성 필요 | 장애 격리(회로 차단기 등) | 로그/관찰성 삽입 용이 |
| Frameworks & Drivers | 네트워크/스토리지 지연 지배 | 인프라 스케일 아웃 | 외부 시스템에 종속 | 드라이버/커넥션 관리 중요 | 배포/구성 자동화 중요 |
구현 예: 주문 생성 Use Case
환경은 Java 17, Spring Boot 3.3+, Gradle/Maven이고 단일 DB 트랜잭션과 통화·세금 단순화를 가정한다.
// Use Case 포트
public interface CreateOrderUseCase {
record Input(UUID customerId, List<Item> items) {}
record Item(UUID productId, int qty, BigDecimal unitPrice) {}
record Output(UUID orderId, BigDecimal total) {}
Output execute(Input in);
}
// Entity (Aggregate)
final class Order {
private final UUID id;
private final UUID customerId;
private final List<OrderLine> lines;
private BigDecimal total;
private Order(UUID id, UUID customerId, List<OrderLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("빈 주문 불가");
this.id = id; this.customerId = customerId; this.lines = List.copyOf(lines);
recalc();
}
static Order create(UUID customerId, List<OrderLine> lines) {
return new Order(UUID.randomUUID(), customerId, lines);
}
void recalc() {
this.total = lines.stream()
.map(l -> l.price().multiply(BigDecimal.valueOf(l.qty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw new IllegalStateException("총액 오류");
}
// getters...
record OrderLine(UUID productId, int qty, BigDecimal price) {}
}
// Repository 포트
interface OrderRepository {
void save(Order order);
boolean existsById(UUID id);
}
// Interactor (Application Service)
@org.springframework.stereotype.Service
@org.springframework.transaction.annotation.Transactional
class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository repo;
CreateOrderService(OrderRepository repo) { this.repo = repo; }
@Override
public Output execute(Input in) {
var lines = in.items().stream()
.map(i -> new Order.OrderLine(i.productId(), i.qty(), i.unitPrice()))
.toList();
var order = Order.create(in.customerId(), lines);
repo.save(order); // 트랜잭션 경계 내부
return new Output(order.getId(), order.getTotal());
}
}
// Adapter (JPA 예시)
@org.springframework.stereotype.Repository
class JpaOrderRepository implements OrderRepository {
private final jakarta.persistence.EntityManager em;
JpaOrderRepository(jakarta.persistence.EntityManager em) { this.em = em; }
public void save(Order order) { em.persist(mapToEntity(order)); }
public boolean existsById(UUID id) { return em.find(OrderEntity.class, id) != null; }
// mapToEntity / Entity 매핑 생략
}
// Presenter 예시
final class CreateOrderPresenter {
record ViewModel(String orderId, String total) {}
ViewModel toView(CreateOrderUseCase.Output out) {
return new ViewModel(out.orderId().toString(), out.total().toPlainString());
}
}
경계는 CreateOrderUseCase가 트랜잭션 경계를 맡고 엔터티는 순수 계산만 수행한다. Controller는 InputDTO를 유스케이스 Input으로 매핑하고 Presenter는 Output을 ViewModel로 변환한다. 테스트는 엔터티 단위 테스트, 유스케이스는 In-memory Repo를 이용한 빠른 테스트, 어댑터는 통합·계약 테스트로 나눈다.
운영 시에는 재고 차감처럼 경쟁 자원이 포함되면 낙관적 락과 재시도 정책, 또는 CQRS와 예약 패턴 도입을 고려한다. 외부 결제·배송 연동은 Gateway에 회로 차단기·타임아웃·폴백을 적용해 장애를 격리하고, 유스케이스 단위 추적 ID와 도메인 이벤트 로깅으로 SLA 기반 대시보드를 구성한다.
경계를 명확히 할수록 매핑 비용은 늘어난다
DTO와 엔터티를 분리해 직렬화·검증 책임을 어댑터에 집중시키면 매핑 비용이 늘어나는 대신 경계가 명확해진다. Repository는 애그리게이트 단위로 반환해 일관성 경계를 명확히 하되, 조회 유연성이 줄어드는 트레이드오프가 있어 필요하면 읽기 모델을 CQRS로 분리한다. 도메인 이벤트를 활용하면 모듈 간 결합도는 줄지만 최종 일관성 지연과 복잡도가 늘어난다. 프레임워크 의존성을 포트·어댑터로 역전시키면 격리는 되지만 초반 설계와 코드량이 늘어나고, 트랜잭션 경계를 유스케이스 단위로 짧게 유지하면 다중 시스템 간 보상 트랜잭션이 필요해질 수 있다.
이 구조가 조직에 자리 잡으면 기능 추가 시 영향 범위가 줄어 리드타임이 2040% 단축될 수 있고(조직·코드베이스에 따라 상이), 도메인·유스케이스 단위 테스트 커버리지가 2030%p 늘어 회귀 버그가 줄며, 장애 격리와 관찰성 개선으로 MTTR이 15~25% 단축되고 롤백·재배포 리스크도 줄어드는 효과를 기대할 수 있다.