Post

이중 결제 (Race Condition)

로그상 정상으로 보였던 이중 과금의 25초 레이스를 찾아, 조건부 UPDATE로 결제 선점을 원자화한 기록

이중 결제 (Race Condition)

문제 상황

학부모용 앱의 간편결제에서 같은 수강료 건이 두 번 결제됐다는 CS 1건 접수

  • 같은 건을 골라 결제 버튼을 연타하고, 화면을 새로고침하며 다시 결제해 봤지만 재현되지 않았다.
  • 발생 건의 요청 로그를 보면 두 요청 모두 미결제 확인을 통과했고, PG 승인에 성공했고, 정상적으로 완료 처리됐다.
  • 어느 한 줄도 틀린 요청이 없는데 결과만 틀려 있었다.

에러가 없는 버그였으므로, 특정 코드 라인이 아니라 구조를 의심하기 시작했다.


결제 흐름

간편결제 파이프라인을 처음부터 따라가며 정리

결제 버튼 → 결제방식 선택 → 카드 선택·확인 → 서버 결제 요청 → 인증·검증 → 결제수단(빌키) 확보 → 건별 PG 승인 요청 → 결제상태를 완료로 변경

서버는 결제 요청을 받으면 건별로 루프를 돌며 세 단계를 처리하고 있었다.

  1. 결제상태 확인 : 결제상태가 미결제인지 SELECT 한다.
  2. PG 승인 요청 : 외부 API를 호출한다. 최대 25초가 걸린다.
  3. 완료 처리 : 결제상태를 완료로 UPDATE 한다.

각 단계만 보면 문제가 없고, 문제는 단계 사이의 시간이었다.


원인 : 25초의 틈

미결제 확인 시점과 완료 마킹 시점 사이에 PG 승인 시간만큼의 간격이 존재

  • PG 요청의 타임아웃은 최대 25초로 잡혀 있었다.
  • 여러 건을 한 번에 결제하면 이 25초 구간이 건마다 순차적으로 반복된다.
  • 실제 발생 건에서는 66건을 결제하는 약 3분 동안 이 구간이 이어졌고, 그 사이 사용자가 앱을 이탈했다 재진입해 다시 결제했다.

1
2
3
4
5
t1  요청 A : 결제상태 확인, 미결제 통과
t2  요청 B : 결제상태 확인, 똑같이 미결제 통과   (A가 아직 완료 처리 전)
t3  요청 A : PG 승인 성공
t4  요청 B : PG 승인 성공                        (이중 과금)
t5  A, B 모두 완료 처리
  • 전형적인 check-then-act 레이스 컨디션이다. 주문번호도 요청마다 새로 생성되니 PG 입장에서는 둘 다 정상 거래다.
  • 로그에 에러가 없었던 이유도 여기에 있다. 각 요청은 정상 동작했고, 잘못된 것은 확인과 완료 처리가 원자적이지 않은 구조였다.
  • 재현이 안 됐던 이유도 설명된다. 두 요청이 25초 구간 중 하나에서 겹쳐야 발생하는데, 그런 겹침은 다양한 경로로 만들어지고 있었다.
    • 확인 버튼 더블탭 : 확인창의 결제 버튼에 재클릭 방지가 없었다.
    • 응답 지연 후 재시도 : PG가 오래 걸리면 실패로 인식한 사용자가 다시 요청한다.
    • 가족 다중 기기 : 엄마 폰과 아빠 폰에서 같은 미납 건을 동시에 결제한다.
    • 결제 경로 교차 : 일반결제 경로와 간편결제 경로가 서로의 진행 여부를 모른다.
  • 프론트의 결제 핸들러에는 로딩 가드가 있었다. 하지만 API를 호출하는 확인창 버튼은 공용 컴포넌트라 가드 밖에 있었고, 프론트 방어로는 다중 기기 케이스를 막을 수 없다.

방어선은 서버에 있어야 했다.


해결책 검토

