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

[신입 개발자의 첫 번째 아키텍쳐 설계 도전기 - Fin] 신규 아키텍쳐의 기대효과와 개선점

by 시니성 2025. 1. 5.

지금까지 레거시 아키텍쳐의 문제점을 분석하고, 이를 개선하기 위한 헥사고날 아키텍쳐의 도입과 설계에 대해 다루었습니다.
이번 마지막 글에서는 실제로 새 아키텍쳐가 어떤 효과를 가져왔는지를 구체적인 예시와 함께 살펴보겠습니다.

1. 풍부한 도메인 모델을 통한 비즈니스 로직의 응집도 향상

레거시 코드

// TransactionCoreService 클래스(서비스 레이어)에서 일일이 총액을 계산
fun tranSaleHeaderGenerate(
    transactionInformation: PaymentEndRequestDTO,
    paymentData: PaymentsReqResData
): TranSaleHeader {
    val cardAmt = BigDecimal(credit.sumOf { it.request?.totalAmount ?: 0L })            
    val giftAmt = paymentData.giftReqResData.sumOf { it.useMoney ?: BigDecimal.ZERO }
    val pntAmt = paymentData.crmReqResData.pointRequestData?.paymentAmt ?: BigDecimal.ZERO
    // ... 더 많은 계산 로직들
}

신규 코드

data class PaymentHeader(
    val cardPayments: List<CardPayment> = emptyList(),
    val cardRefunds: List<CardRefund> = emptyList(),
    val cashPayment: CashPayment? = null,
    val cashRefund: CashRefund? = null,
) {
    //...
    // 도메인 객체가 자신의 비즈니스 로직에 대한 책임을 가짐
    val availableCardPayments = cardPayments.filter { it.status.isApproved() }
    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
    )
    //...
}

2. 명확한 의존성 방향과 책임 분리

레거시 코드

// Service레이어 끼리의 의존관계가 어지럽게 얽혀 있고, core 모듈에서 외부세계(ex. van사 결제 모듈, 프린터 모듈)에 직접 의존하고 있음
@Service
class PaymentCoreService @Inject constructor(
    //...
    // 이름만 Service이고 실제로는 특정 van사의 결제 모듈(외부 세계)을 직접 호출하는 서비스
    private val creditCardService: CreditCardService,
    private val cashReceiptService: CashReceiptService,
    private val commonCoreService: CommonCoreService,
    private val session: Session,
    //...
)

신규 코드

// 서비스는 UseCase 구현에만 집중
class PaymentService(
    //...
    private val paymentExecutor: PaymentPort,
    private val paymentHeaderRepository: PaymentHeaderRepositoryPort,
    private val orderRepository: OrderRepositoryPort,
    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()

        // 액터에 의한 UseCase가 아닌 비즈니스 로직은 별도 프로세서로 분리
        if (paymentUpdatedOrder.isCompletedPayment) {
            return paymentUpdatedOrder
                .also { completedOrder -> 
                    postOrderCompleteProcessor.process(completedOrder) 
                }
                .paymentHeader
        }

        return newPaymentHeader
    }
}

3. 외부 세계와의 명확한 경계

레거시 코드

// 서비스라는 이름만 가진 단순 API 호출 래퍼
@Service
class AspApiService(
    private val environment: Environment,
) {
    private val aspApiService: AspApi =
        ApiServiceContext(baseUrl, AspApi::class.java).getApiService() as AspApi

    fun transactionUpload(transactionUploadRequestDTO: TransactionUploadRequestDTO): AspApiCommonResponse? {
        return try {
            val call = aspApiService.transactionUpload(transactionUploadRequestDTO)
            val response = call.execute()
            response.body()
        } catch (e: Exception) {
            throw e
        }
    }
}

신규 코드

// Port를 통한 외부 세계와의 격리
interface AspApiClientPort {
    //...
    fun transactionUpload(transactionData: TransactionDataReqDto): Result<Unit>
    //...
}
interface PaymentPort {
    //...
    fun processCardPayment(request: PaymentRequestDto): Result<PaymentResponse>
    fun processCardRefund(request: RefundRequestDto): Result<RefundResponse>
    //...
}

// 액터에 의한 UseCase가 아닌 비즈니스 로직은 별도 프로세서로 분리
class PostOrderCompleteProcessor(
    private val localPrinter: LocalPrinterPort,
    private val salesDataGenerator: SalesDataGenerator,
    private val aspApiClient: AspApiClientPort,
) {
    fun process(order: Order) {
        // 영수증 출력
        localPrinter.printReceipt(order)

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

4. 주요 개선 효과

  1. 비즈니스 로직의 응집도 향상
    • 도메인 객체가 자신의 책임을 가짐
    • 상태와 행위가 한 곳에서 관리됨
    • 비즈니스 룰의 중앙화
  2. 테스트 용이성
    • 외부 의존성이 Port로 격리되어 있어 Mocking이 용이
    • 비즈니스 로직 테스트가 외부 시스템과 분리됨
  3. 유지보수성 향상
    • 명확한 책임 분리로 코드 이해도 증가
    • 변경의 영향 범위가 명확함
    • 새로운 기능 추가가 용이
  4. 재사용성 확보
    • POS, KIOSK, 급식 입장관리 등 다양한 오프라인 결제 솔루션에서 핵심 비즈니스 로직 재사용 가능
    • 플랫폼 독립적인 설계로 크로스플랫폼 지원이 용이

5. 향후 개선점

도메인 이벤트 도입

  • 현재는 PostProcessor를 통해 부가 기능을 처리하지만, 도메인 이벤트를 도입하면 더 낮은 결합도 달성 가능
  • 예를 들어 OrderCompletedEvent, PaymentRefundedEvent 등의 이벤트를 발행하고 구독하는 방식으로 변경

이번 프로젝트를 통해 신입 개발자로서 아키텍처의 중요성을 몸소 체험할 수 있었습니다.

특히 기존의 레거시 코드와 새로운 헥사고날 아키텍처를 비교하면서, 깔끔한 아키텍처가 코드의 품질과 유지보수성에 얼마나 큰 영향을 미치는지 실감했습니다.

물론 아직 개선의 여지가 많이 남아있지만, 이번 경험을 토대로 앞으로도 더 나은 설계를 위해 지속적으로 고민하고 개선해나갈 예정입니다.

끝으로 이런 도전 기회를 준 회사와 관리자 분들께 감사드립니다.
앞으로도 더 좋은 코드를 위해 끊임없이 공부하고 성장하는 개발자가 되도록 하겠습니다.

728x90