콘텐츠를 조립하는 결제탭 피드 서버의 코드 아키텍처

콘텐츠를 조립하는 결제탭 피드 서버의 코드 아키텍처

요약: 이 글은 결제탭 서버의 Server Driven UI(SDU) 방식을 설명하며, 안정적이고 유연한 구조로 결제탭의 다양한 콘텐츠를 효율적으로 서빙하는 방식을 다룹니다. 결제탭은 MSA 구조와 비동기 API 호출을 활용해 사용자 맞춤형 혜택과 기능을 제공하며, Row 설정을 통해 각 UI 요소를 동적으로 관리합니다. 또한, 서버가 다운되거나 예외 상황이 발생하더라도 안정성을 유지하기 위해 Result와 3중화 캐싱 기법을 사용하여 피드 데이터의 무결성을 보장합니다.

💡 리뷰어 한줄평

larry.charry 유연함과 안정성 두 마리 토끼를 잡기 위해 많은 고민을 녹여주셨네요. 아름다운 코드 아키텍처와 서비스 무중단을 위해 파일까지 관리하는 모습이 인상적이었습니다.

daisy.dani 결제탭의 다양한 콘텐츠를 빠르고 안정적으로 서빙하기 위해 어떤 기법을 적용했을까요? 해피와 함께 이 글에서 확인해 보시죠!

rain.drop 외부 연동 데이터를 어떻게 하면 재사용할 수 있을지, 개발 생산성까지 고려한 설계에 대해 여러 고민과 적용이 잘 드러나는 글이에요! 어떻게 아키텍처를 설계하고 개발 생산성을 향상했는지 함께 들여다봐요~

시작하며

안녕하세요. 채널서버유닛에서 결제탭 서버를 개발하고 있는 해피입니다. 결제 콘텐츠 피드인 결제탭 개발 과정에서, 변칙적인 요구사항을 만족하는 코드 아키텍처를 개발하기 위한 접근 방식에 대해 이야기해 보려고 합니다.

결제탭은 결제와 관련된 다양한 혜택과 편의 기능을 제공하는 피드입니다. 사용자들에게 결제 중심으로 다양한 혜택을 보여주기 위해 만들어졌습니다. 사용자에게 맞춤 혜택과 멤버십 쿠폰 등을 추천하고, 위치 기반으로 당장 사용할 수 있는 혜택을 제공합니다.

카카오페이 앱 결제탭
카카오페이 앱 결제탭

이렇게 사용자 중심으로 다양한 콘텐츠를 제공하는 피드 서비스는 어떤 구조로 개발되어 있을까요?

이 글에서는 결제탭에서 Server Driven으로 다양한 데이터를 서빙하기 위해 적용한 코드 아키텍처와 안정성을 위한 다양한 기법들을 소개하겠습니다. 여러 마이크로 서비스를 호출해 콘텐츠를 서빙하는 피드를 개발하거나, 코드 아키텍처에 관심 있는 분들에게 흥미로운 내용이 될 것 같습니다.

크게 3가지로 나눠서 살펴보겠습니다.

  1. 결제탭과 Server Driven UI
  2. Server Driven UI에 대응하기 위한 코드 아키텍처
  3. 안정적인 피드를 위한 기법

결제탭과 Server Driven UI

결제탭은 BFF(Backend For Frontend) 입니다. BFF는 클라이언트가 화면을 그릴 때 필요로 하는 모든 데이터를 제공하기 위해, 마이크로 서비스 호출을 대신하고 응답을 하나로 만들어서 내려줍니다. 클라이언트가 퍼블릭 인터넷 환경에서 여러 개의 API를 호출해 조합하는 것보다 서버가 애플리케이션 네트워크 망 내 고속 네트워크 환경에서 여러 마이크로 서비스를 호출해 하나의 응답으로 만들어주는 게 훨씬 응답 속도 관점에서 유리하기 때문입니다. 또한, 클라이언트는 데이터 조합 대신 서버와 확정한 데이터 모델을 통해 UI 구현에 집중할 수 있습니다. 자세한 내용은 WebFlux와 코루틴으로 BFF 구현하기에서 보실 수 있습니다.

결제탭은 카카오페이 앱을 실행했을 때 처음에 보이는 화면입니다. 첫 화면에서는 빠른 렌더링이 중요하기 때문에 결제탭에서는 네이티브 UI를 사용합니다. 네이티브는 웹뷰와 달리, 앱 심사와 배포 과정으로 인해 최신 UI 변경사항이 즉시 반영되지 않습니다. 따라서 사용자가 앱을 업데이트하기 전까지 오래된 UI와 기능이 유지됩니다.

