문제 발생

유저가 인앱결제를 진행했을 때 사용자의 중복 요청으로 인해 오류가 발생했다.
첫 결제 성공 이후 코드단에서 중복결제를 예외처리로 막아두었지만,
사용자에게 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 를 사용하여 컨트롤러 메서드 실행 자체를 사전에 제어하는 구조로 전환했다.
- IdempotencyKeyResolver를 통해 요청 파라미터(예: traceKey)를 기반으로 멱등성 판단에 사용할 고유 키를 생성한다.
- IdempotencyLockManager를 통해 해당 키로 Redis 분산 락 획득을 시도한다.
- 락 획득에 실패한 경우, 이는 이미 동일한 요청이 처리 중이거나 처리된 상태로 판단하고 즉시 예외를 발생시켜 비즈니스 로직 실행을 차단한다.
- 락 획득에 성공한 경우에만 실제 컨트롤러 메서드를 실행한다.
- 메서드 실행 결과와 관계없이 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 블록에서 락 해제