여러 제휴사와 연동하는 신규 프로젝트 개발기 1편

여러 제휴사와 연동하는 신규 프로젝트 개발기 1편

요약: 카카오페이는 사용자별로 자동차보험을 비교해 볼 수 있는 서비스를 개발했습니다. 이 서비스는 제휴 보험사의 상품을 비교하여 사용자가 유리한 상품을 선택하도록 돕습니다. 엔지니어는 이 비교 서비스를 위해 제휴사 API 관리, 병렬 및 비동기 처리, 입력 검증 및 코드 테스트 방법 등 다양한 고려사항을 해결해야 합니다. 공통 구조 설계, 제휴사별 토큰 관리, DTO 변환 로직, UseCase 구현 등을 통해 유지보수가 용이하고 확장성이 높은 서비스 구조를 마련했습니다. 이를 통해 사용자와 개발자 양측의 요구를 충족하며 효율적인 서비스를 운영합니다.

시작하며

안녕하세요, 카카오페이 보험마켓파티의 카펀입니다. 최근 카카오페이는 사용자별로 자동차보험을 비교해 볼 수 있는 자동차보험 비교 서비스를 오픈했는데요. 자동차보험 가입을 앞둔 사용자가 카카오페이와 제휴된 다양한 보험사의 상품을 비교해 보고, 유리한 상품을 선택하도록 돕는 서비스입니다.

엔지니어의 입장에서 이러한 비교 서비스는 다방면으로 고려해야 할 점이 많습니다. 비교를 위한 입점 제휴사들을 코드 구조 관점에서 어떻게 관리하는 것이 좋을까요? 각 제휴사에 요청을 보내고 응답을 받는 것은 어떻게 처리해야 하며, 어떤 문제점이 발생할 수 있을까요? 사용자로부터 받는 입력에 대해 검증이 필요하다면, 어떤 방식으로 검증하고 처리하는 것이 좋을까요? 작성한 코드에 대한 테스트는 어떤 관점에서 접근해야 할까요?

이러한 고민을 어떻게 해소했는지 이번 글을 통해 소개해 보고자 합니다. 1편에서는 여러 제휴사를 관리하기 위한 공통 구조와 여러 제휴사를 호출할 때 필요한 병렬 및 비동기 처리에 대해 다루어 보겠습니다.

🔗 여러 제휴사와 연동하는 신규 프로젝트 2편

여러 제휴사를 어떻게 관리하면 좋을까?

무언가를 비교할 때를 생각해 보면, 사용자가 일일이 조회하는 경우도 있지만, 검색 등을 통해서 손쉽게 비교할 때도 있습니다. 인터넷에서 물건을 산다면, 특정 제품을 한 번 검색하면 해당 제품을 판매하는 쇼핑몰이 가격 정보와 함께 한 번에 보입니다. 비슷하게, 비교 서비스에서는 사용자가 정보를 입력하고, ‘비교하기’ 버튼을 한 번만 누르면 됩니다.

하지만 뒤에서는 조금 더 복잡한 일이 일어나고 있는데요. n개의 제휴사가 있고, 이들의 상품을 비교하려면, 한 번의 비교를 위해 n개의 요청을 보내야 합니다. 이때 발생한 고민들에 대해 소개합니다.

제휴사 API 구조 소개

저희가 비교를 위해 사용하는 API는 다행히 큰 틀에서 같은 구조를 가지고 있습니다. 덕분에 공통화 및 관리가 유리했는데요. 간단히 소개하자면 아래와 같습니다.

  • 다른 API 호출을 위한 token 발급 API
  • 정보 전송 API 1
  • 정보 전송 API 2
  • 정보 전송 및 결과 조회 API

사용하는 API의 개수와, API 별로 사용하는 필드가 동일한 형태입니다. 여기까지는 모든 제휴사가 지켜야 하는 규칙입니다. 하지만 필드명, API 별 구조와 이를 위한 DTO (Data Transfer Object), 토큰 관리 정책 등 다른 점도 많았습니다. 모든 부분이 동일하지는 않기 때문에, 이를 공통화하여 관리할 필요성이 부각되었습니다.

공통화가 필요한 이유

