본문 바로가기
Kotlin Spring/Kotlin Spring 강의 내용

6) 개발 architecture (3) 클린 아키텍처(Clean Architecture)

by Bill Lab 2025. 8. 31.
728x90

1. 클린 아키텍처 정의

    : 클린 아키텍처는 도메인 중심 아키텍처로,

 

     1) 비즈니스 규칙(Entity, Use Case)을 가장 안쪽에 두고,

     2) 외부 의존성(웹, DB, 메시지 브로커, 프레임워크)을 바깥쪽에 둠

     3) 의존성은 항상 안쪽으로만 향한다 (DIP: Dependency Inversion Principle)

즉, 외부 기술은 교체 가능하고, 핵심 도메인은 독립적이라는 것이 핵심!

 

2. 구조 설명

(출처: Pusher official site)

 

    1) Entities (Domain Model)

         - 순수한 비즈니스 규칙 (ex. Concert, User, Order 등)

         - 프레임워크에 전혀 의존하지 않는 POJO/POKO

 

    2) Use Cases (Application Service)

         - 애플리케이션 규칙 (비즈니스 절차)

         - 엔티티를 조합하여 유스케이스 수행

         - 외부 시스템(DB, API)은 인터페이스를 통해서만 접근

 

    3) Interface Adapters

         - 외부와 내부 간의 변환 계층

         - Controller, Presenter, Repository 구현체, Mapper 등이 위치

 

    4) Frameworks & Drivers (Infrastructure)

         - Spring, DB, Kafka, Redis, UI 등

         - 가장 바깥쪽 계층, 쉽게 교체 가능해야 함

 

3. 핵심가치

    : SOLID + 컴포넌트 수준 원칙(REP, CCP, CRP, ADP, SDP, SAP)

 

    1) SOLID(Class level)

 

"SRP (단일 책임 원칙)"
: 클래스는 오직 하나의 변경 이유만 가져야 함

"OCP (개방-폐쇄 원칙)" 
: 확장에는 열려있고, 변경에는 닫혀있어야 함 (→ 인터페이스, 다형성 활용)

"LSP (리스코프 치환 원칙)" 
: 상위 타입을 하위 타입으로 치환 가능해야 함

"ISP (인터페이스 분리 원칙)" 
: 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않도록 인터페이스를 작게 쪼개라

"DIP (의존성 역전 원칙)" 
: 고수준 모듈은 저수준 구현에 의존하지 않고, 둘 다 추상에 의존해야 함

     

    2) 컴포넌트(여러 클래스들의 집합) 레벨 원칙

"REP (재사용/릴리스 등가 원칙)"
 - 재사용 단위 = 릴리즈 단위.
   예: payment-core 라이브러리를 따로 빼서 배포하면, 그 안에는 반드시 하나의 응집된 책임만 있어야 함.
   
"CCP (공통 폐쇄 원칙)"
 - 함께 변경되는 클래스는 같은 컴포넌트에 두어야 함.
   예: Order와 OrderValidator는 항상 같이 변경되므로 같은 domain.order에 위치.

"CRP (공통 재사용 원칙)"
 - 쓰지도 않는 클래스들에 의존하지 않도록 컴포넌트를 분리.
   예: payment-core를 썼는데 내부적으로 coupon도 딸려오면 안 됨.

"ADP (비순환 의존 원칙)"
 - 컴포넌트 간 의존성은 DAG(방향 비순환 그래프)여야 한다.
 - 순환 참조가 생기면 변경과 빌드, 배포가 지옥됨. DIP로 인터페이스를 추가해 깨야 함.
 
"SDP (안정된 의존 원칙)"
 - 불안정한 컴포넌트는 안정된 컴포넌트에 의존해야 한다.
 - 도메인 계층은 안정된 계층이고, 프레젠테이션/UI 계층은 변하기 쉬움 → UI가 도메인에 의존하는 건 OK, 반대는 안 됨.

"SAP (안정된 추상 원칙)"
 - 안정적인 컴포넌트일수록 추상화돼 있어야 한다.
   예: domain은 가장 안정적이므로 인터페이스(Repository, Service Port) 중심

 

 

4. 예제 코드

com.example.concert
 ├── domain
 │    ├── model
 │    │     └── Concert.kt
 │    ├── repository
 │    │     └── ConcertRepository.kt   // Port (interface)
 │    └── service
 │          └── ConcertValidator.kt
 │
 ├── application
 │    └── usecase
 │          └── ReserveConcertUseCase.kt
 │
 ├── adapter
 │    ├── inbound
 │    │     └── web
 │    │           └── ConcertController.kt
 │    └── outbound
 │          ├── persistence
 │          │     └── ConcertJpaRepository.kt  // Adapter 구현체
 │          └── messaging
 │                └── ConcertKafkaProducer.kt
 │
 └── infrastructure
       ├── config
       │     └── JpaConfig.kt
       └── persistence
             └── ConcertJpaEntity.kt

 

    (1) Domain Layer - Entity

package com.example.concert.domain.model

