MSA 환경에서 네트워크 예외를 잘 다루는 방법

MSA 환경에서 네트워크 예외를 잘 다루는 방법

시작하며

안녕하세요. 카카오페이 Payment Clan에서 페이상품권 서버 개발을 하고 있는 larry입니다.

고객에게 정확한 결제서비스를 제공하기 위해선 높은 수준의 무결성이 요구되는데요, 이번 글에서는 마이크로 서비스 환경의 다양한 네트워크 통신 과정에서 어떤 문제들이 발생할 수 있고 상품권 도메인은 결제 트랜잭션을 보장하기 위해 어떻게 문제를 풀어가고 있는지 공유드리고자 합니다.

아래 내용과 개념이 등장합니다. :)

  • MSA
  • 글로벌 트랜잭션
  • 스프링 트랜잭션
  • 멱등성
  • 함수형 프로그래밍

MSA와 글로벌 트랜잭션

카카오페이의 결제시스템은 수십여개의 서버들이 연결된 마이크로 서비스 아키텍쳐(이하 MSA)로 구성되어 있습니다. 온라인, 오프라인, 해외 결제 시스템 등 각 서비스마다 DB를 관리하고 있으며 고객의 결제 일관성을 보장하기 위해 다양한 서버들과 연계합니다.

특히 페이머니, 페이상품권, 포인트 등과 같은 잔액을 담당하는 서비스들과의 밀접한 트랜잭션 관리가 필요합니다.

결제 수단별로 호출하는 간략한 도식
결제 수단별로 호출하는 간략한 도식

결제 트랜잭션 관리

데이터를 저장하는 서버들은 트랜잭션 관리가 기본이지만 결제 도메인에 있어서는 매우 중요하고 민감하게 다뤄야 한다고 생각합니다. 단 한 번의 예외적인 상황으로도 고객에게 큰 불편을 야기할 수 있고 서비스 신뢰도까지 영향을 줄 수 있기 때문입니다.

주관적으로 예외 상황별 매운맛을 표시해보았습니다.

고객 입장

  • 두 번 결제가 되거나 🌶
  • 결제 취소가 되지 않거나 🌶
  • 결제가 성공했는데 잔액이 빠져나가지 않거나 🌶🌶
  • 결제가 취소됐는데 잔액이 잘못 돌아오거나 🌶🌶
  • 결제가 취소됐는데 잔액이 돌아오지 않거나 🌶🌶

서버 입장

  • 여러 번 결제 요청이 오거나 🌶
  • 결제가 실패됐는데 기록이 없을 때 🌶
  • 결제 취소 요청이 성공요청보다 먼저 오거나 🌶
  • 결제가 실패했는지 성공했는지 알 수 없을 때 🌶🌶🌶

이처럼 정확하고 안전한 결제를 위해서는 여러 예외적인 상황에서 나타날 수 있는 결과를 고려해야 하기 때문에 모놀리식(monolithic) 구조라 할지라도 무조건적인 롤백은 지양하고 도메인 환경에 맞는 적절한 트랜잭션 관리를 해야 합니다.

글로벌 트랜잭션 관리

MSA는 각 서비스마다 DB가 따로 있기 때문에 트랜잭션에 참여하는 서비스가 여럿이라 하더라도 각 DB에 걸쳐서 데이터 일관성을 보장할 수 있어야 합니다. 이를 여러 서버, DB에 걸친 트랜잭션을 글로벌 트랜잭션, 분산 트랜잭션이라고도 표현합니다.

예를 들어 페이상품권을 발송(선물)할 때 페이머니 서비스로 머니 잔액 차감 요청을 하게 되는데요, 상품권 발송이라는 트랜잭션에 참여하는 페이머니 서비스에서 잔액 차감 성공을 응답했다면 당연하게도 상품권 서비스는 발송성공으로 데이터를 저장하게 됩니다.

가정의 달 페이상품권으로 마음 전달해보세요.
가정의 달 페이상품권으로 마음 전달해보세요.

상품권 발송이라는 트랜잭션에 참여하는 데이터 상태는 여러 서비스에 걸쳐 일관되어야 합니다.
상품권 발송이라는 트랜잭션에 참여하는 데이터 상태는 여러 서비스에 걸쳐 일관되어야 합니다.