이러한 공통화가 왜 필요할까요? 결론부터 말씀드리면, ‘유지보수가 용이하다’라고 할 수 있습니다. 앞서 소개드린 제휴사가 지켜야 하는 규칙 아래에서, 저희 서비스는 아래 항목들을 수행할 수 있어야 합니다.

  • 특정 제휴사의 점검 또는 장애, 노출 여부 등을 관리
  • 사용자로부터 받은 입력을 모든 제휴사에 적합한 형태로 가공하여 전달
  • 제휴사로부터 받은 응답을 동일한 형태로 만들어 비교
  • 신규 제휴사 추가

이들을 잘 설계된 공통 구조 없이 시도한다면, 데이터 정합성 등의 문제가 발생하였을 때 원인을 찾기 매우 어렵습니다. 신규 제휴사를 추가할 때마다 기존 시스템에 어떻게 포함시킬 수 있을지 고민해야 하며, 저희 또는 제휴사의 정책이 변경될 때마다 이를 수정하기 복잡합니다. 또한, 각 영역 별 책임을 나누어 기능을 테스트하기에도 어렵습니다.

비교 서비스 구조 소개

사용자로부터 제휴사까지의 요청과, 제휴사로부터 사용자까지의 응답을 위해 아래와 같이 구조를 설계하였습니다.

요청

request
request

응답

response
response

구조가 다소 복잡하지만, 크게 ‘서비스용 구조’, ‘공통 구조’, ‘개별 구조’로 구분할 수 있습니다. 이 중 주목하고자 하는 부분은 ‘개별 구조’ 부분입니다. 개별 구조는 각 제휴사별로 구현되어야 하는 영역이지만, 동시에 일종의 인터페이스와 같이 정해진 기능을 수행할 수 있어야 합니다 (인터페이스가 아닌, 일종의 인터페이스인 이유는 후술합니다).

개별 구조

우리의 서비스에서 개별 구조로 관리해야 하는 영역은 아래와 같습니다.

  • 제휴사별 조회용 토큰 관리
  • 제휴사별 request, response DTO 생성 및 공통 구조와의 형태 변환 로직 구현
  • 제휴사별 UseCase 구현

하나씩 살펴보도록 하겠습니다.

제휴사별 조회용 토큰 관리 및 사용

각 제휴사별 토큰을 조회하는 API가 있습니다. 이를 이용하여, 우리의 서비스 내에서 토큰을 갱신 및 관리하는 기능을 구현합니다. 어떤 제휴사든 토큰을 가져올 수 있어야 하지만, 토큰 만료 정책 등은 제휴사별로 다릅니다.

각 제휴사별로 토큰을 조회하는 API를 호출하는 SsoFetcher 클래스를 만들었습니다. 이를 이용하여, 아래와 같이 TokenManager 인터페이스를 작성합니다.

interface TokenManager {
    val type: BizCompanyCode

    fun isValidToken(): Boolean

    fun cacheToken(): String

    fun refreshToken(): String

    fun validateToken(): String {
        return if (this.isValidToken()) cacheToken()
        else refreshToken()
    }
}

모든 제휴사는 이 인터페이스를 따라서 토큰을 관리하게 됩니다. 토큰 타입, 유효 여부 등의 역할을 수행합니다. 각 제휴사별로 이 인터페이스를 구현하고 나면, TokenManager를 사용할 수 있습니다.

@Component
class TokenRepository(
    private val tokenManagers: List<TokenManager>
) : TokenRepository {
    fun getToken(type: BizCompanyCode): String {
        val tokenManager = requireNotNull(tokenManagers.find { it.type == type })
        return tokenManager.validateToken()
    }
}

단순히 interface를 활용한 개별 구조의 공통화입니다.

제휴사별 request, response DTO 생성 및 형태 변환 로직 구현

제휴사의 경우, 일부 제휴사는 같은 구조의 DTO를 사용하고, 일부 제휴사는 개별 DTO를 사용합니다. 이 경우, interface를 사용할 수 없지만, 기대하는 역할은 동일합니다.

data class SomethingApiRequestRaw (
    val bizCode: String,
    val requestUuid: String,
    val userPersonalInformation: String,
    val userNotPersonalInformation: String,
    val partnerUserId: String
)

일부 제휴사는 위 구조를 사용한다고 가정합니다. 다른 제휴사는 아래와 같은 형태를 사용한다면,

