오늘도 경험 한 스푼
Published on

승인 응답 직후 서버가 죽으면, 돈은 나갔는데 주문은 어떻게 되나

Authors
  • avatar
    Name
    김민석
    Twitter

승인 응답과 저장 사이에서 서버가 죽으면 돈만 나가고 주문은 결제 진행중으로 남는다

배포로 서버가 재시작되거나 메모리 부족으로 프로세스가 죽는 순간이 결제 승인과 겹치면, PG는 승인을 끝냈는데 서버는 그 결과를 저장하지 못한다. 고객 카드에서 돈은 나갔고 화면은 오류, 주문은 결제 진행중 PAYMENT_PENDING으로 남는다. 결제 사고 사례 중 "결제는 됐다는데 상품이 안 왔다"는 문의가 여기서 나온다.

승인 코드는 주문을 결제 진행중으로 바꾸고 나서 PG에 승인을 요청하고, 응답을 검증한 뒤 별도 트랜잭션에서 결제와 상품 지급을 저장한다. PG 승인과 DB 저장은 서로 다른 시스템이라 한 트랜잭션으로 묶을 수 없고, 그 사이에 프로세스가 죽으면 예외조차 나지 않는다. 그래서 복구 배치가 10분마다 결제 진행중으로 10분 넘게 남은 주문을 찾아 PG에 조회하고, 승인된 건이면 사고 기록과 운영자 알람을 남기고, 승인되지 않은 건이면 FAILED로 닫는다. 돈이 나간 건을 자동으로 지급하지는 않는다. 사람이 PG에서 다시 확인하고 지급하는 쪽이 안전해서다.

두 가지를 숫자로 확인했다. 승인 응답 직후 죽은 주문이 사고로 기록되기까지 얼마나 걸리고 그 사이 무엇으로 알 수 있는지, 승인 요청 직전에 죽은 주문은 돈이 안 나갔으니 사고 없이 닫히는지다.

DB 커넥션 풀 10개인 서버에 PG 응답을 300ms로 두고, 승인 응답을 검증한 직후 저장 트랜잭션에 들어가기 전에 프로세스를 즉시 죽였다. 서버는 10초 뒤 자동으로 다시 뜨고 기동에 약 36초가 걸린다. 한 건에 약 55초 간격으로 10건이다. 복구 배치는 따로 부르지 않고 제 시간에 도는 회차를 기다렸다. 고치기 전에 먼저 재고 고친 다음 같은 조건으로 다시 잰다.

죽은 주문 10건은 12분에서 20분 뒤에야 사고로 기록됐다

10건 전부 돈은 나갔고 주문은 결제 진행중으로 남았다. 복구 배치가 10건 전부 사고로 기록했지만, 죽은 시점부터 사고 기록까지 최소 11분 54초, 최대 19분 55초가 걸렸다. 그동안 어느 지표에도 흔적이 없었다.

돈은 10건 전부 나갔고 사고 기록은 12분 뒤부터 20분 뒤까지 퍼졌다

항목결과
PG 승인 / 취소10 / 0
주문 상태결제 진행중 PAYMENT_PENDING 10, PAID 0, FAILED 0
결제 저장 / 상품 지급0 / 0, 회원 잔액 그대로
사고 기록 / 누락10 / 0
사고 기록까지 최소 / 중앙 / 최대11분 54초 / 15분 56초 / 19분 55초
운영자 알람10통, 제목 전부 결제 보상 트랜잭션 실패
승인 결과 카운터 증가0
재기동10회, 건당 46 ~ 48초

복구 배치는 결제 진행중으로 잔류 임계 10분 넘게 남은 주문만 보고, 주기 10분마다 돈다. 죽은 직후 10분은 대상이 아니고, 10분이 지나도 다음 회차까지 최대 10분을 더 기다린다. 상한이 20분이고, 실측 11분 54초 ~ 19분 55초가 그 안에 고르게 퍼졌다. 21:46부터 21:56까지 죽였다. 21:50 전에 죽은 3건은 첫 회차 22:00에, 그 뒤 7건은 다음 회차 22:10에 잡혔다.