이 문제를 해결하기 위해 카카오페이 클라이언트 실에서 Server Driven UI(이하 SDU) 라는 시스템을 만들었고, 이를 통해 네이티브에 즉시적인 UI 변경을 반영할 수 있게 되었습니다. SDU 어드민에서 각 UI에 대한 구성과 라벨 등을 설정하여 배포하면, UI 갱신 시에 변경이 바로 반영됩니다.

한편, 커스텀한 애니메이션이 들어가거나 데이터의 양에 따라 UI가 완전히 달라지는 경우 등에서는 클라이언트에서 직접 UI를 구현해야 합니다. 광고 지면의 경우 광고 SDK를 사용해야 하는 부분도 있습니다.

결제탭은 피드에서 보이는 아이템들을 Row라고 부릅니다. 각 Row의 RenderType은 SDU, NATIVE, AD 3가지로 나뉩니다.

  • SDU: SDU SDK를 통해 UI를 렌더링
  • NATIVE: 결제탭에서만 UI를 커스텀하게 렌더링
  • AD: 광고 SDK를 통해 UI를 렌더링

피드 내 Row 구성
피드 내 Row 구성

결제탭 서버는 UI 중심의 응답 구조를 가지며, 클라이언트가 주고받는 데이터 모델인 Row 규격은 다음과 같습니다.

- rowId: String         // row 식별자
- rowType: Enum         // row_type
- renderType: Enum      // SDU, NATIVE, AD
- data: Data            // UI 구현에 필요한 데이터
- meta: Meta            // 지표 태깅 등 메타데이터

각 Row는 고유 식별자인 rowId를 가지고, 앞서 보았듯이 UI 표현에 대한 구분을 RowType으로 하게 됩니다. data는 UI를 구현하는데 필요한 데이터이고, meta에는 지표 수집을 위한 데이터 또는 기타 설정들이 포함됩니다.

결제탭 서버에서는 사용자 피드를 구성하기 위해 속성과 정책을 RowSetting이라는 정보로 DB에 저장합니다. 이로써 운영자가 필요에 맞게 어드민에서 설정을 즉시적으로 조정할 수 있습니다.

어드민 피드 구성 화면
어드민 피드 구성 화면

Row 설정 화면
Row 설정 화면

앞서 결제탭과 Server Driven UI의 개념과 필요성을 살펴보았습니다. 결제탭은 SDU를 사용해 즉시적인 UI 변경을 반영하고, 어드민 설정을 기반으로 동적으로 피드 데이터를 조합해 응답합니다. 지금부터 동적으로 동작하는 결제탭 서버의 내부 코드 아키텍처가 어떻게 구성되어 있는지 살펴보겠습니다.

연동과 Row의 N:M을 지원하는 코드 아키텍처

결제탭은 논블록킹으로 클라이언트에 응답을 빠르게 전달하기 위해, 기술 스택으로 Kotlin, Spring, Webflux를 사용하고 DB는 R2dbc를 사용합니다.

결제탭 심플 버전
결제탭 심플 버전

먼저 결제탭을 단순화한 버전을 가지고, 구현 방식에 대해 pseudo 코드로 접근해 보겠습니다.

외부 마이크로 서비스인 혜택, 쿠폰 서버를 호출해 필요한 데이터를 가져오고 이걸 하나의 응답으로 만들어줘야 합니다. (API 조회 또는 DB 질의 등 외부 시스템을 호출하는 경우를 연동이라고 포괄하여 표현하겠습니다)

coroutineScope {
  async { benefitService.createRow() }
  async { couponService.createRow() }
}

혜택 서버를 호출하여 결제탭 Row 응답을 만드는 BenefitService 컴포넌트를 선언하고, 쿠폰에 대한 Row 응답을 만드는 CouponService 컴포넌트도 선언합니다. 코루틴 async 빌더를 사용하여 각 서비스를 비동기로 호출하고, 결과를 받아서 하나의 응답으로 만들어줍니다.

결제탭은 DB에 저장된 피드 정보인 RowSetting을 기반으로 동적으로 Row를 생성해야 합니다. RowSetting 기반으로 동작하도록 앞선 코드를 수정합니다.

val responseRows = getRowSettings()    // DB에 설정된 Row 설정을 읽어옴
  .map { it.rowType }                  // Row의 종류(RowType)에 따라
  .map { async { process(it) } }       // 해당되는 데이터를 호출하여 응답을 생성함
  .awaitAll()

