메인으로 돌아가기

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

네트워크 재시도, 일시적인 브라우저 중복 클릭 등으로 동일한 결제 또는 송금 요청이 두 번 이상 전달되더라도 단 한 번만 실제로 처리되도록 만드는 API 멱등성(Idempotency) 보장 설계입니다.

1. API 멱등성(Idempotency)이란?

멱등성이란 동일한 요청을 여러 번 수행해도 서버의 최종 리소스 상태와 응답 결과가 동일하게 유지되는 성질을 의미합니다. 결제(`POST /payments`), 충전(`POST /points/charge`) 등 리소스를 새로 생성하거나 자산을 가감하는 API는 호출 횟수만큼 결제가 중복으로 실행될 우려가 있으므로, 클라이언트가 발급한 고유 식별값인 Idempotency-Key (멱등성 키)를 검증하여 부수효과를 차단해야 합니다.

2. 핵심 프로세스

  • 1단계 (키 수신 및 상태 예약): 클라이언트의 HTTP 요청 헤더에서 `Idempotency-Key`를 추출합니다. Redis에 해당 키로 `STARTED` 상태를 기록하여 락처럼 활용합니다. (만약 이미 존재하는 키라면 이전 진행 결과를 반환하거나, 작업 중이면 에러를 냅니다).
  • 2단계 (로직 수행 및 캐싱): 데이터베이스 트랜잭션을 실행하고, 응답 결과(Status Code, Body)를 해당 키와 함께 Redis에 짧은 TTL(예: 24시간)로 캐시 저장합니다.
  • 3단계 (최종 상태 갱신): Redis 키 상태를 `COMPLETED`로 변경하고 캐시된 응답을 즉시 반환합니다.

3. Go, NestJS, Spring Boot 구현 예제

Go (Golang) - Gin Middleware & Redis

Go Gin 웹 프레임워크 환경에서 미들웨어를 만들어 중복 요청을 멱등하게 거르고 캐시된 응답을 반환하는 구조입니다.

package main

import (
	"context"
	"encoding/json"
	"net/http"
	"time"
	"github.com/gin-gonic/gin"
	"github.com/redis/go-redis/v9"
)

type IdempotencyMiddleware struct {
	rdb *redis.Client
}

type CachedResponse struct {
	Status int    `json:"status"`
	Body   string `json:"body"`
}

func (m *IdempotencyMiddleware) HandleIdempotency() gin.HandlerFunc {
	return func(c *gin.Context) {
		key := c.GetHeader("Idempotency-Key")
		if key == "" {
			c.Next()
			return
		}

		ctx := context.Background()
		// 1. SETNX로 요청 상태를 'STARTED'로 설정
		success, err := m.rdb.SetNX(ctx, "idemp:"+key, "STARTED", 10*time.Minute).Result()
		if err != nil {
			c.AbortWithStatus(http.StatusInternalServerError)
			return
		}

		// 2. 이미 등록된 키라면
		if !success {
			status, _ := m.rdb.Get(ctx, "idemp:"+key).Result()
			if status == "STARTED" {
				c.JSON(http.StatusConflict, gin.H{"error": "request is already in progress"})
				c.Abort()
				return
			}
			
			// 완료된 요청의 경우 캐시된 응답 복원
			var cached CachedResponse
			json.Unmarshal([]byte(status), &cached)
			c.Data(cached.Status, "application/json", []byte(cached.Body))
			c.Abort()
			return
		}

		// 3. 실제 비즈니스 로직 수행을 유도하기 위해 writer 커스텀
		// (로직 수행 후 응답 데이터를 가로채기 위해 Gin ResponseWriter 랩핑 가능)
		c.Next()
	}
}

Node.js (NestJS) - Idempotency Interceptor

NestJS에서는 NestInterceptor를 사용해 들어오는 API의 멱등성 헤더를 파싱하고 캐시 응답을 제어하기에 적합합니다.

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, ConflictException } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import Redis from 'ioredis';