MSA에서 글로벌 트랜잭션을 관리하는 방식은 여러 가지가 제시되어 있지만(대표적인 saga pattern) 어떤 방식이든 이기종간 네트워크 요청을 주고받는 형태가 됩니다.

다양한 트랜잭션 패턴들을 잘 적용하기 위해서도 네트워크 요청의 예외적인 상황부터 잘 다룰 수 있어야 하는데요, 이번 글에서 이야기하고 싶은 내용은 코드레벨에서 네트워크의 예외적인 상황을 효과적으로 다룰 수 있는 방법에 관한 내용입니다.

네트워크 요청의 성공과 예외 처리

네트워크 통신에서는 아래와 같은 예외적인 상황이 존재할 수 있습니다.

  • 같은 요청이 짧은 시간에 두 번 이상 발생한 경우
  • 네트워크 순서가 뒤집힌 경우 (취소 요청 후 결제 요청)
  • 각종 타임아웃
  • 인프라 문제로 인한 실패

이런 상황들은 글 상단에 매운맛으로 표현했던 문제들을 야기하는 근복적인 원인이 되기도 합니다.

네트워크 요청(API)을 안전하게 다루기 위해서 두 가지 측면을 고려했습니다.

API를 요청하는 입장
-> 알 수 없는 에러를 처리하기

API를 제공하는 입장
-> 멱등성 API 제공하기

보다 쉬운 이해를 위해 일반적인 주문과 결제 서비스의 관계를 예를 들어 설명하겠습니다.

요청응답상황
요청응답상황

알 수 없는 에러 처리

대표적으로 타임아웃과 같은 상황은 요청에 대한 성공 응답을 받지 못했지만, 트랜잭션의 결과가 성공했는지 실패했는지 명확하게 판단하기 어려운 경우입니다. 아래 세 가지 정도 결과를 예상해 볼 수 있기 때문인데요,

  • 결제 서버로 요청이 들어갔는지?
  • 요청은 갔으나 성공했는지?
  • 요청은 갔으나 실패했는지?

이런 상황을 쉽게 실패로 간주하면 꽤나 난감한 상황이 벌어질 수 있습니다.

결제 서버는 요청을 받아 잔액을 차감했지만, 주문 서버에서는 타임아웃으로 제대로 응답을 받지 못하여 실패로 저장했다면 고객 입장에서는 돈이 빠져나갔는데 주문은 실패가 된 상황이겠죠.😭

실패로 간주하기 어려운 상황은 아래의 후처리를 통해 트랜잭션을 보정해볼 수 있습니다.

  • 즉시 재요청 시도
  • 일정 시간 뒤 재시도
  • 요청이 성공했는지 확인 후 재시도
  • 결제 취소 요청 (트랜잭션 무효화 요청, 보상 트랜잭션 개념)
  • 무조건 성공 후 뒤처리
  • 수기로 처리

방식에 따라서 결제 서버에서 트랜잭션 결과를 확인할 수 있는 API 또는, 결제 취소(무효화) API 등을 제공해야 할 필요도 있습니다. 어떤 방식이든 API의 요청의 결과는 아래 그림과 같이 성공(Success), 실패(Failure) 그리고 ‘알 수 없음(Unknown)‘으로 나뉘어질 수 있고, 각 상태에 따른 처리가 요구됩니다.

요청 응답 상황
요청 응답 상황

무한 루프 에러 처리

하지만 조금 더 생각해보면 후처리 또한 API를 요청하는 형태이므로 이 또한 알 수 없는 예외 상황이 발생할 수 있습니다.

예를 들어 보상 트랜잭션(결제 무효화)을 요청했지만 응답을 받지 못했을 때 보상 트랜잭션의 보상 트랜잭션(결제 무효화 요청의 무효화 요청..?)을 요청해야 하는 상황으로 볼 수 있겠죠. 결국 꼬리의 꼬리를 무는 무한 루프 에러처리를 해야 할 상황인데, 아무리 알 수 없는 에러를 처리하더라도 제한 없이 반복된 방어로직으로 풀어낼 수는 없을 것입니다. 그래서 상품권에서는 처음 요청의 예외상황까지는 고객 응답이 나가기 전에 후처리를 진행해보지만, 그것마저 실패가 되면 ‘알 수 없는 상태(Unknown)‘로 저장 후 사용자에게는 재시도 안내와 함께 응답을 하게 됩니다. 그리고 고객에 의해 재시도하게 되면, 해당 결제건이 알수없음으로 저장된 트랜잭션인지 확인하고 후처리를 다시 진행하게 됩니다. 혹여나 이마저도 실패하는 경우가 있다면 다른 장치들로 데이터를 보정하고 있습니다.

