Redis 로 따닥 방지하기 (API 중복 요청 방지)

사용자가 버튼을 실수로 두 번 누르게 될 수 있다. (따닥!하고 더블 클릭한다고 하여 개발자들이 따닥이라고 부른다고 한다.)

일반적으로는 front에서 중복방지를 위한 처리를 해주지만 서버 입장에서는 들어오는 요청이 올바르게 들어오길 기대만 하고 있을 수는 없다. 그 어떤 요청에 대해서 원하는 결과가 나오도록 하는게 서버의 역할이기 때문이다.

따닥 시 발생하는 일

사용자가 결제요청 시 임시주문이 등록되는 처리 flow 를 통해 확인해보겠다.

임시주문 등록 flow

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2023-01-18_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_9.41.09.png

사용자가 ‘결제하기’를 누르면 기존에 생성되어있던 주문시트 정보를 기반으로 임시주문이 등록된다.

임시주문은 결제가 완료될 때 주문, 결제 등의 정보를 RDB 에 저장할 때 사용할 데이터다. 또한 결제서버가 별도로 있는 프로젝트의 경우 결제서버에게 주문 관련 정보를 줄 때 참조할 수도 있다.

따닥이 발생하면?

중복 요청하게 되면 아래와 같은 문제가 발생한다.

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2023-01-18_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_9.41.48.png

한 번 수행할 로직을 두번 이상 수행 = 리소스 낭비

  • 해당 API가 처리하기 위해 많은 데이터를 조회하거나 외부와 통신을 해야한다면 해당 어플리케이션이 쓸대없이 일을 하게 된다.
  • 중복 데이터 저장 가능성 있음*

이런 불필요한 데이터를 제거하는 방향도 있다.

  • 배치가 주기적으로 처리한다
  • Redis에 저장한다면 ttl을 설정하여 시간이 지난 후 소실되도록 한다

위의 두 방법은 어떻게 됐든 ‘시간’을 기준으로 문제점을 생각해볼 수 있다.

시간을 짧게 가져가면,

  • 결제처리에 오래 걸리는 사용자가 결제 중에 데이터 소실 가능성
  • 배치 프로세스가 자주 실행되기 때문에 DB에 I/O 하는 비용이 자주 발생

시간을 길게 가져가면,

  • 불필요 데이터가 쓸대없이 많이 쌓임. 데이터를 가지고 있는 것 또한 비용
  • 결국 중복 데이터로 인해 또 다른 리소스 혹은 비용이 발생하게 되어버린다.

따닥은 리소스 낭비

불필요하게 일을 하는 프로세스를 만드는 것도, 비용이며 불필요 데이터를 가지고 있는 것 또한 비용이다.

방법으로 ‘Redis 분산 락’을 이용해보겠다.

Redis 분산 락

분산락이 무엇인지 ChatGPT에게 물어보았다.

💡 분산 잠금은 분산 시스템에서 한 번에 하나의 프로세스 또는 스레드만 특정 리소스 또는 코드 섹션에 액세스할 수 있도록 하는 데 사용되는 동기화 메커니즘입니다. 이는 일반적으로 여러 프로세스 또는 스레드가 동일한 리소스에 동시에 액세스하려고 할 때 발생할 수 있는 경쟁 조건 및 기타 동기화 문제를 방지하는 데 사용됩니다. 분산 잠금은 중앙 집중식 잠금 서버를 사용하거나 Paxos 또는 Raft와 같은 분산 합의 알고리즘을 사용하는 등 다양한 기술을 사용하여 구현할 수 있습니다.

Redis 문서 (Distributed Locks with Redis) 에서는 분산락을 구현한 알고리즘으로 redlock을 제공해준다. 그리고 redlock 구현체들을 소개해준다. 그 중에 자바 구현체인 ‘Redisson’ 를 확인할 수 있다.

구현

Redisson

Redisson - Spring Boot Starter 를 참고하여 의존성을 추가해준다.

Gradle

implementation 'org.redisson:redisson-spring-boot-starter:3.19.1'

락 가져오기

RLock lock = redissonClient.getLock("orderTemp:" + orderSheetId);
boolean isLocked = false;
try {
        // 0 = 락 획득 대기, 3 = 획득 시 유효시간, TimeUnit.SECONDS = 앞 숫자들의 단위
        // 조건으로 락이 구해지는지 확인할 수 있다.
        // 0초 설정해서
    isLocked = lock.tryLock(0, 3, TimeUnit.SECONDS);

        // 락이 획득되지 않으면, 
    if (!isLocked) {
        throw new IllegalStateException("락을 획득할 수 없습니다.");
    }

    /* 비즈니스 로직 */
    log.info("주문 등록 처리중 ... ");
    Thread.sleep(1000); // 의도적 sleep
    log.info("주문 처리 완료");
} catch (IllegalStateException | InterruptedException e) {
    log.error("에러 발생 {} ", e.getMessage(), e);
} finally {
    if (isLocked) { // 락을 획득했을 때만 unlock 수행
        log.info("(unlock 수행)");
        lock.unlock();
    }
}

tryLock 으로 락 획득을 대기할 수 있다.

lock.tryLock(락 획득 대기시간, 획득 시 유효시간, 앞 숫자들의 단위)

리턴되는 boolean 값으로 락이 대기시간 이후에 획득 됐는지 확인할 수 있다.

락을 획득했을 때만 unlock이 정상적으로 수행되기 때문에 획득여부로 unlock 을 진행한다.

테스트를 하기 위해 의도적으로 Thread.sleep을 1초간 주었다.

테스트

@SpringBootTest
class OrderServiceTest {
    @Autowired
    private OrderService orderService;

    @Test
    void 동일한사용자가_따닥했을_때_에러발생() {
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        // 같은 orderSheetId 로 동시에 2개 요청
        executorService.execute(() -> {
            orderService.register("orderSheetId-1");
        });

        executorService.execute(() -> {
            orderService.register("orderSheetId-1");
        });

                // 위의 실행은 별도의 두 개의 스레드로 동작하기 때문에 결과 로깅을 위하여 의도적으로 3초 sleep
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

( 해당 로직은 AOP를 이용하여 락 로직을 단순화하는 리팩토링 까지 진행해봐도 좋을 것 같다.)

테스트 결과

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2023-01-18_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_10.51.00.png

락을 획득한 pool-2-thread-2 이름의 스레드는 주문처리하고 unlock 을 수행했다.

반면 pool-2-thread-1 는 락 획득에 실패하여 실패할 때 던진 IllegalStateException 에러가 발생했다.

에러를 내지 않고 다른 처리를 해줄 수 있겠다.

마무리

분산 환경에서 데이터를 다루는 것은 고려해야 하는 것이 많다고 느꼈다. 만약 단일 어플리케이션이라면 자바의 synchronized 를 고려해볼 수도 있을 것 같다.

## 참고