@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  private redis = new Redis();

  async intercept(context: ExecutionContext, next: CallHandler): Promise> {
    const request = context.switchToHttp().getRequest();
    const key = request.headers['idempotency-key'];

    if (!key) return next.handle();

    const cacheKey = `idemp:${key}`;
    // 1. Lock 및 상태 등록 시도
    const status = await this.redis.set(cacheKey, 'STARTED', 'NX', 'EX', 600);
    if (!status) {
      const current = await this.redis.get(cacheKey);
      if (current === 'STARTED') {
        throw new ConflictException('Request is already processing');
      }
      // 이미 처리가 완료되어 응답 캐시가 있는 경우
      return of(JSON.parse(current));
    }

    return next.handle().pipe(
      map(async (data) => {
        // 2. 비즈니스 성공 후 최종 상태 캐시
        await this.redis.set(cacheKey, JSON.stringify(data), 'EX', 86400); // 24시간
        return data;
      }),
    );
  }
}

Kotlin (Spring Boot) - HandlerInterceptor

Spring Boot 환경에서는 HandlerInterceptorContentCachingResponseWrapper를 결합해 구현을 진행합니다.

@Component
class IdempotencyInterceptor(private val redisTemplate: StringRedisTemplate) : HandlerInterceptor {

    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
        val key = request.getHeader("Idempotency-Key") ?: return true
        val cacheKey = "idemp:$key"

        // 1. SETNX
        val isFirst = redisTemplate.opsForValue().setIfAbsent(cacheKey, "STARTED", Duration.ofMinutes(10))
        if (isFirst == false) {
            val status = redisTemplate.opsForValue().get(cacheKey)
            if (status == "STARTED") {
                response.status = HttpServletResponse.SC_CONFLICT
                response.writer.write("{\"error\": \"Request is already in progress\"}")
                return false
            }
            // 완료된 결과 반환
            response.contentType = "application/json"
            response.writer.write(status ?: "")
            return false
        }
        return true
    }
}

Guaranteeing API Idempotency: Implementing Idempotency Keys in Go, NestJS, and Spring Boot

To prevent accidental double charges, double shipments, or duplicate updates from network retries and double click events, you must implement API Idempotency Key matching.

1. What is API Idempotency?

An API is idempotent if making identical requests multiple times results in the same resource state and response without introducing duplicate side-effects. Sensitive APIs like payments (`POST /payments`) and points-charging must be guarded by verifying client-provided Idempotency-Key headers.

2. Process Flow

  • Phase 1: Reserve Key Status: Extract the `Idempotency-Key` from request headers. Set its status to `STARTED` inside Redis with a TTL using a SETNX command. If it already exists, return either a "Processing" conflict error or return the completed response cache.
  • Phase 2: Core Execution & Caching: Execute the database business logic and store the exact response status and payload in Redis mapped to the key.
  • Phase 3: Update State: Update the Redis entry from `STARTED` to the serialized response string, and return.

3. Go, NestJS, and Spring Boot Implementations

Go (Golang) - Gin Middleware & Redis

Configure a custom Gin HandlerFunc middleware checking keys inside Redis before routing to business handlers.

// Refer to the KO tab code for Go implementation details.
// SetNX sets the lock, and caching returns early for duplicate keys.

Node.js (NestJS) - Idempotency Interceptor

Configure a NestJS `NestInterceptor` returning cached response Observables when checking keys in Redis.

// Refer to the KO tab code for NestJS implementation details.
// RxJS of(cachedData) returns cached payloads instantly, bypassing core route handlers.

Kotlin (Spring Boot) - HandlerInterceptor

Configure a Spring `HandlerInterceptor` executing `preHandle` checking logic and caching responses in Redis.

// Refer to the KO tab code for Kotlin/Spring Boot implementation details.
// interceptor preHandle stops matching duplicates early by writing directly to standard responses.