블로그 목록

결제 및 충전 API 설계의 핵심: Idempotency Key(멱등성 키) 구현 가이드

결제 버튼을 눌렀는데 화면이 멈춰서 한 번 더 눌렀다면, 돈이 두 번 빠져나갈까요? 잘 만든 서비스라면 그렇지 않습니다. 같은 요청이 여러 번 도착해도 실제 처리는 한 번만 일어나도록 설계하기 때문입니다. 이 글은 그 설계의 핵심인 멱등성(Idempotency)과 Idempotency-Key(멱등성 키)를 처음 접하는 분도 이해할 수 있도록 정리한 글입니다.

1. 비유로 이해하는 멱등성

엘리베이터 호출 버튼을 떠올려 보면 쉽습니다. 버튼을 한 번 누르든 열 번 누르든 엘리베이터는 한 대만 옵니다. 반면 자판기의 동전 투입구는 넣는 횟수만큼 금액이 쌓입니다. API도 마찬가지로 "여러 번 호출해도 결과가 한 번 호출한 것과 같은" 성질을 멱등성이라고 부릅니다.

HTTP 표준(RFC 9110)은 GET, PUT, DELETE를 멱등한 메서드로 정의합니다. 여기서 말하는 멱등성은 서버 상태에 미치는 의도된 효과가 같다는 뜻이며, 응답 내용까지 매번 같아야 한다는 의미는 아닙니다. 예를 들어 같은 자원을 두 번 DELETE하면 두 번째 응답은 404일 수 있지만, "자원이 없다"는 최종 상태는 같습니다. 문제는 결제(POST /payments)나 포인트 충전처럼 호출할 때마다 새 기록을 만드는 POST 요청입니다. 이런 API에 멱등성을 더하는 방법이 Idempotency-Key입니다.

2. 구체적인 사고 시나리오: 결제 재시도

  1. 사용자가 10,000원 결제를 요청합니다.
  2. 서버는 결제를 정상 처리했지만, 응답이 돌아가는 도중 모바일 네트워크가 끊깁니다.
  3. 앱은 응답을 받지 못했으니 실패로 판단하고 같은 요청을 자동 재시도합니다.
  4. 서버 입장에서는 새 결제 요청이므로 10,000원을 한 번 더 결제합니다.

클라이언트는 "요청이 실패했는지, 응답만 사라졌는지"를 구분할 수 없습니다. 그래서 재시도 자체를 막기보다, 서버가 같은 요청임을 알아보고 첫 번째 결과를 다시 돌려주는 것이 현실적인 해법입니다.

3. Idempotency-Key의 동작 방식

클라이언트는 결제 시도마다 UUID v4 같은 충분히 무작위한 값을 만들어 Idempotency-Key 헤더에 담고, 재시도할 때는 같은 키를 그대로 보냅니다. 서버는 다음 순서로 처리합니다.

  1. 키 선점: 처음 보는 키라면 "처리 중" 상태로 저장합니다. 이 저장은 반드시 원자적이어야 합니다(Redis SET ... NX, DB의 UNIQUE 제약 등).
  2. 요청 지문 비교: 요청 본문의 해시(지문)를 함께 저장해 두고, 같은 키로 다른 내용이 오면 거절합니다.
  3. 실행 후 결과 저장: 비즈니스 로직이 끝나면 상태 코드와 응답 본문을 키와 함께 저장합니다.
  4. 재요청 응답: 이미 완료된 키가 다시 오면 로직을 실행하지 않고 저장된 응답을 돌려줍니다.

IETF의 Idempotency-Key 헤더 초안(draft-ietf-httpapi-idempotency-key-header)은 키가 필요한데 없으면 400, 같은 키가 다른 본문과 함께 오면 422, 같은 키의 요청이 아직 처리 중이면 409를 예시로 제시합니다. 다만 이 문서는 확정된 표준(RFC)이 아니라 초안이므로, 상태 코드는 서비스 문서에 명확히 적어 두는 것이 좋습니다.

4. 저장소 설계 예시 (SQL)

결제처럼 돈이 걸린 작업은 멱등성 기록을 비즈니스 데이터와 같은 DB, 같은 트랜잭션에 두면 "결제는 됐는데 키 기록은 유실됨" 같은 불일치를 줄일 수 있습니다. 키는 사용자별로 구분해 다른 사용자의 키와 충돌하지 않게 합니다.

