
결제창에서 취소하고 다시 결제했는데, 서버는 이미 진행 중인 주문이 있다고 막는다. 카드 앱을 다녀오니 로그인 세션이 끝나 있고, 다시 로그인하면 홈으로 돌아간다. 결제 결과 화면에는 확인 중이라는 문구가 뜨는데, 다시 주문 버튼을 누르면 이번에도 막힌다.
각각 다른 화면에서 발생했지만 공통점이 있었다. 사용자는 다음 행동을 하려는데 시스템은 이전 시도의 상태를 제대로 설명하거나 정리하지 못했다.
이 흐름을 고치면서 결제의 성공·실패보다 먼저 구분해야 할 상태가 있다는 점을 정리했다. 아직 승인을 시작하지 않은 주문과, 승인을 시작해서 결과를 모르는 주문은 같은 방식으로 재시도하면 안 된다.
결제를 시작할 때 서버에 대기 주문을 만든다. 당시에는 결제창을 닫거나 SDK 호출이 실패해도 이 주문이 PENDING으로 남을 수 있었다. 다음 결제는 중복 주문 검사에 걸렸고, 사용자는 취소한 결제 때문에 다시 결제할 수 없었다.
우선 실패 경로에서 정리하도록 인증된 실패 처리 API를 추가했다. 핵심은 무조건 실패로 바꾸는 것이 아니라, 현재 사용자 소유이고 아직 PENDING인 주문만 바꾸는 것이다. 아래는 조건을 보여주기 위해 줄인 쿼리다.
UPDATE pe_payment
SET status = 'FAILED'
WHERE order_no = ?
AND userId = ?
AND status = 'PENDING';
취소 리다이렉트와 SDK 예외 처리에서 이 API를 호출했다. 다만 이것만으로는 끝나지 않는다. 탭을 닫거나 앱을 종료하면 실패 화면 자체를 지나지 않기 때문이다. 정리 요청도 세션 만료나 통신 장애로 실패할 수 있다.
그래서 새 주문을 만드는 서버 경로에도 복구를 넣었다. 사용자 단위 잠금 안에서 같은 구매 범위의 이전 PENDING을 대체 처리한 뒤 새 주문을 만든다. 이전 시도는 PAYMENT_SUPERSEDED로 남겨 구분했다. 실패 화면이 서버에 도착해야만 재결제할 수 있는 구조에서 벗어난 것이다.
이때 재시도는 실패한 주문을 되살리는 것이 아니다. 새 주문번호와 새 결제 시도를 만든다. 이미 실패 처리된 예전 탭에서 늦게 승인 요청이 들어오더라도 그 주문을 다시 확정해서는 안 된다.
화면에서 버튼을 막아도 다른 탭의 요청이나 늦은 응답은 들어올 수 있다. 승인 시작 역시 상태를 조건으로 갱신했다.
UPDATE pe_payment
SET status = 'CONFIRMING', payment_key = ?
WHERE order_no = ?
AND userId = ?
AND status = 'PENDING';
이 조건부 갱신이 승인 시작의 경계다. PENDING에서 넘어간 요청만 승인 절차를 진행하고, 이미 전이된 주문은 현재 상태를 응답한다. 취소 처리와 승인 시작이 경쟁하더라도 뒤늦은 취소 요청이 CONFIRMING을 실패로 덮어쓰지 않는다.
PENDING ── 승인 시작 조건 충족 ──> CONFIRMING ── 확정 ──> CONFIRMED
│ │
└─ 취소 / 새 시도로 대체 ──> FAILED └─ 결과 불명: 상태 유지, 재조회
이 그림에서 중요한 것은 CONFIRMING을 새 주문 생성 과정에서 지우지 않는다는 점이다. 승인을 요청한 뒤 응답만 끊겼다면 실제로는 승인됐을 수 있다. 화면에서 실패처럼 보였다는 이유로 새 시도를 열어주는 것은 안전한 복구가 아니다.
결제창의 성공 리다이렉트와 서버 승인도 구분해야 한다. 토스페이먼츠 연동 문서 역시 인증 이후 서버에서 주문과 금액을 검증하고 승인을 요청하는 흐름을 설명한다. 브라우저가 성공 URL에 도착했다는 사실만으로 우리 서비스의 주문 확정까지 끝났다고 볼 수는 없다.
중복을 막는 기준을 상품 ID 하나로 잡으면 충분할 것 같지만, 연장 상품에서는 그렇지 않았다. 서로 다른 연장 상품이 같은 연장 자격을 소비하는데 상품별로만 잠그면 두 결제가 동시에 진행될 수 있었다.
그래서 대기 주문 대체와 확정 중 검사에서는 같은 자격을 사용하는 연장 상품군을 묶었다. 반대로 이미 확정된 주문 검사까지 무조건 상품군 전체로 넓히지는 않았다. 나중에 새로 받은 다른 연장 자격까지 과거 구매 이력 때문에 막힐 수 있기 때문이다.
PENDING·CONFIRMING의 충돌 범위와 CONFIRMED 구매 이력의 범위가 같아야 한다는 전제부터 버려야 했다. 중복 방지는 요청의 모양이 아니라 경쟁하는 도메인 자원을 기준으로 설계해야 한다.
카드 앱이나 은행 앱을 다녀오는 동안 로그인 세션이 만료될 수 있다. 이때 로그인만 다시 시키고 홈으로 보내면 결제 확인에 필요한 위치를 잃는다.
중단된 보호 화면에서는 현재 경로와 쿼리를 next로 전달하고, 로그인 후 그 경로로 돌아오도록 바꿨다. 예를 들면 이런 흐름이다. 식별자는 설명용 자리표시자다.
/order/success?orderId=ORDER_EXAMPLE&paymentKey=KEY_EXAMPLE
→ /signin?next=<인코딩한 현재 경로>
→ 로그인 성공
→ 원래 결제 확인 화면
다만 next는 그대로 신뢰할 수 없다. /로 시작하는 경로인지 확인하고 현재 origin을 기준으로 URL을 해석한 뒤, 같은 origin인지 다시 검사했다. 로그인 화면으로 되돌아가는 경로도 제외했다. //외부주소처럼 겉으로는 슬래시로 시작하지만 외부로 이동할 수 있는 입력을 단순 문자열 검사만으로 허용하지 않았다.
돌아갈 주소를 보존하는 것은 편의 기능이지 권한 검증이 아니다. 복귀한 뒤에도 서버는 로그인 사용자와 주문 소유자를 확인해야 한다. 사용자가 자발적으로 누른 일반 로그인과, 작업 중 세션이 만료돼 중단된 로그인도 구분했다.
기존 결과 화면은 CONFIRMING을 한 번 받으면 확인 중 문구와 재주문 버튼을 함께 보여줬다. 그런데 서버는 확정 중 주문을 보호하고 있으니 버튼을 눌러도 충돌한다. 화면이 서버와 다른 행동을 권하고 있었다.
결과가 불확실하면 같은 주문의 confirm API를 제한적으로 다시 조회하도록 바꿨다. 이미 승인 시작 경계를 지난 주문은 상태를 응답하므로 이 재조회 때문에 결제사 승인을 반복 호출하지 않는다.
재조회 간격은 2·4·8·16초, 최대 네 번이다. 최초 호출까지 합하면 최대 다섯 번이며, 설정된 대기 시간의 합이 30초다. 네트워크 응답 시간까지 포함한 전체 소요 시간이 정확히 30초라는 뜻은 아니다.
응답 자체가 없는 통신 오류는 승인 결과를 모르는 상태로 다뤘다. 반면 서버가 반환한 명확한 HTTP 오류는 별도의 오류 안내로 처리했다. 컴포넌트를 떠날 때는 타이머를 정리하고 늦은 응답의 화면 갱신도 막았다.
재조회 한도를 넘겨도 실패로 단정하지 않는다. 새 주문 버튼 대신 주문 내역 확인과 고객센터 안내를 제공했다. 승인 여부를 모르는 사용자에게 재결제를 권하는 대신, 기존 시도를 확인할 출구를 남긴 것이다.
프론트의 짧은 재조회는 즉각적인 안내를 위한 것이고, 서버 복구 배치는 별도의 역할을 한다. 당시 구현은 10분 주기로 일정 시간이 지난 확정 중 주문을 조회해 결제사 상태와 주문번호·금액을 대조하고 결과를 반영하는 구조였다.
조회 실패나 진행 중 상태를 무조건 실패로 바꾸지 않는 만큼, 모든 건이 일정 시간 안에 반드시 끝난다고 보장할 수는 없다. 결제사 승인은 끝났는데 로컬 반영이 실패한 경우에는 재처리와 운영 확인도 필요하다. 자동 복구 경로가 있다는 것과 복구 시간 SLA가 있다는 것은 다르다.
또한 당시 개발 환경에서는 이 복구 배치가 실행되지 않았다. 모의 API로 화면의 확인 중 안내가 바뀌는 것을 확인한 결과와 운영 배치의 실제 복구 결과를 섞어 설명하지 않아야 한다.
변경 당시에는 취소 후 재시도, 늦은 이전 주문 승인, confirm과 fail의 경쟁, 연장 상품군 간 충돌을 검증 대상으로 삼았다. 결과 화면에서는 모의 응답으로 확정 중 재조회와 재주문 버튼 제거, 로그인 후 원래 경로 복귀를 확인했다. 화면 검증과 실제 결제사·운영 배치 검증의 범위는 구분해서 남겼다.
이 작업에서 가장 크게 달라진 것은 오류 문구가 아니라 재시도의 기준이다. 취소한 시도는 정리하고, 중단된 로그인은 이어주고, 결과를 모르는 승인은 보호한다. 같은 ‘다시 시도’라도 어떤 상태에서 무엇을 반복할지부터 정해야 했다.
앞으로 결제 화면을 볼 때는 정상 완료 화면만큼이나 중간에 나간 사용자가 돌아왔을 때의 상태를 먼저 확인하려 한다. 복구 가능한 흐름은 사용자에게 버튼을 하나 더 주는 일이 아니라, 서버가 허용하는 다음 행동을 화면에서도 정확하게 설명하는 일이다.
이 흐름에서 세션 확인 실패를 단순 로그아웃으로 다루지 않은 기준은 계정·자녀 전환의 비동기 경계에서도 이어진다. 작업을 중단시키기 전에 확정된 상태와 아직 확인하지 못한 상태를 구분하는 관점이다.
취소 후 재시도 #9500, 로그인 복귀 경로 #9508, 승인 확인 화면 #9511, 연장 상품 충돌 범위 #9577에 나눠 반영했다. 저장소 링크는 접근 권한이 필요할 수 있다.