본문 바로가기

아키텍처

이펙티브 헥사고날 아키텍처

최근에 [만들면서 배우는 클린 아키텍처]를 읽고, 개인 프로젝트에서 헥사고날 아키텍처를 실험해 봤다.
 
이번 실험으로 기존에 계층형 아키텍처를 사용하면서 느꼈던 단점에 대한 괜찮은 해결법을 발견했다. 가능하다면 앞으로 진행하는 모든 백엔드 프로젝트에 헥사고날 아키텍처를 적용할 예정이다. 물론, 나중에 더 괜찮은 아키텍처를 발견하면 갈아탈 생각이다.

내가 생각하는 계층형 아키텍처의 단점은 작업자에 따라 비즈니스 로직의 구현 위치가 달라질 수 있다는 점이다. 단순한 계층형 아키텍처는 웹(컨트롤러) → 도메인(서비스) → 영속성(레파지토리) 순으로 의존성이 설정된다. 의존성 방향으로 데이터가 전파되기 때문에, 의존성의 마지막 단계인 영속성 계층에 비즈니스 로직이 쌓일 가능성이 있다. 팀에서 규칙을 정하면 이 현상을 제어할 수 있지만, 팀마다 규칙이 달라질 수 있다.
 
헥사고날 아키텍처는 계층형 아키텍처에 몇 가지 규칙을 추가해서, 비즈니스 로직을 애플리케이션 코어 계층에 구현하도록 유도한다. 아래의 '용어 정리'에서 소개하는 헥사고날 아키텍처 구성 요소에 대한 자세한 설명은 [만들면서 배우는 클린 아키텍처]을 참고하는 것을 추천한다. (참 좋은 책이다!) 이 포스트의 독자는 해당 책을 읽어봤거나, 헥사고날 아키텍처 구성 요소에 대한 기본적인 이해가 있는 사람이라고 가정한다.

지금부터 헥사고날 아키텍처를 적용하면서 고민했던 내용을 공유한다. 책에서 설명하는 대부분의 내용에 동의하지만, 일부 내용에 대해선 다른 의견도 있다.
 

용어 정리

이 포스트에서는 아래와 같이 Bold 처리한 용어를 사용한다.

