중복 요청에도 안전한 API 설계 : 멱등성(Idempotency)과 멱등성 키

들어가며

결제 버튼을 빠르게 두 번 누르면 어떻게 될까?

사용자가 두 번 눌렀다는 이유로 결제까지 두 번 처리되어서는 안 된다.

 

결제뿐만 아니라 주문 생성, 포인트 차감, 쿠폰 사용과 같이 한 번만 실행되어야 하는 기능에서

중복 요청을 안전하게 처리할 수 있어야 한다.

 

 

 

1. 중복 요청은 왜 발생할까?

중복 요청은 개발자나 사용자가 의도하지 않아도 발생할 수 있다.

  1. 사용자가 버튼을 빠르게 두 번 클릭한 경우
  2. 네트워크 타임아웃으로 클라이언트가 요청을 자동 재전송한 경우
  3. 서버의 응답을 받지 못해 사용자가 다시 시도한 경우
  4. 동일한 요청을 고의로 반복해서 호출한 경우

따라서 “사용자가 한 번만 누를 것이다”라는 가정만으로 API를 설계해서는 안 된다.

 

2. 레이어별 중복 요청 방어 전략

중복 요청은 하나의 계층에서만 막기보다 프론트엔드, 애플리케이션, 데이터베이스에서 각각 방어하는 것이 좋다.

 

1) 프론트엔드

버튼을 클릭한 뒤 비활성화하거나 로딩 스피너를 표시할 수 있다.

요청 진행 여부를 나타내는 플래그를 두어 이미 요청 중이라면 추가 요청을 보내지 않는 방법도 있다.

하지만 새로고침이나 네트워크 계층의 자동 재전송까지 막을 수는 없다.

 

2) 데이터베이스

주문 번호나 결제 번호에 UNIQUE 제약 조건을 설정하면 동일한 데이터가 여러 번 저장되는 것을 방지할 수 있다.

하지만 데이터베이스 제약 조건만으로 외부 API 호출을 되돌릴 수는 없다.

 

예를 들어 PG사 결제 요청을 먼저 보낸 뒤 데이터베이스 저장 과정에서 중복이 발견되었다면, 우리 데이터베이스에는 한 건만 저장되었어도 PG사에는 결제 요청이 중복으로 요청될 수 있다.

 

3) 애플리케이션

따라서 API를 처리하는 애플리케이션 계층에서도 중복 요청을 식별해야 한다.

이때 멱등성 키를 사용한다.

 

3. 멱등성(Idempotency)이란?

멱등성이란 동일한 연산을 한 번 수행하든 여러 번 수행하든 최종 결과가 달라지지 않는 성질을 의미한다.

엘리베이터 호출 버튼을 여러 번 눌러도 엘리베이터가 여러 대 호출되지 않는 것과 비슷하다.

결제 API에서는 같은 요청을 100번 보내더라도 실제 결제는 한 번만 발생해야 한다.

 

여기서 중요한 점은 중복 요청 자체를 없애는 것이 아니다.

중복 요청이 들어오더라도 부작용이 반복되지 않도록 안전하게 처리하는 것이 핵심이다.

 

4. 멱등성 키의 동작 방식

먼저 클라이언트에서 하나의 작업을 식별할 수 있는 고유한 키를 생성한다.

일반적으로 충돌 가능성이 낮은 UUID를 사용할 수 있다.

 

 

POST /api/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

 

서버의 처리 흐름은 위 이미지와 같다. 다시 한 번 정리하자면

  1. 클라이언트가 요청 헤더에 멱등성 키를 담아 전송한다.
  2. 서버는 Redis와 같은 저장소에서 해당 키의 처리 이력을 확인한다.
  3. 처음 받은 키라면 결제 로직을 실행한다.
  4. 결제 결과와 응답 내용을 멱등성 키와 함께 저장한다.
  5. 같은 키가 다시 전달되면 결제 로직을 실행하지 않고 저장된 결과를 반환한다.

따라서 같은 요청이 여러 번 전달되더라도 실제 결제는 한 번만 실행된다.

 

 

5. Redis로 구현할 때의 주의점

단순히 Redis에서 키를 조회한 뒤, 키가 없으면 결제를 실행하는 방식에는 문제가 있다.

두 요청이 거의 동시에 들어오면 두 요청 모두 키가 없다고 판단하여 결제를 실행할 수 있기 때문이다.

요청 A → 키 없음 확인 → 결제 실행
요청 B → 키 없음 확인 → 결제 실행

 

따라서 최초 요청이 키를 선점하는 과정은 원자적으로 처리해야 한다.

Redis를 사용한다면 SET NX와 만료 시간을 함께 설정하는 방법을 고려할 수 있다.

SET idempotency:{key} PROCESSING NX EX 86400

키 생성에 성공한 요청만 실제 비즈니스 로직을 실행한다.

이미 키가 존재한다면 저장된 상태에 따라 처리한다.

  • PROCESSING이면 현재 처리 중이라는 응답을 반환한다.
  • SUCCESS이면 저장된 기존 응답을 반환한다.
  • 동일한 키에 요청 내용이 다르면 잘못된 키 재사용으로 판단한다.

결제가 완료되면 상태 코드와 응답 본문을 함께 저장한다.

 

외부 PG사가 멱등성 키를 지원한다면 PG사 요청에도 같은 작업을 식별할 수 있는 키를 전달하는 것이 안전하다. 외부 결제는 성공했지만 내부 결과 저장 전에 서버가 종료되는 상황까지 고려해야 하기 때문이다.

 

6. 멱등성 키는 언제 변경할까?

멱등성 키는 사용자의 의도 단위로 생성한다.

같은 결제를 재시도하는 것이라면 동일한 키를 사용해야 한다.

반대로 사용자가 새로운 상품을 주문하거나 결제 내용을 변경했다면 새로운 키를 생성해야 한다.

동일 주문 재시도 → 기존 멱등성 키 사용
새로운 주문 생성 → 새로운 멱등성 키 생성

동일한 키로 금액이나 상품이 다른 요청을 보내는 것도 막아야 한다.

이를 위해 최초 요청의 파라미터를 해시로 저장하고, 재요청의 내용과 비교할 수 있다.

 

7. 멱등성 키의 만료 시간

멱등성 키를 영구적으로 보관할 필요는 없다.

사용자의 연속 클릭이나 네트워크 재전송이 발생할 수 있는 시간을 고려하여 만료 시간을 설정하면 된다.

 

24시간 또는 48시간 정도를 적용할 수 있지만, 모든 서비스에 동일한 기준이 적용되는 것은 아니다.

결제 재시도 정책, 외부 시스템의 처리 시간, 장애 복구 시간을 고려하여 결정해야 한다.