data class Concert(
    val id: Long? = null,
    val title: String,
    val availableSeats: Int
) {
    fun reserveSeat(): Concert {
        require(availableSeats > 0) { "No seats available" }
        return this.copy(availableSeats = this.availableSeats - 1)
    }
}

 

    (2) Domain Layer - Repository Port

package com.example.concert.domain.repository

import com.example.concert.domain.model.Concert

interface ConcertRepository {
    fun findById(id: Long): Concert?
    fun save(concert: Concert): Concert
}

 

    (3) Application Layer - Use Case

package com.example.concert.application.usecase

import com.example.concert.domain.repository.ConcertRepository

class ReserveConcertUseCase(
    private val concertRepository: ConcertRepository
) {
    fun reserve(concertId: Long) {
        val concert = concertRepository.findById(concertId)
            ?: throw IllegalArgumentException("Concert not found")

        val updatedConcert = concert.reserveSeat()
        concertRepository.save(updatedConcert)
    }
}

 

    (4) Adapter Layer - Inbound (Controller)

package com.example.concert.adapter.inbound.web

import com.example.concert.application.usecase.ReserveConcertUseCase
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/concerts")
class ConcertController(
    private val reserveConcertUseCase: ReserveConcertUseCase
) {
    @PostMapping("/{id}/reserve")
    fun reserve(@PathVariable id: Long) {
        reserveConcertUseCase.reserve(id)
    }
}

 

    (5) Adapter Layer - Outbound (Persistence Adapter)

package com.example.concert.adapter.outbound.persistence

import com.example.concert.domain.model.Concert
import com.example.concert.domain.repository.ConcertRepository
import org.springframework.stereotype.Repository

@Repository
class ConcertJpaRepository(
    private val jpa: SpringDataConcertJpaRepository
) : ConcertRepository {
    override fun findById(id: Long): Concert? =
        jpa.findById(id).orElse(null)?.toDomain()

    override fun save(concert: Concert): Concert =
        jpa.save(ConcertJpaEntity.from(concert)).toDomain()
}

 

    (6) Infrastructure - JPA Entity & Spring Data Repository

package com.example.concert.infrastructure.persistence

import com.example.concert.domain.model.Concert
import jakarta.persistence.*

@Entity
@Table(name = "concerts")
data class ConcertJpaEntity(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    val title: String,
    val availableSeats: Int
) {
    fun toDomain() = Concert(id, title, availableSeats)

    companion object {
        fun from(concert: Concert) = ConcertJpaEntity(
            id = concert.id,
            title = concert.title,
            availableSeats = concert.availableSeats
        )
    }
}

interface SpringDataConcertJpaRepository : org.springframework.data.jpa.repository.JpaRepository<ConcertJpaEntity, Long>

 

5. 장점

"비즈니스 로직 독립성"
 - 도메인 모델과 유즈케이스(application logic)는 프레임워크, UI, DB에 의존하지 않음.
 - 예를 들어 DB를 JPA에서 MongoDB로 교체해도 도메인/유즈케이스는 영향을 거의 안 받음.

"테스트 용이성"
 - 도메인과 유즈케이스 계층이 외부 의존성(DB, Kafka, Redis 등) 없이 순수 Kotlin/Java 코드로 존재.
 - 단위 테스트 작성이 쉬움 (Mock Adapter만 교체).

"유연한 확장성"
 - 다양한 Adapter (REST, GraphQL, Kafka Consumer 등)를 붙이기 용이.
 - 동일한 도메인/유즈케이스에 대해 REST API, gRPC, CLI를 동시에 제공할 수 있음.

"의존성 역전(DIP)"
 - 항상 바깥 계층이 안쪽 계층(도메인, 유즈케이스)에 의존 → 변경에 강함.
 - 핵사고날(Ports & Adapters)과 개념적으로 유사.

"관심사 분리 (Separation of Concerns)"
 - 도메인 로직, 유즈케이스, 인프라, UI가 명확히 나뉘어 책임 구분이 확실.

 

6. 단점

"초기 복잡도 높음"
 - 패키지 구조와 계층이 많아져 작은 프로젝트에는 오버엔지니어링.
 - 레이어드 아키텍처보다 학습 곡선이 큼.

"초반 개발 속도 저하"
 - 의존성 역전을 위해 인터페이스(Port) + Adapter를 항상 고려해야 함.
 - 단순 CRUD에도 Interface/Adapter를 작성해야 하는 부담.

"네이밍 혼란 가능"
 - UseCase / ApplicationService / DomainService / Adapter 등 구분이 모호할 수 있음.
 - 팀원 간 "어디에 뭘 넣어야 하는지" 컨벤션이 없으면 혼란 발생.

"과도한 추상화 위험"
 - 인터페이스(Port)가 늘어나면서 실제 구현보다 추상화가 많아져 관리 비용 증가.
 - 잘못 설계하면 DIP가 불필요한 계층을 양산.

"DDD 적용 어려움"
 - DDD를 잘 이해하지 못하면 "클린 아키텍처"가 단순히 레이어 분리 수준으로 전락.
 - 결국 SRP(단일책임원칙)가 무너져 Controller, UseCase, Service가 뒤섞일 수 있음.

 

 

728x90