data class NothingApiRequestRaw (
    val businessCode: String,	// bizCode
    val connectUuid: String,	// requestUuid
    val userPersonalInfo: String,	// userPersonalInformation
    val userNPInfo: String,	// userNotPersonalInformation
    val parterUserId: String
)

또는 다른 제휴사는 아래와 같은 구조를 사용한다면,

data class AnythingApiRequestRaw (
    val request: SomethingApiRequestRaw
)

형태 또는 이름은 모두 제각각이지만, 저마다 사용하려는 목적 및 기대하는 결과는 동일합니다. 이는 interface로 역할을 분리하기 어렵지만, 분명 공통 내용을 개별 형태로 관리해야 하는 경우입니다.

이들에 대한 암호화/복호화 정책 역시 제휴사별로 다릅니다. 또한 이를 공통의 형태로 상호 변환이 가능해야 합니다. 이를 함께 처리하도록 하는데, 이는 interface를 사용할 수 있습니다.

interface TransactedConverter {
    fun convertSomethingRequest(request: SomethingApiRequest): SomethingApiRequestRaw

    fun convertSomethingResponse(response: SomethingApiResponseRaw): SomethingApiResponse
}

@Component
class AlphaTransactedConverter(	// 제휴사명이 Alpha라고 가정
    private val alphaKeyIv: KeyIv
): TransactedConverter {
    override fun convertSomethingRequest(request: SomethingApiRequest): SomethingApiRequestRaw =
        somethingApiEncrypt(SomethingApiRequestRaw.from(request, BizCompanyCode.ALPHA))

    fun somethingApiEncrypt(
        requestRaw: SomethingApiRequestRaw
    ): SomethingApiRequestRaw = with(requestRaw) {
        requestRaw.copy(
            userPersonalInformation = encrypt(userPersonalInformation)
        )
    }

    private fun encrypt(input: String): String {
        return AESCipherManager.encrypt(input, alphaKeyIv.key, alphaKeyIv.iv)
    }
}

각 제휴사의 경우에 대해 구현하여 사용할 수 있습니다. 이를 통해 공통 DTO와 제휴사별 DTO로의 변환, 제휴사별 암호화/복호화 정책을 코드에 적용하여 관리할 수 있습니다.

제휴사별 UseCase 구현

UseCase의 정의는, 어떤 목적을 달성하기 위해 주어진 역할과 시스템 사이의 상호 작용을 가리킵니다. 이 상호 작용은 특정 동작의 순서로 정의되는데요. 우리의 시스템에서는 제휴사의 API를 호출하고 결과를 얻기 위해 아래 순서를 따릅니다.

  • tokenRepository로부터 해당 제휴사의 토큰을 가져 옴
  • transactedConverter를 통해 request DTO를 제휴사의 형태에 맞게 변경
  • apiFetcher를 통해 위에서 얻은 제휴사의 토큰 및 request DTO를 사용하여 제휴사 API를 호출
  • 제휴사로부터 받은 응답을 transactedConverter를 통해 공통 형태로 변환 및 반환

즉, 이러한 역할은 모든 제휴사에 대해 요구하는 것이며, 이것 역시 인터페이스를 사용하여 역할을 정의할 수 있습니다.

interface SomethingUseCase {
    val type: BizCompanyCode

    /**
     * 정보 전송 1 API
     */
    fun bizParterApi(transactionId: String, request: SomethingApiRequest): TransactedApiResult
}

@Component
class AlphaUseCase(
    private val alphaTransactedConverter: AlphaTransactedConverter,
    private val alphaApiFetcher: AlphaApiFetcher,
    private val tokenRepository: TokenRepository
) : SomethingUseCase {
    override val type = BizCompanyCode.ALPHA

    override fun bizParterApi(
        transactionId: String,
        request: SomethingApiRequest
    ): TransactedApiResult {
        val alphaConvertedRequest = alphaTransactedConverter.convertSomethingRequest(request)
        val alphaResponse =
            alphaApiFetcher.bizParterApi(token = token(), transactionId = transactionId, requestBody = alphaConvertedRequest)
        val response = alphaTransactedConverter.convertSomethingResponse(alphaResponse)

        return TransactedAgreeResult(request, response)
    }
}

