멱등키라는 개념을 처음 접했을 때는 대략 “POST 요청에서 중복 요청을 막기 위해 사용하는 값” 정도로 이해하고 있었다.
이를테면 결제 요청을 보냈는데 네트워크 문제로 응답을 받지 못했다거나, 사용자가 버튼을 따닥 눌러 동일한 요청이 여러 번 들어온다거나 하는 상황에서 동일한 멱등키를 함께 보내면 서버가 동일한 요청을 한 번만 처리할 수 있도록 해주는 장치 정도로 생각했다.
틀린 이해는 아니지만, 최근에 팀의 채용 과정에서 사전과제를 리뷰하고 면접을 진행하면서 이 설명만으로는 조금 부족하다는 생각이 들었다.
사전과제에는 금전과 관련된 특정 행위에 대해 중복 처리를 방어하는 요구사항이 있었고, 많은 분들이 멱등키를 사용해 구현해주셨다. 그런데 실제 면접에서 “멱등키는 어디에서 언제 발급하나요?”, “지금 구현하신 멱등키로는 어느 범위의 중복까지 막는 것을 고려하셨나요?” 같은 질문을 드렸을 때 답변이 다소 모호한 경우들이 있었다.
클라이언트에서 방어를 못해 버튼 따닥 이슈가 발생할 수도 있고, 네트워크 유실로 인해 클라이언트가 재시도를 했을 수도 있고, 혹은 실제로 사용자가 같은 행위를 다시 요청했을 수도 있다.
이 모든 상황을 똑같이 “중복”이라고 부를 수는 있겠지만, 과연 멱등키 하나로 이 모든 중복을 막을 수 있을까? 생각난김에 멱등키가 무엇이고, 정확히 어떤 범위의 중복을 막아주는지 정리해보자.
멱등성이란?
멱등성(Idempotency)은 동일한 연산을 여러 번 수행하더라도 결과가 한 번 수행한 것과 동일하게 유지되는 성질을 말한다.
HTTP 메서드 기준으로 보면 GET, PUT, DELETE 등은 일반적으로 멱등한 메서드로 분류되고, POST는 멱등하지 않은 메서드로 분류된다.
예컨대 같은 리소스를 여러 번 조회한다고 해서 서버 상태가 바뀌진 않으니 GET은 멱등하다고 볼 수 있으며, PUT 역시 같은 리소스를 같은 값으로 여러 번 수정한다면 최종 상태는 동일하게 유지된다.
DELETE도 조금 헷갈릴 수 있는데, 같은 리소스에 대해 DELETE를 여러 번 호출했을 때 첫 번째 요청은 성공하고 이후 요청은 404를 반환할 수도 있다. 하지만 서버의 최종 상태는 “해당 리소스가 삭제된 상태”로 동일하므로 서버 상태 관점에서는 멱등하다고 볼 수 있다.
반대로 결제 생성, 주문 생성, 포인트 적립 같은 POST 요청은 같은 요청이 여러 번 처리될 경우 서버 상태가 계속 바뀔 수 있다. 이 말은 즉, POST 요청은 네트워크 재시도나 사용자 중복 클릭에 의해 같은 행위가 여러 번 처리될 위험이 있다는 것이다.
그리고 이 문제를 방어하기 위한 대표적인 방법 중 하나가 멱등키(Idempotency Key)를 사용하는 것이다.
멱등키란?
멱등키는 클라이언트가 특정 요청을 식별하기 위해 서버에 함께 전달하는 고유한 키이다. 서버는 이 키를 기준으로 “이 요청이 이전에 처리된 적 있는 요청인지”를 판단한다. 대략적인 흐름은 아래와 같다.

