본문 바로가기
🚀 데브 노트 (IT 지식 & 팁)

"따닥!" 두 번 결제되는 버그 막기: API 멱등성(Idempotency) 완벽 가이드와 Redis 분산 제어

by 데브프리 2026. 8. 11.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.

 

스마트폰으로 쇼핑몰에서 결제를 하거나, 커뮤니티에 게시글을 등록할 때 네트워크가 살짝 느려져 화면이 멈춘 적 있으신가요? 답답한 마음에 '결제하기' 버튼을 "따닥!" 하고 두세 번 연속으로 누르는 사용자는 실무에서 생각보다 정말 많습니다.

 

프론트엔드에서 자바스크립트로 버튼을 비활성화(Disable) 처리해두었다고요? 아쉽게도 그것만으로는 부족합니다. 악의적인 매크로 봇이나, 모바일 네트워크 불안정으로 인해 재전송(Retry)된 패킷은 언제든 백엔드 서버로 동시에 밀고 들어올 수 있습니다. 아무런 방어 로직이 없는 서버는 결국 고객의 신용카드에서 똑같은 금액을 두 번 결제해 버리는 최악의 사고를 터뜨리고 맙니다.

 

오늘은 이렇게 중복으로 들어오는 트래픽 폭격 속에서도 서버의 데이터 무결성을 지켜내는 백엔드의 핵심 설계 원칙, 'API 멱등성(Idempotency)'의 개념과 실무에서 Redis를 활용해 이를 완벽하게 제어하는 베스트 프랙티스를 짚어드리겠습니다.

 

1. 멱등성(Idempotency)이란 도대체 무엇인가요?

멱등성이라는 단어는 수학에서 빌려온 개념으로, "연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질"을 의미합니다.

 

HTTP API 설계 관점에서 설명하자면, 클라이언트가 동일한 API 요청을 1번 보내든, 100번 보내든 서버의 상태(DB 데이터 등)가 똑같이 유지되어야 한다는 뜻입니다.

  • 원래 멱등한 메서드 (GET, PUT, DELETE): 게시글을 100번 조회(GET)한다고 해서 조회수 로직을 제외하면 글 내용이 변하지 않습니다. 프로필 이름을 '데브프리'로 수정(PUT)하는 요청을 100번 보내도 최종 이름은 여전히 '데브프리'입니다. 이들은 태생적으로 멱등성이 보장됩니다.
  • 위험한 메서드 (POST): 문제가 되는 것은 새로운 데이터를 생성하는 POST입니다. 결제 요청(POST)을 2번 보내면 결제가 2번 일어납니다. 게시글 작성(POST)을 3번 보내면 똑같은 글이 3개 도배됩니다. POST는 태생적으로 멱등하지 않으므로 백엔드 개발자가 인위적으로 방어막을 쳐주어야 합니다.

 

2. 실전 트러블슈팅: 유니크 제약조건(Unique Constraint)의 한계

초보 개발자들은 중복 생성을 막기 위해 데이터베이스(DB)의 유니크 인덱스(Unique Index)에 의존하곤 합니다. 예를 들어, order_id에 유니크 제약을 걸어두면 두 번 저장이 안 될 것이라 믿는 것이죠.

 

하지만 실무의 결제 로직이나 주문 로직은 그렇게 단순하지 않습니다.

DB에 데이터를 저장하기 전에 외부 API(PG사 결제 요청, 재고 차감 등)를 먼저 호출하는 아키텍처라면 어떻게 될까요?

Java

// 🚨 [초보적인 실수] 중복 요청 시 외부 결제가 2번 일어나는 코드
@Transactional
public void processPayment(OrderRequest request) {
    // 1. 외부 PG사 결제 승인 API 호출 (네트워크 통신)
    pgClient.pay(request.getAmount()); 
    
    // 2. 결제 성공 후 우리 DB에 주문 내역 저장 (여기서 유니크 제약 에러가 터짐)
    orderRepository.save(new Order(request)); 
}

"따닥!" 하고 2개의 스레드가 동시에 진입하면, 두 스레드 모두 1번 라인을 통과해 고객의 카드를 두 번 긁어버립니다. 2번 라인에 가서야 뒤늦게 도착한 스레드가 DB 유니크 에러를 뿜으며 롤백되겠죠.

하지만 이미 카드는 두 번 긁힌 상태입니다. 데이터베이스의 제약조건만으로는 외부 시스템과의 연동이 포함된 로직의 멱등성을 지킬 수 없습니다.

Idempotency-Key(멱등성 키)를 활용한 중복 요청 차단 비유
멱등성 키는 클라이언트가 발급하는 고유한 입장권과 같습니다. 서버는 이 입장권의 사용 여부를 빠르게 검사하여 동일한 요청의 중복 실행을 완벽하게 차단합니다.

 

3. 해결 베스트 프랙티스: Idempotency-Key와 Redis의 조합

글로벌 IT 기업(Stripe, Toss 등)과 최근 IETF 표준안에서 권장하는 멱등성 보장 패턴은 바로 HTTP 헤더에 Idempotency-Key를 포함하여 통신하는 것입니다.

동작 원리

  1. 클라이언트(프론트엔드/앱)가 결제 버튼을 누르기 직전, 고유한 UUID(예: 123e4567-e89b...)를 생성하여 HTTP 헤더(Idempotency-Key)에 담아 보냅니다.
  2. 백엔드는 비즈니스 로직을 시작하기 전에 이 키를 Redis에 저장 시도합니다.
  3. 만약 저장이 성공하면(처음 본 키라면) 정상적으로 로직을 수행합니다.
  4. 만약 저장이 실패하면(방금 전 누군가 등록한 키라면) 로직을 아예 실행하지 않고, 튕겨내거나 이전 처리 결과를 그대로 반환합니다.

