사용자가 버튼을 실수로 두 번 누르게 될 수 있다. (따닥!하고 더블 클릭한다고 하여 개발자들이 따닥이라고 부른다고 한다.)
일반적으로는 front에서 중복방지를 위한 처리를 해주지만 서버 입장에서는 들어오는 요청이 올바르게 들어오길 기대만 하고 있을 수는 없다. 그 어떤 요청에 대해서 원하는 결과가 나오도록 하는게 서버의 역할이기 때문이다.
따닥 시 발생하는 일
사용자가 결제요청 시 임시주문이 등록되는 처리 flow 를 통해 확인해보겠다.
임시주문 등록 flow

사용자가 ‘결제하기’를 누르면 기존에 생성되어있던 주문시트 정보를 기반으로 임시주문이 등록된다.
임시주문은 결제가 완료될 때 주문, 결제 등의 정보를 RDB 에 저장할 때 사용할 데이터다. 또한 결제서버가 별도로 있는 프로젝트의 경우 결제서버에게 주문 관련 정보를 줄 때 참조할 수도 있다.
따닥이 발생하면?
중복 요청하게 되면 아래와 같은 문제가 발생한다.

한 번 수행할 로직을 두번 이상 수행 = 리소스 낭비
- 해당 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를 이용하여 락 로직을 단순화하는 리팩토링 까지 진행해봐도 좋을 것 같다.)
테스트 결과

락을 획득한 pool-2-thread-2
이름의 스레드는 주문처리하고 unlock 을 수행했다.
반면 pool-2-thread-1
는 락 획득에 실패하여 실패할 때 던진 IllegalStateException 에러가 발생했다.
에러를 내지 않고 다른 처리를 해줄 수 있겠다.
마무리
분산 환경에서 데이터를 다루는 것은 고려해야 하는 것이 많다고 느꼈다. 만약 단일 어플리케이션이라면 자바의 synchronized
를 고려해볼 수도 있을 것 같다.
## 참고
- https://redis.io/docs/manual/patterns/distributed-locks/
- https://github.com/redisson/redisson
- https://hudi.blog/distributed-lock-with-redis/
- 해당 링크로 Redisson 이 제공하는 분산락 매커니즘이 메시지 브로커 기능을 활용했음을 알게되었다.