중복 결제 요청을 하나의 결과로 수렴시키는 다층 멱등성 설계
결제 버튼, 로컬 재전송, 여러 API 인스턴스의 동시 요청을 프런트 잠금·Redis 분산 락·기존 거래 재조회로 한 결과에 수렴시킨 과정을 정리합니다.
결제 화면에서 버튼을 한 번 눌렀다고 서버 요청도 반드시 한 번만 발생하는 것은 아닙니다. 더블 클릭, 느린 응답 뒤 재시도, 로컬에 보관한 결제 기록의 재전송, 서로 다른 단말기의 동시 결제가 같은 승인 결과를 여러 번 저장할 수 있습니다.
이 문제를 프런트엔드 버튼 비활성화만으로 막으면 브라우저 밖에서 발생하는 경쟁 조건을 놓칩니다. 반대로 서버 락만 두면 이미 완료된 요청의 재시도를 오류로 돌려 사용자가 결제 상태를 알기 어려워집니다. 이번 작업에서는 같은 결제 의도를 여러 번 받아도 하나의 기존 결과로 수렴시키는 것을 목표로 프런트, API, Redis, DB 조회를 함께 정리했습니다.
중복 요청은 한 경로에서만 생기지 않았다
문제의 진입점은 크게 세 가지였습니다.
- 결제 버튼을 빠르게 다시 눌러 같은 요청이 겹칩니다.
- 승인 뒤 저장에 실패해 로컬에 남긴 기록을 여러 실행 흐름이 동시에 재전송합니다.
- 웹 결제와 단말기 결제가 같은 청구서를 거의 동시에 처리합니다.
세 경우 모두 “요청을 한 번만 보내자”만으로는 해결되지 않습니다. 네트워크에서는 성공 응답이 유실될 수 있고, 사용자는 결과를 모른 채 다시 시도할 수 있습니다. 여러 서버 인스턴스가 요청을 나눠 받으면 프로세스 메모리의 플래그도 공유되지 않습니다.
따라서 다음처럼 각 계층이 서로 다른 실패 구간을 막도록 구성했습니다.
flowchart LR
A[결제 요청] --> B[화면 중복 실행 차단]
B --> C[결제 요청 기준 분산 락]
C --> D[기존 정상 거래 재조회]
D --> E{이미 처리됨?}
E -->|예| F[기존 거래 ID 반환]
E -->|아니오| G[트랜잭션으로 저장]아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.
핵심은 재시도를 무조건 거절하는 것이 아니라, 이미 끝난 작업이면 같은 성공 결과를 돌려주는 것입니다.
프런트엔드는 중복의 양을 줄이되 최종 방어선이 되지 않는다
결제 화면에는 진행 상태를 두고 처리 중에는 버튼을 비활성화했습니다.
const isSubmitDisabled = isSubmitting || !isValidAmount;
<Button onClick={submitPayment} disabled={isSubmitDisabled}>결제하기</Button>
이 장치는 사용자의 연속 클릭을 줄이고, 처리 중이라는 상태를 UI에 표현합니다. 하지만 탭이 여러 개 열렸거나 앱이 재시작된 경우까지 막을 수는 없습니다.
승인 후 저장하지 못한 결제 기록은 로컬 스토리지에서 다시 전송합니다. 이 경로에는 실행 전체를 보호하는 플래그와 레코드별 잠금을 함께 적용했습니다. 잠금에는 만료 시간을 두고, 자신이 만든 토큰과 일치할 때만 해제했습니다.
const acquireRetryLease = (recordId: string) => {
const key = makeRetryKey(recordId);
const owner = createRandomId();
const current = readLease(key);
if (current && current.expiresAt > Date.now()) return null;
writeLease(key, { owner, expiresAt: Date.now() + leaseDuration });
return readLease(key)?.owner === owner ? { key, owner } : null;
};
브라우저 잠금은 동일한 저장 기록이 한 클라이언트에서 겹쳐 전송되는 일을 줄입니다. 다만 사용자가 다른 기기에서 요청하거나 서버가 여러 인스턴스로 동작하는 상황은 여전히 서버가 책임져야 합니다.
서버에서는 결제 요청 식별자를 기준으로 동시 실행을 제어했다
결제 요청을 구분하는 식별자로 분산 락 키를 만들었습니다. 식별자가 제공되지 않는 경로에는 요청 문맥을 조합한 별도 키를 사용했습니다.
const requestKey = buildOpaqueRequestKey(paymentRequest);
const lock = await lockStore.acquire(requestKey, lockDuration);
분산 락은 로드밸런서 뒤의 서로 다른 API 인스턴스가 같은 요청을 동시에 처리하는 것을 직렬화합니다. 여기서 중요한 점은 락 획득 실패를 곧바로 결제 실패로 간주하지 않았다는 것입니다.
다른 요청이 먼저 처리 중이라면 잠시 기다린 뒤 같은 요청의 정상 거래를 다시 찾았습니다. 이미 저장됐다면 새 거래를 만들지 않고 기존 거래 ID를 성공 응답으로 반환했습니다.
if (!lock.acquired) {
await waitForConcurrentRequest();
const previous = await paymentRepository.findCompleted(requestId);
if (previous) return response.ok(previous.id);
return response.conflict("결제 처리 중입니다. 잠시 후 다시 시도해주세요.");
}
락은 동시 실행을 줄이고, 기존 결과 조회는 재시도를 성공으로 수렴시킵니다. 둘 중 하나만으로는 멱등한 사용자 경험을 만들기 어렵습니다.
락을 얻은 뒤에도 저장 직전에 다시 확인했다
락을 얻었다고 해서 중복 검사가 끝난 것은 아닙니다. 이전 요청이 락을 해제한 직후 현재 요청이 락을 획득했을 수 있기 때문입니다. DB 트랜잭션 안에서 다시 기존 거래를 확인한 뒤, 없을 때만 결제 이력과 거래를 생성했습니다.
const resultId = await database.transaction(async (tx) => {
const previous = await paymentRepository.findCompleted(requestId, tx);
if (previous) return previous.id;
return paymentRepository.save(paymentRequest, tx);
});
위 코드는 실제 구현의 핵심 흐름만 남긴 축약본입니다. 저장 작업을 하나의 트랜잭션으로 묶고, 락 해제는 finally에서 수행했습니다.
묶음 결제는 “하나라도 결제됨”을 성공으로 보면 안 됐다
여러 청구서를 한 번에 처리하는 경로는 단건보다 판정이 까다로웠습니다. 일부 청구서만 완료됐거나 각 청구서가 서로 다른 거래를 가리키는데도 기존 성공으로 반환하면, 불완전한 묶음이 정상 결제로 보일 수 있습니다.
후속 개선에서는 다음 두 조건을 모두 만족할 때만 기존 거래를 멱등 성공으로 반환했습니다.
- 묶음의 모든 청구서가 완료 상태다.
- 모든 청구서가 동일한 거래를 가리킨다.
일부만 완료됐거나 거래 ID가 섞인 경우에는 자동으로 성공 처리하지 않고 명시적으로 차단했습니다. 이는 멱등성이 단순한 중복 제거가 아니라 요청 전체가 같은 결과에 도달했는지 검증하는 계약임을 보여줍니다.
멱등성 조회도 운영 비용을 고려해야 했다
기존 결제 조회는 요청마다 실행됩니다. 조회 조건은 요청 소유자, 요청 식별자, 거래 상태, 생성 시각이었고 이에 맞춰 복합 인덱스를 추가했습니다.
-- 예시 스키마
CREATE INDEX idx_payment_lookup
ON example_payment (organization_id, request_id, state, created_at);
마이그레이션은 기존 인덱스 존재 여부를 먼저 확인하도록 구성했습니다. 중복 방어 로직이 새로운 조회 병목이나 배포 잠금으로 이어지지 않게 한 것입니다.
확인된 결과와 한계
이 변경으로 확인할 수 있는 구조적 결과는 다음과 같습니다.
- 연속 클릭과 로컬 기록 재전송이 프런트에서 중복 실행되지 않도록 했습니다.
- 여러 API 인스턴스의 동시 요청을 Redis 분산 락으로 직렬화했습니다.
- 락 대기 후와 DB 저장 직전에 기존 정상 거래를 재조회했습니다.
- 묶음 결제는 전체 완료와 단일 거래 일치를 모두 확인하도록 강화했습니다.
- 관련 변경은 웹과 API의 운영 브랜치에 반영됐습니다.
이 설계는 승인번호가 결제 의도를 안정적으로 식별한다는 전제에 기대고 있습니다. 결제 수단마다 식별자 품질이 다르다면 서버가 별도 idempotency key를 발급하는 방식도 검토해야 합니다.
멱등성은 중복을 거절하는 기능이 아니다
이번 작업에서 얻은 가장 중요한 기준은 다음과 같습니다.
- 클라이언트 방어는 요청량을 줄이는 역할로 한정합니다.
- 서버는 여러 인스턴스에서도 공유되는 잠금을 사용합니다.
- 락 획득 여부와 별개로 기존 결과를 다시 확인합니다.
- 재시도에는 가능한 한 동일한 성공 결과를 반환합니다.
- 묶음 요청은 일부 성공을 전체 성공으로 오인하지 않습니다.
좋은 멱등성은 두 번째 요청을 막는 것이 아니라, 같은 의도를 언제 다시 보내도 같은 최종 결과를 돌려주는 설계입니다.