RowSetting에 대해 필요한 연동 데이터를 가져와 응답을 생성하는 과정인 process 로직을 수행하도록 변경했습니다. 아까 각각 호출했던 benefitService.createRow()couponService.createRow() 로직이 process에 해당됩니다.

RowType에 대한 process를 수행할 Processor를 정의하면 설정에 따라 필요한 데이터만 응답으로 만들어서 내려줄 수 있습니다. “Row의 속성에 따라 필요한 데이터를 불러오고, 응답에 필요한 형태로 변환한다” 구조로 만들 수 있습니다.

하지만 결제탭에서는 좀 더 복잡한 요구사항이 있었기에, 이를 대응하기 위한 코드 아키텍처로 변경되었습니다.

결제탭에서 사용된 연동되는 데이터와 Row의 관계에 대한 경우의 수는 다음과 같습니다.

1. 한 연동에서 가져온 데이터를 한 Row에서 사용 = 1:1

1:1 맵핑
1:1 맵핑

결제탭에서 결제팁 테이블에 저장된 데이터들을 가져와 하나의 Row로 보여주는 요구사항입니다. 가장 일반적인 요구사항으로, 이러한 방식만 존재한다면 위에 소개드렸던 process 처리로도 충분합니다.

2. 여러 연동에서 가져온 데이터를 한 Row에서 사용 = N:1

N:1 맵핑
N:1 맵핑

5초마다 결제내역과 받은 혜택이라는 2가지의 데이터를 롤링하는 카드인데요. 이를 위해 서버에서 2개의 API를 호출한 내용을 하나의 Row로 합쳐 응답해야 합니다. 여러 개의 API를 사용하더라도 그걸 하나의 컴포넌트에서 실행되도록 만들면 되기 때문에 1:1 맵핑과 동일하게 처리할 수 있습니다.

코드 아키텍처의 변경을 야기한 사항은 다음의 경우였습니다.

3. 이미 카드에 쓰였던 연동에서 가져온 데이터를 다른 Row에 재활용 = N:M

N:M 맵핑
N:M 맵핑

위 이미지 속 3개의 Row인 내 쿠폰, 퀵 버튼, 멤버십에서 각기 쿠폰조회 API와 멤버십 조회 API를 중복하여 사용하게 되는데요. 이미 콘텐츠를 만드는 데 사용되었던 연동 데이터를 또 다른 곳에 사용해야 하는 경우가 발생합니다. process 구조를 유지한다면 동일한 연동 데이터를 2번 호출할 수밖에 없습니다. 결제탭 화면은 페이앱의 첫 화면의 트래픽을 받는데, 이를 연동한 마이크로 서비스에 2배의 부하를 전달하게 할 수는 없습니다. 코드 아키텍처의 변화가 필요합니다.

기존 구조에서 문제였던 것은 연동에서 가져온 데이터와 응답으로 만드는 로직이 process라는 하나의 단계로만 진행되었다는 점입니다. 연동된 데이터를 가져오는 부분과 응답을 생성하는 부분을 분리하면 문제를 해결할 수 있습니다.

Provider, Consumer, Aggregator

앞선 pseudo 코드에서는 연동과 응답 생성 두 가지가 하나의 Processor에 담겨있었습니다. 요구사항이 복잡해짐에 따라 이들의 관계가 1:1이 아니므로 Processor를 더 작은 단위로 나눕니다. 연동 데이터를 가져오는 컴포넌트들을 Provider로, 데이터를 기반으로 카드 응답을 만드는 컴포넌트를 Consumer로 분리합니다. 각각의 Provider와 Consumer를 적절하게 맵핑하고 조합해 주는 역할이 필요한데, 이를 Aggregator로 정의합니다.

Processor를 Provider, Aggregator, Consumer로 분리
Processor를 Provider, Aggregator, Consumer로 분리

  • Provider: 외부로부터 연동 데이터를 가져오는 컴포넌트
  • Consumer: 연동 데이터(들)를 기반으로 UI로 표현할 응답을 만드는 컴포넌트
  • Aggregator: 두 컴포넌트가 중복 호출 없이 비동기로 동작할 수 있도록 중계해 주는 컴포넌트

Provider는 API나 DB로부터 데이터를 가져오는데 집중하고, Consumer는 데이터로부터 응답으로 반환할 Row를 만드는 데 집중합니다. Aggregator는 두 개의 컴포넌트 사이를 중계하며, 비동기로 Provider를 실행시켜 가져온 데이터를 Consumer로 넘겨주는 역할을 합니다.

