프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

[JAVA/REDIS] Redis를 이용해서 중복요청 방지하는 방법

매운할라피뇨 2026. 2. 6. 09:42
반응형

Redis를 이용해서 중복요청 방지하는 방법

 

사용자가 버튼을 빠르게 두 번 클릭하거나, 네트워크 지연으로 인해 결제 요청이 중복으로 들어오는 이슈는 실무에서 매우 빈번하게 발생합니다.

DB Unique Key로 막을 수도 있지만, 이는 최후의 보루여야 합니다. 애플리케이션 레벨에서 먼저 쳐내는 것이 트래픽 관리나 UX 측면에서 훨씬 좋습니다. 이번 글에서는 RedisSpring AOP를 활용해 비즈니스 로직 침투 없이 깔끔하게 멱등성을 보장하는 방법을 알아보려고 합니다.


1. 왜 Redis인가?

중복 요청을 막으려면 "지금 이 요청이 처리 중인가?"를 어딘가에 기억해야 합니다. 이를 위한 방법은 여러가지가 있습니다.

1. Java ConcurrentHashMap 활용

가장 간단한 방법은 자바 메모리에 요청 상태를 저장하는 것입니다. putIfAbsent() 메서드를 사용하면 동시성 환경에서도 안전하게 락을 걸 수 있습니다.

private final ConcurrentHashMap<String, Boolean> lockMap = new ConcurrentHashMap<>();

public void process(String key) {
    // 키가 없으면 true를 넣고 null 반환 (성공)
    // 키가 이미 있으면 기존 값 반환 (실패)
    if (lockMap.putIfAbsent(key, true) !=    null) {
        throw new IllegalStateException("이미 처리 중입니다.");
    }
    // 비지니스 로직
}

 

구현이 매우 쉽고 별도 인프라가 필요 없다는 장점이 있지만, 서버가 여러 대인 분산 환경(Scale-out)에서는 메모리가 공유되지 않기 때문에 중복 요청을 완벽하게 막을 수 없다는 치명적인 한계가 있습니다.

2. DB Lock 활용 (Unique Index / Pessimistic Lock)

데이터베이스의 강력한 일관성을 이용하여 분산 환경에서도 확실하게 중복을 막을 수 있는 방법입니다.

1) Unique Index (유니크 제약조건)
가장 확실한 최후의 보루입니다. 테이블 컬럼에 유니크 인덱스를 걸어두면 동시 요청이 와도 DB가 알아서 하나만 INSERT를 허용하고 나머지는 에러(DataIntegrityViolationException)를 뱉습니다.

2) 비관적 락 (Pessimistic Lock / SELECT FOR UPDATE)
JPA를 사용한다면 데이터를 읽을 때부터 물리적인 락을 걸 수 있습니다.

public interface PaymentRepository extends JpaRepository<Payment, Long> {

    // 조회하는 순간 row-lock을 획득하여 다른 트랜잭션의 접근을 차단
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Payment p where p.orderId = :orderId")
    Optional<Payment> findByOrderIdWithLock(String orderId);
}

 

단점
데이터의 정합성은 완벽하게 보장되지만, "성능이 느리고 DB에 불필요한 부하를 준다"는 치명적인 단점이 있습니다. 락을 획득하기 위해 DB 커넥션을 점유하고 대기해야 하므로, 트래픽이 몰리는 구간에 사용하면 DB가 전체 시스템의 병목이 될 위험이 큽니다.

3. Redis (권장)

앞선 두 방식의 단점을 보완하는, 이 문제 해결의 치트키입니다.

  1. 모든 서버가 공유하는 저장소:
    • 여러 대의 WAS(Web Application Server)가 떠 있어도 Redis는 하나이므로(혹은 클러스터로 관리되므로) 상태를 완벽하게 공유합니다.
  2. 압도적인 속도 (In-Memory):
    • 디스크 I/O가 발생하는 DB와 달리 메모리에서 동작하므로 락을 걸고 해제하는 속도가 매우 빠릅니다. 사용자 경험(Latency)에 영향을 주지 않습니다.
  3. TTL (Time To Live) 자동 만료:
    • "3초 뒤에 자동 삭제" 같은 설정이 가능합니다. 로직 수행 중 서버가 죽어서 락 해제 코드가 실행되지 않아도, 시간이 지나면 알아서 락이 풀립니다. (Deadlock 방지)
  4. SETNX (Atomic Operation):
    • "데이터가 없을 때만 저장한다"는 연산을 원자적(Atomic)으로 수행해주기 때문에, 동시성 이슈를 직접 제어할 필요가 없습니다.

2. 구현 설계: Redis 를 활용한 중복방지(커스텀 어노테이션 + AOP)