원장 항등식 PG 승인 건수 = PAID 주문 + 취소 + 사고 기록10 = 0 + 0 + 10으로 맞았다. 돈은 정확히 세어졌다. 문제는 아는 데 걸린 시간과 알게 되는 방법이다. 운영자 알람 10통은 제목이 전부 결제 보상 트랜잭션 실패였다. 실제로는 저장 실패 때 승인을 되돌리는 보상 취소를 부른 적이 없고, 승인은 됐는데 저장을 못 한 것인데, 사고 기록 코드가 제목을 한 가지로 고정하고 있었다. 승인 결과 카운터는 10건 동안 0이었다. 카운터는 승인이 성공이든 실패든 결과가 나와야 세는데 죽은 요청은 결과가 없다.

image.png
  • 회원 A의 결제는 PG 승인까지 끝나 카드에서 돈이 나갔는데, 서버가 결과를 저장하기 직전에 죽어 주문은 결제 진행중 그대로고 결제 기록은 없다. 회원 A 화면은 오류다.
  • 서버는 자동으로 다시 뜨지만 죽은 요청은 돌아오지 않는다. 복구 배치가 결제 진행중으로 오래 남은 주문을 찾아 PG에 승인 여부를 묻고, 승인됐으면 사고 기록과 운영자 알람을 남긴다. 상품 지급은 운영자가 어드민에서 한다.

image.png
  • 앱 다운 선이 21:46부터 21:56까지 골짜기로 보인다. 다시 뜬 뒤 다음 건을 죽이기까지 살아 있는 시간이 그래프 간격 15초보다 짧아 이웃 골짜기가 붙어 6개로 보이지만 원값은 10회다. 복구 배치 누적이 22:003으로 오른다.
  • 승인 결과별 패널은 No data다. 죽은 요청 10건은 어느 결과에도 세어지지 않았다.

image.png
  • CPU 선이 죽어 있는 동안 끊기고 재기동 직후 100% 점 하나가 찍힌다. 기동 몫이다.

image.png
  • 커넥션 풀 사용 0, 대기 0이다. 죽은 요청은 커넥션도 남기지 않는다.

승인 요청 직전에 죽은 5건은 사고 없이 실패로 닫혔다

같은 방식으로, 주문을 결제 진행중으로 바꾼 뒤 PG를 부르기 직전에 프로세스를 죽였다. 5건이다.

항목결과
PG 승인0
주문 상태FAILED 5, 결제 진행중 0
사고 기록 / 운영자 알람0 / 0
닫히기까지 최소 / 최대11분 43초 / 19분 54초

복구 배치가 PG에 조회하면 404 미존재로 답하고, 배치는 승인된 적 없는 것으로 확정해 FAILED로 닫는다. 사고도 알람도 없다. 돈이 나간 건과 안 나간 건을 가르는 기준은 PG 조회 결과가 DONE이냐 404냐 하나고, 두 묶음이 한 건도 섞이지 않았다. 닫히기까지 12 ~ 20분이 걸리는 건 같은 구조다.

상한 20분은 임계 10분에 주기 10분이 더해진 값이다

원인은 셋이고, 넷째는 세는 방법이다.

  1. 잔류 임계 10분이 코드 상수다. 승인 요청 하나가 결제 진행중인 채 살아 있을 수 있는 최대는 PG 호출 다섯 번에 호출당 연결 5초와 응답 15초100초다. 다섯 번은 승인, 주문번호 조회, 보상 취소, 결제키 조회, 취소 재시도다. 임계는 100초보다 길기만 하면 되는데 6배10분이었고, 주기 10분이 더해져 상한 20분이 됐다.
  2. 알람 제목이 사고 종류와 무관하게 결제 보상 트랜잭션 실패로 고정돼 있다. 사고 기록 코드가 한 종류만 알았다. 운영자는 제목만 보고 돈이 나갔는지 돌려줬는지 알 수 없다.
  3. 승인 결과 카운터는 결과가 나와야 센다. 죽은 요청은 결과가 없으니 0이고, 12 ~ 20분 동안 어느 지표에도 없다.
  4. 복구 배치 카운터와 로그는 이미 기록된 주문도 회차마다 다시 셌다. 22:10 회차 로그가 10건인데 새로 잡은 건 7건이고 3건은 앞 회차 것이다. 사고 기록과 알람은 중복되지 않았지만, 회차마다 몇 건이 새로 났는지는 DB를 봐야 알 수 있었다.

