← 블로그 목록

Developer Knowledge

개발자가 알아야 할 지식: 멱등성 키, 재시도해도 한 번만 처리되게 만드는 약속

네트워크 실패와 중복 요청이 피할 수 없는 환경에서 결제, 주문, 가입 같은 작업을 안전하게 재시도하기 위한 멱등성 키 설계 원칙을 설명한다.

Stripe
  • 개발자가 알아야 할 지식
  • Software Engineering
  • API Design
  • Distributed Systems
  • Reliability

왜 개발자가 알아야 하나

실무 시스템에서 가장 위험한 버그는 “실패했다”고 보였는데 사실은 성공한 경우다. 사용자가 결제 버튼을 눌렀고, 서버는 카드 승인까지 끝냈지만 응답을 보내는 순간 네트워크가 끊겼다고 해보자. 클라이언트 입장에서는 요청이 실패한 것처럼 보인다. 그래서 같은 요청을 다시 보낸다. 서버가 이 요청을 새 결제로 처리하면 사용자는 두 번 결제된다. 서버가 무조건 재시도를 막으면 사용자는 실제로 실패한 결제를 복구하지 못한다. 이 애매한 회색 지대가 멱등성 키가 필요한 자리다.

분산 시스템에서는 “정확히 한 번 처리”가 생각보다 비싸고 어렵다. 클라이언트, API 서버, 데이터베이스, 메시지 큐, 외부 결제사, 알림 서비스가 모두 같은 순간에 완벽히 합의해주지 않는다. 타임아웃, 연결 종료, 프로세스 재시작, 큐 재전달, 사용자의 더블 클릭은 정상적인 사건이다. 그래서 좋은 시스템은 실패가 없다고 가정하지 않고, 같은 의도의 요청이 여러 번 도착해도 결과가 망가지지 않도록 설계한다.

멱등성 키는 이 문제를 해결하기 위한 작지만 강력한 계약이다. 클라이언트가 “이 요청은 이전 요청과 같은 의도다”라는 식별자를 함께 보내고, 서버는 그 키를 기준으로 처음 처리한 결과를 기억한다. 같은 키가 다시 오면 새 작업을 만들지 않고 이전 결과를 돌려준다. Stripe가 결제 API에서 멱등성 키를 강조하는 이유도 여기에 있다. 돈, 주문, 쿠폰, 포인트처럼 부작용이 큰 작업에서는 재시도를 허용하면서도 중복 실행을 막아야 한다.

개발자가 이 개념을 알아야 하는 이유는 단순히 결제 API를 붙일 때만 쓰기 때문이 아니다. 회원가입 이메일 발송, 주문 생성, 파일 업로드 완료 처리, 배치 작업 재시작, 웹훅 수신, 메시지 소비자 처리까지 “한 번만 일어나야 하는 부작용”은 곳곳에 있다. 멱등성을 모르면 장애 상황에서 운영자가 수동으로 중복 데이터를 지우게 되고, 멱등성을 알면 애초에 재시도 가능한 시스템을 만들 수 있다.

핵심 개념

멱등성은 같은 연산을 여러 번 적용해도 결과가 한 번 적용한 것과 같다는 성질이다. HTTP 메서드로 보면 GET은 보통 멱등적이고, PUT도 같은 리소스를 같은 상태로 만드는 요청이라면 멱등적으로 설계하기 쉽다. 반면 POST /orders처럼 새 주문을 만드는 요청은 기본적으로 멱등적이지 않다. 같은 요청이 두 번 오면 주문이 두 개 생길 수 있기 때문이다.

멱등성 키는 비멱등 작업에 “같은 의도”라는 경계를 추가한다. 예를 들어 클라이언트가 주문 생성 요청을 보낼 때 Idempotency-Key: 8f6c... 같은 값을 함께 보낸다. 서버는 이 키와 요청자, 엔드포인트, 요청 본문의 해시, 처리 상태, 응답 값을 저장한다. 첫 요청이 성공하면 주문 ID와 응답을 저장한다. 같은 키로 다시 요청이 오면 서버는 주문을 새로 만들지 않고 저장된 응답을 반환한다.

여기서 중요한 점은 키가 “같은 내용”이 아니라 “같은 의도”를 나타내야 한다는 것이다. 사용자가 같은 상품을 오늘 두 번 주문할 수도 있다. 두 주문은 본문이 같아도 다른 의도다. 따라서 클라이언트는 주문 시도 하나마다 고유한 키를 만들고, 같은 시도를 재시도할 때만 그 키를 재사용해야 한다. UUID v4처럼 충돌 가능성이 매우 낮은 랜덤 값을 쓰는 방식이 흔하다.

서버 구현에는 몇 가지 선택지가 있다. 가장 단순한 방식은 키별로 최종 응답을 저장하는 것이다. 이 방식은 재시도 응답이 일관적이지만 저장 공간과 보존 기간을 정해야 한다. Stripe 문서처럼 일정 시간이 지나면 키를 정리하는 정책이 필요하다. 다른 방식은 키별로 생성된 리소스 ID만 저장하고 현재 리소스를 조회해 응답을 재구성하는 것이다. 저장량은 줄지만 응답 형식이 시간이 지나며 바뀔 수 있으므로 호환성을 조심해야 한다.

또 하나의 핵심은 동시성이다. 같은 멱등성 키를 가진 요청 두 개가 거의 동시에 들어오면 둘 다 “아직 처리 기록이 없네?”라고 판단하고 중복 작업을 만들 수 있다. 그래서 저장소에는 유니크 제약 조건이나 원자적 insert가 필요하다. 일반적으로 idempotency_keyuser_id 또는 merchant_id 같은 소유자 범위를 묶어 유니크 인덱스를 둔다. 첫 요청만 처리권을 얻고, 나머지는 처리 중 상태를 보거나 완료 결과를 기다리게 해야 한다.

