멱등성 설계: 결제와 메일에서 중복 실행을 막는 법
재시도가 있는 한 중복은 피할 수 없다는 전제에서 출발해 멱등성 키, 유니크 제약, 외부 호출 순서까지 중복 처리를 막는 설계를 정리했습니다.
결제 버튼을 두 번 누르면 어떻게 되나
사용자가 결제 버튼을 눌렀는데 응답이 느립니다. 답답해서 한 번 더 누릅니다. 결제가 두 번 됩니다.
또는 메일 발송 작업이 큐에서 실패로 판정되어 재시도됩니다. 사실 첫 시도에서 메일은 이미 나갔고, 응답만 못 받은 것이었습니다. 같은 메일이 두 번 갑니다.
둘 다 코드에 버그가 없어도 발생합니다. 네트워크는 응답을 잃어버릴 수 있고, 재시도는 그 상황을 구분하지 못하기 때문입니다. 그래서 필요한 것이 멱등성입니다. 같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 만드는 설계입니다.
재시도가 있는 한 중복은 막을 수 없다
먼저 전제를 정리해야 합니다. 중복 요청 자체를 없애는 것은 불가능합니다.
클라이언트가 요청을 보내고 응답을 못 받았을 때, 그 요청이 서버에 도달하지 않은 것인지 처리는 됐는데 응답만 유실된 것인지 알 방법이 없습니다. 재시도하면 중복 위험이 있고, 재시도하지 않으면 유실 위험이 있습니다. 둘 중 하나를 골라야 한다면 대부분의 시스템은 재시도를 택합니다.
그래서 방어선은 클라이언트가 아니라 서버가 같은 작업을 두 번 하지 않는 것에 있습니다.
멱등성 키: 요청에 이름표를 붙인다
방법은 단순합니다. 클라이언트가 요청마다 고유한 키를 만들어 보내고, 서버는 그 키를 이미 처리했는지 확인합니다.
POST /api/payments
Idempotency-Key: 7c2a1f9e-4b3d-4a1a-9b7e-2f5c8d0a1e33
키는 사용자의 의도 하나당 하나여야 합니다. 결제 화면에 진입할 때 하나 만들어두고, 그 화면에서 보내는 재시도는 전부 같은 키를 씁니다. 버튼을 누를 때마다 새로 만들면 아무 의미가 없습니다.
서버 쪽 처리는 이렇습니다.
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_fingerprint text NOT NULL, -- 같은 키에 다른 내용이 오는 것을 탐지
status text NOT NULL, -- in_progress | done
response jsonb, -- 완료된 응답을 그대로 재사용
created_at timestamptz NOT NULL DEFAULT now()
);
처리 흐름은 세 갈래입니다. 키가 없으면 새로 기록하고 작업을 진행합니다. 키가 있고 완료 상태라면 저장해둔 응답을 그대로 돌려줍니다. 키가 있고 진행 중이라면 동시에 들어온 중복이므로 잠시 후 다시 시도하라고 응답합니다.
여기서 중요한 것이 두 번째 갈래입니다. 이미 처리된 요청에 "중복입니다" 오류를 주면 안 됩니다. 첫 요청과 같은 응답을 줘야 클라이언트가 정상 흐름을 이어갈 수 있습니다. 응답을 저장해두는 이유가 이것입니다.
request_fingerprint는 같은 키로 다른 내용이 오는 경우를 잡기 위한 장치입니다. 금액이 다른 결제가 같은 키로 들어왔다면 클라이언트 버그이므로 오류로 처리해야 합니다.
경쟁 조건은 유니크 제약으로 막는다
"조회해서 없으면 삽입"은 동시에 들어오면 뚫립니다. 두 요청이 동시에 조회해 둘 다 "없음"을 확인하고 둘 다 삽입을 시도합니다.
막는 방법은 애플리케이션 코드가 아니라 데이터베이스 제약입니다. 키 컬럼에 유니크 제약을 걸고, 삽입을 먼저 시도한 뒤 충돌하면 중복으로 처리합니다.
try {
await db.insert(idempotencyKeys).values({ key, requestFingerprint, status: "in_progress" });
} catch (err) {
if (isUniqueViolation(err)) return handleExisting(key); // 이미 누군가 처리 중이거나 완료
throw err;
}
조회 후 분기하는 코드는 테스트에서 절대 재현되지 않고 운영에서만 터집니다. 제약으로 막는 것이 유일하게 확실한 방법입니다.
작업 자체를 멱등하게 만들기
키를 쓰지 않고도 멱등해지는 경우가 있습니다. 작업의 성격에 따라 다릅니다.
원래 멱등한 것들: 값을 특정 상태로 지정하는 작업입니다. "상태를 완료로 바꿔라"는 몇 번을 해도 결과가 같습니다. 삭제도 마찬가지입니다.
멱등하지 않은 것들: 상대적인 변경입니다. "잔액을 1,000원 줄여라"는 실행 횟수만큼 줄어듭니다. 메일 발송, 알림 전송, 외부 API 호출도 여기 속합니다.
그래서 가능하면 상대 변경 대신 절대 지정으로 설계하는 편이 안전합니다. 잔액을 직접 깎는 대신 거래 내역을 기록하고 합계를 계산하는 방식이 대표적입니다. 거래 내역에 고유 키를 두면 중복 삽입이 유니크 제약에 막힙니다.
외부 호출이 끼면 순서가 중요하다
결제 API를 호출하고 그 결과를 저장하는 작업을 생각해봅시다. 호출은 성공했는데 저장 직전에 프로세스가 죽으면, 우리 쪽에는 기록이 없고 상대 쪽에는 결제가 남습니다. 재시도하면 두 번 결제됩니다.
그래서 외부 호출 전에 의도를 먼저 기록해야 합니다. "이 키로 결제를 시도하려 한다"를 저장하고, 호출하고, 결과를 갱신하는 순서입니다. 중간에 죽어도 기록이 남아 있으므로, 복구 시 상대 쪽에 상태를 조회해 실제로 처리됐는지 확인할 수 있습니다.
대부분의 결제 API가 자체 멱등성 키를 지원합니다. 우리가 만든 키를 그대로 전달하면 상대 쪽에서도 중복이 막힙니다. 외부 API를 고를 때 확인해볼 만한 항목입니다.
키는 얼마나 보관해야 하나요?
재시도가 발생할 수 있는 기간보다 길면 됩니다. 사용자의 수동 재시도까지 고려하면 24시간에서 며칠 정도가 일반적입니다. 영구 보관하면 테이블이 계속 커지므로 만료된 키를 정리하는 작업을 함께 두세요. 다만 결제처럼 분쟁 가능성이 있는 작업은 키가 아니라 거래 기록 자체를 오래 남기는 것이 맞습니다.
GET과 DELETE도 멱등성 키가 필요한가요?
대체로 필요 없습니다. 조회는 상태를 바꾸지 않고, 삭제는 여러 번 해도 결과가 같기 때문입니다. 신경 써야 할 것은 생성과 상대적 변경, 그리고 외부에 영향을 주는 작업입니다. 실무에서는 결제, 메일·알림 발송, 외부 API 호출, 포인트·재고 변동이 주요 대상입니다.
클라이언트에서 버튼을 비활성화하면 되지 않나요?
필요한 조치지만 충분하지는 않습니다. 사용자가 새로고침하거나, 탭을 두 개 열거나, 네트워크가 끊겼다 붙는 경우를 막지 못합니다. 게다가 큐 재시도처럼 서버 내부에서 발생하는 중복은 클라이언트와 무관합니다. 버튼 비활성화는 사용자 경험을 위한 것이고, 정확성은 서버에서 보장해야 합니다.
한 번 끊어보면 알 수 있다
개발 중에 요청을 보내고 응답이 오기 전에 새로고침해보세요. 또는 같은 요청을 두 번 연속 보내보세요. 지금 구조가 중복을 견디는지 몇 초 만에 확인됩니다.
결제나 알림이 들어간 프로젝트를 만들었다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 중복 처리 문제는 사용자가 예상 밖의 순서로 조작할 때 드러나는데, 실제 방문자만큼 다양한 순서를 시도해주는 존재가 없습니다.
