본문 바로가기
회사

실제 PG 결제창에서 발생한 중복 결제 문제 해결기

by 개발 어렵다. 2025. 2. 17.

 기존 결제창에서 가장 큰 문제는 중복 결제결제 데이터 누락(가맹점 기준)이었다.

  • 중복 결제란 동일한 결제가 두 번 처리되는 경우를 의미한다.
  • 결제 데이터 누락은 결제 완료 후, 우리 데이터베이스에는 결제 정보가 저장되었지만, 가맹점의 데이터베이스에는 저장되지 않아 서버 간 데이터 정합성이 맞지 않는 문제를 뜻한다.

데이터 정합성 문제 원인

 

 문제의 원인을 정확히 파악하려면, 결제 과정에서 인증과 승인 단계가 어떻게 이루어지는지 이해해야 한다. 일반 사용자들은 결제 시 단순히 금액이 출금된다고 생각하지만, PG 시스템에서는 반드시 인증 단계와 승인 단계를 거친다.

  • 인증: 카드 소유자가 본인임을 확인하는 과정
  • 승인: 결제 금액이 실제로 출금되는 과정

 인증 단계는 페이북, KB Pay 등 카드사에서 제공하는 앱을 통해 진행된다. 기존 결제창의 경우, 인증이 완료되면 "인증 완료" 화면을 표시하고, 사용자가 다음 버튼을 누르면 승인 단계로 넘어간다.

 

 승인이 완료되면 "승인 결과" 화면을 보여주고, 사용자가 닫기 버튼을 누르면 가맹점이 지정한 URL 페이지로 이동한다. 이때, URL의 쿼리 파라미터를 통해 결제 데이터를 가맹점 서버에 저장하는 방식이 사용된다.

 

 문제의 원인은 닫기 버튼에 있다. 대부분의 구매자는 버튼이 보이면 자연스럽게 누르지만, 일부는 누르지 않거나 결제 완료 화면을 마지막 단계로 착각하고 탭을 닫거나 브라우저를 종료하는 경우가 있다. 이러한 상황이 발생하면 가맹점 서버로 결제 데이터가 정상적으로 전달되지 않아 데이터 정합성 문제가 생긴다.

 

 또한, 네트워크 환경에 따라 중복 결제 데이터가 저장되는 문제도 발생할 수 있다. 예를 들어,

  1. 구매자가 닫기 버튼을 눌러 가맹점 서버에 결제 데이터를 전송했지만,
  2. 네트워크 불안정으로 응답을 받지 못한 상태에서 연결이 재수립되며 IP가 변경되었고,
  3. 구매자가 닫기 버튼을 다시 눌러 가맹점 서버로 같은 결제 요청이 다시 전송되는 경우다.

 이로 인해, 가맹점 서버에는 같은 결제 건이 2번 저장되어 우리 서버와 가맹점 서버 간의 데이터 정합성이 깨지는 문제가 발생한다.


데이터 정합성 해결 과정 - 1

 

 기존에도 이 문제를 인지하고 있었기 때문에, 결제가 완료되면 가맹점 서버로 웹훅(노티)을 전송하는 방식을 적용했다.

 

 하지만 일부 가맹점에서는 닫기 버튼을 눌렀을 때 전달되는 결제 데이터를 메인 데이터로 간주하고, 이후 웹훅으로 전달된 데이터를 비교하여 결제 데이터를 업데이트하는 방식을 사용한다.

 

 이러한 로직을 가진 가맹점의 경우, 닫기 버튼을 누르지 않아 결제 데이터가 저장되지 않으면, 이후 웹훅이 도착하더라도 기존 데이터가 없기 때문에 업데이트 작업이 수행되지 않는 문제가 발생한다.