N:M 맵핑
N:M 맵핑

이전 pseudo 코드에서 RowType에 대해 process 동작을 수행했던 것을 provide, consume 과정으로 분리합니다. 따라서 내부적으로 처리하는 타입도 ProviderTypeConsumerType으로 각각 분리되어야 합니다. consume 하여 Row를 만들어 내고, 클라이언트에게 RowType으로 UI 종류를 전달하기 때문에, ConsumerType 이름보단 RowType을 사용합니다.

이제 연동과 Row를 맵핑할 타입이 필요합니다. ProviderTypeRowType을 맵핑할 타입으로 RowProviderType를 정의합니다. DB에 저장되는 RowSetting이 각 RowProviderType을 컬럼으로 가집니다. 이러한 맵핑 정보는 enum으로 관리하게 됩니다.

// API, DB 등 연동 종류
enum class ProviderType {
  MEMBERSHIP_API,    // 멤버십 API 연동
  COUPON_API         // 쿠폰 API 연동
}

// 클라이언트에서 보일 Row 종류
enum class RowType {
  MY_MEMBERSHIP_UI,  // 멤버십 Row UI
  QUICK_BUTTON_UI,   // 퀵 버튼 Row UI
  COUPON_UI          // 쿠폰 Row UI
}

enum class RowProviderType(
    private val rowType: RowType,
    vararg val providers: ProviderType,
) {
    MEMBERSHIP(
        // MEMBERSHIP은 MEMBERSHIP_API 연동하여 MY_MEMBERSHIP_UI로 렌더링
        RowType.MY_MEMBERSHIP_UI,
        ProviderType.MEMBERSHIP_API
    ),
    QUICK_BUTTON(
        // QUICK_BUTTON은 MEMBERSHIP_API, COUPON_API 연동하여 QUICK_BUTTON_UI로 렌더링
        RowType.QUICK_BUTTON_UI,
        ProviderType.MEMBERSHIP_API, ProviderType.COUPON_API
    ),
    COUPON(
        // COUPON은 COUPON_API 연동하여 COUPON_UI로 렌더링
        RowType.COUPON_UI,
        ProviderType.COUPON_API
    )
}

ProviderType에는 연동 포인트의 종류가 정의되고, RowType에서는 어떤 UI 데이터로 렌더링 해야 하는지 정의됩니다. RowProviderType에서 ProviderTypeRowType을 통해 연동과 Row를 맵핑합니다.

이렇게 연동과 UI별로 구분된 타입들은 로직 실행 시 컴포넌트를 트리거하는 구분점으로 동작합니다.

Aggregator

RowType에 따라 하나로 묶여 수행되던 process 과정을 ProviderTypeRowType으로 분리하여 provide, consume으로 동작할 수 있도록 기반을 마련했습니다.

이제는 이 분리된 과정들이 피드 조회 요청 시 하나로 묶여 비동기로 동작하도록 Aggregator를 만드는 과정이 필요합니다. 각각의 provide 결과를 하나로 합치고, 필요한 곳에서 consume 하는 과정이 필요한데요, 이 과정을 aggregate에서 수행합니다.

fun aggregate(rowSettings: List<RowSetting>): List<Row> {
    // ProviderType에 따라 Provider 실행
    val results = rowSettings
        .flatMap { it.rowProviderType.providerTypes }
        .map { async { providers.provide(it) } }
        .awaitAll()

    // RowType에 따라 Consumer 실행
    return rowSettings
        .Map { it.rowProviderType.rowType }
        .mapNotNull { consumers.consume(it, results) }
}

새로운 pseudo 코드에서 DB에 저장된 피드 정보인 RowSetting을 기반으로, RowProviderType에 따라 Provider가 수행되고, RowType에 따라 Consumer가 수행됩니다. ProviderType에 따라 Provider를 맵핑해 주는 부분과 RowType과 Consumer 구현체를 연결해 주는 부분이 필요한데, 그 부분은 실제 코드 구현에서 살펴보겠습니다.

provide는 여러 연동 포인트를 호출하는 로직이기 때문에 비동기 코루틴 빌더를 통해 실행되도록 합니다. 연동 포인트로부터 모든 데이터를 받을 때까지 최대 Read Timeout 만큼의 시간이 소요됩니다. Timeout 내 응답을 받지 못한 연동 포인트들은 버립니다. 꼬리 지연 시간을 줄이고, 사용성을 위해서 버림이 필요합니다.