서버에서 임계구역을 보호하는 방법 세 가지 비교

  • SELECT … FOR UPDATE (비관적 잠금) : 두 가지 이유로 제외했다.
    • 이 서버는 트랜잭션 없이 autocommit으로 동작하므로 FOR UPDATE의 잠금이 문장 종료와 함께 즉시 풀린다. 쓰려면 트랜잭션 도입부터 해야 한다.
    • 임계구역 한가운데에 최대 25초짜리 외부 HTTP 호출이 있다. DB 행 잠금을 외부 호출 내내 붙들면 경합하는 다른 요청은 락 타임아웃까지 블로킹된다.
  • Redis 분산 락 : 동작은 하겠지만 이 문제 하나 때문에 인프라를 추가하는 것은 과했다. DB로 해결되는지부터 보기로 했다.
  • 조건부 UPDATE + 변경 행 수 판정 : 확인과 선점을 UPDATE 한 문장으로 합치는 방식이다. 최종 선택했다.
 비관적 잠금Redis 분산 락조건부 UPDATE
잠금 보유 시간외부 호출 내내 (최대 25초)TTL 동안밀리초 (문장 실행 동안)
경합 요청의 동작블로킹 대기즉시 실패즉시 실패
추가 필요 사항트랜잭션 도입Redis 인프라컬럼 1개

외부 호출을 품은 임계구역에는 행 잠금 대신 논리 플래그 선점이 맞았다.


조건부 UPDATE로 선점 원자화

확인과 선점을 DB가 원자적으로 실행하는 UPDATE 한 문장으로 통합

1
2
3
4
5
UPDATE 결제건
   SET 진행중락 = 현재시각
 WHERE 해당_결제건
   AND 결제상태 = '미결제'
   AND (진행중락이 없음 OR 진행중락이 5분 경과)   -- TTL
  • 동시에 두 요청이 도착해도 행 잠금에 의해 직렬화되고, 늦은 쪽은 WHERE 조건 재평가에서 0건 매칭이 된다.
  • 변경 행 수가 1이면 선점 성공이므로 결제를 진행하고, 0이면 선점 실패이므로 과금 없이 즉시 차단한다.
  • 락의 성격이 바뀐다. DB 행 잠금은 밀리초 수준으로 짧게 잡았다 놓고, 대신 진행중이라는 논리 플래그가 행에 남아 요청과 커넥션의 경계를 넘어 상태를 지킨다.
  • 경합한 요청은 블로킹 대기 대신 즉시 실패(fail-fast) 응답을 받는다. 사용자도 50초를 기다렸다가 에러를 보는 것보다 이미 결제 진행 중이라는 안내를 바로 받는 쪽이 낫다.

승자는 언제나 정확히 하나다.


TTL과 안내 메시지

락 해제는 명시적 해제 대신 시간 경과로 자동 만료되는 리스(lease) 방식

  • 명시적으로 해제하는 설계는 프로세스가 결제 도중 죽으면 영구 잠김이 된다.
  • TTL은 5분으로 잡았다. PG 타임아웃 25초보다 충분히 길어서 정상 결제 중에 락이 풀릴 일이 없고, 실패한 건의 재시도를 너무 오래 막지도 않는다.
  • TTL을 20초처럼 짧게 잡으면 PG 승인이 진행 중인 사이에 락이 만료되어 중복 결제가 그대로 재발한다.
  • 선점 실패를 조용히 넘기지 않았다. 첫 배포에서는 선점 실패 건을 건너뛰었는데, 그러면 사용자는 왜 이 건만 결제가 안 됐는지 알 수 없다. 후속 수정으로 선점 실패 건도 결과 목록에 이미 결제되었거나 결제 진행 중인 건이라는 안내를 담아 응답하도록 했다.

TTL은 PG 타임아웃보다 길고, 실패 건의 재시도 대기보다는 짧은 값이어야 한다.


결과와 남은 과제

최종 수정은 UPDATE 한 문장과 안내 메시지를 합쳐 29줄

  • 배포 이후 재직 기간 동안 재발은 0건이다.
  • 오래 걸린 것은 코드를 고치는 일이 아니라 로그에 남지 않은 25초의 틈을 찾는 일이었다.
  • PG 응답 유실 : PG는 승인했는데 응답이 타임아웃으로 유실되면 결제상태가 미결제로 남아 TTL 만료 후 재결제될 틈이 이론상 존재한다. 락으로는 못 막고 PG 거래조회나 망취소 API로 보정해야 하는 영역이다.
  • 다른 결제 경로 이식 : 일반결제 경로는 아직 비원자적인 시간 체크 방식이라, 같은 원자적 선점 패턴을 이식해 방어 수준을 맞추는 작업이 남아 있다.

선점 한 문장으로 이중 결제는 막았고, PG 응답 유실은 별도 보정이 필요하다.

This post is licensed under CC BY 4.0 by the author.