분산 환경에서 같은 요청은 반드시 두 번 이상 들어온다. 클라이언트와 게이트웨이는 응답이 늦으면 다시 보내고, 서버는 이미 처리를 끝냈어도 그 사실을 알릴 방법이 없을 때가 있다. 이 글은 주문·결제처럼 두 번 실행되면 안 되는 API를 멱등 키(Idempotency-Key)와 유일 제약으로 지키는 방법을, 테이블 설계부터 동시 요청·장애 복구까지 정리한 것이다.

한눈에 보기
| 단계 | 하는 일 | 핵심 장치 |
|---|---|---|
| 키 받기 | 클라이언트가 요청마다 고유 키를 만들어 헤더에 싣는다 | Idempotency-Key 헤더, UUID |
| 키 선점 | 처리 전에 키를 PROCESSING으로 저장한다 |
DB 유일 제약 |
| 업무 처리 | 주문·결제 같은 실제 작업을 한 번 실행한다 | 하류 호출에도 같은 키 전달 |
| 결과 저장 | 응답 코드와 본문을 키 옆에 COMPLETED로 남긴다 |
응답 저장 컬럼 |
| 재시도 응답 | 같은 키가 오면 업무를 다시 돌리지 않고 저장된 응답을 준다 | 요청 해시 비교 |