consume은 results와 RowSetting을 통해 Row 응답을 생성합니다. 에러나 Timeout 등으로 결과가 없는 경우는 Row를 생성하지 않거나 에러일 때 보여줄 UI 응답을 내려줍니다. consume 과정은 데이터 객체를 응답 객체로 변환하는 과정이므로 비동기 로직이 필요 없습니다.

이제는 provide, consume, aggregate를 각 구현체인 Provider, Consumer, Aggregator로 만들고, 실제 코드를 수준으로 살펴보겠습니다.

실제 코드 구현

Provider에서 연동으로부터 결과를 얻는 것으로 시작해서, Consumer에서 그 결과로 응답을 만들어 내는 코드, 이 두 가지를 연결하는 Aggregator 코드 순으로 살펴보겠습니다.

Provider 인터페이스

interface ResultProvider {
    fun getProviderType(): ProviderType

    suspend fun getResult(
        rowSettings: List<RowSetting>,
        userHeaders: UserHeaders
    ): Result<Any?>
}

Provider 구현체는 ResultProvider 인터페이스를 구현해야 합니다.

Provider 구현체

@Component
class MembershipProvider(
    private val membershipAdapter: MembershipAdapter
): ResultProvider {
    override fun getProviderType() = ProviderType.MEMBERSHIP_UI

    override suspend fun getResult(
        rowSettings: List<RowSetting>,
        userHeaders: UserHeaders
    ): Result<Any?> = membershipAdapter.getMembership(
        userHeaders.getPayAccountId()
    )
}

멤버십 API를 호출하여 결과를 가져오는 ResultProvider의 구현체인 MembershipProvider의 코드입니다. 이런 식으로 새로운 연동이 필요한 경우 ResultProvider 구현체를 생성하게 됩니다.

ResultProviderMapper: ProviderType과 Provider 구현체 맵핑

@Component
class ResultProviderMapper(
    resultProviderComponents: List<ResultProvider>
): ResultServiceProvider {
    private val resultProviders: Map<ProviderType, ResultProvider> = resultProviderComponents.associateBy { it.getProviderType() }
    private val boundProviderTypes = resultProviderComponents.map { it.getProviderType() }

    init {
        checkProviderTypes()
    }

    override suspend fun getResult(
        providerType: ProviderType,
        rowSettings: List<RowSetting>,
        userHeaders: UserHeaders,
    ): Result<Any?> = resultProviders[providerType]?.getResult(rowSettings, userHeaders)
        ?: Result.success(null)

    private fun checkProviderTypes() {
        // ProviderType에 대한 연동 ResultProvider가 모두 존재함을 보장하기 위함
        check(boundProviderTypes.containsAll(ProviderType.entries)) {
            "ProviderType에 맵핑되지 않은 ResultProvider가 존재합니다. " +
                ProviderType.entries.filterNot { boundProviderTypes.contains(it) }
        }
    }
}

ResultProviderMapper에서는 피드의 설정인 RowSetting에 해당하는 Provider를 매칭해 줍니다. 각 연동 구현체들이 ResultProvider 인터페이스를 구현하고, 이 구현체들이 주입되기 때문에 getResult 함수에서는 각 구현체들의 getResult를 호출해 줍니다.

ProviderType에 대한 구현체들이 모두 존재하는 걸 보장하기 위해, init 단계에서 resultProviderCompoments가 모든 ProviterType를 가지는 지 확인하고 예외를 던져줍니다. 만일 개발자가 실수로 구현체 없이 새로운 ProviderType만 추가한다면 애플리케이션 start 시에 에러가 발생합니다.

Consumer 구현체

object MembershipResultConsumer {
    fun create(
        rowSetting: RowSetting,
        result: Result<MembershipApi.Response>
     ): Row? = result.map {
        response -> Row(rowSetting, response)
     }.getOrNull()
}

MembershipResultConsumer는 연동 데이터를 가져와서 응답에 필요한 Row 형태로 생성해 줍니다.

ResultConsumerMapper: RowType과 Consumer 구현체 맵핑

ResultProviderMapper처럼 각각의 ResultConsumer에 데이터 넣어 맵핑해 주는 과정이 필요한데요. ResultConsumer는 bean이 아닌 object로 만들었기 때문에, 각 RowType에 직접 맵핑해 주는 로직이 필요합니다. 연동 데이터를 다른 객체로 변환해 주는 단순한 역할이라 object를 사용했습니다. Provider와 달리 Consumer는 method signature가 달라 interface로 묶어서 주입할 수 없다는 이유 때문이기도 합니다.