이 흐름만 보면 멱등키 자체는 생각보다 단순하다. 서버는 멱등키를 기준으로 이미 처리한 요청인지 확인하고, 처리한 적이 없다면 실제 비즈니스 로직을 수행한다. 반대로 이미 처리한 적이 있다면 같은 로직을 다시 수행하지 않고 이전 결과를 반환한다.
핵심은 멱등키라는 헤더나 컬럼이 존재하는 것이 아니라, 동일한 논리적 요청에 대해 동일한 키가 유지되는 것이다.
멱등키는 어디에서 발급해야할까?
여기서 고민해볼만한 포인트는 멱등키가 언제, 어디에서 발급되느냐이다.
멱등키는 일반적으로 클라이언트가 특정 행위를 시작하기 전에 생성하고, 해당 행위가 재시도되는 동안 동일하게 유지되어야 한다.
만약 서버가 요청을 받을 때마다 멱등키를 새로 발급한다면 어떻게 될까? 당연히 매 요청마다 새로운 키가 생기므로 서버 입장에서는 모두 다른 요청처럼 보일 것이다.
클라이언트가 재시도할 때마다 UUID를 새로 만들어 보내는 경우도 마찬가지이다. 이 경우 겉으로는 멱등키를 사용하고 있는 것처럼 보이지만, 실제로는 중복 요청을 식별할 수 없다.
조금 풀어서 말하자면, 멱등키는 “요청마다 고유한 값”이라기보다는 “동일한 논리적 요청을 식별하기 위한 값”에 가깝다. 따라서 사용자가 결제 버튼을 한 번 눌러 시작된 요청이라면, 네트워크 문제로 재시도하더라도 같은 멱등키가 유지되어야 한다.
반대로 사용자가 정말로 새로운 결제 행위를 다시 시작했다면 새로운 멱등키가 발급되는 것이 자연스럽다.
주로 어떤 작업에서 쓰일까?
멱등키는 주로 같은 요청이 두 번 처리되면 곤란한 POST 계열 작업에서 많이 사용된다. 대표적으로 결제, 송금, 주문 생성, 포인트 적립, 쿠폰 발급 같은 작업들이 있다.
이런 작업들의 공통점은 서버 상태를 변경한다는 것이다.
단순 조회 요청이라면 같은 요청이 여러 번 들어와도 큰 문제가 되지 않지만, 금전과 관련된 행위가 두 번 처리되어버리면 꽤나 곤란한 상황이 발생한다. 따라서 이런 작업에서는 클라이언트의 재시도나 응답 유실을 고려해 동일 요청이 여러 번 처리되지 않도록 방어할 필요가 있다.
코드로 간단히 예시를 들어보면 아래와 같은 흐름을 가져갈 수 있다.
public PaymentResult pay(PaymentRequest request, String idempotencyKey) {
// 동일 멱등키 처리내역 조회
IdempotencyHistory history = idempotencyRepository.findByKey(idempotencyKey);
// 처리내역 존재하는 경우
if (history != null) {
// 멱등키는 동일한데 요청 페이로드가 다른 경우
if (!history.hasSamePayload(request)) {
throw new InvalidIdempotencyKeyException();
}
// 동일한 멱등키에 대한 요청을 다른 쪽에서 이미 처리 중인 경우
if (history.isProcessing()) {
throw new RequestAlreadyProcessingException();
}
return history.getSavedResult();
}
// 처리내역 존재하지 않는 경우
// 해당 멱등키로 온 요청을 처리 중으로 마킹
idempotencyRepository.saveProcessing(
idempotencyKey,
request.payloadHash()
);
// 비즈니스 로직 처리
PaymentResult result = paymentService.pay(request);
// 처리완료로 업데이트
idempotencyRepository.saveSuccess(
idempotencyKey,
result
);
return result;
}
물론 실제 구현에서는 이 코드보다 더 많은 것들을 고려해야 한다. 멱등키 저장 자체가 동시성에 안전해야 하고, 처리 중 상태를 어떻게 응답할지도 정해야 하며, 같은 멱등키로 전혀 다른 payload가 들어왔을 때도 막아야 한다.
실패한 요청을 어떻게 저장할 것인지도 고민해야 한다. 예컨대 외부 결제 API 호출 전 validation 단계에서 실패한 요청과, 외부 결제 API 호출 이후 애매하게 실패한 요청은 같은 실패라고 보기 어려울 수 있다.
다만 핵심만 놓고 보면 “같은 멱등키로 들어온 같은 요청에 대해서는 같은 결과를 반환한다” 정도로 이해할 수 있겠다.
멱등키가 막아주는 중복
멱등키를 사용해 막고자 하는 중복은 “같은 요청의 재시도”이다.
대표적으로 아래와 같은 상황이다.
- 클라이언트가 요청을 보냈지만 네트워크 문제로 응답을 받지 못한 경우
- 서버에서는 정상 처리됐지만 응답이 유실되어 클라이언트가 재시도한 경우
- 사용자가 버튼을 빠르게 여러 번 눌러 같은 요청이 반복된 경우
- 클라이언트나 게이트웨이에서 타임아웃 이후 동일 요청을 재전송한 경우
이런 케이스들의 공통점은 사용자의 의도는 한 번이었지만, 기술적인 이유로 같은 요청이 여러 번 서버에 도달했다는 것이다. 따라서 클라이언트가 동일한 멱등키를 유지해 재시도한다면 서버는 이를 같은 요청으로 판단하고 중복 처리를 막을 수 있다.
여기서 중요한 것은 “동일한 멱등키를 유지한다면”이다. 멱등키를 사용한다고 말은 했지만, 재시도할 때마다 새로운 멱등키를 만들어 보낸다면 서버는 해당 요청들을 서로 다른 요청으로 볼 수밖에 없다. 이 경우 멱등키는 존재하지만 멱등하게 동작하지 않는다.
이상하지만 실제로 구현하다보면 충분히 나올 수 있는 케이스이다. (사실 나도 처음엔 별 생각 없이 UUID 박으면 되는거 아닌가 싶었다)
비즈니스 중복은 별개의 문제
멱등키를 이야기할 때 헷갈리기 쉬운 부분은 “중복”이라는 단어의 범위이다.
멱등키로 막고자 하는 중복요청의 범위는 네트워크 유실, 타임아웃, 클라이언트 재시도처럼 동일한 논리적 요청이 다시 들어오는 경우이다.
반대로 비즈니스 관점에서 “이 행위가 이미 수행되었는가?”를 판단해야 하는 문제는 멱등키만으로 해결하기 어렵고, 목적에 맞는 중복방지 처리도 아니다.
예를 들어 특정 주문에 대해 결제는 한 번만 가능해야 한다거나, 특정 사용자에게 특정 쿠폰은 한 번만 발급되어야 한다거나 하는 제약은 요청의 재시도 여부와는 조금 다른 문제이다. 이 경우에는 멱등키보다 orderId, userId + couponId 같은 도메인 식별자를 기준으로 유니크 제약을 걸거나, 상태 전이를 검증하는 쪽이 더 직접적인 해결책이 될 수 있다.
public PaymentResult pay(OrderId orderId, Money amount) {
if (paymentRepository.existsSuccessPayment(orderId)) {
throw new AlreadyPaidOrderException();
}
Payment payment = Payment.create(orderId, amount);
paymentRepository.save(payment);
return PaymentResult.from(payment);
}
물론 위 코드만으로 동시성까지 안전하다는 뜻은 아니며, 실제로는 DB 유니크 제약, 트랜잭션, 락, 상태 전이 검증 등을 함께 고려해야 한다.
핵심은 멱등키와 비즈니스 유니크 제약이 해결하려는 문제가 조금 다르다는 것이다. 멱등키는 같은 요청의 재시도를 막기 위한 장치이고, 유니크 제약이나 상태 검증은 비즈니스적으로 동시에 존재하면 안 되는 상태를 막기 위한 장치이다.
둘 다 중복 방어처럼 보이지만 막고 있는 중복의 범위가 다르다.
마치며
멱등키는 중복 요청을 방어하는 데 굉장히 유용한 방법이다.
특히 결제나 금전 관련 행위처럼 같은 요청이 두 번 처리되면 곤란한 작업에서는 거의 필수적으로 고려해야 하는 장치라고 볼 수 있다.
다만 멱등키를 도입하기 전에 먼저 정해야 할 것은 “어떤 중복을 막고 싶은가”이다.
클라이언트의 재시도까지 막고 싶은 것인지, 사용자의 따닥 클릭을 막고 싶은 것인지, 아니면 비즈니스적으로 동일한 행위가 여러 번 생성되는 것을 막고 싶은 것인지에 따라 해결 방법이 달라진다.
멱등키는 이 중에서도 주로 동일 요청의 재시도를 방어하는 장치에 가까우며, 비즈니스 관점의 중복은 DB 유니크 제약이나 상태 검증, 락, 트랜잭션 같은 더 직접적인 방법을 함께 고려해야 한다.
결국 핵심은 멱등키를 쓰느냐 마느냐보다, 내가 막고 싶은 중복의 범위를 먼저 정의하는 것에 있다.
'그 외 공부 > 기타' 카테고리의 다른 글
| 월 버짓 절반을 이틀만에 태우고 난 후 알아보는 AI 토큰 효율화 방법! (0) | 2026.08.24 |
|---|---|
| AI 엔지니어링 발전 흐름을 따라가보기 (0) | 2026.08.19 |
| Gemini CLI 사용해보기 ("해줘"하기) (0) | 2025.07.07 |
| ArchUnit으로 아키텍쳐 컨밴션 유지하기! (0) | 2025.05.20 |
| Replication Lag 해결전략 정리하기! (2) | 2025.01.17 |