죽는 창을 없애는 것도 카운터로 세는 것도 고르지 않았다

  • 승인 호출과 저장을 한 트랜잭션으로 묶어 죽는 창을 없애는 방법은 PG 승인과 DB 커밋이 다른 시스템이라 어느 순서로 해도 둘 사이에 죽는 순간이 남아서 막힌다. 앞 글에서 승인 호출을 트랜잭션 밖에 둔 이유와 같다.
  • 승인을 시작할 때 카운터를 올려 시작과 결과의 차이로 죽은 요청을 세는 방법은 카운터가 프로세스 메모리에 있어 프로세스와 함께 사라져서 막힌다. Prometheus가 5초마다 읽는데 카운터를 올린 뒤 죽기까지 0.4초라 읽히기 전에 사라진다. 고친 뒤 측정에서 죽은 10건 중 카운터가 잡은 건 1건이었다.
  • 창은 없앨 수 없으니 아는 데 걸리는 시간을 줄이는 쪽을 골랐다. 임계와 주기를 승인 요청이 살아 있을 수 있는 최대 100초에 근거해 다시 잡고, 프로세스가 죽어도 남는 DB를 세는 게이지로 배치보다 먼저 알리고, 알람 제목을 사고 종류로 나눴다.

임계 2분과 주기 5분, DB를 세는 게이지로 상한이 7분이 됐다

고친 것은 넷이다.

  1. 잔류 임계를 10분에서 2분으로 바꿨다. 100초1.2배고, 100초보다 짧은 값은 설정 자체가 거부된다. 주기는 10분에서 5분으로 바꿔 상한이 20분에서 7분이 된다. 배치 락 최대 보유는 주기보다 짧은 4분이라, 앞 회차를 쥔 프로세스가 죽어도 다음 회차가 건너뛰지 않는다.
  2. 사고 종류를 넷으로 나눠 제목과 본문을 각각 두었다. 복구 배치가 올리는 사고는 제목이 승인됐으나 DB 미저장 (수동 지급 필요)이고 본문에 지급 복구 절차가 들어간다. 보상 취소 실패, 승인 결과 불명, 어드민 환불 실패도 각자 제목이 있다.
  3. 결제 진행중으로 임계를 넘긴 주문 수를 DB에서 30초마다 세는 게이지 payment.order.pending_stuck을 두고, 0보다 큰 상태가 1분 이어지면 경보 PaymentOrderPendingStuck이 켜지게 했다. 프로세스가 죽어도 DB는 남으니 재기동 뒤에도 값이 산다. 승인 시작 카운터도 넣었지만 살아 있는 프로세스에 매달린 승인을 보는 보조 신호다.
  4. 복구 배치 카운터를 새로 기록한 건 charged_needs_recovery와 이미 기록된 건 charged_already_recorded로 나누고, 로그도 신규: N건, 이미 기록됨: M건으로 갈랐다. 카운터는 기동할 때 0으로 등록해 첫 증가분이 그래프에서 빠지지 않게 했다.

같은 10건이 6분 50초 안에 전부 기록됐고 경보는 배치보다 먼저 켜졌다

고친 코드로 같은 조건에서 10건을 다시 죽였다.

항목고치기 전 코드고친 코드
PG 승인 / 취소10 / 010 / 0
사고 기록 / 누락10 / 010 / 0
결제 저장 / 상품 지급0 / 00 / 0
사고 기록까지 최소11분 54초2분 42초
사고 기록까지 중앙15분 56초4분 4초
사고 기록까지 최대19분 55초6분 50초
상한20분7분
운영자 알람 제목결제 보상 트랜잭션 실패 10승인됐으나 DB 미저장 (수동 지급 필요) 10
잔류 주문 게이지없음3 → 7 → 9 → 10
경보없음마지막 건이 죽고 2분 33초 뒤 켜짐
회차별 새 사고 / 이미 기록됨로그 3, 10. 10 중 3은 이미 기록된 것3 / 4 / 3, 이미 기록됨 0 / 3 / 7
승인 시작 카운터없음10건 중 1
원장 항등식10 = 0 + 0 + 1010 = 0 + 0 + 10

