Photo by panumas nikhomkhai on Pexels
온라인 커머스 서비스를 운영하는 A사에 입사한 지 3개월 차인 신입 백엔드 개발자 박주니어 씨는 얼마 전 아찔한 경험을 했습니다. 월간 할인 프로모션 날, 특정 고객의 주문 내역에 동일한 상품과 금액이 1초 간격으로 2번 결제된 문의가 접수된 것입니다. 단순한 프론트엔드 버튼 중복 클릭 방지(Debounce) 로직이 모바일 네트워크 불안정으로 인해 작동하지 않았고, 클라이언트 앱이 네트워크 재시도(Retry)를 보내면서 결제 API가 두 번 호출된 것이 원인이었습니다.
데이터베이스에는 유일성 제약조건(Unique Constraint)이 일부 빠져 있었고, API 자체도 여러 번 호출되었을 때 동일한 결과를 보장하지 못하는 구조였습니다. 이 글로 신직 실무자가 서비스 안정성을 높이고 분산 환경에서 동일 요청 중복 처리를 완벽히 막을 수 있도록, A사의 사례를 통해 멱등성(Idempotency) API를 직접 설계하고 구축하는 전 과정을 구체적으로 소개합니다.
장애의 시작: 네트워크 지연과 재시도가 부른 중복 주문 사고
A사의 주문 결제 시스템은 클라이언트가 API를 호출하면 데이터베이스에 주문 레코드를 생성하고 외부에 있는 PG(결제대행사) API를 호출한 뒤 결과를 반환하는 방식이었습니다. 정상적인 네트워크 환경에서는 문제가 없었으나, 모바일 통신 상태가 불안정한 환경에서 다음과 같은 흐름으로 장애가 터졌습니다.
- 고객이 '결제하기' 버튼을 누릅니다. (첫 번째 요청 전송)
- 서버는 DB에 주문을 생성하고 PG사 결제 승인까지 완료했습니다.
- 하지만 서버가 클라이언트에 응답(200 OK)을 보낼 때 모바일 네트워크가 끊겨 응답이 전달되지 않았습니다.
- 클라이언트 라이브러리의 자동 재시도 로직이 발동하여 1.2초 뒤 동일한 결제 요청을 다시 서버로 보냈습니다.
- 서버는 이를 '새로운 주문 요청'으로 인식하고 또 다시 DB 주문 생성 및 PG 결제 승인을 진행했습니다.
결과적으로 고객은 물건을 하나 샀지만 돈은 2번 출금되었습니다. 단 1분의 네트워크 지연 때문에 CS 비용과 PG사 환불 수수료 손실, 고객 신뢰도 하락이라는 삼중고를 겪어야 했습니다. 박주니어 씨는 단순 프론트엔드 처리에만 의존해서는 백엔드 시스템을 보호할 수 없다는 사실을 깨달았습니다.
멱등성(Idempotency)이란 무엇인가
멱등성이란 연산을 여러 번 적용하더라도 결과가 창출하는 상태가 달라지지 않는 성질을 의미합니다. HTTP 메서드로 비유하자면, GET이나 PUT, DELETE는 대표적인 멱등성 메서드입니다. 동일한 UR로 GET 요청을 1번 보내든 10번 보내든 서버의 상태는 변하지 않으며 매번 동일한 리소스를 조회합니다.
반면 POST 메서드는 기본적으로 멱등성을 보장하지 않습니다. 호출할 때마다 새로운 리소스가 생성되기 때문입니다. 결제나 주문, 포인트 차감과 같은 비즈니스 핵심 로직은 대부분 POST 메서드로 작성되므로, 네트워크 재시도나 중복 클릭 상황에서도 **"단 한 번만 실행됨(Exactly-once execution)"**을 서버 차원에서 보장해 주는 추가적인 메커니즘이 필수적입니다.
| HTTP 메서드 | 멱등성 여부 | 실무에서의 의미 |
|---|---|---|
| GET | 보장됨 | 여러 번 조회해도 서버 데이터가 변경되지 않음 |
| PUT | 보장됨 | 동일한 데이터로 여러 번 덮어써도 최종 상태는 동일함 |
| DELETE | 보장됨 | 이미 삭제된 대상을 다시 삭제 요청해도 상태는 삭제됨으로 동일함 |
| POST | 보장 안 됨 | 호출할 때마다 새로운 데이터가 누적 생성됨 (멱등성 키 도입 필요) |
해결 전략: Idempotency-Key 헤더와 Redis 스토리지 설계
A사 개발팀은 결제 및 주문 처리 API에 멱등성을 부여하기 위해 IETF 인터넷 표준 초안으로 제안된 Idempotency-Key HTTP 헤더 방식을 도입하기로 했습니다. 클라이언트가 요청을 보낼 때 고유한 UUID 형태의 키를 헤더에 포함시키고, 서버는 이 키를 사용해 요청의 처리 상태를 추적하는 구조입니다.
전체 처리 프로세스 순서
- 클라이언트 키 생성: 주문 페이지 진입 시 UUIDv4 형식의 고유 키(예:
123e4567-e89b-12d3-a456-426614174000)를 생성하여 HTTP Request Header(Idempotency-Key)에 담아 전송합니다. - 인터셉터/미들웨어 검증: 요청이 컨트롤러에 도달하기 전, Redis에서 해당 키의 존재 여부를 확인합니다.
- 요청 진행 중 (IN_PROGRESS): Redis에 키가 없다면 status를
IN_PROGRESS로 저장하고 실제 비즈니스 로직(DB 저장, PG 결제)을 수행합니다. - 처리 완료 후 결과 저장 (COMPLETED): 로직이 성공하면 API 응답 바디와 HTTP 상태 코드를 Redis에 일정 시간(TTL) 동안 저장하고 status를
COMPLETED로 업데이트합니다. - 중복 요청 처리: 비즈니스 로직 실행 중 동일한 키로 요청이 들어오면
409 Conflict또는422 Unprocessable Entity를 반환하고, 이미 완료된 상태(COMPLETED)로 요청이 오면 실제 로직을 재실행하지 않고 Redis에 저장된 기존 응답을 즉시 반환(200 OK)합니다.
실무 코드 및 단계별 구현 절차
박주니어 씨는 빠른 읽기/쓰기 속도를 제공하고 분산 환경에서 공유 가능한 메모리 DB인 Redis를 활용하여 멱등성 검증 미들웨어를 구축했습니다. 구체적인 동작 구조와 저장 데이터 구조는 다음과 같습니다.
1. Redis 데이터 저장 구조 설계
Redis에는 아래와 같이 키 이름에 고유 식별자와 API 경로를 조합하여 격리성을 확보했습니다.
Redis Key: idempotency:order:123e4567-e89b-12d3-a456-426614174000
Redis Value (JSON 형태):
| 필드명 | 타입 | 설명 및 예시 값 |
|---|---|---|
| status | String | 처리 상태 (IN_PROGRESS / COMPLETED) |
| responseCode | Number | 저장된 HTTP 상태 코드 (예: 200) |
| responseBody | String/JSON | 기존에 반환했던 응답 데이터 전체 |
| createdAt | String | 최초 요청 시각 (ISO-8601) |
2. 로직 처리 세부 흐름 예시
실제 애플리케이션 코드에 들어가는 미들웨어 의사코드(Pseudo Code) 예시입니다.
[단계 1] 헤더 검증 및 Lock 획득
클라이언트로부터 Idempotency-Key 헤더가 들어왔는지 확인합니다. 만약 헤더가 누락되었다면 400 Bad Request 에러를 반환하여 안전하지 않은 요청을 차단합니다.
[단계 2] Redis 상태 체크 및 처리 분기
SET key value NX EX seconds 명령어를 통해 원자적(Atomic)으로 키 생성 및 잠금을 시도합니다.
- 성공시: 최초 요청으로 판단. Status를
IN_PROGRESS로 설정하고 비즈니스 로직 진행. - 실패시 (키가 이미 존재함):
- 상태가
IN_PROGRESS인 경우: 이전 요청이 아직 처리 중이므로409 Conflict(동시 요청 경합) 반환. - 상태가
COMPLETED인 경우: 캐시된responseCode와responseBody를 꺼내어 비즈니스 로직을 전혀 실행하지 않고 클라이언트에200 OK로 응답.
- 상태가
[단계 3] 비즈니스 로직 성공 후 응답 캐싱
주문 DB 저장 및 PG 승인이 무사히 끝나면 Redis의 Value 상태를 COMPLETED로 갱신하고, 응답 데이터와 함께 만료시간(TTL)을 설정합니다.
동시성 이슈를 잡는 분산 락과 TTL(만료 시간) 설정 스킬
멱등성 시스템을 설계할 때 신입 개발자가 흔히 저지르는 실수가 두 가지 있습니다. 첫 번째는 '조회 후 저장' 사이에 발생하는 동시성 문제(Race Condition)이고, 두 번째는 적절한 TTL(Time To Live)을 설정하지 않아 메모리가 터지거나 유효 기간이 너무 짧아 중복을 막지 못하는 문제입니다.
1. 원자성(Atomicity) 보장하기
서버가 여러 대인 분산 환경에서는 "Redis에서 키가 있는지 확인(GET)"한 뒤 "없으면 저장(SET)"하는 방식을 두 단계로 작성하면 안 됩니다. GET과 SET 사이에 다른 서버가 동일한 요청을 읽어 들일 수 있기 때문입니다. 반드시 Redis의 SETNX(Set if Not Exists) 옵션을 사용하거나 Redisson 등의 라이브러리로 분산 락(Distributed Lock)을 취득한 후 작업을 시작해야 합니다.
2. 비즈니스 특성에 맞는 TTL 설정 전략
Redis는 메모리 기반 저장소이므로 멱등성 키를 영구히 보관할 수 없습니다. 서비스 도메인 특성에 맞춘 만료 시간 설정 규칙이 필요합니다.
- 결제 및 주문 API: 최소 24시간~72시간 권장. 네트워크 단선 후 사용자가 다음 날 다시 앱을 켜서 재시도하거나 PG사의 수동 재요청이 올 수 있는 기간을 고려합니다.
- 일반 조회/단순 생성 API: 1시간~6시간 설정.
- 처리 실패(Exception) 시 처리: 비즈니스 로직 수행 중 예외(500 Server Error)가 발생한 경우, 저장했던
IN_PROGRESS키를 즉시 삭제(DEL)해주어야 합니다. 그렇지 않으면 사용자가 오류 발생 후 즉시 재시도할 때 TTL이 끝날 때까지 계속409 Conflict에러를 만나게 됩니다.
실무 적용 전 점검해야 할 멱등성 API 체크리스트
코드 작성을 완료하고 운영 환경에 배포하기 전에 반드시 다음 6가지 항목을 체크해야 합니다. 이 체크리스트를 활용해 테스트 케이스를 작성하면 실무에서 발생할 수 있는 대다수의 중복 장애를 사전에 방지할 수 있습니다.
| 구분 | 점검 항목 | 확인 방법 및 기대 결과 |
|---|---|---|
| 필수값 검증 | Idempotency-Key 헤더 누락 시 예외 처리가 되어 있는가? | 헤더 없이 POST 요청 시 400 Bad Request 반환 확인 |
| 동시성 테스트 | 동일한 Key로 동시에 10개의 요청을 보냈을 때 단 1건만 실행되는가? | JMeter/k6를 활용한 동시 요청 테스트 수행 (1건만 200, 나머지는 409) |
| 응답 동일성 | 첫 번째 요청의 응답과 재시도된 요청의 응답 데이터가 완전히 일치하는가? | 응답 바디의 ID, 생성시각, 결제 번호 등이 기존 캐시값과 동일한지 확인 |
| 예외 처리 | DB 트랜잭션 롤백 시 Redis 키가 적절히 제거되는가? | 강제로 RuntimeException을 발생시킨 후 동일 Key로 재요청 시 정상 재시도되는지 확인 |
| 메모리 관리 | Redis Key에 TTL(만료 시간)이 올바르게 적용되어 있는가? | TTL idempotency:key_name 명령어로 남은 유효시간 확인 |
| 보안 및 격리 | 다른 사용자(User)가 타인의 Idempotency-Key를 남용할 수 없는가? | Redis Key 생성 시 userId:idempotencyKey 형태로 유저 식별자 조합 필수 |
실무 적용을 위한 최종 실행 수칙
신입 개발자가 담당 업무에 멱등성 구조를 도입할 때 바로 실행해볼 수 있는 핵심 수칙 4가지를 요약합니다.
- 중요 자원이 생성/차감되는 API를 식별하세요: 결제, 포인트 사용, 쿠폰 발급, 주문 생성 등 돈이나 재고가 걸려 있는 모든 POST API 목록을 작성합니다.
- 클라이언트 팀과 Idempotency-Key 헤더 규격을 협의하세요: UUIDv4 방식을 표준으로 정하고, 클라이언트가 화면 진입 시점 또는 제출 버튼 클릭 시점에 키를 발행하도록 약속합니다.
- Redis의 SETNX 옵션과 트랜잭션 처리를 철저히 하세요: 키 확인과 생성을 원자적으로 처리하고, 서버 에러(5xx) 발생 시 실행 중인 멱등성 키를 삭제하여 재시도를 허용하도록
try-catch-finally블록을 구성합니다. - DB 레벨의 유일성 제약조건(Unique Key)을 최후의 보루로 남겨두세요: Redis 장애로 캐시 레이어가 무력화되더라도, DB의
UNIQUE INDEX(user_id, idempotency_key)를 통해 최후의 순간까지 중복 데이터 Insert를 방지해야 합니다.