Spring Boot 3 + Redis 실전 코드 스니펫

실무에서는 이 공통 방어 로직을 각 컨트롤러마다 흩뿌리지 않고, Spring Interceptor(인터셉터)나 AOP를 활용해 앞단에서 깔끔하게 차단하는 것이 베스트 프랙티스입니다. Redis의 SETNX (Set if Not eXists) 연산을 제공하는 setIfAbsent를 활용하면 동시성 문제까지 완벽하게 해결됩니다.

Java

@Component
public class IdempotencyInterceptor implements HandlerInterceptor {

    private final StringRedisTemplate redisTemplate;
    private static final long LOCK_EXPIRE_TIME = 5; // 5초 동안 동일 요청 방어

    public IdempotencyInterceptor(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String idempotencyKey = request.getHeader("Idempotency-Key");

        if (!StringUtils.hasText(idempotencyKey)) {
            // 헤더가 없으면 400 에러를 뱉도록 강제할 수도 있습니다.
            return true; 
        }

        String redisKey = "idemp:lock:" + idempotencyKey;
        
        // 💡 Redis의 SETNX 명령어를 활용한 원자적 락(Lock) 획득
        Boolean isFirstRequest = redisTemplate.opsForValue()
                .setIfAbsent(redisKey, "PROCESSING", Duration.ofSeconds(LOCK_EXPIRE_TIME));

        if (Boolean.FALSE.equals(isFirstRequest)) {
            // 이미 누군가 동일한 키로 요청을 진행 중입니다! 409 Conflict 반환
            response.setStatus(HttpStatus.CONFLICT.value());
            return false; 
        }

        return true; // 첫 번째 요청이므로 무사히 컨트롤러로 통과
    }
}

이렇게 앞단에 인터셉터를 세워두면, "따닥!" 하고 들어온 두 번째 요청은 컨트롤러나 서비스 로직(외부 PG사 통신 등)에 발을 들이기도 전에 Redis 선에서 0.001초 만에 가차 없이 차단됩니다.

 

💡 [데브프리의 심층 면접 꿀팁: 5초 락(Lock)은 절반의 완성! '진짜 멱등성'은 결과를 기억합니다]

면접관이 "위 코드대로면 10초 뒤에 똑같은 키로 다시 요청하면 또 결제가 되지 않나요?"라고 예리하게 찌른다면 어떻게 방어하시겠습니까?

사실 위 코드는 '동시성 중복 요청(따닥)'을 막는 데 특화된 락(Lock)입니다. 글로벌 표준(Stripe 등)이 말하는 '진짜 멱등성'은 요청을 튕겨내는 것을 넘어, '이전의 성공적인 결과'를 기억하고 다시 돌려주는 것입니다.

이를 완벽하게 구현하려면 컨트롤러 로직이 성공적으로 끝난 뒤(인터셉터의 postHandle 등 활용), 해당 Redis 키의 값을 "PROCESSING"에서 "COMPLETED (또는 실제 응답 JSON 데이터)"로 업데이트하고 만료 시간을 24시간으로 길게 늘려주어야 합니다. 그리고 다시 똑같은 키로 요청이 왔을 때 키 값이 "COMPLETED"라면, 로직을 타지 않고 곧바로 '기존에 저장해 둔 성공 응답(200 OK)'을 뱉어주도록 코드를 보완해야 진정한 의미의 멱등성이 완성된다는 점을 꼭 어필하세요!

 

4. 전문가의 확고한 결론 (Verdict)

API 멱등성(Idempotency) 제어, 실무에 언제 도입하고 언제 피해야 할까요?

  • 👍 확실한 도입 타이밍: 결제 승인, 포인트 차감, 쿠폰 사용, 한정판 상품 주문 등 돈과 직결되는 핵심 상태 변경 API(POST, PATCH)라면 인프라 비용을 들여서라도 무조건 Redis 기반의 멱등성 키 제어를 도입해야 합니다. 0.1%의 중복 결제 사고가 회사에 미치는 금전적, 신뢰적 타격은 Redis 서버 운영비와 비교할 수 없을 만큼 치명적이기 때문입니다.
  • 🛑 도입을 피하거나 굳이 필요 없는 때: 사용자 프로필 단순 수정(PUT), 단순 게시글 조회(GET), 장바구니 상품 삭제(DELETE) 등 본질적으로 멱등성이 보장되는 HTTP 메서드에는 굳이 멱등성 키 헤더를 검증하는 로직을 붙일 필요가 없습니다. 또한, 트래픽이 거의 없고 단일 서버(Single Server)로만 운영되는 소규모 백오피스 시스템이라면 Redis까지 연동하는 것은 오버엔지니어링입니다. 이때는 단순히 데이터베이스의 Unique Index 처리와 프론트엔드의 디바운싱(Debouncing) 처리만으로도 충분히 타협할 수 있습니다.

"사용자가 버튼을 두 번 누르진 않겠지?"라는 안일한 가정은 실무에서 가장 위험한 생각입니다. 언제나 예측 불가능한 패킷이 들어올 수 있다는 비관적인 태도로 방어막을 겹겹이 두르는 것, 그것이 바로 주니어와 시니어 백엔드 개발자를 가르는 결정적 차이입니다.

 

항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 개발자들에게 유용한 아키텍처 꿀팁으로 찾아뵙겠습니다!

 

👉 실서버 뻗기 전에 꼭 확인하세요! 내 서버 스펙에 맞는 최적의 DB 커넥션 풀 개수 계산기 [링크]