BE-commerce 프로젝트
약 6분 분량 테스트·검증결제·정산

멱등키 15일을 24시간으로 줄이려다 멈춘 이유: 외부 문서보다 호출하는 PG의 계약이 먼저였다

목차

결제 시스템을 고칠 때 외부 문서의 권고를 그대로 가져오면 안전할까요? 이 글에서는 결제 멱등키의 보관 기간과 PG 승인 호출의 트랜잭션 경계를 다시 검토합니다.

두 판단의 결과는 서로 반대였습니다. 멱등키는 Stripe의 24시간을 보고 줄이려다, 실제로 호출하는 토스페이먼츠의 계약을 확인한 뒤 15일을 유지했습니다. 반면 PG 호출은 모놀리스의 단일 트랜잭션 안에 두는 편이 낫다고 봤다가, 커넥션 점유와 장애 사례를 다시 확인하고 3단계 사가로 옮겼습니다.

결론부터

  • 보관 기간은 외부 서비스의 일반적인 값이 아니라 우리 API가 실제로 의존하는 PG의 재시도 계약으로 정해야 합니다.
  • 트랜잭션 안의 외부 호출은 로컬 원자성을 지켜 주지 않습니다. PG가 느려지면 DB 커넥션을 함께 점유하므로, 예약·승인·확정/보상을 분리하는 사가가 필요했습니다.
  • 문서의 문장을 바꾸는 것만으로는 충분하지 않습니다. 실제 코드와 장애 시나리오를 대조해 틀린 전제를 찾고, 전환 뒤 남는 복구 책임까지 구현해야 합니다.

무엇을 다시 확인했는가

자가 감사에서 남긴 하드닝 후보 중 두 가지를 공식 문서와 실제 시스템 동작에 다시 대조했습니다. 하나는 멱등키를 얼마나 오래 보관할지였고, 다른 하나는 PG 승인 호출을 DB 트랜잭션 안에 둘지 밖에 둘지였습니다. 둘 다 처음에는 “업계에서는 이렇게 한다”는 문장만으로 판단했습니다. 그러나 서비스 계약과 장애 경로를 대입하자 결론이 서로 다른 방향으로 바뀌었습니다.

  • 멱등키 보관 기간은 15일에서 24시간으로 줄이자고 했다가 철회했습니다. 이 시스템이 모델링한 토스페이먼츠가 15일을 명시하고 있었습니다.
  • PG 호출을 트랜잭션 밖으로 빼는 건 이 모놀리스에서 득이 없다고 했다가 실제 장애 사례 앞에서 그 판단을 접었습니다. 그런데 사가로 쪼개는 대가도 작지 않아 결론이 한 번 더 움직였습니다.

두 판단 모두 같은 자리에서 틀렸습니다. 업계 기준을 확인하는 것과 그 기준이 이 시스템이 실제로 따르는 계약과 같은지 확인하는 것은 다른 일입니다.

1. 멱등키 보관 기간: 24시간으로 줄이자고 했다가 철회

멱등키는 같은 결제 요청이 두 번 들어와도 한 번만 처리되게 하려고 요청에 붙이는 고유 값입니다. 그 값을 얼마나 보관하느냐가 재시도를 얼마나 오래 막아주는지를 정합니다. 코드를 보니 IdempotencyRecord.TTL = Duration.ofDays(15), 15일이었습니다. 처음 판단은 이랬습니다.

“Stripe는 멱등키를 24시간이면 지운다. 15일은 15배 과하다. 24시간으로 줄이자.”

그럴듯했습니다. 그런데 코드 주석에 “토스페이먼츠와 동일하게 15일”이라고 적혀 있었습니다. 토스페이먼츠 개발자센터를 다시 열어 보니 이렇게 명시하고 있었습니다.

“멱등키는 처음 요청에 사용한 날부터 15일간 유효합니다. 처음 요청한 날부터 15일이 지났다면 새로운 멱등키로 요청하세요.”

Stripe 문서도 다시 읽었습니다. Stripe의 멱등 요청 가이드가 말하는 24시간은 이랬습니다.

“You can remove keys from the system automatically after they’re at least 24 hours old.”

Stripe의 24시간은 그 나이가 지나면 지워도 된다는 하한선이고 고정 보관 기간은 정해 두지 않았습니다. 그리고 그 하한선은 Stripe 자신의 것이고 이 시스템이 실제로 호출하는 곳은 토스였습니다. 이 프로젝트는 클라이언트가 보낸 멱등키를 토스 요청에도 그대로 실어 보내므로 우리 API의 재시도 보장 기간을 PG와 같게 두는 게 맞습니다. PG마다 정책이 다릅니다. Adyen은 7~14일, Stripe는 24시간 이상, 토스는 15일입니다.