CREATE TABLE idempotency_keys (
  user_id          BIGINT       NOT NULL,
  idempotency_key  VARCHAR(255) NOT NULL,
  request_hash     CHAR(64)     NOT NULL,  -- 요청 본문 SHA-256
  status           VARCHAR(20)  NOT NULL,  -- in_progress / completed
  response_code    INT,
  response_body    TEXT,
  created_at       TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (user_id, idempotency_key)
);

같은 키로 동시에 두 요청이 들어와도 PRIMARY KEY 제약 때문에 INSERT는 하나만 성공합니다. 실패한 쪽은 기존 행을 조회해 처리 중이면 409, 완료됐으면 저장된 응답을 돌려주면 됩니다. 오래된 행은 보관 기간(예: 24시간 이상)을 정해 주기적으로 삭제합니다.

5. NestJS Interceptor 예시

아래 코드는 흐름을 보여 주기 위한 축약본입니다. IdempotencyStore는 위 테이블이나 Redis를 감싸는 서비스라고 가정합니다. 핵심은 응답을 저장하는 비동기 작업을 map이 아니라 concatMap으로 연결하고, 실패 시 키를 풀어 주는 것입니다.

@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  constructor(private readonly store: IdempotencyStore) {}

  async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
    const req = ctx.switchToHttp().getRequest<Request>();
    const key = req.header('idempotency-key');
    if (!key) throw new BadRequestException();

    const requestHash = sha256(JSON.stringify(req.body));
    const found = await this.store.begin(key, requestHash); // 원자적 선점

    if (found.state === 'in_progress') throw new ConflictException();
    if (found.state === 'hash_mismatch') throw new UnprocessableEntityException();
    if (found.state === 'completed') return of(found.body);

    return next.handle().pipe(
      concatMap(async (body: unknown) => {
        await this.store.complete(key, requestHash, body);
        return body;
      }),
      catchError(async (err: unknown) => {
        await this.store.release(key); // 재시도할 수 있도록 선점 해제
        throw err;
      }),
    );
  }
}

흔히 보이는 map(async (data) => ...) 형태는 스트림의 값이 Promise가 되어 버리므로, 저장 중 발생한 오류가 파이프라인의 catchError로 전달되지 않는 등 흐름을 예측하기 어려워집니다. RxJS에서 비동기 작업은 concatMap이나 mergeMap으로 연결하는 것이 일반적인 방법입니다. 실패한 요청을 어떻게 기록할지는 정책의 문제입니다. Stripe는 실행이 시작된 요청의 결과는 500 오류까지 저장하고, 검증 단계에서 실패했거나 동시 요청과 충돌한 요청은 저장하지 않는다고 문서에 밝히고 있습니다.

6. 언제 쓰고, 언제 안 써도 되나

상황Idempotency-Key 필요성
결제, 송금, 포인트 충전, 주문 생성강력 권장
외부로 알림·메일을 보내는 POST권장 (중복 발송 방지)
GET 조회, 같은 값으로 덮어쓰는 PUT대체로 불필요
좋아요 토글처럼 중복이 무해한 기능DB 유니크 제약만으로 충분한 경우가 많음

7. 자주 하는 실수

  • 키 선점을 원자적으로 하지 않음: "조회 후 없으면 저장"을 두 단계로 나누면 동시 요청 두 개가 모두 통과합니다.
  • 요청 본문 비교를 생략함: 같은 키로 금액만 바뀐 요청이 오면 엉뚱한 결과를 돌려주게 됩니다.
  • 실패 시 "처리 중" 상태를 남겨 둠: 서버 오류 뒤에도 키가 처리 중으로 남아 있으면 사용자는 TTL이 끝날 때까지 재시도할 수 없습니다.
  • 재시도마다 새 키를 만듦: 클라이언트가 재시도할 때 키를 새로 만들면 멱등성이 전혀 작동하지 않습니다.
  • 민감정보를 키로 사용: 이메일이나 전화번호 대신 무작위 값을 사용합니다.

같은 키의 요청이 동시에 처리되지 않게 막는 부분은 결국 락 문제이기도 합니다. 락에 대해서는 Redis 분산 락과 비관적 락과 낙관적 락 글에서 더 다룹니다.

8. 정리

  • 네트워크 재시도는 막을 수 없으므로, 서버가 같은 요청을 알아보도록 설계합니다.
  • 키 선점은 원자적으로, 요청 지문은 함께 저장하고, 완료된 결과는 재사용합니다.
  • 돈이 걸린 작업은 멱등성 기록을 비즈니스 데이터와 같은 트랜잭션에 두는 방식을 우선 검토합니다.
  • 상태 코드와 키 보관 기간은 API 문서에 명시합니다.

참고 자료