결제 시스템을 만들며 고민했던 부분을 정리해보려 합니다. 현재 결제 시스템은 PG사를 통해 카드 결제를 처리하고 있으며, 구조상 외부 PG사에 종속적으로 동작할 수밖에 없습니다. 즉, 네트워크 이슈나 PG사 장애가 발생하면 사용자가 서버 응답을 기다리다 연결을 끊어 버리는 클라이언트 타임아웃, 서버가 PG사 API 응답을 기다리다 더 이상 대기하지 못하고 끊어 버리는 서버 타임아웃 같은 문제가 발생할 수 있습니다. 특히 결제 요청은 응답만 보면 실패처럼 보이지만 실제로는 결제가 성공했을 수도 있기 때문에, 단순 실패로 처리하지 않고 사후적으로 결제 상태를 확인하고 확정하는 흐름이 필요합니다.
이번 글에서는 타임아웃을 항상 "사용자(브라우저/앱) <-> 서버" 구간을 기준으로 정의합니다.
클라이언트 사이드 타임아웃은 사용자의 브라우저나 앱이 우리 서버에 요청을 보낸 뒤, 자신이 정한 대기 시간 안에 응답을 받지 못해 스스로 요청을 끊는 경우를 말합니다.
서버 사이드 타임아웃은 서버가 사용자의 요청을 처리하는 중, 내부에서 정하나 제한 시간 안에 외부 시스템 호출(PG사 API)의 응답을 받지 못해 요청 처리를 중단하고 종료한 상태를 말합니다. 외부 PG의 내부 사정은 블랙박스로 두고 "응답이 오지 않았다"는 사실을 근거로 이후 처리를 설계해야 합니다.
클라이언트 사이드 타임아웃이 발생했다는 것은 결제가 실패했기보다는 사용자가 서버의 응답을 끝까지 받지 못한 상태에 가깝습니다. 따라서 결과를 확정하지 않고 이후 추가적인 안내가 필요합니다.
일반적으로 2가지 방법을 통해 재시도를 진행합니다.
PENDING 상태가 SUCCESS 또는 FAIL로 확정될 때까지 조회하도록 합니다.클라이언트에서도 해당 재시도를하는 동안 사용자에게는 결제 실패했다고 보여주기 보다는, "결제 상태를 확인중입니다"와 같이 표현해주는게 좋습니다. 이후 결제 상태가 확정되면 사용자에게 보여주거나, 이후 사용자에게 알림을 보내며 결과를 통지하는 방식을 사용합니다. 이를 통해 사용자의 불만을 줄일 수 있습니다.
서버 사이드 타임아웃이 발생하면 서버에서는 "해당 요청을 더 이상 동기적으로 기다릴 수 없다"로 결정한 상태이기 때문에 그 순간의 결제 상태를 저장해둬야 합니다.
일반적으로 서버는 결제 요청을 받는 즉시 DB에 해당 주문에 대한 결제 상태를 PENDING과 같은 중간 상태로 저장하여, 외부 호출이 타임아웃으로 끝나더라도 이 상태를 유지하도록 합니다. 이후, 재처리 플로우에 해당 결제를 넘길 때 다시 PG사 API를 호출해 결제 상태를 조회하거나, 아니면 PG사에서 제공하는 웹훅을 통해 최종 상태를 결정합니다.
네트워크 일시 장애로 인한 타임아웃은 재시도만으로 복구되는 경우가 많기 때문에, 서버에서는 일정 횟수만큼 재시도와 백오프 전략을 함께 사용해 과도한 부하 없이 다시 시도할 수 있도록 합니다. 반대로 PG사 자체에 장애가 발생한 경우에는 재시도를 하더라도 타임아웃이 지속적으로 발생하므로, Circuit Breaker(예: Resilience4j)를 통해 장애가 발생한 PG사로 더 이상 요청이 가지 않도록 막습니다.
다만 특정 PG사의 서킷 브레이커가 열리면 해당 PG로는 결제를 진행할 수 없기 때문에, 전체 결제 서비스가 멈추지 않도록 서킷 브레이커가 열린 경우에만 대체 PG사로 요청이 가도록 구성하는 것이 일반적입니다. 이때 중요한 점은 어느 시점에 대체 PG사로 라우팅해도 안전한지 기준을 명확히 하는 것입니다. 이미 장애가 발생한 PG사에 승인 요청이 실제로 한 번이라도 전달된 이후라면, 뒤늦게 결제가 성공 처리될 가능성을 완전히 배제할 수 없으므로 자동으로 대체 PG사로 재라우팅하지 않고, 이후 재처리 플로우를 타도록 구성하는 편이 안전합니다.
앞서 설명한 PG사 장애 상황에서 결제가 PENDING 상태에 머무르면, "언젠가 반드시 최종 상태를 확정한다"라른 관점에서 재처리 플로우를 설계할 필요가 있습니다. 이때 중요한 목표는 3가지입니다.
가장 단순한 방법은 DB에 남아 있는 PENDING 상태의 결제를 주기적으로 가져와 재처리하는 배치를 두는 방식입니다.