최대 6분 50초는 상한 7분 안이다. 가장 오래 걸린 두 건 409초410초는 회차 정각 1분 48초 전에 죽어 임계 2분12초 모자라 다음 회차로 밀린 경우고, 나머지 8건2분 42초 ~ 4분 32초다. 세 회차 23:45, 23:50, 23:553 / 4 / 3건씩 새로 잡혔고, 로그가 신규: 4건, 이미 기록됨: 3건처럼 갈라져 DB를 보지 않아도 읽힌다. 돈은 고치기 전과 똑같이 정확하다. 달라진 건 아는 데 걸린 시간과 아는 방법이다.

잔류 주문 게이지는 23:44:593, 23:49:597, 23:52:599, 23:53:5910으로 올랐다. 죽은 지 2분이 지나면 30초 안에 게이지에 잡힌다. 마지막 건이 죽은 23:51:26에서 2분 33초 뒤인 23:53:59에 경보 PaymentOrderPendingStuck이 켜졌고, 그 건의 사고 기록 23:55:04보다 1분 5초 먼저다. 죽이는 동안에는 경보가 pending에서 여러 번 되돌아갔다. 55초마다 서버가 죽어 게이지 값 자체가 끊기니 1분 유지 조건이 매번 처음부터다. 서버를 연달아 죽이는 테스트 조건에서 나는 일이고, 경보 조건이 1분 유지라 한 번 죽고 마는 경우엔 게이지가 오른 1분 뒤 켜진다.

image.png
  • 왼쪽 아래 잔류 주문 게이지가 3 → 7 → 9 → 10 계단으로 오르고, 끊긴 구간이 서버가 죽어 있던 때다. 승인 시작 카운터는 10건1만 올랐고, 오른쪽 위 승인 결과별 패널은 전부 0이다.
  • 오른쪽 아래에서 새 사고 기록이 3 / 4 / 3, 이미 기록됨이 0 / 3 / 7로 회차마다 갈린다. 왼쪽 위 누적 원값은 재기동마다 0으로 떨어지지만 회차별 증가분은 그대로 읽힌다.

image.png
  • 경보 PaymentOrderPendingStuckFIRING, 값 10이다. 사고 기록이 끝난 뒤에도 주문이 결제 진행중이라 경보가 유지되고, 운영자가 지급을 마쳐 주문이 닫히면 내려간다.

10건과 5건으로 잡은 이유

  • 한 건 죽이면 재기동에 46 ~ 48초가 걸려 건당 약 55초다. 10건이면 약 10분으로 복구 배치 회차 두세 개에 걸쳐 지연이 최소부터 최대까지 퍼지는 모양이 보인다. 회차 정각 앞뒤로는 죽이지 않았다. 배치가 도는 중에 죽이면 그 회차가 통째로 빠져서다.
  • 승인 직전 5건은 사고 0FAILED를 확인하는 데 충분한 수다. 돈이 안 나가니 지연 분포보다 분류가 맞는지가 요점이다.

남은 것

  • orders 테이블의 상태와 갱신 시각 인덱스. 게이지가 30초마다 세니 주문이 많아지면 필요하다
  • 경보 PaymentOrderPendingStuck을 운영 알림 채널로 보내는 연결
  • 사고 기록 뒤 운영자가 어드민에서 지급 복구를 마치기까지 걸리는 시간. 사람이 하는 구간이다

정리

문제고친 것고치기 전 → 고친 뒤
승인 직후 죽은 주문이 사고로 기록되기까지 최대 20분잔류 임계 2분, 주기 5분10건 최대 19분 55초 → 6분 50초, 중앙 15분 56초 → 4분 4초
그동안 어느 지표에도 안 보임DB를 세는 잔류 주문 게이지와 경보경보 없음 → 마지막 건이 죽고 2분 33초 뒤, 배치보다 1분 5초 먼저
알람 제목이 원인과 다름사고 종류별 제목과 복구 절차결제 보상 트랜잭션 실패 → 승인됐으나 DB 미저장 (수동 지급 필요)
회차마다 이미 기록된 건을 다시 셈신규와 이미 기록됨 분리22:10 회차 로그 승인됨 10건23:55 회차 로그 신규: 3건, 이미 기록됨: 7건

승인 요청 직전에 죽은 5건은 고치기 전 코드에서도 사고 없이 FAILED로 닫혔다.

돈이 나간 주문을 복구 배치가 놓치지 않는다는 건 코드에 있었다. 임계 10분에 주기 10분이 더해져 20분이 된다는 사실은 실제로 죽여 보기 전엔 숫자로 보이지 않았다.