[TIL] 클린 아키텍처 (Clean Architecture)

곽태민·2025년 11월 3일

TIL

목록 보기
70/72

레이어드 아키텍처를 공부하고 적용을 해보면서, 레이어드 아키텍처도 계층을 나누는데, 클린 이키텍처는 왜 존재하고, 어떻게 다른지 궁금했다.

클린 아키텍처는 레이어드 아키텍처의 문제점을 해결하고 더 엄격한 의존성 규칙을 적용한 아키텍처 패턴이다. 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 의존
    }
}

위 코드의 문제점은 아래와 같다.

  • 비즈니스 로직이 외부 시스템(이메일, 결제)과 강하게 결합
  • SMTP 서버가 없으면 테스트 불가능
  • 결제 게이트웨이를 바꾸려면 Service 수정 필요

🎯 클린 아키텍처란?

의존성은 항상 안쪽(고수준 정책)을 향한다 -> 이것이 클린 아키텍처의 핵심 규칙이다.

동심원 구조

┌─────────────────────────────────────────────────┐
│  Frameworks & Drivers (최외곽)                    │
│  ┌───────────────────────────────────────────┐  │
│  │  Interface Adapters                       │  │
│  │  ┌─────────────────────────────────────┐  │  │
│  │  │  Application Business Rules         │  │  │
│  │  │  ┌───────────────────────────────┐  │  │  │
│  │  │  │  Enterprise Business Rules    │  │  │  │
│  │  │  │  (Entities)                   │  │  │  │
│  │  │  └───────────────────────────────┘  │  │  │
│  │  │  (Use Cases)                        │  │  │
│  │  └─────────────────────────────────────┘  │  │
│  │  (Controllers, Presenters, Gateways)      │  │
│  └───────────────────────────────────────────┘  │
│  (Web, DB, UI, External Interfaces)             │
└─────────────────────────────────────────────────┘

의존성 방향은 항상 외부 -> 내부이다.

4개의 계층

Entities (Enterprise Business Rules)

가장 안쪽 - 핵심 비즈니스 규칙

// 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
            )
        }
    }
}

특징은

  • 순수 비즈니스 로직만 포함
  • 프레임워크, DB, UI 등 모든 외부 의존성으로부터 독립
  • 변경 빈도가 가장 낮음

Use Case (Application Business Rules)

애플리케이션 특화 비즈니스 규칙

// 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)
    }
}

특징은

  • 애플리케이션의 use case 구현
  • Entity를 조율
  • 외부 시스템과의 통신은 포트(interface)를 통해서 진행.

Interface Adapters(Controllers, Presenters, Gateways)

외부와 내부를 연결하는 어댑터

// 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}"
        )
    }
}

특징

  • Controller: 웹 요청을 Use Case 호출로 변환 (Input Adapter)
  • Repository Adapter: Domain 포트를 실제 DB 연동으로 구현 (Output Adapter)
  • Notification Adapter: Domain 포트를 실제 이메일 서비스로 구현 (Output Adapter)

Framworks & Drivers

가장 바깥쪽 - 프레임워크와 도구

// infrastructure/config/EmailConfig.kt
@Configuration
class EmailConfig {
    @Bean
    fun emailService(): EmailService {
        return SmtpEmailService(/* SMTP 설정 */)
    }
}

특징

  • Spring, JPA, SMTP 등 구체적인 기술
  • 가장 자주 변경되는 계층
  • 내부 계층에 영향을 주지 않음

🔑 핵심 개념: 포트와 어댑터 (Hexagonal Architecture)

클린 아키텍처는 헥사고날 아키텍처(Ports And Adapter)와 매우 유사하다.

Port

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

포트의 구현체

// 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을 바라봄

🆚 레이어드 아키텍처 VS 클린 아키텍처

구분레이어드 아키텍처클린 아키텍처
구조수평적 계층 (Layer)동심원 구조 (Circle)
의존성상위 → 하위외부 → 내부
Domain 위치중간 계층최중심
외부 시스템Service에서 직접 호출Port를 통해 간접 호출
테스트외부 시스템 Mock 필요Port만 Mock
유연성중간매우 높음
복잡도낮음높음
적용 시기중소 규모 프로젝트대규모, 장기 프로젝트