구현의 차이는 있지만, 각 제휴사별로 정의된 역할을 수행하도록 관리할 수 있습니다.

효율적인 공통화는 서비스 운영을 돕는다

방대한 시스템 내의 제휴사별 구현 내용에 대해, 각 영역별로 가능하다면 인터페이스를, 그렇지 않다면 동일한 역할을 수행할 수 있도록 구조를 제작하였습니다. 소개해 드린 예시를 통해 어떤 식으로 제휴사별 내용들이 관리되는지 확인할 수 있었는데요.

이러한 공통화를 통해, 앞서 기대한 이점을 취할 수 있었습니다. 즉, 유지보수가 용이하다는 장점을 얻었는데요. 실제로 위의 구조를 확인해 보시면,

  • 각 제휴사마다 독립적인 정책 및 구현을 확인하기 수월함
  • 개별 요청 및 응답 형태를 공통의 형태로의 양방향 변환이 수월함
  • 신규 제휴사를 추가할 때도, 제휴사에 대해 프로젝트 내에서 요구하는 기능을 그대로 따를 수 있음
  • 점검, 미노출, 제거 등 기존 제휴사를 호출하지 않도록 하는 경우, UseCase의 목록에서 제외하는 것으로 해결

실제로 이러한 구조를 통해 개발 시에는 기존의 정책대로 시스템이 구현되는지 쉽게 파악할 수 있었고, 오픈 후 운영 중에 발생하는 다양한 요청 및 내용 변경에 어려움 없이 대응할 수 있었습니다.

병렬 처리의 필요성과 접근 방법

앞서 소개한 공통 구조에 따라, 서버에서는 사용자의 조회 요청마다 n개의 제휴사로부터 조회 요청을 주고받습니다. 공통 형태의 조회 형태를 각 제휴사에 맞는 구조로 변경하고, 각 제휴사로부터 받은 응답을 공통 구조로 변환합니다. 또한 보내고 받은 값을 재조회 등의 목적으로 DB에 저장합니다.

제휴사의 수와 독립적으로, 우리의 서비스에서는 전체 제휴사의 API 호출에 소요되는 시간을 일정하게 가져가고자 하였습니다. 이를 통해 사용자는 서비스를 보다 원활하게 이용할 수 있습니다. 병렬 처리 및 비동기 처리를 통해 이를 구현하고자 하였습니다.

또한, 각 API의 응답이 수 초 이상 걸린다면, 클라이언트에서 단순히 호출-조회하는 방식으로 API를 설계하기보다는, 다른 방법을 고민해 볼 수 있습니다. 저희가 시도한 방법은,

  • 클라이언트에서 조회 시작을 요청하는 API를 호출, 서버에서는 제휴사별 조회 요청 시작
  • 클라이언트에서 서버의 제휴사 조회 작업 완료 여부를 확인
  • 서버에서 조회가 완료된 후, 클라이언트에서 서버로부터 결과를 조회

이런 구조로 간다면, 클라이언트의 조회 요청마다 서버에서 자원을 일정 시간 동안 점유하게 됩니다. 따라서, 각 요청 별로 서버에서 제휴사로부터 요청을 보내고 받는 과정을 최대한 효율적으로 작성해야 합니다.

Kotlin Coroutines 소개 및 사용

코루틴을 아시나요? 코루틴은 실행 도중 중단 및 재개가 가능한 코드 요소입니다. 스레드를 블로킹한 것과 유사하지만 다릅니다. 프로그램의 실행이 일시 중단 된다는 점에서는 동일하지만, 중단된 코루틴은 스레드를 블로킹하지 않기 때문에, 스레드는 다른 작업을 진행할 수 있습니다. 이를 이용하여, API 호출 후 응답이 올 때까지 코드를 잠시 중단하고, 그동안 스레드는 다른 일을 처리합니다. API를 10개 호출한다면, 하나의 API를 호출한 후 응답이 오기 전에 해당 작업을 일시 중단한 후, 다른 API를 또 호출하는 작업을 반복하면 됩니다. 응답을 받는 API부터 작업을 재개할 수 있고, 덕분에 단일 스레드를 효율적으로 활용하여 여러 API 요청을 거의 동시에 보낼 수 있습니다.