데이터 정합성 해결 과정 - 2

 

 토스페이먼츠를 통해 결제창을 연동할 경우, successUrl을 필수로 지정해야 한다.

 

 successUrl은 인증이 완료된 후, 결제를 식별할 수 있는 키 값과 주문 번호를 URL의 쿼리 파라미터로 전달하는 역할을 한다. 이후, 가맹점의 successUrl에서 토스페이먼츠의 승인 API를 호출하여 결제를 최종 완료한다.

 

 즉, 토스페이먼츠는 인증과 승인 단계를 모두 자체 서버에서 처리하는 것이 아니라, 인증 후 가맹점 페이지(successUrl)를 거치도록 설계하여 결제 과정을 두 단계(인증 단계 → 승인 단계)로 분리했다.

 

 이에 따라, 우리도 인증과 승인 단계를 분리하기로 결정했다.

 

 인증이 완료되면, 가맹점 URL로 결제 토큰, 상품 주문번호, 결제 금액 정보를 URL 쿼리 스트링을 통해 전달하고, 가맹점이 이를 기반으로 우리 서버의 승인 API를 호출하도록 구성했다.

 

 즉, 가맹점 서버에서 직접 승인 API를 호출하는 방식으로 변경하여 결제 프로세스를 보다 명확하게 분리했다.

 

 단계를 분리함으로써 데이터 정합성 문제를 대부분 해결할 수 있었다.

 

 그러나 승인 API 호출 과정을 아래와 같이 구성하면, 다시 데이터 정합성 문제가 발생할 가능성이 있다.

  1. 가맹점 페이지에서 인증 완료 데이터를 받아 즉시 승인 API 호출
  2. 승인 API 응답 데이터를 화면에 표시한 후, 해당 데이터를 다시 가맹점 API로 전달

 이 방식에서는 앞서 언급한 네트워크 불안정 문제로 인해 연결이 재수립되면 응답을 받지 못하는 상황이 발생할 수 있다. 따라서, 페이지에서 직접 승인 API를 호출하는 대신, 먼저 가맹점이 만든 API를 호출하고, 해당 서버에서 승인 API를 Server-to-Server 방식으로 호출하는 구조가 보다 안전하다. 그리고 이 구조를 결제 연동 방식 문서에 그림으로 정리하여 추가했다.


 중복 결제 원인

 

 데이터 정합성 문제는 위 과정을 통해 해결했다. 이제 남은 핵심 과제는 중복 결제 문제다.

 

 중복 결제가 발생하는 주요 지점은 인증 완료 화면이다. 기존 결제창에서는 거래 ID 값을 다음 버튼을 누를 때 생성하도록 되어 있었다. 이로 인해, 사용자가 빠르게 두 번 클릭하면 중복 결제가 발생할 수 있었다.

 

 그러나, 엄밀히 말하면 거래 ID는 하나의 결제를 식별하는 값이므로, 기술적으로 보면 이는 중복 결제가 아닌 다른 거래 요청이 두 번 처리되는 문제라고 볼 수 있다.

 

 또한, "중복 결제"라는 표현보다는 "하나의 상품에 대한 중복 결제"가 더 정확한 표현이다. 결제창을 호출할 때, 가맹점이 직접 생성한 상품 번호를 포함해야 하므로, 궁극적으로 해결해야 할 문제는 이 상품 번호를 기준으로 중복 결제를 방지하는 것이다.


중복 결제 해결 과정

 

 기존 서버와 데이터베이스만을 사용해야 했기 때문에, 추가적인 서버를 도입하는 것은 어려웠다. 따라서 현재 환경 내에서 중복 결제를 방지할 수 있는 방안을 고민했다.

 

 우선 인증용 테이블승인용 테이블 두 개를 만들었다. 승인용 테이블에는 상품 주문번호와 가맹점 ID를 복합 PK(Primary Key)로 설정했다. 이를 통해 다중 서버 환경에서 동일한 요청이 들어올 경우, 중복 데이터 삽입 시 DuplicatedKeyException이 발생하도록 설정하여, 이를 이용해 중복 결제를 감지할 수 있도록 했다.

 

 추가적으로, 승인용 테이블에 준비, 승인 진행 중, 승인 완료, 승인 실패네 가지 상태를 저장하는 컬럼을 추가했다. 그리고 승인 요청 시 다음과 같은 쿼리를 사용했다.

UPDATE 승인용_테이블 
SET status = '승인 진행 중' 
WHERE 상점 ID = '상점 ID' and 상품 주문 번호 = '상품 주문 번호' and status = '준비'

 

 UPDATE 문은 기본적으로 원자성을 보장한다. 즉, 하나의 트랜잭션 내에서 해당 쿼리가 완전히 실행되거나 전혀 실행되지 않게 된다. 이 과정에서 데이터베이스는 Row-Level Locking을 활용하여 특정 행에 락을 걸고, 다른 트랜잭션이 동일한 행을 동시에 수정하지 못하게 한다

 

 따라서 여러 서버에서 동시에 동일한 조건으로 UPDATE 요청이 들어오더라도, 먼저 실행된 트랜잭션이 락을 획득하여 업데이트를 진행하고, 나머지 트랜잭션은 업데이트된 행의 수가 0으로 반환되므로 중복 요청을 감지할 수 있었다. 또한, PK를 조건으로 사용하여 유일한 행을 보장함으로써 데드락 발생 확률이 현저히 낮아졌으며, 성능도 뛰어났다.

 

 이 쿼리를 실행하면 업데이트된 행의 개수를 확인할 수 있다.

  • 반환 값이 1 -> 해당 서버에서 승인 요청을 진행
  • 반환 값이 0 -> 다른 서버에서 이미 승인 요청이 진행 중이므로 예외를 반환

 이러한 방식으로 다중 서버 환경에서도 중복 결제를 방지할 수 있었다.