중복 요청은 왜 생기나
중복은 버그가 아니라 정상 동작의 결과다.
- 응답 유실 뒤 재시도. 서버는 주문을 저장했는데 응답이 오는 길에 타임아웃이 나서 클라이언트가 다시 보낸다. 가장 흔하다.
- 중간 계층의 자동 재시도. 게이트웨이, HTTP 클라이언트 라이브러리, 서비스 메시가 연결 오류나 5xx에 재시도를 건다. 개발자가 모르는 사이에 켜져 있는 경우가 많다.
- 사용자 동작. 버튼을 두 번 누르거나 새로 고침을 한다. 버튼 비활성화는 서버 쪽 방어를 대신하지 못한다.
- 최소 한 번 전달 메시징. 소비자가 확인(ack) 전에 죽으면 브로커가 같은 메시지를 다시 준다. 메시지 쪽 중복 제거는 Outbox·Inbox 패턴 글에서 다뤘고, 이 글은 API에 집중한다.
공통점은 "보낸 쪽은 결과를 모르는데 받은 쪽은 이미 처리했다"는 상태다. 재시도를 막으면 요청이 유실되고, 그대로 받으면 두 번 실행된다. 해법은 재시도를 받되 같은 요청임을 알아보는 것이다.
HTTP 메서드만으로는 부족하다
RFC 9110은 GET, HEAD, PUT, DELETE 같은 메서드를 멱등(idempotent)으로 정의한다. 여러 번 보내도 서버에 남는 효과가 한 번 보낸 것과 같다는 뜻이다.
문제는 POST와 PATCH다. 주문 생성, 포인트 차감, 송금은 메서드 정의만으로는 멱등이 아니다. 그래서 요청에 "같은 요청"이라는 표식을 붙인다. 그것이 멱등 키다.
- 헤더 이름. IETF httpapi 작업 그룹의 초안 "The Idempotency-Key HTTP Header Field"가
Idempotency-Key헤더를 다룬다. 아직 RFC가 아닌 초안이지만 결제 API들이 써 온 방식을 정리한 것이라 맞춰 두면 연동이 쉽다. - 키 값. 클라이언트가 요청을 처음 만들 때 UUID를 만들고, 재시도할 때는 같은 값을 다시 싣는다.
- 키가 없을 때. 멱등 키가 필수인 API라면 400으로 거절한다.
키 저장소 테이블 설계
키 저장소는 이 키로 무엇을 요청했고 무엇을 응답했는지 남긴다. 업무 데이터와 같은 DB에 두면 트랜잭션과 유일 제약을 그대로 쓴다.
CREATE TABLE idempotency_key (
client_id VARCHAR(64) NOT NULL, -- 키 범위: 사용자·가맹점 단위
idem_key VARCHAR(255) NOT NULL,
request_hash CHAR(64) NOT NULL, -- 메서드 + 경로 + 본문의 SHA-256
status VARCHAR(16) NOT NULL, -- PROCESSING / COMPLETED
response_code INT,
response_body TEXT,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY (client_id, idem_key)
);
- 키 범위를 정한다. 기본 키를
idem_key하나가 아니라(client_id, idem_key)로 잡는다. 키만으로 찾으면 다른 사용자가 같은 키를 보냈을 때 남의 응답을 돌려주는 정보 노출이 생긴다. - 요청 해시를 남긴다. 같은 키에 다른 본문이 오면 클라이언트 버그다. 해시가 다르면 저장된 응답을 주지 않고 422로 거절한다.
- 상태를 둔다.
PROCESSING은 "누군가 처리 중",COMPLETED는 "응답이 확정됨"이다. - 응답을 통째로 저장한다. 상태 코드와 본문을 남겨야 재시도에 처음과 같은 응답을 줄 수 있다.
처리 순서와 동시 요청
순서는 "키 선점 → 업무 처리 → 결과 저장"이다. 선점은 INSERT 한 번으로 하고, 유일 제약 위반이 나면 이미 누군가 그 키를 가졌다는 뜻이다.
같은 키가 동시에 두 번 오면
INSERT는 하나만 성공한다. 진 쪽은 저장된 행의 상태를 본다.
COMPLETED면 저장된 응답을 그대로 준다.PROCESSING이면 409 Conflict로 "처리 중이니 잠시 뒤 다시 보내라"고 알린다. 기다렸다 다시 읽는 방식도 있지만 스레드를 붙잡으므로 상한을 둔다.- 해시가 다르면 422로 거절한다.
유일 제약이 진짜 방어선이다
Redis SET key value NX PX 30000 같은 분산 락으로 막는 설계도 많다. 락은 동시 실행은 막지만 중복 실행을 막는 기록은 아니다.
- 락은 처리가 끝나면 풀린다. 그 뒤에 도착한 재시도는 락을 새로 얻고 업무를 한 번 더 실행한다.
- 처리가 락 만료 시간을 넘거나 Redis 장애 조치 중 복제가 늦으면 두 요청이 동시에 락을 가진다.
- 락에는 응답이 남지 않아 재시도에 처음 결과를 돌려줄 수 없다.
락을 쓰더라도 마지막에는 DB 유일 제약이 막아야 한다. 주문 테이블에 UNIQUE(order_no)가 있으면 어떤 경로로 중복이 와도 두 번째 주문은 저장되지 않는다.
처리 중에 서버가 죽으면
선점과 업무 처리를 한 트랜잭션에 넣을 수 있으면 단순하다. 서버가 죽으면 키 행과 업무 데이터가 함께 롤백된다. PostgreSQL이나 MySQL InnoDB에서는 커밋되지 않은 같은 키의 INSERT가 앞 트랜잭션이 끝날 때까지 기다렸다가 결과에 따라 성공하거나 중복 오류를 받는다.
외부 결제처럼 트랜잭션에 넣을 수 없는 호출이 끼면 PROCESSING을 먼저 커밋한다. 이때 죽으면 남은 PROCESSING 행은 하류 시스템에 같은 키로 결과를 조회해 COMPLETED로 마무리하거나, 하류가 처리하지 않았음이 확인될 때만 지우고 재시도를 받는다. 확인 없이 지우면 두 번 결제된다.
Spring에서 구현하기
실행기를 하나 두고 컨트롤러는 키와 요청 해시만 넘긴다.
public record StoredResponse(int code, String body) {}
@Service
@RequiredArgsConstructor
public class IdempotentExecutor {
private final JdbcTemplate jdbc;
// 호출하는 쪽에 @Transactional 이 없어야 선점 INSERT 가 바로 커밋된다
public StoredResponse execute(String clientId, String key, String hash,
Supplier<StoredResponse> action) {
try {
jdbc.update("INSERT INTO idempotency_key (client_id, idem_key, request_hash, status, expires_at) "
+ "VALUES (?, ?, ?, 'PROCESSING', ?)",
clientId, key, hash, Timestamp.from(Instant.now().plus(Duration.ofHours(24))));
} catch (DuplicateKeyException e) {
return replay(clientId, key, hash);
}
StoredResponse res = action.get(); // 일시 오류 처리는 아래 본문 참고
jdbc.update("UPDATE idempotency_key SET status = 'COMPLETED', response_code = ?, response_body = ? "
+ "WHERE client_id = ? AND idem_key = ?", res.code(), res.body(), clientId, key);
return res;
}
private StoredResponse replay(String clientId, String key, String hash) {
Map<String, Object> row = jdbc.queryForMap(
"SELECT request_hash, status, response_code, response_body FROM idempotency_key "
+ "WHERE client_id = ? AND idem_key = ?", clientId, key);
if (!hash.equals(row.get("request_hash"))) throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY);
if (!"COMPLETED".equals(row.get("status"))) throw new ResponseStatusException(HttpStatus.CONFLICT);
return new StoredResponse(((Number) row.get("response_code")).intValue(), (String) row.get("response_body"));
}
}
@PostMapping("/orders")
public ResponseEntity<String> create(@RequestHeader("Idempotency-Key") String key,
@RequestBody String body, Principal user) {
StoredResponse r = executor.execute(user.getName(), key, sha256("POST /orders " + body),
() -> orderService.place(body));
return ResponseEntity.status(r.code()).body(r.body());
}
@RequestHeader는 기본이 필수라 헤더가 없으면 400이 나간다. 저장할 결과도 정해 둔다.
- 성공과 입력 검증 실패 같은 확정된 4xx는 저장한다.
- 5xx나 하류 타임아웃 같은 일시 오류는 저장하지 않는다. 하류가 처리하지 않았음이 확실하면 키 행을 지우고, 불확실하면
PROCESSING으로 남겨 위 복구 절차를 따른다.
하류 호출, 자연 키, 보관 기간
하류 호출에도 키를 넘긴다
안에서 부르는 결제·발송 API가 멱등이 아니면 중복은 거기서 생긴다. 하류가 멱등 키를 받으면 내 키에서 파생한 값(예: K1:payment)을 넘기고, 받지 않으면 호출 결과를 내 쪽에 기록해 재시도 때 먼저 확인한다.
자연 키가 있으면 먼저 쓴다
주문번호처럼 업무에 이미 고유한 값이 있으면 그것이 가장 좋은 멱등 키다. 주문서를 열 때 번호를 발급하고 제출할 때 그 번호로 주문을 만들면, 업무 테이블의 유일 제약이 중복을 막는다. 클라이언트 키는 자연 키가 없는 요청(송금, 포인트 적립)에 쓴다.
보관 기간을 정한다
키는 클라이언트의 최대 재시도 시간보다 길게만 두면 된다. Stripe는 공개 문서에서 키를 최소 24시간이 지난 뒤 정리할 수 있다고 밝힌다. 만료 뒤 같은 키는 새 요청으로 처리된다는 점을 API 문서에 적는다.
| 구성요소 | 맡는 일 | 없으면 생기는 일 |
|---|---|---|
Idempotency-Key 헤더 |
재시도가 같은 요청임을 알린다 | 서버가 재시도와 새 요청을 구분하지 못함 |
| 유일 제약 | 동시 요청 중 하나만 통과시킨다 | 락이 풀린 뒤 도착한 재시도가 두 번 실행됨 |
| 요청 해시 | 같은 키의 다른 요청을 걸러낸다 | 금액이 바뀐 요청이 이전 성공 응답을 받음 |
| 저장된 응답 | 재시도에 처음과 같은 결과를 준다 | 재시도 응답이 처음과 달라짐 |
PROCESSING 상태 |
처리 중과 완료를 구분한다 | 동시 요청·장애 복구 판단 불가 |
자주 하는 실수
- 재시도마다 새 키를 만든다. 서버는 매번 새 요청으로 본다.
- 키만으로 조회한다. 다른 사용자의 응답이 새어 나갈 수 있다.
- 분산 락을 중복 방지로 착각한다. 락은 처리 중에만 막고, 끝난 뒤의 재시도는 못 막는다.
- 처리 결과를 확인하지 않고
PROCESSING을 지운다. 하류 결제가 끝났다면 재시도가 두 번째 결제를 만든다. - 5xx 응답을 저장한다. 일시 오류를 확정 응답으로 저장하면 재시도가 영원히 실패를 받는다.
점검표
- 중복 실행이 위험한
POST·PATCHAPI 목록이 있다 - 클라이언트가 재시도할 때 같은
Idempotency-Key를 다시 싣는다 - 키 저장소의 기본 키가
(사용자 범위, 키)다 - 같은 키에 다른 본문이 오면 422로 거절한다
- 처리 중인 키로 온 요청은 409 또는 상한 있는 대기로 처리한다
- 업무 테이블에 자연 키 유일 제약이 걸려 있다
- 오래된
PROCESSING행을 하류 결과 확인 뒤에만 정리한다 - 5xx·타임아웃은 저장하지 않고 재시도를 허용한다
- 하류 결제·발송 호출에 파생 멱등 키를 넘긴다
- 키 보관 기간이 클라이언트 최대 재시도 시간보다 길다
멱등 API의 핵심은 재시도를 막는 것이 아니라, 같은 요청을 알아보고 처음과 같은 답을 돌려주도록 키·유일 제약·저장된 응답을 함께 설계하는 것이다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.