leo.dev
backend

결제 중복을 막는 장치와 그 대가

SWING의 구독 결제를 붙이는 업무를 맡았다. 결제를 성공시키는 코드보다 실패와 중복을 다루는 코드가 더 많았고, 그 대부분이 같은 태스크가 두 번 이상 일어나지 않게 막는 코드였다.

중복을 막아야 하는 곳은 세 군데였고, 곳마다 쓴 방법이 달랐다. 결제 원장은 UNIQUE 제약, 구독 주기 이력은 락 확인, 즉시 결제의 상태 전이는 결제 ID 프리픽스를 이용해 막기로 했다.

그런데 이 장치들이 실패한 작업의 복구까지 막았다. 결제는 나갔는데 뒤따르는 DB 작업이 실패하면 구독이 반만 갱신된 채 남는다. PG가 같은 이벤트를 다시 보내주니 그때 나머지를 끝내면 되는데, 중복을 막으려고 둔 UNIQUE가 “이미 처리한 결제”로 판정해 핸들러를 통째로 건너뛴다.

위조된 webhook 막기

webhook 엔드포인트는 로그인 없이 열려 있는 공개된 URL이다. PG사가 호출하는 엔드포인트라 인증 헤더를 붙일 수 없고, 아무나 같은 주소로 POST할 수 있다는 뜻이다. 결제 실패 이벤트를 위조해 보내면 남의 구독을 결제 실패 상태(PAST_DUE)로 떨어뜨릴 수 있다.

그래서 핸들러의 첫 줄이 서명 검증이다. 파싱된 JSON이 아니라 raw body로 해야 한다. 서명은 전송된 바이트열로 계산되므로, 프레임워크가 JSON을 파싱했다가 다시 직렬화하면 키 순서나 공백이 달라져 검증이 실패한다. 따라서 NestJS를 rawBody: true로 띄워야 한다.

payment.controller.ts
const rawBody = req.rawBody?.toString("utf-8") ?? ""
if (!rawBody) throw new UnauthorizedException("Invalid webhook signature")
try {
webhook = await verify(
this.paymentService.getWebhookSecret(),
rawBody,
headers
)
} catch (err) {
this.logger.warn(`Webhook 서명 검증 실패: ${String(err)}`)
throw new UnauthorizedException("Invalid webhook signature")
}

시크릿이 비어 있으면 onModuleInit에서 예외를 던져 서버가 아예 뜨지 않는다. 검증을 조용히 건너뛰는 서버가 도는 것보다 부팅에서 바로 실패하는 쪽이 낫다.

서명은 위조를 막지만 재전송은 못 막는다. PG는 응답이 늦거나 5xx가 나면 같은 이벤트를 다시 보내는데, 그건 PortOne이 자기 시크릿으로 정상 서명해 보낸 진짜 이벤트다. 검증은 그대로 통과하고, 핸들러 입장에서 처음 보는 요청과 구별되지 않는다. 이 지점부터는 받는 쪽이 같은 이벤트를 두 번 처리하지 않게 스스로 막아야 했다.

결제 원장의 UNIQUE 제약

PortOne 문서에 따르면 웹훅 전송이 실패하면 backoff를 두고 최대 다섯 번까지 다시 보낸다. 그래서 같은 결제 이벤트가 두 번, 세 번 올 수 있다. “정확히 한 번”이 아니라 “최소 한 번”이라, 같은 webhook을 몇 번 받아도 결과가 한 번 받은 것과 같아야 했다(멱등). 그렇다면 가장 먼저 떠오르는 답은 UNIQUE 제약이다.

portone_payment_id varchar(100) NOT NULL,
UNIQUE KEY UQ_portone_payment_id (portone_payment_id)

같은 결제 ID로 행을 두 번 넣으려 하면 코드가 무엇을 하든 DB가 거부한다. 서버가 몇 대 떠 있든, 요청이 어떤 순서로 들어오든 결과가 같다.

구독 주기 이력의 락과 존재 확인

구독 주기 이력(swing_subscription_periods)에는 같은 방법을 쓰지 못했다.

portone_payment_id varchar(120) DEFAULT NULL -- 인덱스 없음

컬럼이 nullable이기 때문인데, 결제가 아예 시도되지 못한 실패 주기는 붙일 결제 ID가 없어서 그렇다.

MySQL 문서UNIQUE 인덱스가 NULL을 어떻게 다루는지 적혀 있다.

A UNIQUE index creates a constraint such that all values in the index must be distinct. An error occurs if you try to add a new row with a key value that matches an existing row. If you specify a prefix value for a column in a UNIQUE index, the column values must be unique within the prefix length. A UNIQUE index permits multiple NULL values for columns that can contain NULL.