요청 응답 상황
요청 응답 상황

멱등성 API

실패로 간주하기 어려운 예외상황에서 주문 서버는 결제 서버를 향해 후처리 API 요청을 하게 됩니다.

예를 들어 주문 서버는 타임아웃으로 인해 알 수 없는 에러로 간주하고 결제 요청을 다시 시도하는 상황이라고 가정해봅니다. 그리고 결제 서버의 결제API는 아래 두 가지 방식으로 설계해볼 수 있을 것입니다.

  • 이미 성공한 결제 요청이 두 번(혹은 여러 번) 들어왔을 때
    1. 실패로 응답
    2. 성공으로 응답

재시도를 요청하는 이유가 알 수 없는 예외상황에 대한 후처리 목적이라면 2번과 같이 성공으로 응답을 해야 하겠죠.

동일한 요청을 여러 번 보내도 같은 응답을 줄 수 있으면 해당 API는 멱등성이 있다고 표현하고, 결제 서버에서 멱등성을 제공한다는 것은 한 번 성공, 실패가 되었다면 동일한 결제 요청이 이후 여러 번 오더라도 같은 응답을 준다는 것입니다.

멱등성은 본래 정의에 따르면 GET, PUT, DELETE, HEAD 메서드에 적용되는 개념이지만 결제 API의 경우 재요청이 가능하도록 POST 메서드 에서도 가능하도록 하는 것입니다.

이렇게 같은 요청에 대해서는 몇번이고 같은 응답을 줄 수 있다면 주문 서버 쪽에서는 Unknown 상황에 결제무효화(보상 트랜잭션) 요청을 사용할 경우가 줄어듭니다. 예외상황이 일시적인 네트워크 지연이었다면 재요청을 통해 성공 응답을 받을 수 있습니다.

한편 동일한 요청이 무엇인지 정의하는 것은 매우 중요합니다.

아래와 같은 형태의 결제 요청 API가 여러 번 온다면 재요청으로 볼 수 있을까요? 아니면 재요청이 아닌 새로운 결제 요청으로 볼 수 있을까요?

// POST /api/payment
{
  "user_id": 1,
  "amount": 1000
}

동일한 요청임을 구분할 수 있게 API 요청 값에 유니크한 값을 추가로 받도록 하면 반복해서 동일한 응답을 주는 것이 가능합니다. 유니크 키는 API 요청 혹은 헤더값에 포함할 수 있습니다.

// POST /api/payment
{
  "user_id": 1,
  "amount": 1000,
  "tx_key": "unique key per transaction"
}

타사 사례 소개

결제의 멱등성에 관해서는 다른 결제 기업의 사례들을 참고했었는데요, 도큐먼트가 매우 잘 되어있어서 간단히 소개드리고자 합니다.

▪ Adyen

https://docs.adyen.com/development-resources/api-idempotency

▪ Stripe

https://stripe.com/docs/idempotency https://stripe.com/docs/api/idempotent_requests

두 문서의 특징은 멱등성 API를 어떤 이유에서 제공하는지 설명하는데 그치지 않고 특별히 예외상황으로 API를 제대로 처리하지 못했을 때 어떻게 해야 할지 자세히 가이드를 제공하고 있습니다.

예외를 잘 다루는 방법

예외를 다룬다는 것은 코드로 표현하면 try catch를 사용하는 것이겠죠.

아래 상황을 코드로 표현해보겠습니다. 결제 요청을 한 후에

  1. 성공, 실패, 알 수 없는 예외를 구분하여 처리 (편의상 알 수 없는 예외는 SocketTimeoutException으로만 한정)
  2. 알 수 없는 예외이면 한 번 더 결제 요청 재시도
  3. 재시도 결제 요청에 대해서도 성공, 실패, 타임아웃 구분