그래서 “TTL을 줄여라”는 철회했습니다. 대신 진짜 문제는 따로 있었습니다.

TTL은 15일인데 만료된 레코드를 지우는 장치가 없어서 idempotency_keys 테이블이 무한히 자랍니다.

TTL은 토스 정합대로 그대로 두고 유효기간이 지난 레코드만 주기적으로 벌크 삭제하는 스케줄러(IdempotencyCleanupScheduler)를 붙였습니다.

@Modifying
@Query("delete from IdempotencyRecord r where r.expiresAt < :threshold")
int deleteByExpiresAtBefore(Instant threshold); // 벌크 삭제

한 건씩 지우면 정리가 뒤처집니다. 엔티티 loop 삭제를 피하고 단일 벌크 DELETE로 했습니다.

이 스케줄러는 app.idempotency.cleanup.enabled=true일 때만 켜집니다. 기본은 꺼져 있습니다. 이 저장소의 스케줄러는 대체로 이렇게 기본이 꺼져 있고 운영에서 명시적으로 켜야 동작합니다. 보상 태스크 스케줄러도 같은 방식으로 게이트를 둡니다(결제 API는 타임아웃됐는데, 카드 승인은 이미 됐다면? 참고).

업계 표준을 안다고 아무 데나 들이대면 안 됩니다. 이 시스템이 어느 PG를 모델링하는지가 정답을 바꿉니다. 검증 안 했으면 멀쩡한 값을 15배 잘못 줄일 뻔했습니다.

2. PG 호출을 트랜잭션 밖으로: 득 없다고 했다가 실사고 사례 앞에서 뒤집고 실제로 이행

두 번째는 반대 방향으로 틀렸습니다. 체크아웃이 PG 승인(외부 HTTP)을 DB 트랜잭션 안에서 호출하고 있었습니다. 처음 결론은 이랬습니다.

“안티패턴이긴 한데 정합성은 이미 UNKNOWN 복구로 해결됐고 커넥션 점유는 fast-fail로 완화되니 득이 별로 없다. 그냥 문서화하자.”

그런데 읽는 분이 되물었습니다. “득이 진짜 없어? 현업은 어떤데?” 다시 확인해 보니 이번에도 판단이 틀렸습니다.

@Transactional이 외부 HTTP 호출을 감싸면 PG가 응답을 기다리는 동안 DB 커넥션도 함께 붙잡힙니다. PG가 느려지면 결제와 무관한 조회까지 커넥션을 못 얻어 기다리게 됩니다. PG 지연이 커넥션 풀 고갈로 번지는 구조라서 득은 실재했습니다.

“득 없다”는 접었습니다. 그럼 고쳐야 하나? 여기서 한 겹이 더 있었습니다.

현업 정석은 이 흐름을 사가로 쪼개는 것입니다. 짧은 로컬 트랜잭션(예약) → PG(트랜잭션 밖) → 확정/보상입니다. 그런데 이 시스템은 모듈러 모놀리스라 체크아웃이 단일 트랜잭션이었습니다. 사가로 쪼개면 그 원자성을 포기하게 되고 예약(포인트 선점)이 커밋된 뒤 확정 단계를 크래시로 못 하면 어중간하게 멈춘 사가가 남습니다. 이걸 복구하는 게 사가의 진짜 어려운 부분이고 여기까지 안 하면 회귀(포인트 누수)입니다.

“외부 콜을 트랜잭션 밖으로”는 대규모·MSA의 규칙입니다. 모놀리스에서 사가 복잡도를 떠안는 건 이 규모에선 과할 수 있습니다. 커넥션 점유 위험은 connection-timeout: 3s가 이미 상한을 걸어준다고 적었습니다.

그런데 이 결론도 틀렸습니다

두 번 틀렸습니다.

첫째, connection-timeout: 3s는 PG 호출을 3초로 제한하지 않습니다. 그건 HikariCP가 풀에서 커넥션을 빌리려고 기다리는 시간 상한입니다. 이미 빌린 커넥션을 트랜잭션이 얼마나 오래 쥐고 있는지, PG HTTP 요청이 언제 끝나는지와는 무관합니다.

DB 커넥션 획득 → 트랜잭션 시작 → PG 호출 30초 대기 → 커넥션도 30초 점유

connection-timeout은 이 30초를 끊지 않습니다. 풀이 찬 뒤 다음 요청이 3초 기다리다 실패할 뿐입니다.