코틀린에서는 코루틴을 매우 편리하게 사용할 수 있도록 지원합니다. 기본적으로 언어에 내장되어 있기도 하지만, kotlinx.coroutines 라이브러리를 통해 더욱 방대한 코루틴 지원을 편리하게 제공합니다.

앞서 상정한 필요 요건을 구현하기 위해, 코루틴을 이용하였습니다.

코루틴을 이용해서 API를 병렬 호출하기

코루틴을 이용하여 아래와 같이 코드를 작성하였습니다.

@Service
class AsyncSomethingService(
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,		// 2
) {
    fun getSomething(request: SomethingApiRequestCommon) {
        runBlocking {	// 1
            CoroutineScope(dispatcher).launch {	// 3
                val useCaseJobs = activeUseCases.map {
                    launch(dispatcher) {	// 4
                        val apiRequest = SomethingApiRequest.of(request, it.type)    // 공통 형태로부터 조회 요청 생성
                        somethingUseCase.saveSend(apiRequest, it.type)    // 조회 요청 저장
                        try {
                            val result = it.getSomething(apiRequest)    // 제휴사 API 호출
                            somethingUseCase.saveResult(result, it.type)    // 제휴사 응답 저장
                        } catch (e: Exception) {
                            // 예외 발생 시 처리 과정
                        }
                    }
                }
                useCaseJobs.joinAll()
            }
        }
    }
}

기본적인 형태는 앞에서 소개하였던 공통 구조 (UseCase)를 사용하였습니다. useCaseJobs는 조회하고자 하는 모든 제휴사의 UseCase의 목록이며, 각 UseCase에 대해 조회 작업을 수행합니다.

이때 사용한 코루틴 키워드는 아래와 같습니다.

  • runBlocking
  • dispatcher
  • CoroutineScope(dispatcher).launch
  • launch(dispatcher)

하나씩 소개해 보겠습니다.

runBlocking (주석 1번)

코루틴은 일반적으로 스레드를 블로킹하지 않고 작업만을 중단시키지만, 블로킹이 필요한 경우도 종종 있습니다. 이 경우에 runBlocking을 사용합니다. 대표적인 예시는 main 함수 혹은 단위 테스트입니다. 위의 코드에서는 getSomething을 호출하는 메인 스레드가 getSomething의 리턴값을 받을 때까지 스레드를 블로킹하도록 합니다.

dispatcher (주석 2번)

코루틴 라이브러리는 디스패처를 통해 코루틴이 실행될 스레드 혹은 스레드 풀을 지정할 수 있도록 지원합니다. 다양한 종류가 있는데, 저희가 사용한 디스패처는 Dispatchers.IO입니다. I/O 연산으로 스레드를 블로킹할 때 사용하기 위해 제작된 디스패처인데, I/O와 같이 대기하는 작업이 많은 경우에 유리합니다. 저희는 제휴사들의 API를 호출하고, DB에 작성하는 등 I/O 작업을 수행하기 때문에 Dispatchers.IO로 선택하였습니다.

CoroutineScope(dispatcher).launch (주석 3번)

CoroutineScope는 코루틴이 실행될 스코프를 정의합니다. 이 경우에는 앞서 정의한 CoroutineContext인 Dispatchers.IO를 사용하였습니다. 이후 launch를 통해 신규 코루틴을 생성하고, 정의한 현재 스코프에서 실행시킵니다.

launch(dispatcher) (주석 4번)

launch 빌더입니다. thread 함수를 호출하여 새로운 스레드를 호출하는 것과 비슷하게, 새로운 코루틴을 시작하는 역할입니다. CoroutineScope와 비슷하게, 앞에서 지정한 dispatcher를 인자로 받습니다. 앞의 CoroutineScope를 통해 생성한 코루틴 스코프 내에서, 각 UseCase에 대해 코루틴을 시작하게 됩니다.

사용된 코루틴 키워드를 간단하게 소개하였습니다. 즉 요약하면,

  • getSomething을 호출할 때 스레드를 블로킹하도록 runBlocking으로 코루틴 내용을 감쌉니다.
  • API를 호출하는 작업이 주가 되므로 Dispatchers.IO를 선택합니다.
  • CoroutineScope(dispatcher).launch를 통해 코루틴 스코프를 생성합니다.
  • 생성된 코루틴 스코프 내에서, 각 제휴사의 UseCase에 대해 launch(dispatcher)를 통해 코루틴을 생성합니다.

