PLOT

[PLOT] 인앱 결제 멱등성 어노테이션 적용(@Idempotent)

sagecode 2026. 1. 23. 16:25

문제 발생

datadog 로그

유저가 인앱결제를 진행했을 때 사용자의 중복 요청으로 인해 오류가 발생했다.

첫 결제 성공 이후 코드단에서 중복결제를 예외처리로 막아두었지만,

사용자에게 2번째 요청에 대한 오류가 발생하게 되면서 사용자 경험에 문제가 생긴다는 점을 파악했다.

// 기존 코드
T receiptData = buyRubyRequestDto.getReceiptData();
if (receiptData != null) {
    String traceKey = receiptData.getTraceKey();
    if (traceKey != null && payInAppRepository.findByTransactionId(traceKey).isPresent()) {
        throw new DuplicatedInAppTransactionException();
    }
}

기존 로직은 receipt의 traceKey(transactionId) 를 기준으로 이미 결제가 처리된 경우 예외를 던지는 방식으로 중복 결제를 방어하고 있었다. 이 방식은 데이터 정합성 측면에서는 안전했지만,

 

1. 사용자 경험 관점에서 오류가 생성된다.

2. 코드를 작성할 때 마다 서비스 단에서 예외처리를 해야한다.

 

즉, 서버에서는 이미 결제가 성공했음에도 불구하고 클라이언트는 정상결제, 실패 여부를 확실하게 알 수 없다.

 

왜 service 단 예외 처리만으로는 부족했는가

기존 결제 처리 방식은 “이미 처리된 결제인지 여부” 만을 기준으로 중복 요청을 판단하고, 중복이라고 판단될 경우 예외를 발생시키는 구조였다. 하지만 이 방식에는 다음과 같은 문제점이 존재했다.

 

1. 요청이 이미 깊은 레이어까지 수행된다

중복 요청임에도 불구하고 요청은 Controller → Service → Repository → DB 조회 까지 모두 수행된다.

이는 불필요한 DB 접근과 트랜잭션 비용을 발생시키며, 트래픽이 증가할수록 시스템 부하로 직결된다.

 

2. 중복 요청을 ‘재시도’가 아닌 ‘실패’로 처리한다

기존 방식은 중복 요청을 예외로 처리하기 때문에, 클라이언트 입장에서는

  • 첫 요청은 정상적으로 처리되었음에도 네트워크 지연이나 타임아웃으로 인해 발생한 재요청이 결제 실패로 인식된다.

3. 동시에 들어오는 요청에 취약하다

첫 번째 요청이 DB에 반영되기 전에 동일한 결제 요청이 동시에 들어올 경우,

  • 두 요청 모두 “아직 처리되지 않음”으로 판단
  • 중복 실행 가능성 발생

DB 조회 결과에 의존한 판단 방식은 동시성 상황에서 안전하지 않다는 한계를 가진다.

 

해결 방법

이를 위해 어노테이션 + Redis 기반의 멱등성 처리 구조를 도입했다.

 

이 구조를 통해:

  • 중복 요청이 비즈니스 로직까지 진입하는 것을 방지하고
  • 재시도 요청을 정상 흐름으로 흡수하며
  • 동시 요청 환경에서도 단일 실행을 보장할 수 있었다.

 

멱등성이란 무엇인가?

멱등성이란,

동일한 요청을 여러 번 보내더라도
서버의 상태가 한 번 요청했을 때와 동일하게 유지되는 성질

 

지금 현재 문제에서

  • 동일한 receipt(traceKey)로 요청이 여러 번 들어와도 실제 결제 처리는 한 번만 발생해야 한다.
  • 이후 요청은 이전 결과를 재사용하거나 조용히 종료되어야 한다.

 

어노테이션으로 멱등성 처리하기

모든 API에 멱등성을 강제로 적용하는 것은 적절하지 않다.
특히 다음과 같은 API들만 멱등성 보장이 필요하다.

  • 인앱 결제 API
  • 재화 지급 API
  • 쿠폰 발급 API

이를 위해 어노테이션 기반 멱등성 처리 방식을 선택했다.

 

어노테이션 방식의 장점

  • API 단에서 멱등성 적용 여부가 명확히 드러난다
  • 비즈니스 로직이 깔끔해진다
  • 공통 멱등성 로직을 한 곳에서 관리할 수 있다
  • 대기 시간, 락 유지 시간 등 정책 변경이 용이하다

컨트롤러와 서비스는 “이 요청은 한 번만 실행된다” 는 가정 하에 작성하고,
중복 요청 제어는 인터셉터에서 일괄 처리하도록 역할을 분리했다.

 

해결책: @Idempotency 어노테이션 도입

@Idempotency

중복 결제 문제를 해결하기 위해 비즈니스 로직 내부에서 예외를 던지는 방식이 아니라,
요청 진입 시점에서 중복 요청을 제어하는 구조로 전환했다.

 

이를 위해 메서드 단위로 멱등성을 선언할 수 있는
@Idempotency 어노테이션을 직접 구현했다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotency {

    String key();

    long waitMs() default 5000;

    long leaseMs() default 8000;

    boolean failFast() default true;
}

 

IdempotencyAspect