@Component
class ResultConsumerMapper {
    fun createRow(
        rowSetting: RowSetting,
        resultMap: Map<ProviderType, Result<Any?>>
    ): Row? = runCatching {
        when (rowSetting.rowProviderType.rowType) {
            // 멤버십 UI는 멤버십 연동결과만 사용
            RowType.Membership_UI ->
                MembershipwResultConsumer.create(
                    rowSetting = rowSetting,
                    membershipResult = requireAs(resultMap[MEMBERSHIP_API])
                )
            // 퀵버튼 UI는 쿠폰, 멤버십 연동결과를 사용
            RowType.QUICK_BUTTON_UI ->
                QuickButtonResultConsumer.create(
                    rowSetting = rowSetting,
                    couponResult = requireAs(resultMap[COUPON_API]),
                    membershipResult = requireAs(resultMap[MEMBERSHIP_API])
                )

            //.. 기타 나머지 RowType에 대한 Consumer 수행 로직
        }
    }

    // reified 키워드를 사용하여 타입 파라미터를 런타임에 알 수 있게 함
    inline fun <reified T> requireAs(any: Any?): T {
        require(any is T) { "${T::class}로의 타입 변환 실패!!" }
        return any
    }
}

각 Consumer를 호출할 때, 모든 연동 데이터가 모여있는 resultMap: Map<ProviderType, Result<Any?>> 필요한 파라미터를 전달해 줍니다. 예시의 멤버십 데이터는 MembershipwResultConsumer에서도 사용되고, QuickButtonResultConsumer에서도 인자로 들어가는데요. 두 가지에서 동시에 사용됩니다.

Aggregator

FeedAggregator는 피드 설정인 RowSetting에 따라 ResultProviderMapper사용해 비동기로 데이터를 호출해 조합하고, 그 데이터를 ResultConsumerMapper를 사용해 응답으로 변환하는 중계 컴포넌트입니다.

@Component
class FeedAggregator(
    private val resultProviderMapper: ResultProviderMapper,
    private val resultConsumerMapper: ResultConsumerMapper,
) {
    suspend fun aggregate(
        rowSettings: List<RowSetting>
    ): List<Row> = coroutineScope {
        val resultMap: Map<ProviderType, Result<Any?>> =
            // 중복된 외부 요청을 하지 않도록 provider type을 distinct 한 후에 데이터 조회
            rowSettings.groupByProvider()
                .mapEntryAsync { (providerType, rowSettings) ->
                    providerType to resultProviderMapper.getResult(providerType, rowSettings, userHeaders)
                }.toMap()  // 연동 데이터들을 resultMap에 aggregation


        // RowSetting 기반으로 연동 결과를 통해 List<Row> 생성
        rowSettings.mapNotNull { rowSetting ->
            resultConsumerMapper.createRow(it, resultMap)
        }
    }

    fun List<RowSetting>.groupByProvider(): Map<ProviderType, List<RowSetting>> =
        this.flatMap { rowSetting ->
            rowSetting.getProviderTypes().map { provider -> provider to rowSetting }
        }.groupBy(keySelector = { it.first }, valueTransform = { it.second })
}

결과적으로 코드의 구조는 다음과 같습니다.

클래스 다이어그램
클래스 다이어그램

FeedAggregator가 RowSettings에 정의된 RowType의 ProviderType에 대한 ResultProvider들을 가져오기 위해 ResultProviderMapper가 사용됩니다. 마찬가지로 ResultConsumerMapper를 통해 ResultConsumer에 접근합니다. 결과적으로 FeedAggregator를 통해 여러 ResultProvider로부터 비동기로 가져온 Result<T> 연동 데이터는 ResultConsumer로 전달됩니다.

이렇게 컴포넌트를 분리하면 변경에 영향받는 범위가 작아집니다.

신규 연동이 필요한 경우

Provider만 변경
Provider만 변경

신규 연동이 있을 때는 ResultProvider 영역만 변경하면 됩니다.

신규 UI가 추가된 경우

Consumer만 변경
Consumer만 변경

신규 UI 타입이 발생하면 ResultConsumer 영역만 변경되면 됩니다.

최종적으로 변경이 발생하는 영역

최종 변경 영역
최종 변경 영역

ResultProviderMapper에서는 Spring DI(Dependency Injection)을 통해 ResultProvider를 주입받으므로 최종적으로 다음의 영역만이 새로운 기능 추가 시 변경됩니다.

