포트원의 V2 시스템은 고객들에게 더 나은 가치를 제공하기 위해 다양한 방면으로 기술적 고도화를 진행했습니다. 기존에 결제 서비스를 운영하며 발생했던 여러 문제들에 대해 새롭게 고민했고, 그 중에는 결제 과정에서 반드시 필요한 웹훅 기능도 있었습니다.
웹훅은 포트원과 고객사 서버의 상태를 동기화하는 매우 중요한 수단인데, 기존 시스템을 운영하며 웹훅과 관련한 몇 가지 문제점을 발견했고 이를 해결하기 위해 많은 기술적 고민을 거쳤습니다. 지금부터, V2 시스템을 설계하며 웹훅과 관련해 고민했던 여러 기술적 문제들과 그 해결 과정을 공유하고자 합니다.
웹훅이란?
웹훅은 하나의 시스템이 다른 시스템에 어떤 이벤트가 발생했음을 알리는 방법입니다.
일반적으로 네트워크 통신은 서비스를 제공받는 클라이언트 앱에서 서비스를 제공하는 서버 앱을 호출하는 방향으로 이루어지지만, 서비스를 이용하는 과정에서 서버 앱에 특정 이벤트가 발생했는지 확인해야 하는 경우가 생길 수 있습니다. 이 경우, 클라이언트는 서버에게 데이터를 조회하는 요청을 규칙적인 주기로 전송하여 확인하고자 하는 특정 이벤트가 발생했는지를 반복적으로 확인해야 하는데 이를 폴링(Polling)이라고 합니다.
그러나 이는 특정 이벤트가 어느 시점에 발생할 지 모르는 상황에서 클라이언트가 서버를 확인하는 작업을 반복하는 것이기 때문에 이벤트가 발생하기 전까지의 무의미한 네트워크 호출 과정에서 인프라 자원의 비효율을 발생시키며, 이벤트 처리에 대한 실시간성 역시 좋지 않은 방식입니다. 따라서 특정 이벤트가 발생한 경우, 서버 측에서 클라이언트 측으로 HTTP 요청을 발송하여 정보를 역방향으로 전달하는 방식을 사용할 수 있고 이를 웹훅이라고 합니다. 그리고 이러한 특성 때문에 웹훅은 Reverse API, Push API 라고도 불립니다.
웹훅을 사용하게 되면 클라이언트는 더 이상 이벤트의 발생 시점을 확인하기 위해 서버를 폴링할 필요가 없어지게 됩니다. 그 대신 서버에게 클라이언트 측 URL을 미리 전달하고, 이벤트가 발생하면 해당 엔드포인트로 서버가 발송한 이벤트 관련 데이터(Payload)를 통해 적절한 이벤트 처리 로직을 실행시킬 수 있습니다. 이는 이벤트가 발생한 시점에 서버가 직접 요청하기 때문에 실시간성 또한 매우 높은 방식이라고 할 수 있습니다.
포트원의 웹훅 사용 방식
포트원은 이러한 웹훅의 특성을 잘 활용하고 있으며 결제 서비스를 제공하는 과정에서 발생하는 여러 이벤트들을 고객사에 전달하고 있습니다.
한 번의 결제 과정에서도 여러 상황에 대한 웹훅을 발송하고 있지만, 비동기 이벤트에 대한 알림을 전달하는 웹훅의 특성을 가장 잘 설명하는 대표적인 사례는 가상계좌 결제에서 찾아볼 수 있습니다.
가상계좌 결제란 사용자에게 일시적으로 할당된 계좌로 결제할 수 있는 방법을 말합니다. 이 방식에서는 사용자가 계좌를 생성한 이후 입금 기한 내에 계좌로 돈을 입금하면 상품 또는 서비스에 대한 결제로 처리됩니다. 이는 전통적인 신용카드 결제와 달리, 고객사 페이지에서 가상계좌가 생성 처리되는 시점 이후에 입금이 비동기적으로 이루어지는 결제 방식입니다.
이러한 결제 방식에서, 고객이 입금을 완료했는지를 실시간으로 확인하기 위해 폴링 방식을 사용하는 것은 비효율적입니다. 입금 시점 전까지 반복적으로 대역폭을 불필요하게 소모하고, 서버에 부담을 주며, 결제 확인에 대한 지연을 초래하기 때문입니다. 반면, 웹훅을 사용하면 입금이 확인되는 즉시 서버에서 고객사의 지정된 웹훅 URL로 알림을 보냄으로써 이러한 비효율을 없앨 수 있습니다.
현재 포트원은 가상계좌 방식 입금 통보 외에도 결제 성공 혹은 실패와 같은 다양한 이벤트에 대해서도 웹훅을 전달하며, 이는 고객사가 결제 상태를 실시간으로 추적하고 관리하는 데에 도움을 줍니다
포트원에서 웹훅은 단순한 알림 기능을 넘어서는 중요한 역할을 하기도 합니다. 예를 들어, 고객이 결제 과정 중에 문제를 겪어 고객사 페이지로 콜백이 오기 전에 결제 창을 비정상적으로 종료하더라도, 포트원 서버에서는 결제 승인 시 웹훅을 발송하기 때문에 고객사 서버에서는 이 결제 건을 적절히 처리할 수 있습니다. 이는 고객의 결제 경험을 개선하고, 잠재적인 매출 손실을 방지하는 데 도움이 됩니다.
결국, 웹훅은 포트원이 제공하는 결제 시스템에서 여러 이벤트를 처리하기 위해 꼭 필요한 요소이며, 이를 통해 고객사는 더 빠르고 효율적인 결제 처리와 함께 향상된 사용자 경험을 제공할 수 있습니다. 포트원은 이러한 웹훅 기술을 통해 결제 서비스의 신뢰성과 효율성을 동시에 높이고 있습니다.
웹훅 시스템에서 발생하는 문제점
웹훅은 효율적인 이벤트 알림 메커니즘을 제공하지만, 그 자체로 완벽하지는 않습니다. 가장 큰 문제 중 하나는 웹훅 호출이 실패할 경우 발생합니다.
웹훅 호출의 실패는 고객사의 결제 데이터와 이벤트 처리 로직의 누락으로 이어질 수 있기 때문에 적절한 처리가 필요합니다. 이 때 웹훅의 재시도 정책이 그에 대한 좋은 방법이 될 수 있습니다. 단순히 한 번의 네트워크 호출 실패로 인해 전체 서비스의 비즈니스 로직을 실패로 처리하거나 대체 로직(fallback)으로 전환하는 것은, 네트워크 호출을 재시도하는 것과 비교하여 더 큰 리소스 낭비가 될 수 있기 때문입니다.
또한 실제로 웹훅이 실패하는 대부분의 원인은, 웹훅을 받는 시스템에 큰 장애가 발생했거나 명확한 비즈니스 로직상의 실패 응답을 주는 경우보다, 네트워크의 일시적인 장애가 있거나 간헐적인 실패 응답으로 인해 발생하는 경우가 더 많기 때문에 재시도 정책은 이러한 일시적 문제를 극복하고 웹훅이 중요한 데이터를 성공적으로 전송하는 데에 큰 도움을 줍니다.
그러나 단순히 재시도를 하는 것이 문제를 해결하지는 않습니다. 이를테면 네트워크 문제로 인한 시간 초과가 발생한 경우, 문제가 해결될 때까지 지속적으로 웹훅이 실패할 수 있습니다. 그리고 이러한 상황에서 재시도를 일정 간격 없이 바로 실행한다면, 이는 문제가 있는 네트워크에 더 큰 부담을 주고 오히려 상황을 악화시킬 수 있습니다. 만약 서버 또는 중간 네트워크에 일시적인 과부하가 발생하여 요청이 지연되고 있는 상황에서, 모든 클라이언트가 연속적으로 최대 3회 재시도를 한다면 네트워크 트래픽은 예상치 못하게 최대 3배까지 증가할 수 있습니다.
위와 같은 문제를 방지하기 위해 재시도의 적절한 간격을 결정하기 위한 정책이 필요합니다. 그리고 재시도 간격에 대한 정책을 결정하더라도, 이러한 재시도 정책을 기술적으로 구현하여 원하는 웹훅 발송 시점에 적절히 재시도를 실행해야 하고, 재시도 이벤트가 시스템의 변경이나 배포 과정에서도 누락되지 않도록 안전하게 관리되는 시스템을 설계해야 합니다.
이제부터, 포트원 V2가 어떻게 이러한 문제들을 해결하고 안전한 웹훅 시스템을 설계했는지 살펴보겠습니다.
문제 해결, 재시도 시스템 고도화
포트원 V2는 웹훅 시스템의 이러한 문제점들을 해결하기 위해 웹훅 재시도 시스템을 고도화하는 데에 중점을 두었습니다. 그리고 이 과정에서 구현한 몇 가지의 핵심 전략들을 소개하고자 합니다.
1. Exponential Backoff
Exponential Backoff는 재시도 간의 대기 시간을 지수함수(exponential)의 형태로 점차 증가시키는 방법입니다.
예를 들어, 첫 번째 시도를 위한 대기 시간이 1분이면, 두 번째 시도의 대기 시간은 2분, 세 번째 시도는 4분이 됩니다. 이렇게 함으로써, 네트워크에 갑작스럽게 부하를 주는 것을 방지하고, 동시에 많은 요청이 집중되는 것을 피할 수 있습니다.
만약 모든 재시도 요청이 동일한 시간 간격으로 이루어진다면, 장애 시간동안 재시도 해야할 요청들이 누적됨에 따라 네트워크 트래픽이 기하급수적으로 증가하게 됩니다. 따라서 네트워크 트래픽이 적절히 분산되도록 만들기 위해 재시도 간격 또한 기하급수적(exponential)으로 증가시킨다고 생각하면 쉽게 이해할 수 있습니다. 이를 통해 장애 시간 동안 재시도 해야 할 요청 수가 증가하더라도 단위 시간 당 네트워크 트래픽이 고루 분산되어 웹훅을 수신하는 서버의 부담을 줄일 수 있습니다.
아래는 현재 Scala로 구현된 V2 Core 컴포넌트에서 재시도 간격을 계산하는 코드입니다. 첫 번째 웹훅 시도는 바로 진행하기 때문에 0 으로 시작하며 이후부터는 동일한 웹훅에 대한 재시도 횟수가 증가함에 따라 1 → 2 → 4 .. → 256 까지 지수함수의 형태로 증가하도록 구현되어 있습니다. 이 때 단위는 분이지만 함수를 호출하는 코드에서 사용하기 위해 초 단위로 변경하여 응답하고 있습니다.
def calculateDelaySeconds: Duration = {
"""
현재 시도 횟수에 따른 지연 시간(초) 계산
시도 횟수에 따라 0 -> 1 -> 4 -> 16 -> 64 -> 256 순으로 간격 설정
"""
if (currentExecutionCount == 1)
// 첫 번째 시도는 backoff 없이 바로 시도
return Duration.fromSeconds(0.minute.toSeconds)
else
// 두 번째 시도부터 4의 지수 형태로 증가
return Duration.fromSeconds(
math.pow(4, currentExecutionCount - 2).toInt.minute.toSeconds
)
}
우리는 Exponential Backoff 전략을 통해 장애 시간 동안 누적되는 재시도 요청들이 단위 시간 당 네트워크 트래픽을 기하급수적으로 증가시키는 현상을 막을 수 있었습니다. 그러나, 이 전략을 사용하더라도 여전히 동일한 시간대에 발생한 모든 요청은 동일한 시간 간격으로 함께 수행된다는 한계가 존재합니다. 따라서 우리는 이러한 한계를 극복하기 위해 Jitter 라는 또 다른 전략을 함께 사용하여 문제를 해결합니다.
2. Jitter
Jitter는 정해진 시간 간격에 무작위성(randomness)을 추가하여, 모든 재시도가 동시에 이루어지지 않도록 합니다. 이는 동일한 시간대에 실패한 각 재시도 이벤트들이 서로 다른 시간에 재시도하도록 만들고 결국, 네트워크 트래픽이 골고루 분산되도록 만드는 효과를 가져옵니다. 이 또한 Exponential Backoff 전략과 함께 트래픽 급증이나 네트워크 오버로드로 인한 장애를 방지하는 데 도움이 됩니다.
아래 코드는 위에서 보았던 재시도 간격 계산 함수 calculateDelaySeconds
에 jittered 함수를 추가로
사용한 코드입니다. 내용을 정리하면 Exponential Backoff 전략을 통해 계산된 시간 간격(delay)에 0부터
1사이의 무작위 숫자를 곱해서 확률적으로 지연 시간을 결정하는 방식입니다.
이 때 min, max 라는 변수는 무작위 결과가 나올 수 있는 범위를 결정하는 요소라고 볼 수 있습니다. Jitter 방식은 다시 Full Jitter, Equal Jitter 로 나눠지는데 이는 무작위 결과가 산출되는 범위를 전체 범위로 할 것인지 아니면 최소 지연 시간을 결정하고 남은 범위로 할 것인지의 차이를 가집니다. 예를 들면 현재 재시도 간격이 4분으로 정해졌다면, Full Jitter의 경우 0-4분 사이에서 무작위로 선택하지만 Equal Jitter의 경우 최소 지연 시간을 2분이라고 설정한 뒤 2-4분 사이에서 무작위로 선택하는 방식입니다.
두 가지 방식 모두 장단점이 있으나 현재 포트원은 성능상 네트워크 트래픽을 더 고루 분산시키는 Full Jitter 방식을 사용하고 있으며 이에 따라 min 값을 0으로 사용하고 있습니다.
(만약 이 주제에 대해 더 자세하게 알아보고 싶으시면 AWS Architecture Blog 에서 소개한 글을 읽어보셔도 좋습니다)
def calculateDelaySeconds: Duration = {
"""
무작위 시간 추가 (jitter)
"""
if (currentExecutionCount == 1)
// 첫 번째 시도는 backoff 없이 바로 시도
return Duration.fromSeconds(
jittered(
0.minute.toSeconds
).toLong
)
else
// 두 번째 시도부터 4의 지수 형태로 증가
return Duration.fromSeconds(
jittered(
math.pow(4, currentExecutionCount - 2).toInt.minute.toSeconds
).toLong
)
}
private def jittered(delay: Long) = {
// min, max 를 설정하여 무작위로 정해질 시간의 범위를 조정
val min = 0.0
val max = 1.0
val random = scala.util.Random.nextDouble()
delay * min * (1 - random) + delay * max * random
}
앞서 살펴본 Exponential Backoff, Jitter 전략은 단순히 재시도 간격을 늘리는 것을 넘어서, 전체적인 네트워크 부하를 전략적으로 분산시키고 이를 통해 고객사의 장애 상황에 최대한 영향을 주지 않도록 시스템을 설계합니다.
우리는 앞서 세운 전략들로 적절한 재시도 간격을 결정할 수 있게 되었습니다. 그렇다면, 이제 기술적으로 이러한 정책을 구현할 수 있는 시스템이 필요합니다. 결정된 시간이 지난 후 어떻게 원하는 시점에 실제 재시도 요청이 이루어지도록 할 수 있을까요? 그리고 만약 새로운 버전이 배포되면서 서버가 종료되었을 때에도 재시도 해야 할 이벤트들이 누락되지 않도록 하려면 어떻게 해야할까요?
3. Message Queue - SQS
포트원 V2에서는 위와 같은 요구사항을 달성하기 위해 메시지 큐(SQS)를 활용하여 시스템을 설계했습니다.
물론 재시도 간격을 기술적으로 구현하기 위한 방법에는 여러 가지가 있겠지만, 가장 처음 생각할 수 있는 단순한 방법은 외부 인프라를 이용하지 않고 언어나 라이브러리에서 제공하는 타이머를 이용하는 방법입니다. 그러나 만약, 서버가 언어 혹은 라이브러리에서 지원하는 타이머와 비동기 구현의 조합을 활용하여 직접 애플리케이션 메모리에 재시도 할 이벤트들을 보유하게 되면, 새로운 버전의 배포 시 메모리에 저장된 재시도 이벤트들이 모두 손실되는 문제가 발생할 수 있습니다. 이는 웹훅 데이터의 누락으로 이어질 수 있어, 안정적인 시스템 운영에 큰 장애가 됩니다. 이를 해결하기 위해, 재시도 이벤트들을 서버가 아닌 외부 인프라에 저장할 필요가 있었습니다.
이 때, 데이터를 저장할 수 있는 외부 인프라에는 다양한 종류가 있지만 우리의 시나리오는 재시도 할 이벤트들을 일회성으로 등록하고 처리하는 것이었기 때문에 메시지 큐를 사용하는 것이 가장 적합하다고 판단했습니다. 그리고 Kafka, Kinesis, RabbitMQ, SQS 등 여러 종류의 메시지 큐 중에서도 SQS를 선택했는데, 그 이유는 다음과 같습니다.
Delay Queue 기능 자체 지원
Exponential Backoff와 Jitter와 같은 기법을 적용하기 위해 메시지 처리를 일정 시간 지연시킬 필요가 있습니다. 만약 메시지가 지연되지 않고 반복적으로 애플리케이션에 짧은 주기로 도달한다면 인프라 비용이 상당히 비효율적으로 소모될 것이기 때문입니다.
SQS는 큐 자체적으로 이러한 지연 처리 기능을 지원하며, 각 메시지의 생산 시점에 원하는 지연 시간을 매 번 설정할 수 있는 기능을 제공합니다. 이는 결정된 재시도 간격의 기술적 구현을 별도의 로직이나 커스터마이징 없이도 SQS 기능만으로 간단하게 달성할 수 있게 해줍니다.
FIFO 정책을 강제하지 않는 Standard 모드 지원
모든 재시도 이벤트들은 큐에 전송된 순서에 관계 없이 서로 다른 지연시간을 가지고 독립적으로 처리되어야 합니다.
예를 들어, 먼저 전송된 메시지는 16분 후에 처리되어야 하지만 이후에 전송된 다른 메시지는 1분의 지연시간을 가질 수 있습니다. 이 때, FIFO 정책이 엄격하게 지켜진다면 어느 메시지의 지연 시간에 맞추더라도 두 메시지의 지연 처리 요구사항을 모두 만족시키는 것은 불가능합니다.
그러나 SQS는 Standard와 FIFO 두 가지 종류를 가지고 있는데, Standard 모드에서는 FIFO(First-In, First-Out) 정책을 강제하지 않습니다. 이는 각 메시지가 서로 다른 지연시간을 가질 수 있게 하여, 메시지들이 독립적으로 운영될 수 있도록 해야 한다는 요구사항을 쉽게 만족시킵니다.
Dead Letter Queue(DLQ)를 통한 실패 관리
최대 재시도 횟수를 초과한 메시지는 DLQ(Dead Letter Queue)로 전송됩니다. 이를 통해 실패한 메시지에 대한 검사와 관리가 가능해져, 시스템의 안정성과 신뢰성을 향상시킬 수 있습니다.
AWS Fully Managed Service의 이점
SQS는 AWS에서 제공하는 완전 관리형 서비스입니다. 따라서 추가적인 인프라 관리를 필요로 하는 다른 메시지 큐들과 달리 시스템에 쉽게 통합할 수 있다는 장점을 가지고 있습니다.
즉, SQS를 통해 앞서 설계한 우리의 재시도 정책을 기술적으로 간단하게 구현하면서도 애플리케이션의 변경이나 시스템의 장애에도 손실을 최소화할 수 있는 웹훅 시스템을 구축할 수 있었습니다.
최종 아키텍처
최종적으로, 웹훅 시스템의 아키텍처를 그림으로 표현하면 다음과 같습니다.
Transaction Service는 V2 시스템에서 결제의 코어 기능을 담당하는 컴포넌트이며 실질적으로 웹훅을 발송하는 주체입니다. 그리고 Transaction Webhook Service 는 SQS를 구독하여 웹훅 재전송을 위한 단순한 로직을 반복하는 작은 컴포넌트입니다. 이 컴포넌트가 하는 작업은 SQS에 처리할 메시지가 있는지 지속적으로 확인하고, 만약 메시지가 있으면 Transaction Service로 웹훅 재전송을 요청한 뒤 메시지를 삭제하는 것이 전부입니다. 즉, 웹훅의 발송을 담당하는 컴포넌트와 메시지 큐를 폴링하여 전달하는 컴포넌트가 협업하는 구조입니다.
이 시스템이 동작하는 시나리오를 간단히 설명하면 다음과 같습니다.
-
고객사 서버로 최초 웹훅을 발송합니다.
-
고객사로부터 응답을 받습니다. 이 때, 정상 응답을 받은 경우 웹훅 프로세스는 완료됩니다. 하지만 실패 응답을 받았을 경우 재시도 프로세스가 시작됩니다.
-
재시도 할 웹훅에 대한 정보를 담아 SQS에 메시지를 전송합니다. 이 때, SQS의 지연 발송 기능을 이용하여 재시도 간격 이후에 메시지가 수신될 수 있도록 합니다.
-
정해진 지연 시간이 지나면 Transaction Webhook Service가 메시지를 수신하고, 재시도 로직을 시작합니다.
-
Transaction Service로 웹훅을 재발송 하라는 요청을 전송합니다. Transaction Service는 간단한 Validation 이후 고객사 서버로 웹훅을 재발송합니다. 이 때, 성공하면 프로세스가 완료되며 다시 실패한 경우 3번 로직으로 돌아갑니다. 그리고 Transaction Webhook Service로 처리가 완료되었다는 응답을 전송합니다.
-
웹훅 재발송에 대한 응답을 받습니다.
-
처리한 메시지를 삭제합니다. 웹훅의 성공 여부와 관계없이 정상적으로 응답이 오기만 했다면 해당 재발송 이벤트는 이미 처리된 것이기 때문에 메시지를 삭제해도 문제 없습니다. 만약 웹훅이 실패했다면, 다음 재발송 이벤트를 위한 SQS 메시지는 Transaction Service가 새롭게 전송했을 것입니다.
포트원 V2 코어에서는 위와 같은 아키텍처로 앞서 설명한 요구사항들을 모두 만족시키는 웹훅 시스템을 구현할 수 있었습니다.
마무리
지금까지 포트원의 결제 기능에서 핵심적인 역할을 하는 웹훅에 대해 알아보았으며, V2 시스템에서 안전하고 효과적으로 웹훅을 제공하기 위해 시도한 여러 가지 고도화 방법들을 탐구해보았습니다.
V2는 이러한 웹훅 시스템 고도화를 통해 고객의 결제 데이터 손실을 최소화하고, 고객사 서버의 장애 상황으로 인한 웹훅 재시도 상황에서도 트래픽 부하를 매우 효율적으로 관리할 수 있게 되었습니다.
이 글을 통해 소개한 웹훅 시스템의 고도화 이외에도, 포트원의 V2 시스템은 다양한 기술적 도전과 혁신을 통해 계속해서 발전하고 있으며, 앞으로 더 많은 주제와 기술적 성과들을 여러분과 공유할 예정입니다. 포트원의 기술 여정에 여러분을 초대하며, 다음 주제에 대한 기대감을 가져주시길 부탁드립니다. 저희는 여러분과 함께 성장하며, 더 나은 서비스를 제공하기 위해 끊임없이 노력할 것입니다. 다음 주제에서 뵙겠습니다!