트랜잭션 고립 수준 설정

 

 실제로 UPDATE 문은 row-level locking을 통해 보호되므로, 단일 UPDATE 연산에 한해서는 어떤 고립 수준을 사용하더라도 중복 업데이트 자체는 방지된다.

 

 하지만 현재 트랜잭션의 기본 고립 수준은 REPEATABLE_READ 로 설정했다. 그 이유는 UPDATE 전후에 같은 행을 여러 번 읽어야 하는 로직이 추가될 가능성이 있기 때문이다.

 

만약 트랜잭션의 고립 수준을 READ_COMMITTED 로 설정할 경우, UPDATE 수행 전후의 SELECT 결과가 달라질 수 있다. 반면, REPEATABLE_READ 를 사용하면 트랜잭션이 시작된 시점의 데이터를 고정할 수 있어 일관된 데이터를 보장할 수 있다.


재시도(Retry) 로직 설계

 

 승인 상태를 변경하는 UPDATE 로직은 가장 중요한 핵심 로직이다. 하지만 DB 베타락을 사용했기 때문에 여러 트랜잭션이 몰리면 성능 이슈가 발생할 수 있기 때문에 LOCK_TIMEOUT을 설정해 놓았다. 따라서, 예외 발생 시 안정적으로 재시도(Retry)할 수 있도록 설계했다.

 

재시도는 다음과 같은 상황에서 한 번만 수행하도록 제한했다.(2번은 발생한 적이 없고 1, 3번만 간혹 발생)

  1. 커넥션 획득 중 예외 발생
  2. UPDATE 실행 중 오류 발생
  3. 타임아웃 발생

 한 번의 재시도 간격은 500ms 로 설정했다. 이는 내부 승인 처리 시간이 약 1초 정도 소요되므로, 전체 응답 시간을 1.5초 이내로 유지하기 위한 고려 사항이었다.

 

🚨 왜 단 한 번만 재시도할까?

 

 서버가 여러 차례 재시도를 시도하면 응답 시간이 길어져 사용자 경험(UX)이 저하될 가능성이 높다. 따라서 응답 속도를 보장하면서도, 일시적인 오류를 완화할 수 있도록 단 한 번의 재시도를 허용하고, 500ms의 딜레이를 적용했다.

 

 또한, 재시도 로직을 여러 곳에서 일관되게 적용할 수 있도록 커스텀 어노테이션(Custom Annotation)을 만들어 관리했다.

추가적으로, 재시도에도 실패할 경우 "재시도 요망"이라는 응답을 가맹점에 반환하여 추가적인 처리를 할 수 있도록 했다.


 그리고 내부적으로는 실패 이력을 데이터베이스에 저장하여 후속 조치를 취할 수 있도록 설계했다.

 

 상태를 바꾸는 UPDATE 로직은 정말 중요한 로직이다. 그래서 상태를 변경하는 로직은 예외 발생 시 안정적으로 재시도 할 수 있도록 만들었다.


만약 추가적인 자원이 있었다면?

 

 중복 결제를 방지하기 위해 데이터베이스를 활용했지만, 사실상 데이터베이스 기반의 분산 락(Distributed Lock) 을 구현한 셈이다. 만약 추가적인 자원 이 있었다면, Redis를 활용한 분산 락 을 구현해볼 수도 있었을 것이다.

 

 Redis는 단일 처리 스레드(single-threaded) 로 동작하기 때문에, 가맹점 ID와 주문번호를 키 값(Key) 으로 생성한 후, 상태 정보를 저장하는 방식으로 접근할 수 있다. 그리고 다중 서버 환경에서 특정 서버만 승인 절차를 진행할 수 있도록 하기 위해, 분산 락을 활용한 상태 변경 로직 을 추가할 것이다.

 

 예를 들면 아래와 같이 할 수 있다.

  1. Redis의 SETNX 사용해 중복 요청을 차단한다.
  2. 상태를 변경할 때 Redis의 TTL을 설정하여 특정 시간이 지나면 자동으로 락이 해제되도록 한다.
  3. 상태를 변경한 서버만 승인 요청을 진행할 수 있도록 구성하고, 나머지 서버에서는 예외를 발생시킨다.

이 방식은 데이터베이스 락보다 더 가벼운 방식으로 중복 요청을 방지할 수 있으며, 높은 트래픽에서도 성능을 유지할 수 있는 장점이 있다.