CREATE INDEX Statement, MySQL 8.0 Reference Manual

이 컬럼에 UNIQUE를 걸어도 ID가 없는 행은 얼마든지 들어간다. 정작 막아야 할 경우를 막지 못하는, 있으나 마나 한 제약이 된다.

그래서 이 테이블에서는 DB 대신 코드가 멱등을 보증한다. 트랜잭션 안에서 같은 결제 ID의 행이 이미 있는지 확인하고, 있으면 실행되지 않는다.

여기까지는 순차 재전송만 막는데, 두 webhook이 거의 동시에 도착하면 둘 다 존재 확인을 통과하고 둘 다 행을 쓴다. 확인과 쓰기 사이가 벌어져 있는 check-then-act 상황이다.

따라서 트랜잭션 첫머리에서 구독 행에 pessimistic_write를 걸어 같은 구독의 webhook 처리를 직렬화한다. 뒤에 들어온 쪽은 앞선 트랜잭션이 커밋된 뒤에야 존재 확인을 하니, 동시에 도착해도 두 번 실행되지 않는다.

handleSubscriptionPaymentFailed (트랜잭션):
1. 구독 행 pessimistic_write 락 ← 동시 도착 직렬화
2. portone_payment_id 존재 확인 → 있으면 return ← 재전송 차단
3. 구독 status = PAST_DUE, scheduledPaymentId = null
4. subscription_periods에 FAILED 행 1개

같은 중복을 막는 데 결제 원장은 인덱스 한 줄이면 됐고, 여기서는 락과 존재 확인 두 가지가 들었다. 컬럼이 NOT NULL이었다면 DB가 해줬을 일을, nullable이라 트랜잭션과 락으로 코드가 대신한다.

즉시 결제의 프리픽스 분기

즉시 결제 쪽 중복은 종류가 다르다. 즉시 결제(업그레이드·PAST_DUE 복구)에서는 서비스 코드가 그때 구독 상태를 다 쓰는데, 그 결제의 webhook도 나중에 온다. 그대로 두면 같은 전이를 두 번 한다. 결제 ID에 프리픽스를 박아 구분했다.

프리픽스용도
sw-pay-즉시 결제 (업그레이드, PAST_DUE 복구)
sw-sch-예약 자동 결제

프리픽스가 정하는 건 webhook 전체가 아니라 그 뒤의 구독 상태 전이다. 결제 원장에 행을 남기는 일은 두 종류 모두에 필요하니 그대로 하고(거기선 결제 원장의 UNIQUE가 중복을 막는다), 상태를 바꾸는 핸들러만 프리픽스로 걸러낸다.

payment.controller.ts
const companyId = await this.paymentService.handleTransactionPaid(paymentId)
// sw-pay-(즉시 결제)는 호출한 서비스가 그때 상태를 다 썼다
if (companyId && paymentId.startsWith("sw-sch-")) {
await this.subscriptionService.handleSubscriptionPaymentSuccess(
companyId,
paymentId
)
}

PG 호출과 DB 쓰기 사이의 경계

즉시 결제는 트랜잭션 경계를 기준으로 세 단계로 쪼개진다.

Phase 1 (트랜잭션): 구독 행 비관적 락 → 검증 → 필요한 값 수집
Phase 2 (트랜잭션 밖): PortOne 즉시 결제
Phase 3 (트랜잭션): 결제내역 + 구독상태 + 주기 이력 원자적 기록

PG 호출을 트랜잭션 안에 넣을 수 없는 건, 외부 HTTP는 느리고 실패할 수도 있는데 그 몇 초 동안 DB 커넥션과 행 잠금을 쥐고 있을 수는 없기 때문이다. 그래서 결제는 트랜잭션 밖에서 하고, 결과만 트랜잭션 안에서 쓴다.

문제는 Phase 2와 3 사이다. PG에선 결제가 됐는데 Phase 3에서 DB 쓰기가 실패하면, 고객사 카드에서는 출금이 끝났는데 구독은 그대로다. PG와 우리 DB는 서로 다른 시스템이라 하나의 트랜잭션으로 묶을 방법이 없다. 순서를 어떻게 바꿔도 결제는 끝났는데 DB에는 아직 안 적힌 시간이 반드시 생긴다.

중복을 막는 장치가 복구까지 막을 때

Phase 3이 실패해도 결제 원장 행은 이미 커밋돼 있다. 그래서 PortOne이 같은 이벤트를 다시 보내면, 결제 원장의 UNIQUE가 “이미 처리한 결제”로 판정하고 핸들러가 통째로 건너뛴다. 중복 청구를 막으려고 만든 것이, 출금은 끝났는데 구독은 안 올라간 행을 마저 채울 기회까지 같이 막았다.