try {
    paymentAdapter.pay(request)
    markSuccess(payTx)
} catch (e: Exception) {
    if (isFailedException(e)) {
        markFailure(payTx)
    } else {
        // 알 수 없는 예외라면 재시도
        try {
            retry(request)
            markSuccess(payTx)
        } catch (e: Exception) {
            if (isFailedException(e)) {
                markFailure(payTx)
            } else {
                markMarkUnknown(payTx)
            }
        }
    }
}

fun isFailedException(e: Exception): Boolean = when (e) {
    is RuntimeException -> true
    is SocketTimeoutException -> false
    else -> {
        println("unknown exception. $e")
        false
    }
}

보시다시피 코드의 중복이 있으므로 개선을 해야 합니다. try catch 가 깊어지는 부분에서의 리팩토링은 주의할 점이 있는데요. exception을 상위로 전파시키는 형식의 트랜잭션 분리는 의도치 않은 롤백이 될 수 있기 때문입니다.

그래서 저희는 함수형 패러다임을 사용하여 예외를 좀 더 안전하게 처리할 수 있게 하였습니다.

Option, Result, Either, Try

앞으로 소개드릴 데이터 구조를 사용하면 효과적으로 성공, 실패, 그외 의 경우를 다룰 수 있습니다.

자바의 Optional<T>는 T 타입의 데이터가 있거나, 없을 때(null)를 안전하게 다룰 수 있는 데이터 구조입니다. 비슷하게 예외가 일어날 수 있거나(throw exception) 일어나지 않을 때를 예외의 전파 없이 안전하게 다룰 수 있는 데이터 구조가 있습니다. Either, Result, Try 등으로 알려져 있는데요. 코틀린에서는 Arrow라는 라이브러리를 통해 Either를 사용할 수 있고 코틀린 자체에서도 Result라는 데이터 구조가 있습니다.

저희는 성공, 실패 또는 그 외의 상황까지 처리할 수 있도록 데이터 구조를 직접 만들었습니다.

sealed class ActResult<out A> {
    abstract val resultType: ResultType

    internal class Success<A>(val data: A) : ActResult<A>() {
        override val resultType = ResultType.SUCCESS
    }

    internal class Failure<A>(
        val errorResponse: ErrorResponse
    ) : ActResult<A>() {
        override val resultType = ResultType.FAILURE
    }

    internal class Unknown<A>(
        val errorResponse: ErrorResponse
    ) : ActResult<A>() {
        override val resultType = ResultType.UNKNOWN
    }

    companion object {
        fun <A> success(data: A): ActResult<A> = Success(data)
        fun <A> failure(errorResponse: ErrorResponse): ActResult<A> = Failure(errorResponse)
        fun <A> unknown(errorResponse: ErrorResponse): ActResult<A> = Unknown(errorResponse)

        operator fun <A> invoke(func: () -> A): ActResult<A> =
            executeExceptionSafeContext(
                run = { Success(func()) },
                failure = { Failure(it) },
                unknown = { Unknown(it) }
            )
    }
}

// ======

inline fun <T> executeExceptionSafeContext(
    run: () -> T,
    failure: (e: ErrorResponse) -> T,
    unknown: (e: ErrorResponse) -> T,
) = try {
    run()
} catch (e: Exception) {
    with (e.toErrorResponse()) {
        // 도메인 상황에 맞게 예외상황을 분기
        when (this.getFailType()) {
            FailType.U -> unknown(this)
            FailType.F -> failure(this)
        }
    }
}

enum class ResultType {
    SUCCESS, FAILURE, UNKNOWN
}

data class ErrorResponse(val ex: Exception)

코틀린의 sealed class를 사용하여 ActResult가 가질 수 있는 상태를 3가지로 구분했습니다.

  1. 성공(Success)일 때는 성공 데이터를 갖게 되고, 나머지 경우에는 ErrorResponse를 가지도록 했습니다.
  2. 예외가 일어날 수 있는 함수 블럭을 인자로 받을 수 있도록 companion object에 invoke 함수를 구현했습니다.
  3. Success, Failure, Unknown 경우를 판단하는 부분은 executeExceptionSafeContext라는 inline function을 만들어 사용하도록 했습니다.