우리의 목표는 비즈니스 로직(Service)에 중복 방지 코드를 덕지덕지 붙이는 것이 아니라, 어노테이션 하나만 붙이면 동작하게 만드는 것입니다.

@Idempotent(key = "#orderId", ttl = 3000) // 3초 동안 동일 orderId 요청 방지
public void payOrder(String orderId) {
    // ... 결제 로직
}

2-1. 어노테이션 정의 (@Idempotent)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    String key();          // 중복 확인의 기준이 되는 키 (SpEL 지원 예정)
    long ttl() default 1000; // 락 유지 시간 (ms)
}

2-2. AOP Aspect 구현

핵심 로직은 Redis의 setIfAbsent (커맨드: SETNX)입니다. 키가 없을 때만 저장하고, 있으면 false를 반환하는 원자적(Atomic) 연산입니다.

@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

    private final RedisTemplate<String, String> redisTemplate;

    @Around("@annotation(idempotent)")
    public Object preventDuplication(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {

        // 1. 키 생성 (SpEL 파싱 로직은 생략하고 간단히 표현)
        // 실제로는 CustomSpelParser 등을 이용해 파라미터 값을 읽어와야 함
        String key = "LOCK:" + parseKey(idempotent.key(), joinPoint); 
        long ttl = idempotent.ttl();

        // 2. Redis에 키 저장 시도 (SETNX)
        Boolean isSuccess = redisTemplate.opsForValue()
                .setIfAbsent(key, "LOCKED", Duration.ofMillis(ttl));

        if (Boolean.FALSE.equals(isSuccess)) {
            // 3. 이미 키가 존재하면 중복 요청으로 간주하고 예외 발생
            throw new IllegalStateException("이미 처리 중인 요청입니다. 잠시 후 다시 시도해주세요.");
        }

        try {
            // 4. 비즈니스 로직 수행
            return joinPoint.proceed();
        } finally {
            // 5. 처리가 끝나면 락을 해제할지, TTL만큼 유지할지 결정
            // '따닥' 방지가 목적이라면 수행 후 바로 지우지 않고 TTL만큼 유지하는 게 유리할 수 있음 (정책에 따라 다름)
            // redisTemplate.delete(key); 
        }
    }
}

3. 실제 적용 시 주의사항

3-1. 키 설계가 가장 중요하다

무엇을 기준으로 중복인지 정의해야 합니다.

  • 단순 등록: 사용자 ID (USER:123:REGISTER)
  • 결제: 주문 ID (ORDER:ABC:PAY)
  • 이벤트 참여: 사용자 ID + 이벤트 ID (EVENT:Open:USER:123)

3-2. 락 유지 시간 (TTL) vs 락 해제 시점

락을 언제 풀어줄 것인가는 비즈니스 목적에 따라 신중하게 결정해야 합니다.

1) 로직 수행 후 바로 삭제 (finally 블록에서 delete)

  • 목적: "동시에 두 개가 실행되지만 않으면 돼." (DB 데이터 정합성 보장용)
  • 동작: 처리가 0.1초 만에 끝나면 락도 0.1초 만에 풀립니다.
  • 문제점: 사용자가 1초 동안 마우스를 5번 광클했다면?
    • 1번째 요청 (0.0s ~ 0.1s): 성공
    • 2번째 요청 (0.2s): 락이 이미 풀려 있어서 또 성공해버림 (중복 발생)
    • 따라서 '따닥' 방지용으로는 적합하지 않습니다.

2) TTL 시간만큼 무조건 유지 (delete 안 함)

  • 목적: "이 버튼은 3초에 한 번만 누를 수 있어." (Rate Limiting 성격)
  • 동작: 로직이 0.1초 만에 끝나더라도, Redis에 저장된 키는 지정한 TTL(예: 3초) 동안 살아있습니다.
  • 장점: 사용자가 아무리 빨리 클릭해도 3초 동안은 "이미 처리 중"이라는 응답을 받게 되므로 완벽한 중복 방지가 가능합니다.
  • 이 글의 주제인 '중복 클릭 방지'에는 이 방식이 훨씬 적합합니다.

3-3. 예외 처리 규격

사용자에게 단순히 500 에러를 주기보다, 409 Conflict 상태 코드나 429 Too Many Requests와 함께 "처리 중입니다"라는 친절한 메시지를 내려주는 것이 좋습니다.


4. 결론

Redis와 AOP를 조합하면 비즈니스 로직을 전혀 건드리지 않고도 강력한 중복 요청 방지 시스템을 구축할 수 있습니다.

  • 장점: 코드 오염 없음, 서버 간 동기화 완벽 해결, 빠른 성능
  • 단점: Redis 의존성 발생 (Redis 죽으면 기능 마비)

하지만 현대적인 아키텍처에서 Redis는 거의 필수 요소이므로, 이 패턴은 매우 가성비 좋은 선택입니다.

반응형