사진 출처: [카카오뱅크 기술 블로그](https://tech.kakaobank.com/posts/2311-hexagonal-architecture-in-messaging-hub)
  • 인커밍 어댑터 (Driving Adapter)
    • 유스케이스를 호출하는 컴포넌트
    • 외부 요청을 받아서 유스케이스에게 작업을 위임하고, 작업 결괏값을 요청자에게 전달한다
  • 아웃고잉 어댑터 (Driven Adapter)
    • 유스케이스가 호출하는 컴포넌트
    • 유스케이스 작업에 필요한 데이터를 조회하거나, 유스케이스가 요청하는 데이터를 저장한다
    • 주로 DB 통신, 외부 API 호출 같은 외부 프로세스와 통신하는 로직을 구현한다
  • 애플리케이션 코어 계층 (Application Core)
    • 유스케이스 (Use Case) 
      • 계층형 아키텍처에서 Service로 불리는 컴포넌트
      • 인커밍/아웃고잉 어댑터의 데이터를 조합해서, 도메인 엔티티에게 비즈니스 로직 처리를 위임한다
      • 종종 도메인 엔티티를 사용하지 않을 수도 있다
    • 도메인 엔티티 (Entity)
      • 유스케이스가 요청한 비즈니스 로직을 처리한다
    • 인커밍 포트 (Input Port)
    • 아웃고잉 포트 (Output Port)
      • 유스케이스가 인커밍/아웃고잉 어댑터와 통신하기 위해서 사용하는 Interface이다

 

라이브러리와 프레임워크를 사용하는 로직은 어댑터에 구현하자

헥사고날 아키텍처의 핵심 개념은 "어댑터는 다른 어댑터로 쉽게 교체할 수 있어야 한다"이다. 이 개념을 지키려면 비즈니스 로직을 어댑터에 구현하면 안 된다. 어댑터에 비즈니스 로직이 있으면, 어댑터를 교체하기 힘들어지기 때문이다.
 
반면, 프레임워크와 라이브러리를 사용하는 로직은 어댑터에 구현하는 것이 좋다. 프레임워크와 라이브러리는 항상 대체제가 있다. 종종, 라이브러리 공식 문서에서는 벤치마크를 제시하면서, 타 라이브러리 대비 실행 성능과 생산성이 향상된다는 점을 자랑하는 경우가 있다. (벤치마크를 제대로 측정했다면) 라이브러리 교체만으로도 프로젝트의 가치가 상승할 수 있다는 점에서 의미가 있다.
 
JPA는 좋은 라이브러리지만, JPA Entity에 비즈니스 로직을 구현하는 일은 경계해야 한다. 라이브러리는 항상 대체 가능하다는 점을 의식하고, 더 좋은 선택권을 열어 놓는다면 프로젝트의 가치를 높일 수 있다.
 
프레임워크도 비슷하다. Spring WebFlux에서 Ktor로 전환해야 한다고 가정해 보자. Spring WebFlux에서 제공하는 기능을 어댑터에서만 사용했다면, 적은 노력으로 마이그레이션 할 수 있다.
 

비즈니스 로직은 도메인 엔티티에 구현하자

헥사고날 아키텍처의 또 다른 핵심 개념은 "비즈니스 로직을 최대한 재사용할 수 있어야 한다"이다. 비즈니스 로직은 도메인 엔티티에 구현하는 것이 가장 좋고, 그다음으로 유스케이스에 구현하는 것이 좋다.
 
도메인 엔티티는 항상 재사용할 수 있지만, 유스케이스는 상황에 따라 재구현 할 가능성이 있다. 예를 들어, Spring Web MVC에서 Spring WebFlux로 마이그레이션 해야 한다고 가정해 보자. 이 경우, 도메인 엔티티는 수정 없이 재사용할 수 있지만, 유스케이스는 재구현해야 한다. Spring WebFlux에서는 비동기에 특화된 유스케이스를 사용해야 하기 때문이다.
 

아웃고잉 포트는 잘게 쪼개서 선언하고, 아웃고잉 어댑터는 하나의 클래스로 구현하자

(처음에 거부감이 있었지만) 포트 인터페이스를 잘게 쪼개서 사용하면, 단위 테스트 작성이 쉬워진다. 나는 포트 인터페이스를 '개별 Port Interface'와 '대표 Port Interface'로 구분해서 사용한다.
 
'개별 Port Interface'는 메서드를 1개만 선언한 인터페이스를 의미하고, '대표 Port Interface'는 관련 있는 '개별 Port Interface'를 상속하는 인터페이스를 의미한다.

package com.newy.market_account.port.out

// 대표 Port Interface
interface MarketAccountPort :
    ExistsMarketAccountPort,
    FindMarketServerPort,
    SaveMarketAccountPort

// 개별 Port Interface
fun interface ExistsMarketAccountPort {
    suspend fun existsMarketAccount(domainEntity: MarketAccount): Boolean
}

// 개별 Port Interface
fun interface FindMarketServerPort {
    suspend fun findMarketServer(market: Market, isProductionServer: Boolean): MarketServer?
}

// 개별 Port Interface
fun interface SaveMarketAccountPort {
    suspend fun saveMarketAccount(domainEntity: MarketAccount): MarketAccount
}

 
아웃고잉 어댑터는 '대표 Port Interface'를 구현한다. 아웃고잉 어댑터는 하나의 클래스로 구현하는 것이 좋다. 아웃고잉 어댑터의 구현 로직은 복잡하지 않은 경우가 많다. 복잡하지 않고, 서로 관련 있는 로직들은 하나의 클래스에서 관리하는 것이 편리하다.

package com.newy.market_account.adapter.out.persistence

@Component
class MarketAccountPersistenceAdapter(
    private val marketServerRepository: MarketServerRepository,
    private val marketAccountRepository: MarketAccountRepository
) : MarketAccountPort {
    override suspend fun findMarketServer(market: Market, isProductionServer: Boolean) {
        ...
    }
    override suspend fun existsMarketAccount(domainEntity: MarketAccount) {
        ...
    }
    override suspend fun saveMarketAccount(domainEntity: MarketAccount) {
        ...
    }
}

 
아웃고잉 포트에서 '개별 Port Interface'를 사용하면, 유스케이스에 대한 단위 테스트 작성이 편해진다. 특정 상황에 대한 시뮬레이션을 표현하기 위해, 최소한의 인터페이스만 구현하면 되기 때문이다. 아래의 단위 테스트는 "중복된(DB에 저장된) key로 등록 요청하는 상황"을 시뮬레이션한다.

@DisplayName("예외 사항 테스트")
class MarketAccountCommandServiceExceptionTest {
    @Test
    fun `중복된 MarketAccount 를 등록하는 경우`() = runTest {
        val alreadySavedAdapter = ExistsMarketAccountPort { true } // 예외사항 발생 시뮬레이션
        val service = newMarketAccountCommandService(
            existsMarketAccountPort = alreadySavedAdapter
        )

        try {
            service.setMarketAccount(incomingPortModel)
            fail()
        } catch (e: DuplicateDataException) {
            assertEquals("이미 등록된 appKey, appSecret 입니다.", e.message)
        }
    }
}

// 테스트 헬퍼 메소드
private fun newMarketAccountCommandService(
    existsMarketAccountPort: ExistsMarketAccountPort = NoErrorMarketAccountAdapter(),
    findMarketServerPort: FindMarketServerPort = NoErrorMarketAccountAdapter(),
    saveMarketAccountPort: SaveMarketAccountPort = NoErrorMarketAccountAdapter(),
) = MarketAccountCommandService(
    existsMarketAccountPort = existsMarketAccountPort,
    findMarketServerPort = findMarketServerPort,
    saveMarketAccountPort = saveMarketAccountPort,
)

 

인커밍 포트는 잘게 쪼개서 선언하고, 유스케이스는 CQRS 패턴을 적용해서 구현하자

인커밍 포트는 아웃고잉 포트와 비슷한 전략으로 구성한다. 인커밍 포트에서의 차이점은 CQRS 패턴 적용을 위해서, '대표 Port Interface'를 2개로 분리해서 사용한다는 점이다. '[읽기 전용] 대표 Port Interface'와 '[쓰기 전용] 대표 Port Interface'로 분리해서 사용한다.

package com.newy.user_strategy.port.`in`

// [읽기 전용] 대표 Port Interface
interface UserStrategyProductQuery :
    GetAllUserStrategyProductQuery,
    GetUserStrategyProductQuery

// [쓰기 전용] 대표 Port Interface
interface UserStrategyUseCase :
    SetUserStrategyUseCase

// 개별 Port Interface
fun interface GetAllUserStrategyProductQuery {
    suspend fun getAllUserStrategyKeys(): List<UserStrategyKey>
}

// 개별 Port Interface
fun interface GetUserStrategyProductQuery {
    suspend fun getUserStrategyKeys(userStrategyId: Long): List<UserStrategyKey>
}

// 개별 Port Interface
fun interface SetUserStrategyUseCase {
    suspend fun setUserStrategy(strategy: SetUserStrategyCommand): Long
}

 
서비스 클래스는 '대표 Port Interface'를 각각 구현해서, '읽기 전용 서비스'와 '쓰기 전용 서비스'로 분리한다. 책에서는 '단일 책임 원칙'을 이유로 '얇은 서비스 클래스(잘게 쪼갠 Interface를 1개만 구현한 클래스)'를 추천하지만, 유스케이스를 얇은 서비스로 구현하면 클래스 개수가 증가해서 관리하기 불편해진다. 
 
나는 유스케이스를 도메인 엔티티 호출을 위한 도우미 객체 정도로 생각한다. 이런 방식으로 사용하면 유스케이스 로직이 복잡해지지 않게 되고, '읽기 전용 서비스'와 '쓰기 전용 서비스' 정도로만 분리해서 사용해도 관리하기 불편하지 않다.

package com.newy.user_strategy.service

// 읽기 전용 서비스
open class UserStrategyProductQueryService(...) : UserStrategyProductQuery {
    override suspend fun getAllUserStrategyKeys(): List<UserStrategyKey> {
    	...
    }
    override suspend fun getUserStrategyKeys(userStrategyId: Long): List<UserStrategyKey> {
        ...
    }
}

// 쓰기 전용 서비스
open class UserStrategyCommandService(...) : UserStrategyUseCase {
    override suspend fun setUserStrategy(userStrategy: SetUserStrategyCommand): Long {
        ...
    }
}

 
CQRS 패턴을 적용한 이유는 단순하다. 많은 웹 프레임워크는 트랜젝션 사용여부를 어노테이션으로 지원한다. 읽기 전용 트랜젝션과 쓰기 전용 트랜젝션을 쉽게 적용하기 위해서 CQRS 패턴을 적용한다.

package com.newy.user_strategy.service

// 스프링에 특화된 읽기 전용 서비스
@Service
@Transactional(readOnly = true)
open class SpringUserStrategyProductQueryService(...) : UserStrategyProductQueryService(...)

// 스프링에 특화된 쓰기 전용 서비스
@Service
@Transactional
open class SpringUserStrategyCommandService(...) : UserStrategyCommandService(...)

 
인커밍 포트에서 '개별 Port Interface'를 사용하면, 인커밍 어댑터에 대한 단위 테스트 작성이 편해진다. 인커밍 어댑터가 하는 일은 많지 않다. 아래 단위 테스트에서 인커밍 어댑터에게 기대하는 일은 (1) 외부 입력 데이터를 인커밍 포트 모델로 변환해서, (2) 유스케이스에게 잘 전달하는지 확인하는 일이다.

class UserStrategyControllerTest {
    @Test
    fun `webRequest 는 incomingPortModel 로 매핑되어야 한다`() = runBlocking {
        var incomingPortModel: SetUserStrategyCommand? = null
        val controller = UserStrategyController(
            setUserStrategyUseCase = { strategy ->
                1.toLong().also {
                    incomingPortModel = strategy
                }
            }
        )
        val webRequest = SetUserStrategyRequest(
            marketAccountId = 1,
            strategyClassName = "BuyTripleRSIStrategy",
            productCategory = "USER_PICK",
            productType = "SPOT",
            productCodes = listOf("BTCUSDT", "ETHUSDT"),
            timeFrame = "M1",
        )

        controller.setMarketAccount(webRequest)

        assertEquals(
            SetUserStrategyCommand(
                marketAccountId = 1,
                strategyClassName = "BuyTripleRSIStrategy",
                productCategory = ProductCategory.USER_PICK,
                productType = ProductType.SPOT,
                productCodes = listOf("BTCUSDT", "ETHUSDT"),
                timeFrame = Candle.TimeFrame.M1,
            ),
            incomingPortModel
        )
    }
}

 

어댑터 모델과 애플리케이션 모델을 분리해서 사용하자 (매핑 전략)

인커밍 어댑터와 유스케이스 사이는 '완전' 매핑 전략을 사용한다.


'입력 유효성 검증 로직(다음 섹션에서 설명한다)'을 위해서 '완전' 매핑 전략을 사용한다. 단, 외부 입력 데이터가 없는 경우에는 '인커밍 포트 모델'을 생략한다. 매핑 로직은 인커밍 어댑터 모델에서 구현한다. 어댑터 계층에서 애플리케이션 코어 계층에 대한 의존성은 발생해도 괜찮다. 아래는 웹 어댑터 모델에서 매핑 로직을 구현한 예시이다.

package com.newy.market_account.adapter.`in`.web.model

// 웹 어댑터 모델
data class SetMarketAccountRequest(
    val market: String,
    val isProduction: Boolean,
    val displayName: String,
    val appKey: String,
    val appSecret: String
) {
    // 인커밍 포트 모델 매핑 로직
    fun toIncomingPortModel() =
        SetMarketAccountCommand(
            userId = GlobalEnv.ADMIN_USER_ID,
            market = Market.valueOf(market),
            isProduction = isProduction,
            displayName = displayName,
            appKey = appKey,
            appSecret = appSecret,
        )
}

 
유스케이스와 아웃고잉 어댑터 사이에는 '양방향' 매핑 전략을 사용한다.


매핑 로직은 아웃고잉 어댑터 모델이나 아웃고잉 어댑터에서 구현한다. 아래는 영속성 모델에서 매핑 로직을 구현한 예제이다.

package com.newy.run_strategy.adapter.out.persistence.repository

// 영속성 모델
@Table("trade_strategy")
data class StrategyR2dbcEntity(
    @Id val id: Long = 0,
    val className: String = "",
    @Column("entry_order_type") val entryType: OrderType,
    val nameKo: String = "",
    val nameEn: String = "",
) {
    // 도메인 엔티티 매핑 로직
    fun toDomainEntity() = Strategy(
        id = id,
        className = className,
        entryType = entryType
    )
}

 

'입력 유효성 검증'은 인커밍 포트 모델에서 구현하고, '비즈니스 규칙 검증'은 도메인 엔티티(또는 유스케이스)에서 구현하자

책을 통해 배운 내용이다. 단순하고 명확해서 좋은 방법이라고 생각한다. 책에서는 '입력 유효성 검증'과 '비즈니스 규칙 검증'을 분리해서 설명한다. 단순하게 설명하면, '입력 유효성 검증'은 인커밍 어댑터가 제공하는 데이터를 검증하는 것을 뜻하고, '비즈니스 규칙 검증'은 아웃고잉 어댑터에서 조회한 데이터를 검증하는 것을 뜻한다.
 
'입력 유효성 검증' 로직은 인커밍 포트 모델에서 구현한다.

 
'입력 유효성 검증' 로직이 애플리케이션 코어 계층에 위치하는 것이 중요하다. 인커밍 어댑터를 변경해도, 검증 로직은 재사용할 수 있어야 하기 때문이다. 모델의 생성자에서 검증 로직을 실행하도록 구현한다. 아래 코드는 jakarta.validation 라이브러리를 사용해서 구현한 예시이다.

package com.newy.market_account.port.`in`.model

// 인커밍 포트 입력 모델
data class SetMarketAccountCommand(
    @field:Min(1) val userId: Long,
    val market: Market,
    val isProduction: Boolean,
    @field:NotBlank val displayName: String,
    @field:NotBlank val appKey: String,
    @field:NotBlank val appSecret: String
) : SelfValidating() {
    init {
        validate()
    }
}

open class SelfValidating {
    companion object {
        private val VALIDATOR = Validation.buildDefaultValidatorFactory().validator
    }

    protected fun validate() {
        val violations = VALIDATOR.validate(this)
        if (violations.isNotEmpty()) {
            throw ConstraintViolationException(violations)
        }
    }
}

 
'비즈니스 규칙 검증' 로직은 도메인 엔티티나 유스케이스에서 구현한다. 이 부분은 아직 고민 중이다. 검증 로직을 둘 중 하나에서 구현할 수 있는 점이 마음에 걸린다. (좀 더 확인이 필요하지만) 나중에는 도메인 엔티티에서만 '비즈니스 규칙 검증' 로직을 구현하도록 규칙을 수정할 생각이다.

 
아래는 유스케이스에서 '비즈니스 규칙 검증' 로직을 구현한 예시이다.

package com.newy.strategy.service

open class StrategyQueryService(
    private val findStrategyPort: FindStrategyPort,
) : StrategyQuery {
    override suspend fun getStrategyId(className: String): Long =
        findStrategyPort.findStrategy(className)?.id
            ?: throw NotFoundRowException("Strategy not found (className: $className)")
}

 
아래는 도메인 엔티티에서 '비즈니스 규칙 검증' 로직을 구현한 예시이다.

package com.newy.notification.domain

data class SendNotificationLog(...) {
    fun statusProcessing(): SendNotificationLog {
        if (status != REQUESTED) {
            throw PreconditionError("Must be REQUESTED (status: ${status.name})")
        }

        return copy(status = PROCESSING)
    }
    ...
}

 

아무리 단순한 기능이라도 일관된 구조로 구현하자

단순한 조회 API에 대한 구현은 어떻게 할까? 인커밍 어댑터에서 직접 아웃고잉 어댑터를 호출하도록 하는 것이 좋을까?
2가지 관점에서 생각해 보면 좋다. '대칭성'과 '비즈니스 규칙 검증' 로직의 유무이다.
 
프로그래밍에서 중요하게 생각하는 원칙 중 하나가 대칭성(일관성)이다. 여기서 대칭성은 예상할 수 있는 위치에 예상할 수 있는 로직이 있다는 것을 의미한다. 단순한 조회 API라도 다른 로직과 비슷한 구조로 구현하는 편이 좋다.

또 다른 경우로는 단순한 조회 API에서도 '비즈니스 검증 규칙' 로직이 필요할 수 있다. 예를 들어, '상품 상세 조회 API' 구현 시, 상품 데이터를 못 찾는 경우가 발생할 수 있다.
 

마치며

이번 실험에서 가장 좋았던 점은 '비즈니스 로직'과 '프레임워크/라이브러리(이하 프레임워크) 호출 로직'을 의도적으로 분리하는 방법에 대한 아이디어를 발견했다는 점이다. 헥사고날 아키텍처는 구성요소를 통해서 작업자에게 대체 가능한 로직을 어디에 배치해야 하는지 힌트를 준다. 
 
프레임워크 공식 문서에서는 자신들이 제공하는 기능에 대한 사용법을 가장 단순한 케이스로 설명한다. 이런 사용법을 보다 보면, '비즈니스 로직'과 '프레임워크 호출 로직'을 섞어서 사용하고 싶어지는 경우가 많다. 프로젝트에서 프레임워크에 대한 의존성은 프레임워크 제작자 입장에서 큰 문제가 아니다. 오히려 그들은 락인 효과가 생기기 때문에 좋아할 수도 있다.
 
계층형 아키텍처에 몇 가지 규칙을 추가해도 이런 문제를 해결할 수 있을 텐데, 헥사고날 아키텍처를 사용해야 할까? 헥사고날 아키텍처의 장점은 디자인 패턴의 장점과 비슷하다. 디자인 패턴의 장점은 (1) 반복되는 일반적인 문제에 대한 해결책을 제시한다는 점과 (2) 의사소통 용어로 사용할 수 있다는 점이다. 헥사고날 아키텍처도 비슷한 장점을 가진다. 계층형 아키텍처에서 발생할 수 있는 일반적인 문제에 대한 적당한 해결책을 제시하고, 구성요소를 통해서 의사소통 용어를 제공한다.
 
헥사고날 아키텍처는 구성요소 간의 책임이 명확하고, 애플리케이션 조립에 도움을 준다. 한동안 계속 사용해 볼 예정이다.