Selector 기법

앞선 N:M 맵핑에서 설명하지 않았던 케이스가 하나 있는데요. 하나의 연동에서 가져온 데이터를 여러 Row에서 사용하는 경우인 1:N 맵핑입니다.

4. 하나의 연동에서 가져온 데이터를 여러 Row에서 사용 = 1:N

1:N
1:N

하나의 연동에서 벌크로 가져온 데이터를 여러 Row에서 사용하는 경우가 있습니다. 이때에도 연동이 재활용되어야 함은 물론이고, 한 연동을 여러 Row에서 재활용할 때 각자 어떤 데이터를 가져다 써야 할 지에 대한 정보가 필요합니다. 이를 해결하기 위해 적용한 기법은 Selector라는 개념입니다.

어드민 Selector 설정과 Provider 호출
어드민 Selector 설정과 Provider 호출

Row에 필요한 Selector의 값은 어드민에 설정해 두기 때문에, DB에 저장된 피드 정보인 RowSetting이 Selector 컬럼을 가지고 있습니다.

각각의 배너광고 RowSettings의 Provider는 광고 Slot API를 호출하는 AD_BULK Provider입니다. Aggregator에서 비동기로 다른 연동과 함께 AD_BULK의 연동도 가지고 오게 됩니다.

Consumer에서 Selector 통해 데이터 선택
Consumer에서 Selector 통해 데이터 선택

이 값은 BannerConsumer로 넘겨지는데요. 각 BannerConsumer에서 동일한 연동값인 API Response가 전달됩니다. 이 중 자신에게 해당되는 값이 저장된 Selector의 slotMatch 값과 API Response에 있는 필드인 slot_name을 비교하여 자신에게 해당되는 값을 식별하게 됩니다.

object BannerConsumer {
    fun create(
        rowSetting: RowSetting,
        apiResult: Result<List<SlotBulkApi.SlotItem>>,
    ): Row? {
        // slotMatch와 일치하는 슬롯아이템 선택, 없으면 null 응답하여 표시하지 않음
        val slotMatch = selector.slotMatch
        return apiResult.map { slotItems ->
            slotItems
                .find { it.slotName == slotMatch }
                ?.let { Row(rowSetting, it) }
        }.getOrNull()
    }
}

단순하게는 위 예시처럼 광고 slot name이 일치하는지 확인하는 selector가 있고, 데이터의 순서를 선택하는 indexMatch, 모듈러 연산으로 랜덤을 선택하는 randomModuloRule, 국가 코드 일치를 확인하는 countryMatch 등 다양한 기능이 있습니다. Selector는 json 컬럼으로 관리하기 때문에 Selector 객체에 넣은 다양한 기능을 복합적으로 설정하고 사용할 수 있습니다.

안정적인 피드를 위한 기법

kotlin.Result

안정성을 중요시하는 것은 당연하지만, 결제탭은 카카오페이 앱의 첫 화면이기 때문에 예외 핸들링을 더욱 철저하게 해야 합니다. 결제탭에서는 Result를 활용하여 에러 발생 구간을 명시적으로 처리합니다. 실패가 발생할 수 있는 데이터를 Result로 감싸서 사용합니다.

Result를 사용하면 Success, Failure인 경우를 명시적으로 처리할 수 있기 때문에 의도적으로 실패인 경우를 처리할 수 있습니다. 특히 연동이 실패한 경우, Failure를 핸들링하여 디폴트 콘텐츠를 보여주거나 제거하는 등 명시적으로 코드를 작성할 수 있습니다.

결제탭은 API를 호출하거나 DB, Redis에 액세스 하는 등 예외가 발생할 수 있는 부분을 처리하는 로직은 runCatching을 통해 감싸 Result으로 반환해 사용합니다.

suspend fun getValue(key: String): Result<String?> =
    runCatching { reactiveRedisTemplate.opsForValue().getAndAwait(key) }
        .onFailure { log.error("[RedisCacheService#getValue] Failure key = $key", it) }

Consumer에서 Result<T>로 받아, Failure일 때 빈 응답을 반환할지, 디폴트 콘텐츠를 보여줄지 결정할 수 있습니다.

object RemindMessageConsumer {
    fun create(
        rowSetting: RowSetting,
        result: Result<RemindMessage>,
    ): Row {
        return result.map { message ->
            Row(
                rowSetting = rowSetting, data = message
            )
        }.getOrDefault(
            Row(
                rowSetting = rowSetting, data = RemindMessage.DEFAULT_MESSAGE
            )
        )
    }
}