🤔 언제 클린 아키텍처를 사용할까?

✅ 클린 아키텍처가 적합한 경우

  1. 비즈니스 로직이 복잡한 경우
    • 금융, 이커머스, 헬스케어 등
  2. 외부 시스템 의존성이 많은 경우
    • 결제 게이트웨이, 이메일 서비스, SMS, Push 알림 등
  3. 장기 운영 프로젝트
    • 기술 스택 변경 가능성이 높은 경우
  4. 테스트가 중요한 경우
    • TDD를 적극적으로 적용하는 프로젝트

❌ 클린 아키텍처가 과한 경우

  1. 단순 CRUD 애플리케이션
    • 복잡한 비즈니스 로직이 없는 경우
  2. 프로토타입이나 MVP
    • 빠른 개발이 최우선인 경우
  3. 소규모 팀
    • 아키텍처 유지보수 비용이 부담되는 경우

✅ 클린 아키텍처의 장점

1. 독립성

// ✅ Domain과 Use Case는 외부 기술을 전혀 모름
class Order {
    // Spring, JPA, SMTP 등 외부 의존성 ZERO
}

// ✅ 외부 시스템 교체가 자유로움
// EmailNotificationAdapter → SlackNotificationAdapter
// TossPaymentAdapter → KakaoPaymentAdapter

2. 테스트 용이성

class CreateOrderUseCaseTest {
    @Test
    fun `주문 생성 시 알림이 전송된다`() {
        // Mock 생성이 쉬움 (인터페이스만 구현)
        val mockNotification = mock<NotificationPort>()
        val useCase = CreateOrderUseCaseImpl(
            orderRepository = mockOrderRepository,
            notificationPort = mockNotification
        )
        
        useCase.execute(command)
        
        verify(mockNotification).sendOrderConfirmation(any())
    }
}

3. 비즈니스 로직 보호

  • UI가 바뀌어도 Domain은 안전
  • DB가 바뀌어도 Domain은 안전
  • framwork가 바뀌어도 Domain은 안전

⚠️ 클린 아키텍처의 단점

1. 높은 초기 비용

  • 보일러 플레이트 코드 증가
  • 인터페이스와 구현체를 각각 관리

2. 러닝커브

  • 팀원 모두가 아키텍처를 이해해야 함
  • 잘못 적용하면 오히려 복잡도만 증가

3. 과도한 추상화 위험

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. 작게 시작하기

처음부터 완벽한 클린 아키텍처를 적용하려 하지 말기.

1단계: 레이어드 아키텍처로 시작
2단계: Domain을 중심으로 리팩토링
3단계: 외부 의존성을 Port로 분리
4단계: Adapter 패턴 적용

2. Port는 Domain 관점에서 정의

// ❌ 기술 중심
interface SmtpEmailPort {
    fun sendSmtpEmail(to: String, subject: String, body: String)
}

// ✅ Domain 중심
interface NotificationPort {
    fun notifyOrderCreated(order: Order)
    fun notifyOrderShipped(order: Order)
}

3. 과도한 분리는 금물

// ❌ 너무 세분화
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은 최중심에, 순수하게
  • 외부 의존성은 Port/Adapter로 분리
  • 의존성은 항상 내부를 향하도록
  • 과도한 추상화는 피하되, 핵심은 지키기

클린 아키텍처는 "완벽한 설계" 가 아니라 "비즈니스 로직을 보호하는 도구" 다. 프로젝트의 규모와 복잡도에 맞게 적절히 적용하는 것이 중요하다. 처음부터 모든 것을 완벽하게 하려고 하지 말고, Domain을 중심을 두고 점진적으로 개선을 해나가면 좋을 거 같다.
DDD 공부~? 😓

profile
Node.js 백엔드 개발자입니다!

0개의 댓글