Table of contents
- 동심원이라는 공통 언어
- 클린 아키텍처 — Uncle Bob의 동심원
- 의존성 규칙이 핵심이다
- 어니언 아키텍처 — Palermo의 양파 껍질
- 클린 vs 어니언 — 무엇이 다른가
- 헥사고날, 클린, 어니언 — 세 아키텍처 비교
- 실무에서의 선택 기준
- 흔한 실수들
- 정리
동심원이라는 공통 언어
3편에서 헥사고날 아키텍처를 다뤘다. 포트와 어댑터라는 개념으로 애플리케이션의 안쪽과 바깥쪽을 분리하는 구조였다. 그런데 비슷한 시기에, 비슷한 고민을 다른 이름으로 풀어낸 아키텍처가 둘 더 있다. Robert C. Martin의 클린 아키텍처(Clean Architecture)와 Jeffrey Palermo의 어니언 아키텍처(Onion Architecture)다.
세 아키텍처 모두 핵심 아이디어는 같다. 도메인을 중심에 놓고, 바깥쪽이 안쪽에 의존하게 만든다. 하지만 각각이 강조하는 지점과 계층을 나누는 방식은 조금씩 다르다. 이번 편에서는 클린 아키텍처와 어니언 아키텍처를 구체적으로 살펴보고, 헥사고날까지 포함해서 세 아키텍처의 공통점과 차이를 정리한다.
클린 아키텍처 — Uncle Bob의 동심원
Robert C. Martin(Uncle Bob)이 2012년에 발표한 클린 아키텍처는 그 이전에 나온 헥사고날, 어니언, BCE(Boundary-Control-Entity) 등의 아키텍처에서 공통 원칙을 추출한 것이다. 한마디로 요약하면, 소스 코드의 의존성은 반드시 안쪽을 향해야 한다는 규칙이다.
flowchart TB
subgraph FD["Frameworks & Drivers"]
direction TB
F1["Web Framework"]
F2["DB Driver"]
F3["External API Client"]
end
subgraph IA["Interface Adapters"]
direction TB
A1["Controller"]
A2["Presenter"]
A3["Gateway Implementation"]
end
subgraph UC["Use Cases"]
direction TB
U1["Application Service"]
U2["Input/Output Port"]
end
subgraph EN["Entities"]
direction TB
E1["Domain Model"]
E2["Business Rules"]
end
FD --> IA
IA --> UC
UC --> EN
style EN fill:#4a9eff,color:#fff
style UC fill:#6ab7ff,color:#fff
style IA fill:#a0d4ff,color:#333
style FD fill:#d6ebff,color:#333
이 다이어그램에서 화살표 방향이 곧 의존성 방향이다. 바깥 원은 안쪽 원을 알지만, 안쪽 원은 바깥 원의 존재를 모른다.
Entities — 가장 안쪽 원
엔터프라이즈 비즈니스 규칙을 담는 계층이다. 단일 애플리케이션이 아니라 조직 전체에서 공유할 수 있는 핵심 비즈니스 로직이 여기 들어간다. 주문 금액을 계산하는 규칙, 회원 등급을 판정하는 기준 같은 것들이다.
이 계층은 프레임워크에도, 데이터베이스에도, UI에도 의존하지 않는다. 순수한 도메인 객체와 비즈니스 규칙으로만 구성된다.
아래는 주문 엔티티의 예다.
class Order(
val id: OrderId,
val customerId: CustomerId,
private val _items: MutableList<OrderItem> = mutableListOf()
) {
val items: List<OrderItem> get() = _items.toList()
fun addItem(product: Product, quantity: Int) {
require(quantity > 0) { "수량은 1 이상이어야 한다" }
_items.add(OrderItem(product.id, product.price, quantity))
}
fun totalAmount(): Money {
return _items.fold(Money.ZERO) { acc, item ->
acc + item.price * item.quantity
}
}
}
Order 클래스는 Spring도 모르고, JPA도 모른다. 순수한 Kotlin 코드다.
Use Cases — 애플리케이션 비즈니스 규칙
유스케이스 계층은 애플리케이션 고유의 비즈니스 규칙을 캡슐화한다. Entities 계층의 비즈니스 규칙이 주문 총액은 이렇게 계산한다라면, Use Cases의 규칙은 사용자가 주문을 생성하면 재고를 확인하고, 주문을 저장하고, 결제를 요청한다와 같은 오케스트레이션 로직이다.
클린 아키텍처에서 유스케이스는 입력 포트(Input Port)와 출력 포트(Output Port)를 정의한다.
// Input Port — 유스케이스의 진입점
interface CreateOrderUseCase {
fun execute(command: CreateOrderCommand): OrderResult
}
// Output Port — 유스케이스가 외부에 요청하는 인터페이스
interface OrderRepository {
fun save(order: Order): Order
fun findById(id: OrderId): Order?
}
interface PaymentGateway {
fun requestPayment(orderId: OrderId, amount: Money): PaymentResult
}
유스케이스 구현체는 이 포트들을 조합해서 하나의 흐름을 완성한다.
class CreateOrderService(
private val orderRepository: OrderRepository,
private val paymentGateway: PaymentGateway,
private val inventoryChecker: InventoryChecker
) : CreateOrderUseCase {
override fun execute(command: CreateOrderCommand): OrderResult {
val order = Order(
id = OrderId.generate(),
customerId = command.customerId
)
command.items.forEach { item ->
inventoryChecker.ensureAvailable(item.productId, item.quantity)
order.addItem(item.toProduct(), item.quantity)
}
val savedOrder = orderRepository.save(order)
val paymentResult = paymentGateway.requestPayment(
savedOrder.id, savedOrder.totalAmount()
)
return OrderResult(savedOrder.id, paymentResult.status)
}
}
핵심은 OrderRepository와 PaymentGateway가 인터페이스라는 점이다. 유스케이스는 데이터를 저장해줘라고 요청할 뿐, 그 저장이 MySQL에서 일어나는지 MongoDB에서 일어나는지 알 필요가 없다.
Interface Adapters — 번역 계층
컨트롤러, 프레젠터, 게이트웨이 구현체가 이 계층에 속한다. 바깥 세계(HTTP, DB, 메시지 큐)의 데이터 형식을 유스케이스가 이해하는 형식으로 바꾸고, 유스케이스의 출력을 바깥 세계가 이해하는 형식으로 다시 바꾸는 역할을 한다.
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val createOrderUseCase: CreateOrderUseCase
) {
@PostMapping
fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<OrderResponse> {
val command = request.toCommand() // HTTP → UseCase 형식 변환
val result = createOrderUseCase.execute(command)
return ResponseEntity.ok(result.toResponse()) // UseCase → HTTP 형식 변환
}
}
리포지토리 구현체도 이 계층에 위치한다.
@Repository
class JpaOrderRepository(
private val jpaRepository: OrderJpaRepository
) : OrderRepository {
override fun save(order: Order): Order {
val entity = OrderEntity.from(order) // 도메인 → JPA 엔티티 변환
val saved = jpaRepository.save(entity)
return saved.toDomain() // JPA 엔티티 → 도메인 변환
}
override fun findById(id: OrderId): Order? {
return jpaRepository.findById(id.value)
.map { it.toDomain() }
.orElse(null)
}
}
Frameworks & Drivers — 가장 바깥 원
Spring Boot, JPA, Jackson, 웹 서버 설정 같은 프레임워크와 도구가 이 계층에 속한다. 직접 작성하는 코드가 가장 적은 계층이기도 하다. 대부분 설정과 글루 코드로 이루어진다.
의존성 규칙이 핵심이다
클린 아키텍처에서 가장 중요한 원칙은 단 하나다. 의존성 규칙(Dependency Rule). 소스 코드의 의존성은 반드시 바깥쪽에서 안쪽으로만 향해야 한다. 안쪽 원에 있는 코드는 바깥쪽 원에 대해 아무것도 알아서는 안 된다. 함수 이름, 클래스 이름, 변수명조차 바깥 원에서 선언된 것이면 안쪽에서 언급해서는 안 된다.
이 규칙이 지켜지면 무엇이 좋은가?
프레임워크 교체가 가능해진다. Spring을 Ktor로 바꿔도 Entities와 Use Cases는 수정할 필요가 없다. Interface Adapters만 새로 만들면 된다. 물론 실제로 프레임워크를 통째로 교체하는 일은 드물지만, 그만큼 핵심 비즈니스 로직이 외부 의존성으로부터 격리되어 있다는 뜻이다.
테스트가 쉬워진다. 유스케이스를 테스트할 때 데이터베이스를 띄울 필요가 없다. OrderRepository를 메모리 기반 구현체로 교체하면 그만이다.
class CreateOrderServiceTest {
private val orderRepository = InMemoryOrderRepository()
private val paymentGateway = FakePaymentGateway()
private val inventoryChecker = AlwaysAvailableInventoryChecker()
private val sut = CreateOrderService(
orderRepository, paymentGateway, inventoryChecker
)
@Test
fun `주문 생성 시 총액이 올바르게 계산된다`() {
val command = CreateOrderCommand(
customerId = CustomerId("C001"),
items = listOf(
OrderItemCommand(ProductId("P001"), 2, Money(10_000))
)
)
val result = sut.execute(command)
val savedOrder = orderRepository.findById(result.orderId)
assertThat(savedOrder?.totalAmount()).isEqualTo(Money(20_000))
}
}
DB도 없고, Spring 컨텍스트도 없다. 순수한 단위 테스트다.
어니언 아키텍처 — Palermo의 양파 껍질
Jeffrey Palermo가 2008년에 제안한 어니언 아키텍처(Onion Architecture)도 동심원 구조를 사용한다. 클린 아키텍처보다 4년 먼저 나왔고, 헥사고날 아키텍처(2005년)보다는 3년 뒤다.
어니언 아키텍처의 계층은 다음과 같다.
flowchart TB
subgraph INFRA["Infrastructure"]
direction TB
I1["DB / ORM"]
I2["외부 서비스 클라이언트"]
I3["파일 시스템"]
end
subgraph APP["Application Services"]
direction TB
AP1["Application Service"]
AP2["DTO / Command"]
end
subgraph DS["Domain Services"]
direction TB
DS1["Domain Service"]
DS2["Repository Interface"]
end
subgraph DM["Domain Model"]
direction TB
DM1["Entity"]
DM2["Value Object"]
DM3["Domain Event"]
end
INFRA --> APP
APP --> DS
DS --> DM
style DM fill:#e67e22,color:#fff
style DS fill:#f39c12,color:#fff
style APP fill:#f7c948,color:#333
style INFRA fill:#fde68a,color:#333
클린 아키텍처와 비교하면 눈에 띄는 차이가 있다. 어니언은 도메인을 Domain Model과 Domain Services 두 계층으로 나눈다.
Domain Model — 핵심 중의 핵심
엔티티, 값 객체(Value Object), 도메인 이벤트가 이 계층에 속한다. 다른 어떤 계층에도 의존하지 않으며, 순수한 비즈니스 개념만 표현한다.
// Value Object
data class Money(val amount: Long) {
init {
require(amount >= 0) { "금액은 0 이상이어야 한다" }
}
operator fun plus(other: Money) = Money(amount + other.amount)
operator fun times(multiplier: Int) = Money(amount * multiplier)
companion object {
val ZERO = Money(0)
}
}
// Domain Event
data class OrderPlaced(
val orderId: OrderId,
val customerId: CustomerId,
val totalAmount: Money,
val occurredAt: Instant = Instant.now()
)
Domain Services — 도메인 로직의 조합
단일 엔티티에 속하기 어려운 비즈니스 로직이 여기 들어간다. 또한 리포지토리 인터페이스도 이 계층에서 정의한다.
// 도메인 서비스 — 가격 정책을 적용하는 로직
class PricingService {
fun applyDiscount(order: Order, membership: Membership): Money {
val base = order.totalAmount()
val discountRate = when (membership.grade) {
Grade.VIP -> 0.15
Grade.GOLD -> 0.10
Grade.SILVER -> 0.05
Grade.NORMAL -> 0.0
}
return Money((base.amount * (1 - discountRate)).toLong())
}
}
이 서비스는 Order와 Membership이라는 도메인 객체만 사용한다. 프레임워크, 인프라에 대한 의존이 전혀 없다.
Application Services — 유스케이스 오케스트레이션
클린 아키텍처의 Use Cases와 같은 역할이다. 트랜잭션 경계를 정의하고, 도메인 서비스와 리포지토리를 조합하여 하나의 유스케이스를 실행한다.
class PlaceOrderApplicationService(
private val orderRepository: OrderRepository,
private val pricingService: PricingService,
private val membershipRepository: MembershipRepository,
private val eventPublisher: DomainEventPublisher
) {
fun placeOrder(command: PlaceOrderCommand): OrderId {
val membership = membershipRepository.findByCustomerId(command.customerId)
?: throw CustomerNotFoundException(command.customerId)
val order = Order(OrderId.generate(), command.customerId)
command.items.forEach { order.addItem(it.product, it.quantity) }
val finalAmount = pricingService.applyDiscount(order, membership)
val savedOrder = orderRepository.save(order)
eventPublisher.publish(
OrderPlaced(savedOrder.id, command.customerId, finalAmount)
)
return savedOrder.id
}
}
Infrastructure — 기술적 구현
리포지토리 구현체, ORM 매핑, 외부 API 클라이언트, 메시지 브로커 연결 등 기술적인 세부사항이 모두 이 계층에 몰린다. 여기가 유일하게 외부 라이브러리와 프레임워크에 의존하는 계층이다.
클린 vs 어니언 — 무엇이 다른가
두 아키텍처는 근본적으로 같은 원칙을 공유한다. 차이는 주로 계층의 명명과 세분화에 있다.
| 관점 | 클린 아키텍처 | 어니언 아키텍처 |
|---|---|---|
| 발표 | Robert C. Martin, 2012 | Jeffrey Palermo, 2008 |
| 가장 안쪽 | Entities | Domain Model |
| 도메인 서비스 | Entities에 포함 | Domain Services로 분리 |
| 애플리케이션 로직 | Use Cases | Application Services |
| 바깥쪽 변환 | Interface Adapters | 명시적 계층 없음 |
| 가장 바깥 | Frameworks & Drivers | Infrastructure |
| 포트 개념 | Input/Output Port | Repository Interface 등 |
클린 아키텍처는 Interface Adapters라는 별도 계층을 두어 컨트롤러와 프레젠터의 역할을 명확히 분리하는 반면, 어니언은 그 부분이 Infrastructure에 합쳐지는 경향이 있다. 반대로 어니언은 도메인 내부를 Model과 Services로 세분화하는 반면, 클린 아키텍처의 Entities는 도메인 서비스를 포함하는 더 넓은 개념이다.
실질적으로 프로젝트에 적용할 때 이 차이가 설계를 갈라놓는 경우는 드물다. 어떤 이름을 쓰든 결국 만들어지는 패키지 구조는 비슷해진다.
헥사고날, 클린, 어니언 — 세 아키텍처 비교
flowchart LR
subgraph HEX["헥사고날"]
direction TB
H1["Driving Adapter"]
H2["Application<br/>Port + Use Case"]
H3["Domain"]
H4["Driven Adapter"]
H1 --> H2
H2 --> H3
H4 --> H2
end
subgraph CLEAN["클린"]
direction TB
C1["Frameworks & Drivers"]
C2["Interface Adapters"]
C3["Use Cases"]
C4["Entities"]
C1 --> C2
C2 --> C3
C3 --> C4
end
subgraph ONION["어니언"]
direction TB
O1["Infrastructure"]
O2["Application Services"]
O3["Domain Services"]
O4["Domain Model"]
O1 --> O2
O2 --> O3
O3 --> O4
end
style H3 fill:#2ecc71,color:#fff
style C4 fill:#4a9eff,color:#fff
style O4 fill:#e67e22,color:#fff
세 아키텍처의 공통 원칙을 정리하면 이렇다.
1. 도메인이 중심이다. 비즈니스 로직은 가장 안쪽에 위치하며, 기술적 세부사항으로부터 보호받는다. 프레임워크가 바뀌어도 도메인은 그대로다.
2. 의존성은 안쪽을 향한다. 바깥 계층이 안쪽 계층에 의존하지, 그 반대는 성립하지 않는다. 이를 위해 의존성 역전 원칙(DIP, Dependency Inversion Principle)을 활용한다.
3. 인터페이스로 경계를 만든다. 안쪽 계층이 바깥쪽의 구현을 알지 못하게 하려면 인터페이스(포트)를 사이에 둬야 한다. 구현체는 바깥 계층에서 주입한다.
차이는 강조점에 있다.
| 아키텍처 | 핵심 강조점 | 은유 |
|---|---|---|
| 헥사고날 | 포트와 어댑터의 대칭성 | 육각형의 각 변이 어댑터 |
| 클린 | 의존성 규칙의 엄격한 적용 | 동심원과 화살표 방향 |
| 어니언 | 도메인 계층의 세분화 | 양파 껍질을 벗기면 핵심이 나온다 |
세 아키텍처를 학파처럼 구분해서 우리 팀은 클린을 쓴다, 우리는 어니언이다라고 나누는 것은 큰 의미가 없다. 실무에서는 세 아키텍처의 아이디어를 섞어서 프로젝트에 맞게 변형하는 것이 일반적이다.
실무에서의 선택 기준
“그래서 뭘 쓰면 되는가?” 이 질문에 대한 대답은 상황에 따라 달라진다.
도메인 로직이 복잡한 프로젝트라면 어니언 아키텍처의 Domain Model / Domain Services 분리가 도움이 된다. 도메인 서비스가 많아질수록 이 분리의 가치가 드러난다.
다양한 외부 시스템과 연동하는 프로젝트라면 헥사고날의 포트-어댑터 모델이 직관적이다. 각 외부 시스템이 하나의 어댑터로 대응되니 구조가 명확해진다.
팀에 아키텍처 경험이 적다면 클린 아키텍처의 네 계층 구조가 가장 설명하기 쉽다. 바깥에서 안쪽으로만 의존한다는 규칙 하나만 기억하면 된다.
어떤 아키텍처를 선택하든, 핵심은 같다. 도메인을 중심에 놓고, 기술적 세부사항을 바깥으로 밀어낸다. 이 원칙만 지키면 패키지 이름이 adapter인지 infrastructure인지, 계층이 세 개인지 네 개인지는 팀이 합의하면 되는 문제다.
흔한 실수들
클린이나 어니언을 도입할 때 자주 보이는 실수가 몇 가지 있다.
엔티티에 JPA 어노테이션을 붙이는 것. 도메인 엔티티에 @Entity, @Column 같은 어노테이션을 직접 달면, 가장 안쪽 계층이 프레임워크에 의존하게 된다. 이렇게 되면 아키텍처의 핵심 원칙이 깨진다. 해법은 JPA 엔티티와 도메인 엔티티를 분리하고, 매핑 로직을 두는 것이다.
// 도메인 엔티티 — 프레임워크 의존 없음
class Order(val id: OrderId, val customerId: CustomerId, ...)
// JPA 엔티티 — Infrastructure 계층
@Entity
@Table(name = "orders")
class OrderEntity(
@Id val id: Long,
@Column val customerId: String,
...
) {
fun toDomain() = Order(OrderId(id), CustomerId(customerId), ...)
companion object {
fun from(order: Order) = OrderEntity(order.id.value, order.customerId.value, ...)
}
}
매핑 코드가 늘어나는 것은 사실이다. 하지만 도메인이 프레임워크로부터 자유로워지는 대가로는 감수할 만하다.
모든 계층을 무조건 분리하는 것. CRUD 위주의 단순한 애플리케이션에 네 겹의 동심원을 전부 적용하면 보일러플레이트만 늘어난다. 도메인 로직이 저장하고 조회한다 수준이면 2편에서 다룬 레이어드 아키텍처로 충분하다. 아키텍처는 복잡성을 관리하는 도구이지, 복잡성을 추가하는 도구가 아니다.
Use Case를 서비스 클래스 하나로 퉁치는 것. OrderService에 주문 생성, 주문 조회, 주문 취소, 주문 수정을 전부 넣으면 Use Cases 계층의 의미가 사라진다. 유스케이스 하나당 클래스 하나를 만드는 것이 클린 아키텍처의 의도에 부합한다. CreateOrderUseCase, CancelOrderUseCase처럼 분리하면 각 유스케이스의 변경이 독립적으로 이루어진다.
정리
클린 아키텍처와 어니언 아키텍처는 헥사고날과 함께 도메인 중심, 의존성 안쪽으로라는 같은 철학을 공유하는 아키텍처들이다. 이름과 계층 구분은 다르지만, 프로젝트에 적용하면 결국 비슷한 형태가 된다. 중요한 것은 특정 아키텍처의 이름을 맹목적으로 따르는 게 아니라, 그 아키텍처가 풀려고 했던 문제 — 도메인 보호, 테스트 용이성, 기술 종속 방지 — 를 이해하고 프로젝트의 복잡성에 맞게 선택하는 것이다.
다음 편에서는 관점을 좀 바꿔서, 읽기와 쓰기를 분리하는 CQRS와 이벤트 드리븐 아키텍처를 살펴본다.




Loading comments...