이렇게 감싸진 타임은 함수형 프로그래밍 패러다임에서 자주 사용되는 map과 flatMap 등으로 함수의 결과 상태를 유지하며 안전하게 로직을 이어나갈 수 있습니다. 그리고 Unknown 상태에서 한 번 더 후처리를 해볼 수 있게 recover 함수를 구현했습니다. 마지막으로 onSuccess, onFailure 등 콜백이 각 상태에서 실행될 수 있게 보조 함수들도 구현했습니다.

// Success인 경우만 결과 데이터를 C 타입으로 변환
fun <C> map(
    f: (A) -> C
): ActResult<C> = when (this) {
    is Success -> Success(f(data))
    is Failure -> failure(errorResponse)
    is Unknown -> unknown(errorResponse)
}

// Success인 경우만 결과 데이터를 ActResult<C> 타입으로 변환
fun <C> flatMap(
    f: (A) -> ActResult<C>,
): ActResult<C> = when (this) {
    is Success -> f(data)
    is Failure -> failure(errorResponse)
    is Unknown -> unknown(errorResponse)
}

fun recoverUnknown(
    f: () -> ActResult<A>
): ActResult<A> = when (this) {
    is Success -> success(data)
    is Failure -> failure(errorResponse)
    is Unknown -> f()
}
fun onSuccess(f: (A) -> Unit): ActResult<A> = when (this) {
    is Success -> {
        f(data)
        success(data)
    }
    is Failure -> failure(errorResponse)
    is Unknown -> unknown(errorResponse)
}

fun onFailure(f: (ErrorResponse) -> Unit): ActResult<A> = when (this) {
    is Success -> success(data)
    is Failure -> {
        f(errorResponse)
        this
    }
    is Unknown -> this
}

fun onUnknown(f: (ErrorResponse) -> Unit): ActResult<A> = when (this) {
    is Success -> success(data)
    is Failure -> this
    is Unknown -> {
        f(errorResponse)
        this
    }
}

변환 예제

저희가 구현한 ActResult를 사용하면 네트워크 요청에 관한 3가지 상황을 원하는 흐름으로 쉽게 제어할 수 있습니다. try catch를 여러 뎁스로 작성하지 않아도 되기에 안전하면서도 코드 가독성에도 도움이 된다고 생각합니다.

위의 try catch로 만든 상황을 새로운 데이터 구조로 변경해보았습니다.

class PayService(
    private val paymentAdapter: PaymentAdapter
) {

    fun doPay(request: PayRequest): ActResult<String> =
        ActResult { this.validate(request) }
            .flatMap { // validation이 성공했을 때 flatMap 함수 실행
                paymentAdapter.pay(request)
                  .recoverUnknown { paymentAdapter.pay(request) } // unknown인 경우 재시도 가능
            }
            .onSuccess { markSuccess(it) }
            .onFailure { markFailure(it) }
            .onUnknown { markUnknown(it) }
            .map { payResult -> "SUCCESS" } // 마지막 결과를 응답 형식으로 변환

    private fun validate(request: PayRequest) =
        ActResult { paymentAdapter.validate(request) }
            .onSuccess { println("validate success.") }
            .onFailure { println("validate failed.") }
            .onUnknown { println("validate unknown.") }
}

에러 처리를 잘 알려진 데이터 구조를 사용하지 않고 직접 구현한 구조를 사용하여 아직은 관리 측면이나 개선 및 보완점이 존재합니다. 하지만 앞서 논의된 내용을 직접 구현한 구조 덕분에 쉽게 적용할 수 있었고 앞으로 더욱 편리하게 사용할 수 있도록 발전시킬 예정입니다.

마치며

지금까지 MSA 환경에서 네트워크 오류가 결제 트랜잭션에 어떤 영향을 줄 수 있는지 살펴보았고, 트랜잭션을 보장하기 위한 고민을 나누어보았습니다.

페이상품권 도메인은 각 서비스간 REST API를 사용한 동기 방식을 사용하고 있었고 그에 맞는 적합한 솔루션을 계속 고민해 나가고 있습니다. 비슷한 환경에서 비슷한 고민을 하고 계시다면 이번 내용이 도움이 되었으면 좋겠습니다.😄

larry.charry
larry.charry

카카오페이 Payment Clan에서 서버 개발을 하고 있습니다. 견고한 결제시스템을 만들고자 노력하고 있습니다.

태그