이를 통해 제휴사의 수가 늘어도, 각 제휴사 API 호출 및 호출 전후에 DB에 저장하는 작업을 코루틴 스코프 내에서 병렬로 처리할 수 있었습니다.

비동기 호출

이렇게 작성한 코드는 병렬 처리와 동시에 비동기 처리 역시 가능합니다. getSomething 함수를 잘 보시면 아무런 값도 리턴하지 않습니다. 위의 코드에서는 API가 호출되면 getSomething이 실행된 후, API 요청을 받았던 스레드는 즉시 반납되며, 메서드 내의 작업은 별도의 IO 스레드에서 비동기로 동작합니다.

이렇게 작성한 코드는 아래와 같이 호출하면, 클라이언트에서 조회 시작을 요청하는 역할로 사용할 수 있습니다. API는 getSomething 내의 작업을 기다리지 않고 200 OK 응답을 클라이언트에게 보내게 됩니다.

    @PostMapping("/{lookup_id}/lookup")
    fun somethingStartLookup(
        @PathVariable("lookup_id") lookupId: String,
        userId: UserId
    ) {
        val request = // .. request 생성
        asyncSomethingService.getSomething(request)
    }

병렬 처리와 Coroutine 사용의 효용성

병렬 처리가 필요했던 이유와, 이를 구현하기 위해 코루틴을 사용한 과정을 소개해 드렸는데요. 위와 같이 과정을 나누고, 서버에서 비동기로 병렬 처리를 하도록 설계한 덕분에, 몇 가지 이점을 얻을 수 있었습니다.

  • 클라이언트가 조회 도중 이탈 및 재진입해도, 서버에서는 문제없이 조회 프로세스가 진행 및 완료
  • 특정 제휴사가 타임아웃, 장애 등 문제가 발생해도, 전체 프로세스에 영향 없음
  • 제휴사의 추가, 제거 등에 강함

더불어, 코루틴을 사용한 코드를 보시면, 기존의 코틀린 문법 위에 몇 가지를 추가로 얹은 것으로 병렬 처리를 적용할 수 있다는 점이 크게 와닿았습니다. 이 부분이 코틀린 환경에서 코루틴을 사용하며 가장 좋아하는 점인데요. RxJava 등 다른 JVM 계열 라이브러리에 비해, 코루틴은 기존 코드에 대해 큰 변경 없이, 상대적으로 완만한 러닝 커브를 거쳐 적용할 수 있었습니다.

맺음말

카카오페이 자동차보험비교 서비스를 개발하며 경험한 고민들을 추상화하여 소개해 드렸습니다. 엔지니어가 무언가를 만들 때는 사용자 입장과 제작자 입장 양쪽을 고려하게 됩니다. 사용자 입장에서는 서비스를 보다 편리하게, 불편함 없이 사용할 수 있도록 고민하게 되며, 동시에 엔지니어를 포함한 제작자 입장에서는 관리 및 변경이 용이하도록 고민하게 됩니다.

이 글을 통해 이러한 고민과 결과를 소개해 보았습니다. 사용자 입장에서, 제휴사의 수에 무관하게 최대한 빠른 응답 시간을 받을 수 있도록 설계하였고, 동시에 서버의 자원을 과하게 사용하지 않도록 고려하였습니다. 또한 제작자의 입장에서, 정책이 변경되거나 제휴사가 변경되어도 큰 어려움 없이 수정할 수 있도록 하였습니다. 이러한 점들이 모여 사용하기 편리하며, 유지보수가 용이한 프로젝트가 탄생했습니다. 앞으로 많은 분들이 저희 서비스를 사용해 주시며 더더욱 이러한 장점이 부각될 것입니다. 이러한 저희의 경험이 여러분의 서비스를 위한 고민에 도움이 되었으면 좋겠습니다.

참고한 내용들

함께 읽기

🔗 여러 제휴사와 연동하는 신규 프로젝트 2편

katfun.joy
katfun.joy

카카오페이 서버 개발자 카펀입니다. 어려움과 불편함을 기술을 통해 해소하는 것을 매우 좋아합니다. 한 줄의 코드마다 근거를 가지고 작성하고자 노력합니다.