배치 잡은 status = PENDING이고 next_retry_at 시점이 지난 결제들을 조회한 뒤, 기존 결제 정보를 이용해 PG사에 결제 상태를 조회하거나, 아직 결제가 시작되지 않았다면 다시 결제 요청을 보내 최종 상태를 확정합니다.
이때는 재시도 횟수와 간격을 제한해야 합니다. 장애로 인해 실패하는 결제가 많아질수록, 배치가 동일한 결제들을 계속 재시도하면서 PG사에 과도한 트래픽을 유발해 오히려 장애를 심화시킬 수 있기 때문입니다.
트래픽이 크거나 실시간 API 요청에 부담을 줄이고 싶다면, 메시지 큐를 통해 비동기 재시도 플로우를 설계하는 것이 좋습니다. 배치에서는 PENDING 상태의 모든 결제를 가져와 처리하는 반면, 메시지 큐 방식은 "장애가 발생한 PG사에 요청을 보낸 결제"만 큐에 적재해 처리할 수 있습니다.

메시지 큐에 적재된 결제 정보는 워커가 컨슘하면서 결제 상태를 확인하거나 재결재시도하게 됩니다. 만일 결제가 실패하면 NACK를 반환하고, 이후 재시도하도록 합니다. 결제가 성공하면 ACK를 반환하여 이후 대기하고 있는 결제에 대해서 처리할 수 있도록 합니다.
대부분의 PG사에서는 결제 상태에 대해 웹훅으로 알림을 발송해줍니다. 재처리 플로우를 설계할 때 웹훅을 "최종 상태를 알려주는 채널"로 정의하고 앞선 배치나 메시지 큐 방식은 "웹훅이 누락되거나 장애로 지연될 때를 위한 백업 채널"로 정의하는게 좋습니다.
웹훅의 경우 중복 또는 역순으로 발송될 수 있기 때문에 반드시 멱등하게 처리해야합니다.
앞선 내용들은 타임아웃과 장애 상황에서 결제 요청을 최대한 안전하게 처리하기 위한 플로우입니다. 다만, PG사 기준의 거래 내역과 DB에 저장된 내역이 모두 일치하는지 사후 검증 과정이 한 번더 필요합니다. 이 과정을 보통 "PG 대사" 또는 "정산 대사"라고 부릅니다.
타임아웃이나 PG 장애 때문에 한동안 PENDING 상태로 머물렀던 결제, 주 PG에서 실패 후 대체 PG로 처리한 결제, 여러 번 재시도를 거친 결제들은 특히 대사 과정에서 다시 한 번 확인해야 합니다.
실시간 플로우에서는 재시도와 웹훅으로 최대한 정리했더라도, 사후에는 PG 정산 데이터 기준으로 “이 주문이 최종적으로 어디 PG에서 어떤 금액으로 결제되었는지”를 다시 맞춰보면서 이중 결제나 누락된 결제가 없는지 점검해야 합니다.
중복 결제가 발생한 경우에는 동일 주문에 대해 유효한 결제 1건만 남기고, 나머지 결제들은 취소 또는 환불 처리하여 추가로 청구된 금액을 되돌립니다. 반대로 누락된 결제는 두 가지로 나눌 수 있는데, PG사 데이터에는 성공 내역이 있는데 우리 DB에 결제 레코드가 없는 경우에는 PG 데이터를 신뢰해 내부 결제 내역과 정산 정보를 복구하고, PG사 데이터에도 결제 내역이 없다면 기존 결제 정보를 실패 또는 취소 상태로 돌린 뒤 결제 실패에 대해 사용자에게 안내하고 필요하다면 재결제를 유도합니다.