서버가 결제 원장을 커밋한 뒤 응답만 유실된다고 하자. 클라이언트가 5초 뒤 재시도하면 두 호출은 시간상 겹치지 않는다. 첫 요청의 Redis 잠금이 이미 해제됐다면 잠금은 이 재전송을 구별하지 못한다.
이 글의 질문은 같은 결제 의도의 재시도가 중복 실행 없이 확인 가능한 결과로 이어지려면 무엇을 남겨야 할까다. 요청 키, 로컬 UNIQUE와 외부 PG 계약을 나누고 중복 차단 이후의 응답 정책을 비교한다.
네 가지 장치를 같은 보장으로 묶지 않는다
잠금은 일정 시간의 실행 경합을 조정한다. 요청 ID는 여러 호출이 같은 의도인지 식별한다. UNIQUE는 같은 의도가 로컬 DB에 중복 저장되지 않게 한다. PG 멱등성 키는 외부 시스템의 반복 호출 계약이다.
내부 요청 ID와 PG 거래 ID도 구분한다. Redis 잠금과 DB UNIQUE로 중복을 막고 백엔드가 Conflict를 반환하며 Next.js가 그 상태를 해석하는 계약을 먼저 생각할 수 있다. 이 계약은 최초 성공 응답을 저장해 재사용하는 방식과 다르다. 아래의 응답 저장·상태 모델은 그 이후에 선택할 수 있는 확장 설계다.
키는 결제 의도를 만드는 쪽에서 정하고 재시도에 유지한다. 처리할 때마다 새 키를 만들면 이전 의도와 연결할 수 없다. 같은 키에 다른 금액·주문을 보내는 경우도 구별해야 한다. AWS의 멱등 API 설계는 요청 식별자 기록과 관련 상태 변경을 하나의 원자적 작업으로 묶는 기준을 설명한다.
UNIQUE 다음에는 중복 응답 계약이 필요하다
다음은 명령 상태와 응답 재사용을 추가하는 스키마 예제다.
CREATE TABLE payment_command (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL,
operation varchar(32) NOT NULL,
idempotency_key uuid NOT NULL,
request_hash varchar(64) NOT NULL,
status varchar(24) NOT NULL,
provider_tx_id varchar(128),
response_payload jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_payment_command
UNIQUE (order_id, operation, idempotency_key)
);
request_hash는 같은 키의 입력 일치를 검사하고 response_payload는 완료 결과를 재사용할 때 선택하는 컬럼이다. 저장 범위와 보존 기간, 민감정보 제외를 함께 정한다. 단순 Conflict 방식에 이 컬럼이 필수인 것은 아니다.
명령을 새로 만들지 판정하는 SQL은 다음처럼 구성할 수 있다.
INSERT INTO payment_command (
order_id, operation, idempotency_key, request_hash, status
)
VALUES (:order_id, 'PAY', :idempotency_key, :request_hash, 'PROCESSING')
ON CONFLICT (order_id, operation, idempotency_key)
DO NOTHING
RETURNING id, status;
반환 행이 없으면 새 작업을 시작하지 않고 기존 명령을 확인한다. READ COMMITTED에서는 별도 후속 조회로 충돌 행을 읽는 흐름을 고려한다. 입력 hash가 다른 경우는 키 오용이며, 같은 입력의 중복은 완료·처리 중·결과 불명 가운데 실제 상태에 맞춰 응답한다. DO NOTHING만으로 어떤 응답을 반환할지 결정되지는 않는다. PostgreSQL ON CONFLICT와 유일성 검사가 저장 시점 판정의 근거다.
Conflict를 선택하면 클라이언트는 기존 결제 조회 경로로 결과를 확인할 수 있어야 한다. 결과 재사용을 선택하면 응답 저장과 최종 상태를 함께 커밋하고, 같은 입력에 동일한 의미를 돌려준다. 조회 비용과 보존 정책, 기존 클라이언트 계약을 기준으로 선택한다.
그림의 동일 결과 반환은 응답 재사용을 포함한 확장 설계의 목표다. 중복을 Conflict로 차단하는 기존 계약과 구분한다. PG 멱등성·조회 지원은 사용하는 공급자의 계약을 확인한 뒤 연결한다.
로컬 커밋과 PG 승인 사이에는 빈 구간이 남는다
PG가 승인하고 응답만 잃으면 로컬 시스템은 성공 여부를 모른다. 반대로 PG 호출 전에 로컬 원장을 성공으로 확정하면 프로세스 중단 시 외부 결제가 없는 성공이 남을 수 있다. 일반적인 로컬 DB 트랜잭션은 PG까지 원자적으로 커밋하지 않는다.
외부 호출 전에 명령을 영속화하고, 승인 응답 뒤 원장과 완료 상태를 함께 커밋한다. 두 번째 커밋이 실패하면 남은 PROCESSING 명령을 PG 거래 조회·웹훅·대사로 조정한다. 로컬 UNIQUE가 있다는 이유만으로 외부 승인까지 exactly-once라고 표현하지 않는다.
Stripe는 같은 키의 첫 status와 body를 저장하며 500도 재사용하는 계약을 제공한다. 이는 특정 공급자의 응답 정책이다. 다른 PG가 동일하다고 가정하지 않고 키 보관 기간, 입력 비교, 재시도와 조회 범위를 확인한다. Stripe 멱등 요청.
잠금은 DB 연결 밖에서 기다리고 완료 뒤 해제한다
동일 주문의 비싼 호출 경합을 줄이는 키는 payment-lock:{orderId}처럼 정할 수 있다. 다음 코드는 저장소 구현을 생략한 구조 예제다.
fun pay(command: PayCommand): PayResult {
val lock = lockManager.acquire("payment-lock:${command.orderId}")
?: return PayResult.InProgress
try {
val claim = transactionTemplate.execute {
commandRepository.createOrClaim(command)
} ?: error("transaction failed")
if (claim.requestHash != command.hash()) return PayResult.Conflict
if (claim.isCompleted()) return claim.toResult()
if (!claim.mayCallProvider()) return PayResult.InProgress
val approval = provider.approve(
idempotencyKey = command.idempotencyKey.toString(),
amount = command.amount
)
return transactionTemplate.execute {
paymentLedger.appendOnce(claim.id, approval)
commandRepository.markSucceeded(claim.id, approval)
approval.toPayResult()
} ?: error("transaction failed")
} finally {
lock.releaseIfOwner()
}
}
createOrClaim은 DB 상태와 소유권을 기준으로 외부 호출 가능한 작업자를 판정해야 한다. 기존 PROCESSING 행을 읽었다는 이유만으로 모두 호출하면 TTL 만료 뒤 다시 겹친다. 소유권 변경과 만료 정책, 원장의 고유성, 결과 불명 처리도 구현해야 이 흐름이 완성된다. 외부 키는 공급자가 해당 API에서 지원할 때만 효력이 있다.
트랜잭션 밖에서 잠금을 기다리면 DB 연결을 기다림에 묶지 않을 수 있다. transactionTemplate이 종료된 뒤 해제하는 순서는 메서드 안의 finally와 프록시 커밋 순서가 달라지는 문제를 피하려는 선택이다. 소유 확인 해제와 TTL 초과 방어는 실제 잠금 구현에서 확인한다.
재시도 가능한 실패는 상태로 구별한다
명시적 거절은 REJECTED, 결과를 알 수 없으면 PENDING, 외부 처리가 시작되지 않았다는 근거가 있으면 FAILED_RETRYABLE처럼 구별할 수 있다. 완료 결과 저장을 선택한 SUCCEEDED와 입력 불일치도 따로 처리한다. 상태명보다 각 상태에서 조회·재호출·키 변경을 허용하는 조건이 필요하다.
검증에서는 명령 저장 전, 저장 커밋 후 호출 전, PG 승인 응답 전, 로컬 원장 커밋 전, HTTP 응답 전을 각각 끊는다. 같은 키·같은 입력과 같은 키·다른 입력을 보내 로컬 원장, PG 거래 수, 응답 의미와 미확정 작업 복구를 확인한다. 이는 수행 결과가 아니라 필요한 실패 재현 절차다.
중복 차단만 필요한 API라면 Conflict와 결과 조회 계약을 먼저 명확히 한다. 응답 유실 후 같은 결과를 직접 돌려주려면 영속 상태와 응답 보존을 추가로 설계한다. 어느 경우든 실제 외부 승인과 로컬 저장이 갈라지는 지점을 남겨야 재시도 정책을 판단할 수 있다.