원인은 멱등 키의 단위다. “처리했다”의 기준을 결제 행 한 줄로 잡으면, 그 뒤 단계가 미완이어도 재전송한 쪽에는 완료로 보인다. 재시도로 복구되게 하려면 키를 결제 ID가 아니라 “이 결제의 후속 처리가 다 끝났는지”에 따라 옮겨야 한다.

아웃박스로 못 푸는 이유

이 레포엔 이미 트랜잭셔널 아웃박스가 있다. 같은 결제 도메인에서 PAST_DUE 안내를 그걸로 보낸다. 그러면 Phase 3에도 아웃박스를 얹으면 되지 않나.

아웃박스가 성립하는 조건은 하나다. 메시지 행이 본 트랜잭션에 같이 적재돼야 한다. 그래서 아웃박스 테이블은 업무 테이블과 같은 DB에 있어야 하고, 커밋되면 상태 변경과 발송 예약이 둘 다 남고 롤백되면 둘 다 사라진다.

Phase 3이 실패하는 상황에서는 그 조건이 어디서도 안 선다. 커넥션이 없거나 DB가 죽어서 실패한 거라면 아웃박스 행도 같은 DB에 써야 하므로 못 쓴다. 제약 위반이나 코드 예외로 실패한 거라면 트랜잭션이 롤백되면서 아웃박스 행도 같이 사라진다.

그러면 아웃박스 행만 Phase 3 밖에서 따로 커밋하면 되지 않나 싶지만, 그건 이미 아웃박스가 아니다. 본 트랜잭션과 원자적으로 묶이지 않는 순간 “행은 남았는데 결제 결과는 안 적혔다”와 “결제 결과는 적혔는데 행이 안 남았다”가 다시 어긋난다. 아웃박스는 커밋이 성공한 다음을 책임지는 장치라, 커밋 자체가 실패하는 경우에는 걸 데가 없다.

아웃박스가 해결하는 건 커밋은 됐는데 그 뒤에 나가는 외부 발송이 따로 노는 dual write이고, Phase 3은 “외부에서 이미 일어난 일을 로컬에 못 적었다”라 방향이 반대다. 이걸 자동으로 메우려면 결제 경로가 세 조각으로 늘어난다. PG사를 호출하기 전에 “이 결제를 시도한다”를 트랜잭션 밖에 먼저 적고, 성공하면 그 행을 완료로 넘기고, 미완인 채 남은 행을 주기적으로 훑어 맞춘다.

그럼 남은 방법이 두 가지다. 멱등 판정을 결제 행이 아니라 후속 처리까지로 넓히든가, 미완으로 남은 건 담당자가 고치든가. 판정을 넓히는 쪽을 고르면 결제 경로에 “시도한다”를 먼저 적는 단계와 미완료 상태인 행을 점검하는 단계가 새로 생긴다.

자동 복구 범위

자동으로 막는 범위는 결제 원장까지다. 중복 청구는 DB가 거부하니 발생하지 않고, 그 뒤 단계가 미완으로 남는 건 자동 복구하지 않는다. Phase 3이 실패하면 [CRITICAL] 로그와 함께 관리자 메일이 나가고, 담당자가 구독 상태와 주기를 손으로 정정한다.

결제 실패 자체가 드물고 그중 Phase 3까지 실패하는 건 더 드물다고 간주했다. 그 방법으로 설계하는 데 드는 비용이 아직은 이 빈도에 안 맞는다고 판단했다. (나중에 실제로 세봤다. 운영 백엔드와 배치의 CloudWatch 로그를 5개월치(2026-03-01 ~ 07-29) 훑었고, [CRITICAL] 발생은 0건이다.)

다만 빈도는 고정된 값이 아니다. 구독이 늘어나면 결제 건수가 늘어나고, 실패율이 그대로여도 Phase 3까지 가는 절대 건수는 늘어난다. 실패를 알리는 경로가 관리자 메일 한 통뿐인 것도 마음에 걸린다. 그 메일을 놓치면 출금은 끝났는데 구독은 안 올라간 상태가 아무도 모르게 남고, 지금 구조에서는 그걸 나중에 찾아낼 방법이 로그를 다시 훑는 것밖에 없다.

지금은 그 건수를 손으로 센다. 5개월치가 0건이라 세는 잡을 붙일 근거가 아직 없어서인데, [CRITICAL]이 한 번이라도 0을 넘는 날 로그를 훑는 대신 매일 세는 잡을 붙이고 미완으로 남은 구독을 찾아내는 배치도 그때 같이 만든다.

↑↓ 이동 열기esc 닫기