본문 바로가기
아키텍쳐 설계 경험/헥사고날 아키텍쳐 설계 도전기

[신입 개발자의 첫 번째 아키텍쳐 설계 도전기 - #2] 헥사고날 아키텍처 도입과 설계

by 시니성 2025. 1. 5.

오늘은 레거시 아키텍쳐의 문제점을 개선하기 위해 헥사고날 아키텍쳐의 설계와 실제 구현에 대해 다루어 보겠습니다.

1. 왜 헥사고날 아키텍처인가?

이전 글에서 분석했던 레거시 아키텍처의 핵심적인 문제점들은 아래와 같았습니다.

  1. core 모듈의 순수성 훼손
  2. 의존성 방향의 혼란
  3. 서비스 레이어의 정체성 혼란
  4. 비즈니스 로직의 산재
  5. 빈약한 도메인 모델

이러한 문제들을 해결하기 위해서는 명확한 경계와 의존성 규칙이 필요했습니다.
헥사고날 아키텍처는 이러한 요구사항을 만족시키는 제가 알고 있는한 최적의 선택지였습니다.

2. 새로운 아키텍처의 구조

2.1 모듈 구조

의존성 흐름과는 무관한 단순 구조도 입니다. 의존성 흐름은 아래의 이미지를 참조 부탁드립니다.

새로운 아키텍처는 크게 다음과 같은 구조를 가집니다:

  • shared: 공유 로직을 포함하는 최상위 모듈
  • application: 핵심 비즈니스 로직 담당
  • adapter: 외부 세계와의 통신 담당
  • composeApp: UI 및 진입점 관리

2.2 계층 간 의존성 규칙

가장 중요한 변화는 의존성 방향의 명확화입니다:

  1. application 레이어는 외부에 완전히 독립적
  2. adapter가 port를 통해 application을 의존
  3. 모든 외부 통신은 port를 통해 추상화

3. 도메인 주도 설계의 적용

3.1 풍부한 도메인 모델 구현

기존의 빈약한 도메인 모델을 개선한 예시를 보겠습니다:

data class PaymentHeader(
    //...
    val cardPayments: List<CardPayment> = emptyList(),
    val cardRefunds: List<CardRefund> = emptyList(),
    //...
) {
    //...
    // 도메인 로직 캡슐화
    val cardSaleAmount: BigDecimal
        get() = availableCardPayments.sumOf { it.saleAmount }

    val totalSaleAmount: BigDecimal
        get() = cardSaleAmount + cashSaleAmount + giftSaleAmount +
                kakaoSaleAmount + naverSaleAmount + paycoSaleAmount

    // 상태 변경은 도메인 객체가 책임짐
    fun complete(billNo: String): PaymentHeader =
        copy(
            status = PaymentHeaderStatus.PAID,
            cardPayments = cardPayments.map { it.copy(isNew = false) },
            billNo = billNo
        )
}

3.2 명확한 UseCase 정의

서비스 레이어는 순수하게 UseCase 구현에만 집중합니다:

class PaymentService(
    //...
    private val paymentExecutor: PaymentPort,
    private val paymentHeaderRepository: PaymentHeaderRepositoryPort,
    private val postOrderCompleteProcessor: PostOrderCompleteProcessor,
    //...
) : PaymentUseCase {
    override suspend fun processCardPayment(command: CreditPaymentCommand): PaymentHeader {
        // 비즈니스 규칙 검증
        require(paymentHeader.status.canProceedPayment()) { 
            throw InvalidPaymentStatusException(status = paymentHeader.status) 
        }

        // 외부 시스템과의 통신은 port를 통해
        val newCardPayment = paymentExecutor.processCardPayment(command.toPaymentRequestDto())
            .getOrThrow()
            .toDomain()

        //...

        // 결제 완료 후처리는 별도 프로세서가 담당
        if (paymentUpdatedOrder.isCompletedPayment) {
            return paymentUpdatedOrder
                .also { completedOrder -> 
                    postOrderCompleteProcessor.process(completedOrder) 
                }
                .paymentHeader
        }
        
        // ...

        return newPaymentHeader
    }
}

4. 외부 세계와의 통신 격리

4.1 Port & Adapter 패턴 적용

모든 외부 시스템과의 통신은 port를 통해 추상화됩니다:

interface PaymentPort {
    fun processCardPayment(request: PaymentRequestDto): Result<PaymentResponse>
    fun processCardRefund(request: RefundRequestDto): Result<RefundResponse>
    fun updateConfiguration(terminalId: String, timeout: Pair<Long, TimeUnit>): Result<Unit>
}

이를 통해:

  • 외부 시스템 변경에 유연하게 대응 가능
  • 테스트 용이성 확보
  • 비즈니스 로직의 순수성 보장

4.2 부가 기능의 분리

매출 데이터 생성, 프린터 호출 등 부가 기능은 별도의 프로세서로 분리:

class PostOrderCompleteProcessor(
    private val printerPort: PrinterPort,
    private val salesDataGenerator: SalesDataGenerator,
    private val salesDataUploader: SalesDataUploader,
) {
    fun process(order: Order) {
        // 영수증 출력
        printerPort.printReceipt(order)

        // 매출 데이터 생성 및 업로드
        val salesData = salesDataGenerator.generate(order)
        salesDataUploader.upload(salesData)
    }
}

5. 실제 적용 효과

5.1 코드 품질 향상

  • 비즈니스 로직의 중앙화
  • 테스트 용이성 증가
  • 코드 재사용성 향상

5.2 유지보수성 개선

  • 명확한 책임 분리
  • 변경의 영향 범위 최소화
  • 문제 발생 시 원인 파악 용이

다음 글에서는 마지막으로 `새로운 아키텍처의 효과와 개선점`에 대해 좀 더 자세히 다루어 볼 예정입니다.

728x90