작은 예시 또는 체크리스트

주문 생성 API를 예로 들면 흐름은 대략 이렇다.

POST /orders
Idempotency-Key: 0f0f4f4e-7b51-4b5d-9a92-8b0d7f2c6a30

{ "productId": "pro-plan", "quantity": 1 }

서버는 먼저 (user_id, idempotency_key)로 기록을 찾는다. 기록이 있고 요청 본문 해시가 같다면 저장된 응답을 반환한다. 기록이 있는데 본문 해시가 다르다면 같은 키를 다른 의도로 재사용한 것이므로 409 Conflict400 Bad Request를 반환하는 편이 안전하다. 기록이 없다면 처리 중 상태로 새 기록을 원자적으로 만들고, 주문 생성 트랜잭션을 실행한 뒤 응답을 저장한다.

실무 체크리스트로 보면 다음을 확인하면 좋다.

  • 중복되면 안 되는 POST 작업에 클라이언트 생성 멱등성 키를 요구하는가?
  • 키 범위가 전역이 아니라 사용자, 계정, 테넌트 같은 소유자와 함께 묶여 있는가?
  • 같은 키에 다른 요청 본문이 들어오면 조용히 재사용하지 않고 오류로 막는가?
  • 처리 중인 키가 동시에 들어올 때 유니크 인덱스나 락으로 하나만 실행되게 하는가?
  • 성공 응답뿐 아니라 일부 실패 응답을 어떻게 저장하고 재전달할지 정책이 있는가?
  • 키 보존 기간과 정리 작업이 정해져 있는가?
  • 로그와 운영 도구에서 멱등성 키로 요청 흐름을 추적할 수 있는가?

실무에서 자주 생기는 오해

첫 번째 오해는 “DB에 유니크 키만 있으면 멱등성은 끝난다”는 생각이다. 유니크 키는 중복 생성을 막는 데 중요하지만, 클라이언트가 재시도했을 때 어떤 응답을 받아야 하는지는 별도 문제다. 이미 생성된 주문을 찾아서 같은 의미의 응답을 돌려줄지, 충돌 오류를 줄지, 처리 중이라고 알려줄지 정책이 있어야 한다.

두 번째 오해는 멱등성 키를 서버가 요청 본문에서 자동으로 계산하면 된다는 생각이다. 본문이 같다고 항상 같은 의도는 아니다. 같은 사용자가 같은 상품을 두 번 사는 것은 정상일 수 있다. 반대로 본문에 타임스탬프나 추적 필드가 들어가면 같은 의도인데 해시가 달라질 수도 있다. 그래서 의도의 경계는 클라이언트나 호출자가 명시적으로 정해주는 편이 낫다.

세 번째 오해는 모든 API를 멱등성 키로 감싸면 안전하다는 생각이다. 조회나 단순 업데이트처럼 이미 자연스럽게 멱등적인 작업에는 불필요한 복잡도를 더할 수 있다. 멱등성 키는 특히 새 리소스 생성, 외부 부작용, 돈이나 권한처럼 되돌리기 어려운 작업에 우선 적용하는 것이 좋다.

네 번째 오해는 재시도를 곧바로 무한 반복해도 된다는 생각이다. 멱등성 키가 중복 부작용을 줄여주더라도 서버 부하와 잠금 경합은 남는다. 클라이언트는 지수 백오프와 지터를 써야 하고, 서버는 처리 중 상태에 대한 응답 전략을 가져야 한다. 안전한 재시도와 공격적인 재시도는 다르다.

다섯 번째 오해는 멱등성이 외부 시스템까지 자동으로 보장된다는 생각이다. 내부 주문 생성은 한 번만 되었지만 외부 이메일 서비스 호출이 두 번 나갈 수 있다. 멱등성 경계 안에 어떤 부작용을 포함할지 정하고, 메시지 큐 소비자나 웹훅 처리자도 자체 멱등성을 갖도록 설계해야 한다.

오늘 바로 적용해보기

오늘 코드베이스에서 POST 엔드포인트를 하나 골라보자. 그 요청이 타임아웃 뒤 재시도되면 어떤 일이 생기는지 질문하면 된다. 주문이 두 개 생기는가? 알림이 두 번 가는가? 포인트가 두 번 적립되는가? 운영자가 데이터베이스에서 수동으로 지워야 하는가? 답이 불안하다면 그 엔드포인트는 멱등성 설계 후보가 된다.

작게 시작하려면 결제, 주문, 구독 변경, 쿠폰 사용처럼 부작용이 큰 API 하나에만 멱등성 키를 추가해도 된다. 처음부터 완벽한 프레임워크를 만들 필요는 없다. idempotency_records 테이블을 만들고, 키·소유자·요청 해시·상태·응답·만료 시각을 저장하는 것으로 충분히 많은 사고를 줄일 수 있다.

코드 리뷰에서도 이 질문을 습관화하면 좋다. “이 요청은 재시도되어도 안전한가?” “같은 메시지가 큐에서 두 번 전달되면 어떻게 되는가?” “외부 API 호출 후 응답 저장 전에 죽으면 복구할 수 있는가?” 이런 질문은 장애가 난 뒤에야 떠올리면 너무 늦다. 설계 단계에서 물어보면 구현은 조금 복잡해지지만 운영은 훨씬 단단해진다.

더 알아보기

오늘의 takeaway

재시도는 장애 대응의 기본이고, 멱등성 키는 그 재시도가 사용자의 돈과 데이터를 망치지 않게 만드는 최소한의 안전장치다.