IdempotencyAspect는 @Idempotency가 선언된 메서드 실행 이전에 개입하여, 해당 요청이 이미 처리 중이거나 처리된 적이 있는 요청인지를 판단하는 관문 역할을 수행한다.

 

AOP 기반의 @Around 를 사용하여 컨트롤러 메서드 실행 자체를 사전에 제어하는 구조로 전환했다.

  1. IdempotencyKeyResolver를 통해 요청 파라미터(예: traceKey)를 기반으로 멱등성 판단에 사용할 고유 키를 생성한다.
  2. IdempotencyLockManager를 통해 해당 키로 Redis 분산 락 획득을 시도한다.
  3. 락 획득에 실패한 경우, 이는 이미 동일한 요청이 처리 중이거나 처리된 상태로 판단하고 즉시 예외를 발생시켜 비즈니스 로직 실행을 차단한다.
  4. 락 획득에 성공한 경우에만 실제 컨트롤러 메서드를 실행한다.
  5. 메서드 실행 결과와 관계없이 finally 블록에서 락을 해제하여 데드락이나 장기 점유 상황을 방지한다.
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotencyAspect {

    private final IdempotencyKeyResolver keyResolver;
    private final IdempotencyLockManager lockManager;

    @Around("@annotation(idempotency)")
    public Object around(
            ProceedingJoinPoint joinPoint,
            Idempotency idempotency
    ) throws Throwable {

        String key = keyResolver.resolve(joinPoint, idempotency);

        boolean locked = lockManager.tryLock(
                key,
                idempotency.waitMs(),
                idempotency.leaseMs(),
                idempotency.failFast()
        );

        if (!locked) {
            throw new DuplicatedTransactionException();
        }

        try {
            return joinPoint.proceed();
        } finally {
            lockManager.unlock(key);
        }
    }
}

 

IdempotencyKeyResolver - SpEL 기반 키 생성

IdempotencyKeyResolver는 메서드 파라미터를 분석하여 고유한 락 키를 생성합니다.

package team.plot.core.aop.idempotency;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Component
public class IdempotencyKeyResolver {

    private final ExpressionParser parser = new SpelExpressionParser();
    private final ParameterNameDiscoverer nameDiscoverer =
            new DefaultParameterNameDiscoverer();

    public String resolve(
            ProceedingJoinPoint joinPoint,
            Idempotency annotation
    ) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        EvaluationContext context =
                new StandardEvaluationContext();

        Object[] args = joinPoint.getArgs();
        String[] paramNames =
                nameDiscoverer.getParameterNames(method);

        if (paramNames != null) {
            for (int i = 0; i < paramNames.length; i++) {
                context.setVariable(paramNames[i], args[i]);
            }
        }

        Expression expression = parser.parseExpression(annotation.key());
        Object value = expression.getValue(context);

        return "idempotency:" + value;
    }
}

 

RedisIdempotencyLockManager - Redis 구현체

Redisson의 장점:

  • 자동 갱신: leaseMs 전에 메서드가 끝나지 않으면 자동으로 lease 연장
  • 공정성: FIFO 방식으로 락 대기 순서 보장
  • 안전한 해제: 락을 획득한 스레드만 해제 가능

tryLock 파라미터:

  • waitMs: 락 획득을 기다리는 최대 시간
  • leaseMs: 락 자동 해제 시간 (데드락 방지)
  • 반환값: true = 락 획득 성공, false = 실패

unlock 안전장치:

  • isHeldByCurrentThread(): 현재 스레드가 락의 소유자인지 확인
  • 다른 스레드가 획득한 락을 실수로 해제하는 것 방지
package team.plot.core.aop.idempotency;

import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Primary
@Component
@RequiredArgsConstructor
public class RedisIdempotencyLockManager
        implements IdempotencyLockManager {

    private final RedissonClient redissonClient;

    @Override
    public boolean tryLock(
            String key,
            long waitMs,
            long leaseMs,
            boolean failFast
    ) {
        RLock lock = redissonClient.getLock(key);
        try {
            return lock.tryLock(
                    waitMs,
                    leaseMs,
                    TimeUnit.MILLISECONDS
            );
        } catch (InterruptedException e) {
            if (failFast) {
                throw new IllegalStateException(e);
            }
            return false;
        }
    }

    @Override
    public void unlock(String key) {
        RLock lock = redissonClient.getLock(key);
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

 

시나리오: 결제 플로우

사용자: 구매 버튼 빠르게 3번 클릭

[서버 처리]
요청 1: Redis 락 획득  → 결제 처리 → 성공
요청 2: 락 대기 → 이미 처리됨 확인 → 차단
요청 3: 락 대기 → 이미 처리됨 확인 → 차단

[사용자 화면]
 "결제 완료!" (첫 번째)
 "이미 처리된 요청입니다" (두 번째)
 "이미 처리된 요청입니다" (세 번째)

실제 지급: Ruby 500개 (1번만)


동작 흐름

사용자 중복 클릭
  ↓
IdempotencyAspect 인터셉트
  ↓
SpEL로 키 생성: "userId-product-transactionId"
  ↓
Redis 분산 락 획득 시도
  ↓
- 성공: 비즈니스 로직 실행
- 실패: 대기 또는 차단
  ↓
finally 블록에서 락 해제