점유를 실제로 묶는 건 PG HTTP의 read timeout인데 확인해 보니 그게 설정돼 있지 않았습니다. RestClient.builder()에 타임아웃 지정이 아예 없었습니다. 커넥션 점유에 상한 자체가 없었습니다. connect 2초·read 5초를 걸었습니다. 끊긴 호출은 실패로 적지 않고 TIMEOUT(=UNKNOWN)으로 복구 배치에 넘깁니다.

둘째, “단일 트랜잭션이라 진짜 ACID 원자성”이라고 적은 것도 틀렸습니다. DB 트랜잭션은 로컬 변경끼리만 원자적으로 묶습니다. PG HTTP 호출은 거기 참여하지 않습니다.

PG 승인 성공 → 응답 수신 → DB 커밋 실패 또는 프로세스 종료

카드에서는 빠졌는데 우리 DB엔 결제가 없습니다. UNKNOWN 복구가 존재한다는 사실 자체가 이 구조도 이미 분산 정합성 문제를 갖고 있다는 증거입니다. 완전히 원자적이라면 복구가 필요 없습니다.

유지 근거를 다시 썼습니다. 외부 호출을 트랜잭션 안에 둔 위험은 처음 본 것보다 컸고 완화책이라고 적어둔 connection-timeout은 그 위험을 막는 설정이 아니었습니다. 다만 복구 경로 없이 사가로 옮기는 것도 더 위험합니다. 먼저 PG HTTP 타임아웃과 호출 동시성 상한을 두고 풀 점유를 실측한 뒤 멈춘 시도를 복구할 수 있게 되면 3단계로 옮긴다고 결정 기록(ADR-007)에 적었습니다.

그리고 같은 기록에서 곧바로 뒤집혔습니다

결정 기록에는 “실측 병목이 확인되기 전에는 착수하지 않는다”고 적었습니다. 그런데 지금 코드는 이미 그 마이그레이션 경로를 이행한 상태입니다. CheckoutService.confirm에서 클래스 레벨 @Transactional은 사라졌고 지금은 예약(CheckoutTx.reserve, 트랜잭션 안) → PG 승인(PaymentService.pgApprove, 트랜잭션 밖) → 확정/보상(CheckoutTx.settle, 트랜잭션 안) 3단계 오케스트레이터입니다.

CheckoutTx.Reservation reservation = checkoutTx.reserve(...); // Phase 1, tx
ApprovalOutcome outcome = paymentService.pgApprove(...); // Phase 2, tx 밖
return checkoutTx.settle(orderNo, ..., outcome); // Phase 3, tx

원자성을 포기한 대가로 생기는 “멈춘 사가”(예약이 커밋된 뒤 확정 전에 죽는 경우)는 CheckoutRecoveryService가 맡습니다. 10분 이상 PAYMENT_IN_PROGRESS에 머문 주문을 찾아 PG 조회로 완결하거나 포인트를 복원해 되돌립니다. 이 복구도 스케줄러(app.checkout.recovery.enabled, 기본 off)로 켜야 동작합니다. 실기동 기록에는 정상 3단계의 PAID/DONE과, 멈춘 사가를 복구한 뒤의 PENDING/ABORTED 롤백이 남아 있습니다.

결정 기록에 “아직 안 잰 것”으로 적어 둔 PG 호출 동시성 상한과 read timeout 값은 그 뒤 실측으로 이어졌습니다. 동시 호출 상한은 리틀의 법칙으로 유도한 값을 실측해 넣었고(PG가 느려지면 웹훅까지 줄을 선다), read timeout은 5초에서 토스 공식 권장인 60초로 다시 바꿨습니다(짧은 PG 타임아웃은 우리 쪽 대기만 줄였다). “한시적으로 유지한다”고 적은 그 결정은 기록으로만 남지 않았습니다.

남는 생각

두 판단 모두 “업계에선 이렇게 한다”를 이 시스템이 어느 계약을 따르는지 보지 않고 들이댄 것이었습니다. 멱등키는 우리가 어느 PG를 모델링하는지가 정답을 바꿨고 트랜잭션 경계는 단일 DB 모놀리스인지 MSA인지가 정답을 바꿨습니다. 트랜잭션 경계 쪽에서는 하나 더 배웠습니다. 결정 기록에 “지금은 유지한다”고 적어도 그 기록을 쓴 스스로가 다시 들여다보면 판단은 또 바뀔 수 있습니다. 바뀐 이유를 남겨야 다음에 다시 흔들릴 때 무엇을 물어야 하는지 알 수 있습니다.

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.