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

[신입 개발자의 첫 번째 아키텍쳐 설계 도전기 - #1] 레거시 아키텍처의 문제점 분석

by 시니성 2024. 12. 16.
728x90

1. 레거시 아키텍처의 구조와 문제점

기존 POS 시스템은 common-core-windows의 3계층 구조로 설계되어 있었습니다.
common은 영속성 계층을, core는 비즈니스 로직을, windows는 컨트롤러 역할을 담당했습니다.
얼핏 보면 깔끔해 보이는 이 구조에는 몇 가지 심각한 문제점들이 있었습니다.

1.1 계층 간 의존성 문제

레거시 아키텍처의 가장 큰 문제점은 core 모듈의 순수성이 완전히 깨져있다는 점이었습니다.
클린 아키텍처의 원칙에 따르면 core(비즈니스 로직) 계층은 외부 의존성 없이 순수하게 유지되어야 하며, 다른 계층에서 core를 의존해야 합니다. 하지만 실제로는 정반대였죠.
아래는 실제 core모듈의 build.gradle.kts에 기술된 의존성 설정의 일부입니다.


dependencies {
    // core가 common(영속성 계층)에 직접 의존
    api(project(":common"))

    // ... 기타 바코드 리딩, 프린터 등 외부세계에 직접 의존
}

core 모듈이 common 모듈의 수많은 컴포넌트들을 의존하고 있었고, 이는 사실상 전통적인 3계층 아키텍처와 다를 바 없었습니다.

그렇다고 외부 세계에 대한 의존성은 모두 core에 존재토록 하고, common모듈에는 일관적으로 영속성 계층에 대한 의존성만 정의하도록 했냐? 하면 그마저도 아니었습니다.
결제에 관한 의존성은 또, common에 작성되어 있는 웃지못할 촌극이 벌어지고 있는 곳이 레거시 코드였습니다.

1.2 서비스 계층에서 발생한 모호함

레거시 코드에서는 'Service는 UseCase를 구현한다'는 개념이 없었습니다. 그저 모든 백엔드 로직을 Service라고 이름 붙이고 개발했던 것이죠. 이로 인해 발생한 문제점을 살펴보겠습니다.

@Service
class SimpleEasyPayServiceImpl @Inject constructor(
    private val easyPayService: EasyPayService,
    private val session: Session,
    private val baseSettingService: BaseSettingService
)

@Service
class EasyPayServiceImpl @Inject constructor(
    private val easyPaymentHelper: EasyPaymentHelper,
    private val session: Session,
)

위 코드를 보면 'Simple'이라는 접두어가 붙은 간편 결제 서비스가 또 다른 간편 결제 서비스에 의존하는 기이한 구조가 만들어졌습니다. 이는 서비스의 책임과 역할이 명확하지 않았기 때문입니다.

또, 아키텍쳐의 해상도가 낮아 모든 것을 3계층으로 표현하려다 보니, 실제 액터에 의한 유즈케이스가 아닌 로직들도 서비스라는 이름으로 core 모듈에 직접 작성되어 있었습니다.

대표적인 예로 외부 API 호출이 있었고, 아래는 예시 코드입니다.

@Service
class AspApiService(
    private val environment: Environment,
) {
  // 심지어 '모든 백엔드 로직은 그냥 이름을 서비스 라고 짓는 것이다'라는 수준의 서비스 레이어에 대한 잘못된 이해 때문에,
  // 아래처럼 client역할을 하는 클래스의 인스턴스를 담는 변수명도 service입니다.
  // 때문에 AspApiService가 aspApiService를 프로퍼티로 갖는 촌극이 벌어진 모습입니다.
     private val aspApiService: AspApi =
        ApiServiceContext(baseUrl, AspApi::class.java).getApiService() as AspApi
        
  // 결제 완료후 자동 전송 및, Schedule 작업으로 전송 될 뿐, 
  // 액터가 있는 UseCase를 가지지 않는 매출데이터 업로드 등 동작이 모두, Service로 만들어져 있음
  // 도대체 누구에게 무슨 서비스를 해준다는 걸까요?
  fun transactionUpload(transactionUploadRequestDTO: TransactionUploadRequestDTO): AspApiCommonResponse? {
        return try {
            val call = aspApiService.transactionUpload(transactionUploadRequestDTO)
            val response = call.execute()
            response.body()
        } catch (e: Exception) {
            throw e
        }
    }

    fun couponUpload(tranCouponList: List<TranCouponL>): AspApiCommonResponse? {
        return try {
            val call = aspApiService.couponUpload(tranCouponList)
            val response = call.execute()
            response.body()
        } catch (e: Exception) {
            throw e
        }
    }

    fun standardUpload(tranStndList: List<TranStndL>): AspApiCommonResponse? {
        return try {
            val call = aspApiService.standardUpload(tranStndList)
            val response = call.execute()
            response.body()
        } catch (e: Exception) {
            throw e
        }
    }
}

위 코드를 보면 아래와 같은 문제점이 보입니다.

 

  • 결제 완료 후 자동 전송되거나 스케줄링된 작업으로 실행되는 매출 데이터 업로드 등의 기능을 서비스로 구현
  • 실제 사용자의 UseCase가 존재하지 않음에도 서비스 레이어에 구현
  • 단순 API 호출 wrapper 역할만 수행하는 메서드들이 서비스로 분류됨
  • 클라이언트 인스턴스를 담는 변수명도 'service'로 명명하여 AspApiService가 aspApiService를 프로퍼티로 갖는 모순적인 구조 발생

 

1.3 비즈니스 로직의 산재

심지어는 컨트롤러에서조차 비즈니스 로직이 작성되어 있는 경우를 발견할 수 있었습니다.

@RequestMapping(value = ["/end"], method = [RequestMethod.POST])
fun paymentEnd(@RequestBody body: PaymentEndRequestDTO): ApiCommonResponse {
    // 컨트롤러에서 직접 비즈니스 로직 처리
    if (sessionTotalPaymentAmount != body.totalSaleAmount - body.totalDiscountAmount) {
        logger.info("세션 데이터: $sessionTotalPaymentAmount Request 데이터: ${body.totalSaleAmount - body.totalDiscountAmount}")
        return ApiCommonResponse(
            ApiStatusCode.FAIL.code,
            "저장된 결제 합과 넘어온 판매 합이 다릅니다.",
            null
        )
    }
    // ... 더 많은 비즈니스 로직들
}

1.4 도메인 모델의 빈약함

가장 큰 문제점 중 하나는 도메인 모델이 너무 빈약했다는 점입니다. 예를 들어, 결제 관련 총액 계산 로직을 살펴보겠습니다.

// 서비스 레이어에서 모든 계산을 직접 수행
val cardAmt = BigDecimal(credit.sumOf { it.request?.totalAmount ?: 0L }) +
        easyPayment.sumByBigDecimal { data -> data.cardApprovals.sumByBigDecimal { it.approvalAmount } }
val giftAmt = paymentData.giftReqResData.sumOf { it.useMoney ?: BigDecimal.ZERO }
// ... 더 많은 계산 로직들

이러한 계산 로직들이 도메인 객체가 아닌 서비스 레이어에 있다는 것은 큰 문제였습니다.

2. 신규 아키텍처의 개선점

이러한 문제점들을 해결하기 위해 헥사고널 아키텍처를 도입하고 풍부한 도메인 모델을 따르도록 했습니다.
새로운 아키텍처의 주요 특징들을 살펴보겠습니다.

2.1 풍부한 도메인 모델

data class PaymentHeader(
    val cardPayments: List<CardPayment> = emptyList(),
    val cashPayment: CashPayment? = null,
    // ... 다른 필드들
) {
    val cardSaleAmount: BigDecimal
        get() = availableCardPayments.sumOf { it.saleAmount }

    val totalSaleAmount: BigDecimal
        get() = cardSaleAmount + cashSaleAmount + giftSaleAmount // ...

    // 도메인 로직들이 객체 안에 캡슐화됨
}

새로운 설계에서는 도메인 객체가 자신의 비즈니스 로직을 직접 처리합니다.

2.2 명확한 책임 분리

class PaymentService(
    private val paymentExecutor: PaymentPort,
    private val paymentHeaderRepository: PaymentHeaderExtendedRepository,
    private val orderRepository: OrderExtendedRepository,
    private val postOrderCompleteProcessor: PostOrderCompleteProcessor,
) : PaymentUseCase {
    // 서비스는 UseCase 구현에만 집중
}

서비스는 순수하게 UseCase 구현에만 집중하고, 부가적인 로직들(프린터 호출, 매출 데이터 생성 등)은 별도의 프로세서로 분리되었습니다.

2.3 외부 의존성의 명확한 격리

interface PaymentPort {
    fun processCardPayment(request: PaymentRequestDto): Result<PaymentResponse>
    fun processCardRefund(request: RefundRequestDto): Result<RefundResponse>
    // ...
}

결제 단말기 호출과 같은 외부 시스템과의 통신은 port를 통해 명확히 격리되었습니다.

3. 결론

레거시 아키텍처의 가장 큰 문제는 '의존성의 방향'이 잘못된 것이었습니다.
이는 단순히 코드의 구조적인 문제를 넘어서서, 시스템의 유지보수성과 확장성에 안좋은 영향을 미쳤습니다.

새로운 헥사고널 아키텍처는 이러한 문제들을 해결하면서도, POS, KIOSK, 급식 입장관리 등 다양한 오프라인 결제 솔루션에서 핵심 비즈니스 로직을 재사용할 수 있는 기반을 마련했습니다.

다음 글에서는 헥사고널 아키텍처를 어떻게 실제로 설계하고 구현했는지 더 자세히 다루도록 하겠습니다.

728x90