외부 통신으로 인해 실패가 발생할 수 있는 부분을 명시적으로 Result로 감싸 처리하면, 성공과 실패에 대한 처리가 명확해지기 때문에 코드 안정성이 더욱 증가합니다.

DB, 캐시, 리소스 3중화

결제탭은 DB에 저장된 RowSetting 정보를 기반으로 동작하기 때문에, 장애 시에도 이 데이터를 안전하게 확보하는 것이 중요합니다. 이를 위해 DB에서 가져온 RowSetting을 Redis에 캐싱해 사용하고 있습니다. 또한 Redis 캐시를 갱신하는 시점에 DB에 접근할 수 없거나 Redis 장애로 캐시를 사용할 수 없는 경우를 대비해, 최소한의 Row들만을 정의한 row_setting_backup.json 파일을 코드 내 리소스 파일로 관리하고 있습니다. DB, Redis에 장애가 발생했을 때, 최소한 결제탭 서버만이라도 살아있다면 피드를 서빙할 수 있습니다.

3중화 구조
3중화 구조

기본적으로 API와 DB에서 모든 유저에게 동일하게 보이는 데이터의 경우 Redis 캐시를 사용하고 있습니다. 응답시간을 단축하고, 사용하는 마이크로 서비스에 트래픽 부담을 줄일 수 있습니다.

Redis Caching
Redis Caching

결제탭 서버는 다운되지 않은 이상, 앞서 언급한 여러 기법을 사용하여 안정성을 보장하고 있습니다. 그러나, 서버가 다운되거나, 응답 지연으로 클라이언트에서 응답을 받을 수 없는 경우에는 클라이언트 자체에 캐싱된 데이터를 보여줍니다.

클라이언트에서는 서버로부터 응답을 받기 전까지 기존 피드 데이터를 보여주고, 응답을 받았을 때 피드를 갱신합니다. 이렇게 되면 서버 장애 상황에서도 피드 콘텐츠를 보여줄 수 있습니다. 한편, 각 Row에는 만료 시간도 포함되어 있어 만료된 데이터는 캐시 데이터에서 제외하고 보여줍니다. 이로써 만료된 콘텐츠가 유저에게 보일 위험성도 없습니다.

마치며

서비스가 고도화될수록 개발 비용이 줄어드는 것이 이상적입니다. 특히, 외부 콘텐츠를 제공하는 피드 서비스에서는 새로운 기능을 추가하기보다는 다양한 콘텐츠를 쉽게 확장할 수 있도록 시스템을 설계하는 게 중요합니다. 시스템을 잘 설계하면, 서비스 유지보수 비용을 줄이면서 사용자에게 다양한 콘텐츠를 지속적으로 제공할 수 있습니다.

현재의 코드 아키텍처에서 새로운 콘텐츠가 필요할 때, Consumer만 하나 추가하고 SDU를 사용해 사용자에게 새로운 콘텐츠를 바로 제공할 수 있습니다. 이 아키텍처 덕분에 새 요구사항이 들어왔을 때 들어가는 개발 비용이 크게 감소했습니다. 개발 생산성이 향상되었고, 유저에게 새로운 콘텐츠를 더 빨리 제공할 수 있게 되었습니다.

서비스 고도화에 따른 개발 비용 그래프
서비스 고도화에 따른 개발 비용 그래프

지금도 결제탭에 다양한 요구사항이 발생하고 있는데요, 현재의 코드 아키텍처로도 유연하게 대응할 수 있어서 뿌듯함을 느낄 때가 많습니다. 현재의 코드 아키텍처는 지금은 맞고, 나중엔 틀릴 수 있습니다. ProcessorProvider, Consumer, Aggregator로 나눴던 것처럼 예측하지 못한 변경이 발생하면, 새로운 구조를 만들어내야 합니다.

가장 중요한 것은 계속해서 유연한 구조로 변화해 가는 것입니다. 현재의 구조에서 불가한 것은 무엇이고, 어떤 부분이 문제인지 찾아서 수정하는 과정이 개발자에게 참 중요한 것 같습니다. 저는 이 과정을 즐기는데요, 여러분도 담당 서비스에서 개선할 부분을 찾아 최적화하는 재미를 함께 느껴보셨으면 좋겠습니다.

happy.together
happy.together

카카오페이에서 백엔드 개발을 하고 있는 해피입니다. 개발만큼 자기 개발을 좋아합니다.