배경
멱등성(idempotency)은 단순히 “두 번 호출해도 한 번만 처리된다(Exactly-Once)“를 벗어나 시스템에 큰 이점을 제공한다.
우리 서비스에는 급여계산을 위한 근무시간을 산출할 때, 반복적으로 수행되어도 괜찮게 설계되어 있다. 반복적으로 요청이 들어왔을 때 결괏값이 addUp 하는 게 아니라 refresh 된다.
휴가계산도 동일하게 적용된다. 회사의 연차규정과 직원의 입사일에 따라 연차가 계산되는데 새로운 요구사항이 전달되어 수정하게 되었을때 계산결과가 잘못되어도 올바르게 계산되는 로직으로 수정해서 실행시키면 덮어씌워지게 된다. 물론 돈과 관련된 일이라 항상 조심하고 테스트를 잘 작성해야 한다.
이렇게 좋은 특징을 가진 멱등성을 갖추지 못한 로직처리는 어떻게 해야 할까? 멱등성이 없어도 멱등성 있게 처리하는 방법이 있다. 바로 멱등성 키를 이용한 접근법이다.
멱등성
먼저 멱등성에 대해 좀 더 알아보자. 멱등성을 열 번 외쳐보자. 멱등성멱등성멱등성멱등성멱등성
- 여러 관점에서 이해할 수 있다
- HTTP 관점 : 같은 요청을 여러 번 보내도 논리적 결과가 동일해야 한다. 메서드 PUT/DELETE를 예시로 들 수 있으며 POST는 해당되지 않기 때문에 멱등성 “키"로 멱등성을 추가할 수 있다 [1]
- 비즈니스 관점 : HTTP관점과 별개로 생각한다. 도메인 기준으로 “결제가 두 번 발생해서는 안된다"처럼 설계해야 한다
- 멱등성 키
- 서버와 클라이언트의 계약을 공유하기 위한 장치
- 특정 키로 대표되는 요청이 이미 처리 중/되었다면 여러 요청이 와도 동일한 응답값을 보내도록 하는 것
락(Lock)과 멱등성 키(IdempotencyKey)의 차이
잠깐 짚고 넘어가기:
IdempotencyKey는 같은 의미의 반복적인 요청이 있을 때 한 번만 수행되도록 나머지는 무시하거나 재응답하도록 한다. 말하자면 중복을 방지하는 접근법이다. 반면에 Lock은 공유자원에 대한 접근권한을 제한하는 것이다.
키는 요청 레벨에서의 중복방지이며, 락은 자원 레벨에서 충돌방지라고 생각하자. 둘이 용도에 맞게 복합적으로 사용될 수 있으며, 각각의 차이를 이해하는 것이 중요하다.
멱등성 키를 사용하는 이유와 동작 원리
분산환경에서 네트워크를 통한 요청은 커맨드가 여러 번 들어올 가능성이 있다. 예를 들어, 네트워크 재시도, 버튼을 연타하는 사용자, 게이트웨이 자동 retry 등이 원인이 될 수 있다.
앞서 언급했던 해결전략 중 하나가 멱등성 키다. 요청에 대한 식별자를 유니크하게 만들어서 중복처리를 감지한다. 즉, 동일 요청을 무시하거나 재응답으로 처리해서 효과를 한 번만 발생하게 만드는 장치이다. 멱등성 키의 동작원리는 다음과 같다:
1) 요청 수신
2) (userId, scope, key)로 레코드 조회
- 없으면 INSERT(status=PROCESSING, requestHash=..., createdAt=...)
- 동시에 들어오면 한쪽은 UNIQUE 에러 → 다시 조회
3) status 확인
- COMPLETED → 저장된 응답 그대로 돌려줌
- PROCESSING → 409/202로 "처리 중" 응답 or 짧게 대기 후 재확인
4) 비즈니스 로직 실행
5) 결과/응답 저장 + status=COMPLETED로 UPDATE
클라이언트가 UUID 같은 유니크 키를 헤더에 (예: Idempotency-Key, PayPal-Request-Id)에 담아 보낸다. 서버가 내부적으로 멱등성 키를 생성해선 안되며 클라이언트가 생성해야 한다.
서버는 유니크한 키를 저장하고 처음이면 PROCESSING의 상태값을 갖게 한다. 그리고 요청된 내용이 처리가 완료되면 COMPLETED로 변경한다. 이때 빠르게 분산 저장소에 저장을 하고, 로직 처리를 진행하게 된다. 로직 처리가 다 된 다음에 COMPLETED로 멱등성 키를 만드는 것은 오류를 발생시킬 수 있다. 또한 분산 저장소는 싱글 스레드(예: 레디스)로 구성해서 동시성 이슈가 발생하지 않게 해야 한다.
이후 동일 키에 대해 동일한 응답을 반환하거나 충돌처리(409/202)를 하게 된다. 키에 대한 생명주기는 TTL로 관리할 수 있으며 도메인에 따라 적절한 때에 소멸시키면 된다.
실제 사례
온라인 판매 결제 중개 시스템을 API로 제공하는 Stripe는 네트워크 오류로 POST 요청의 성공 여부가 모호해지는 상황을 Idempotency Key로 해결하였다 [2]. 동일 키에 대한 재시도를 언제 할지 클라이언트에게 지터(Jitter) 혹은 지수 백오프(Exponential Back-off)를 사용하여 지연시켜 썬더링 허드(Thundering Herd)를 피할 수 있게 하였다. 여기서 지터는 재시도를 하기까지 대기시간을 랜덤 한 값으로 하는 것이고, 지수 백오프는 1, 2, 4…처럼 재시도 횟수에 따라 지수만큼 대기시간을 늘리는 것을 나타낸다. 썬더링 허드는 여러 프로세스가 Idle상태에 있다가 하나의 요청이 들어왔을 때 모든 프로세스가 깨어나게 되는데 이를 처리할 수 있는 프로세스는 하나인 상황을 말한다.
클라이언트 예시 :
curl -X POST https://api.stripe.com/v1/charges \
-u sk_test_xxx: \
-H "Idempotency-Key: 84f3b2e0-0d5a-4b0b-9b0d-a2d8e5d4c1ab" \
-d amount=2000 \
-d currency=usd \
-d source=tok_visa \
-d description="Charge for order #1234"
Stripe와 유사하게 PayPal은 PayPal-Request-Id를 사용하며, Square는 idempotency_key, Adyen은 Idempotency-Key헤더로 중복 결제를 막는다. AWS EC2는 ClientToken으로 동일 인스턴스 생성 요청을 한 번만 성공시키며, SQS FIFO는 MessageDeduplicationId로 5분 안에 발생하는 중복 메시지를 걸러낸다. 구글도 BigQuery의 insertId, Cloud Tasks의 사용자 지정 태스트 ID로 동일한 패턴을 적용한다.
결론은 “클라이언트가 유니크 토큰을 붙이고, 서버가 그 토큰 기준으로 최초 처리 결과만 인정한다"는 원칙이 여러 서비스에서 거의 같은 형태로 표준처럼 쓰이고 있다.