레이어드 아키텍처를 공부하고 적용을 해보면서, 레이어드 아키텍처도 계층을 나누는데, 클린 이키텍처는 왜 존재하고, 어떻게 다른지 궁금했다.
클린 아키텍처는 레이어드 아키텍처의 문제점을 해결하고 더 엄격한 의존성 규칙을 적용한 아키텍처 패턴이다. Rovert C.Martin이 제안한 이 패턴은 비즈니스 로직을 외부로부터 완전히 독립시키는 것을 목표로 한다.
레이어드 아키텍처에서는 아래와 같은 구조를 사용했다.
Presentation -> Application -> Domain <- Infrastructure
하지만 규모가 커지면 어떤 문제가 발생할까?
// ❌ 레이어드 아키텍처의 흔한 실수
@Service
class OrderService(
private val orderRepository: OrderRepository, // Domain 인터페이스
private val emailService: EmailService, // 외부 서비스
private val paymentGateway: PaymentGateway // 외부 API
) {
fun createOrder(order: Order) {
// 비즈니스 로직이 외부 시스템과 강하게 결합됨 💥
orderRepository.save(order)
emailService.sendOrderConfirmation(order) // SMTP 의존
paymentGateway.processPayment(order) // PG사 API 의존
}
}
위 코드의 문제점은 아래와 같다.
의존성은 항상 안쪽(고수준 정책)을 향한다 -> 이것이 클린 아키텍처의 핵심 규칙이다.
┌─────────────────────────────────────────────────┐
│ Frameworks & Drivers (최외곽) │
│ ┌───────────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ Application Business Rules │ │ │
│ │ │ ┌───────────────────────────────┐ │ │ │
│ │ │ │ Enterprise Business Rules │ │ │ │
│ │ │ │ (Entities) │ │ │ │
│ │ │ └───────────────────────────────┘ │ │ │
│ │ │ (Use Cases) │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ │ (Controllers, Presenters, Gateways) │ │
│ └───────────────────────────────────────────┘ │
│ (Web, DB, UI, External Interfaces) │
└─────────────────────────────────────────────────┘
의존성 방향은 항상 외부 -> 내부이다.
가장 안쪽 - 핵심 비즈니스 규칙
// domain/order/Order.kt
data class Order(
val id: Long?,
val userId: Long,
val items: List<OrderItem>,
private var status: OrderStatus,
private val _totalAmount: BigDecimal
) {
val totalAmount: BigDecimal get() = _totalAmount
// ✅ 비즈니스 규칙만 포함 (외부 의존성 ZERO)
fun cancel() {
require(status == OrderStatus.PENDING) {
"대기 중인 주문만 취소할 수 있습니다"
}
status = OrderStatus.CANCELLED
}
fun complete() {
require(status == OrderStatus.PENDING) {
"대기 중인 주문만 완료할 수 있습니다"
}
status = OrderStatus.COMPLETED
}
fun canCancel(): Boolean = status == OrderStatus.PENDING
companion object {
fun create(userId: Long, items: List<OrderItem>): Order {
require(items.isNotEmpty()) { "주문 상품이 비어있습니다" }
val totalAmount = items.sumOf { it.calculateAmount() }
return Order(
id = null,
userId = userId,
items = items,
status = OrderStatus.PENDING,
_totalAmount = totalAmount
)
}
}
}
특징은
애플리케이션 특화 비즈니스 규칙
// application/order/usecase/CreateOrderUseCase.kt
interface CreateOrderUseCase {
fun execute(command: CreateOrderCommand): Order
}
// application/order/usecase/CreateOrderUseCaseImpl.kt
@Component
class CreateOrderUseCaseImpl(
private val orderRepository: OrderRepository, // 포트(인터페이스)
private val stockRepository: StockRepository, // 포트(인터페이스)
private val notificationPort: NotificationPort, // 포트(인터페이스)
private val paymentPort: PaymentPort // 포트(인터페이스)
) : CreateOrderUseCase {
override fun execute(command: CreateOrderCommand): Order {
// 1. 재고 확인 및 차감
val stock = stockRepository.findById(command.productId)
?: throw ProductNotFoundException()
stock.decrease(command.quantity)
// 2. 주문 생성 (Entity가 비즈니스 규칙 처리)
val order = Order.create(
userId = command.userId,
items = command.items
)
// 3. 결제 처리 (포트를 통해)
paymentPort.processPayment(order)
// 4. 알림 전송 (포트를 통해)
notificationPort.sendOrderConfirmation(order)
// 5. 저장
stockRepository.save(stock)
return orderRepository.save(order)
}
}
특징은
외부와 내부를 연결하는 어댑터
// adapter/in/web/OrderController.kt (Input Adapter)
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val createOrderUseCase: CreateOrderUseCase
) {
@PostMapping
fun createOrder(@RequestBody request: CreateOrderRequest): OrderResponse {
// DTO → Command 변환
val command = CreateOrderCommand(
userId = request.userId,
items = request.items.map {
OrderItem(it.productId, it.quantity)
}
)
// Use Case 호출
val order = createOrderUseCase.execute(command)
// Domain → DTO 변환
return OrderResponse.from(order)
}
}
// adapter/out/persistence/OrderRepositoryAdapter.kt (Output Adapter)
@Repository
class OrderRepositoryAdapter(
private val jpaRepository: JpaOrderRepository
) : OrderRepository { // Domain의 포트 구현
override fun save(order: Order): Order {
val entity = OrderEntity.from(order)
val saved = jpaRepository.save(entity)
return saved.toDomain()
}
override fun findById(id: Long): Order? {
return jpaRepository.findById(id)
.map { it.toDomain() }
.orElse(null)
}
}
// adapter/out/notification/EmailNotificationAdapter.kt (Output Adapter)
@Component
class EmailNotificationAdapter(
private val emailService: EmailService
) : NotificationPort { // Domain의 포트 구현
override fun sendOrderConfirmation(order: Order) {
emailService.send(
to = getUserEmail(order.userId),
subject = "주문 확인",
body = "주문이 완료되었습니다. 주문번호: ${order.id}"
)
}
}
특징
가장 바깥쪽 - 프레임워크와 도구
// infrastructure/config/EmailConfig.kt
@Configuration
class EmailConfig {
@Bean
fun emailService(): EmailService {
return SmtpEmailService(/* SMTP 설정 */)
}
}
특징
클린 아키텍처는 헥사고날 아키텍처(Ports And Adapter)와 매우 유사하다.
Domain이 정의하는 interface
// domain/port/out/NotificationPort.kt (Output Port)
interface NotificationPort {
fun sendOrderConfirmation(order: Order)
fun sendShippingNotification(order: Order)
}
// domain/port/out/PaymentPort.kt (Output Port)
interface PaymentPort {
fun processPayment(order: Order): PaymentResult
fun refund(order: Order): RefundResult
}
// domain/port/in/CreateOrderUseCase.kt (Input Port)
interface CreateOrderUseCase {
fun execute(command: CreateOrderCommand): Order
}
포트의 구현체
// adapter/out/notification/EmailNotificationAdapter.kt
@Component
class EmailNotificationAdapter : NotificationPort {
override fun sendOrderConfirmation(order: Order) {
// SMTP로 이메일 전송
}
}
// adapter/out/notification/SlackNotificationAdapter.kt
@Component
class SlackNotificationAdapter : NotificationPort {
override fun sendOrderConfirmation(order: Order) {
// Slack API로 메시지 전송
}
}
장점: email에서 slack으로 변경해도 Domain, Use case는 수정 불필요.
src/main/kotlin/com/hhplus/ecommerce/
│
├── domain/ # 1️⃣ Entities (최내부)
│ ├── order/
│ │ ├── Order.kt # 비즈니스 규칙
│ │ ├── OrderItem.kt
│ │ └── OrderStatus.kt
│ │
│ └── port/ # 포트 정의
│ ├── in/ # Input Port (Use Case 인터페이스)
│ │ ├── CreateOrderUseCase.kt
│ │ └── CancelOrderUseCase.kt
│ │
│ └── out/ # Output Port
│ ├── OrderRepository.kt
│ ├── NotificationPort.kt
│ └── PaymentPort.kt
│
├── application/ # 2️⃣ Use Cases
│ └── order/
│ ├── CreateOrderUseCaseImpl.kt
│ └── CancelOrderUseCaseImpl.kt
│
├── adapter/ # 3️⃣ Interface Adapters
│ ├── in/ # Input Adapter
│ │ └── web/
│ │ └── OrderController.kt
│ │
│ └── out/ # Output Adapter
│ ├── persistence/
│ │ └── OrderRepositoryAdapter.kt
│ │
│ ├── notification/
│ │ ├── EmailNotificationAdapter.kt
│ │ └── SlackNotificationAdapter.kt
│ │
│ └── payment/
│ └── TossPaymentAdapter.kt
│
└── infrastructure/ # 4️⃣ Frameworks & Drivers
├── config/
│ ├── DatabaseConfig.kt
│ └── EmailConfig.kt
│
└── external/
└── TossPaymentClient.kt
┌──────────────────────────────────────────┐
│ Controller (Adapter In) │
└────────────┬─────────────────────────────┘
↓ 호출
┌──────────────────────────────────────────┐
│ Use Case (Application) │
└─────┬──────────────────┬─────────────────┘
↓ 의존 ↓ 의존
┌─────────────┐ ┌──────────────────────┐
│ Entity │ │ Port (인터페이스) │
│ (Domain) │ │ (Domain) │
└─────────────┘ └──────────┬───────────┘
↑ 구현
┌──────────────────────┐
│ Adapter Out │
│ (Persistence 등) │
└──────────────────────┘
핵심: 모든 의존성이 Domain을 바라봄
| 구분 | 레이어드 아키텍처 | 클린 아키텍처 |
|---|---|---|
| 구조 | 수평적 계층 (Layer) | 동심원 구조 (Circle) |
| 의존성 | 상위 → 하위 | 외부 → 내부 |
| Domain 위치 | 중간 계층 | 최중심 |
| 외부 시스템 | Service에서 직접 호출 | Port를 통해 간접 호출 |
| 테스트 | 외부 시스템 Mock 필요 | Port만 Mock |
| 유연성 | 중간 | 매우 높음 |
| 복잡도 | 낮음 | 높음 |
| 적용 시기 | 중소 규모 프로젝트 | 대규모, 장기 프로젝트 |
// ✅ Domain과 Use Case는 외부 기술을 전혀 모름
class Order {
// Spring, JPA, SMTP 등 외부 의존성 ZERO
}
// ✅ 외부 시스템 교체가 자유로움
// EmailNotificationAdapter → SlackNotificationAdapter
// TossPaymentAdapter → KakaoPaymentAdapter
class CreateOrderUseCaseTest {
@Test
fun `주문 생성 시 알림이 전송된다`() {
// Mock 생성이 쉬움 (인터페이스만 구현)
val mockNotification = mock<NotificationPort>()
val useCase = CreateOrderUseCaseImpl(
orderRepository = mockOrderRepository,
notificationPort = mockNotification
)
useCase.execute(command)
verify(mockNotification).sendOrderConfirmation(any())
}
}
interface FindUserPort {
fun findById(id: Long): User?
}
interface SaveUserPort {
fun save(user: User): User
}
// ✅ 적절한 수준
interface UserRepository {
fun findById(id: Long): User?
fun save(user: User): User
}
처음부터 완벽한 클린 아키텍처를 적용하려 하지 말기.
1단계: 레이어드 아키텍처로 시작
2단계: Domain을 중심으로 리팩토링
3단계: 외부 의존성을 Port로 분리
4단계: Adapter 패턴 적용
// ❌ 기술 중심
interface SmtpEmailPort {
fun sendSmtpEmail(to: String, subject: String, body: String)
}
// ✅ Domain 중심
interface NotificationPort {
fun notifyOrderCreated(order: Order)
fun notifyOrderShipped(order: Order)
}
// ❌ 너무 세분화
interface FindOrderByIdPort
interface FindOrderByUserIdPort
interface SaveOrderPort
interface DeleteOrderPort
// ✅ 적절한 그룹핑
interface OrderRepository {
fun findById(id: Long): Order?
fun findByUserId(userId: Long): List<Order>
fun save(order: Order): Order
fun delete(id: Long)
}
| 항목 | 내용 |
|---|---|
| 핵심 규칙 | 의존성은 항상 안쪽(Domain)을 향한다 |
| 구조 | Entity → Use Case → Adapter → Framework |
| 장점 | 독립성, 테스트 용이성, 유연성 |
| 단점 | 초기 비용, 학습 곡선, 복잡도 |
| 적용 시기 | 복잡한 비즈니스 로직, 장기 프로젝트 |
핵심 원칙:
클린 아키텍처는 "완벽한 설계" 가 아니라 "비즈니스 로직을 보호하는 도구" 다. 프로젝트의 규모와 복잡도에 맞게 적절히 적용하는 것이 중요하다. 처음부터 모든 것을 완벽하게 하려고 하지 말고, Domain을 중심을 두고 점진적으로 개선을 해나가면 좋을 거 같다.
DDD 공부~? 😓