JVM Lock부터 분산락을 이용한 문제 해결까지

Lock

https://farmer-eom.tistory.com/1 

 

Virtual Thread 너 좋다던데?

최근 보안 이슈가 많이 발생하다보니 회사에서는 외부의 공격으로 부터 페이로드 변조를 검증하는 기능을 추가하게 되었다. 페이로드 검증 기능을 추가하고 보니 피크 타임 기준으로 504 에러가

farmer-eom.tistory.com

이전 포스팅에서 Virtual Thread 관련된 성능 테스트를 해보고 유의사항을 정리 했었다.

그 유의사항 중에서 가장 기억에 남는 포인트는 Pinning 현상이었던것 같다.

사실 JVM Lock 관련되어서 공부한지 너무 오래되기도 했고, 기존에 Velog에서 Tistory로 옮겨오면서 Virtual Thread 사용 시 문제가 됐었던 Pinning 현상의 주범인 Monitor Lock에 대한 정리와 그 Lock 확장인 분산락과 실제 내 경험을 정리해보려고 한다.

 

Java Object

굉장히 기초적인 내용이지만, 아래 설명할 내용의 이해를 돕기 위해서 한 가지 내용을 기억해둬야한다.

Java의 최상위인 Object는 내부적으로 Header, Instance Data를 기본적으로 갖는다. 그리고 필요시 JVM에서는 Monitor(ObjectMonitor)를  생성해 연결하여 갖고 있는다.

 

JVM Lock : Synchronized

JVM Lock에서 가장 쉽게 사용할 수 있는게 synchronized 를 붙이는 방법이다.

synchronized는 이전 포스팅에서처럼 Montior Lock으로 사용한다.

그렇다면 Monitor Lock은 무엇일까?

synchronized를 사용하게 되면 아래와 같이 Lock을 얻어 처리하게 된다.

synchronized
      │
      ▼
Monitor Enter
      │
      ▼
Monitor Lock 획득
      │
      ▼
임계영역 실행
      │
      ▼
Monitor Exit

앞서 한가지 기억해둔 내용 처럼 Monitor는 Java의 모든 Object가 갖고 있다고 보면 된다.

따라서, synchronized를 이용해 Lock을 얻고자 한다면 Monitor 를 이용해 Lock을 얻어 임계영역을 실행하게 된다.

synchronized(lock) {

    doSomething();

}

위와 같이 synchronzied를 수행하면  lock 객체를 이용해 아래와 같이 호출되는것과 같다.

lock.monitor.enter()

...

lock.monitor.exit()

 

그래서 이를 이전 포스팅에서 봤었던 Pinning 현상을 만들고자 한다면,  Carrier Thread가 많은 Virtual Thread를 실행해야하는데 Virtual Thread가 Monitor를 잡고있으니 Monitor 때문에 Carrier Thread를 놓지 못하므로 여러 Virtual Thread를 실행시킬 수 없어 Pinning 현상이 발생하는것이다.

Virtual Thread

↓

Monitor Lock

 

JVM Lock : ReetrantLock

ReetrantLock은 synchronized와 동일하지만 개발자가 직접 락을 제어할 수 있다.

사실 이름 그대로 같은 스레드가 이미 획득한 락을 다시 획득할 수 있는 락이다.

 

간단히 synchronized와 ReetrantLock을 비교해보자.

항목 synchronized ReetrantLock
락 해제 자동 직접 unlock()
재진입(Reentrant) O O
tryLock() X O
timeout X O
Interruptible Lock X O (lockInterruptibly())
공정 락(Fair Lock) X O
사용 난이도 쉬움 조금 복잡

 

ReetrantLock은 말그대로 재진입이 가능한 락인데 아래와 같은 상황에서도 DeadLock 없이 사용이 가능하다.

public void A() {
    lock.lock();

    B();

    lock.unlock();
}

public void B() {
    lock.lock();

    lock.unlock();
}

그 이유는 내부적으로 Count를 이용해서 Lock이 관리되고 있기 때문이며 실제로 Lock을 2번해야 count = 0이 되어 실제 락이 해제된다.

 

이전 Virtual Thread에서 ReentrantLock을 사용하면 Pinning 현상이 발생하지 않는걸로 확인했다.

그 이유는 Monitor를 사용하지 않기 때문이다.

ReetrantLock은 AQS(AbstractQueuedSynchronizer)를 이용해서 구현이 되어있다.

Monitor를 이용하면 JVM이 정책을 정하기 때문에 개발자가 바꿀 수 없다.

하지만 ReentrantLock은 AQS를 이용하고 AQS는 전부 자바 코드로 되어 있기 때문에 JVM이 아니라 정책을 다양하게 가져갈 수 있다.

더 나아가서 ReentrantLock을 사용하면 내부에서 park 명령어를 사용했을때 Carrier Thread를 반환할 수 있기 때문이다.

 

CAS(Compare-And-Swap)

사실 Lock에 대해서 설명하려면 CAS가 필수적이라 한번 정리한다.

CAS는 현재 값이 내가 예상한 값과 같으면 새로운 값으로 변경하는 CPU의 원자 연산이다.

 

CAS가 나온 배경은 멀티스레드환경에서 락을 사용할 경우 Context Switch, OS Scheduler 등 다양한 리소스가 사용되는데 이 비용이 너무 크다. 

그래서 락 없이 원자적으로 값을 변경할 수 없을까? 

 

위 아이디어에서 등장한게 CAS다.

CAS는 CPU가 제공하는 원자 명령어이며 Lock처럼 기다리지도 않는다. 성공/실패만 존재한다.

사실 ReetrantLock 내부에서 CAS를 1번 사용해서 Lock 획득 여부를 체크한다.

아래의 코드는 ReetrantLock의 구현 코드 중 일부이며, 코드 내용을 보면  compareAndSetState를 사용해서 Lock 획득 여부를 체크한다.

        @ReservedStackAccess
        final boolean tryLock() {
            Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, 1)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            } else if (getExclusiveOwnerThread() == current) {
                if (++c < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(c);
                return true;
            }
            return false;
        }

 

CAS와 관련되어서 재밌는 구현이 한 가지 더 있다.  바로 AtomicInteger 구현 내용 중 일부이다.

@IntrinsicCandidate
    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }

 

위 코드는 AtomicInteger의 구현 내용 중 일부이며 여기에서도 CAS를 사용하고 있는걸 확인할 수 있다.

그런데, 반복문안에서 CAS를 호출하고 있다. 만약 1000개에 스레드에서 CPU 연산을 계속 호출한다면 어떻게 될까?

이미 AtomicInteger 사용시 CPU 자원사용에 대한 이야기들이 많은데 그 이유가 바로 CAS 때문이다.


스핀락(Busy Waiting)과 분산락

사실 CAS 이야기를 하면 빼놓을 수 없는 이야기가 스핀락이고, 스핀락 하면 나오는 이야기가 분산락이다.

 

많은 현업에서 분산락은 Redis 이용해서 구현하고 있다. 실제로 또한 배치 중복 실행이나 캐시 스탬피드 현상을 피하기 위해서 분산락을 사용했었다.

 

스핀락(Busy Waiting)

분산락이란?

여러 서버(프로세스)에서 공유 자원에 동시에 접근하지 못하도록 보장하는 락이다.

 

사실 스핀락이란 분산락을 구현하는 과정에서 발생할 있는 락을 기다리는 방식을 말한다. 락을 획득할 때까지 스레드가 잠들지 않고 계속 반복해서 시도하는 락이다.

 

스핀락이 왜 발생할까? 

그 이유는 사실 이미 CAS의 문제점을 보면서 이미 확인했다.

락 획득 실패
   │
   ├── ① 잠든다 (Block) — OS에게 "나 재워줘" 요청, 락 풀리면 깨워달라고 등록
   │
   └── ② 계속 확인한다 (Spin) — CPU 붙잡고 "됐나? 됐나?" 반복 --> CompareAndState 반복

 

스핀락이 존재하는 이유는 ①(블로킹)의 비용이 생각보다 크기 때문이다.

 

스레드를 재우고 꺠우는 건 OS 커널 개입 + Context Switch가 필요하다. 이게 대략 마이크로초 단위의 비용인데, 만약 락이 그 비용보다도 짧은 시간 안에 풀릴 예정이라면?

블로킹 선택 시:
   Context Switch 비용(비쌈) > 실제 기다려야 하는 시간(짧음)
   → 재우고 깨우는 데 드는 비용이 오히려 손해

스핀 선택 시:
   그냥 몇 번 확인만 하면 곧 락이 풀림
   → Context Switch 없이 바로 획득 (더 빠름)

 

즉, 스핀락은 임계영역이 아주 짧은이라는 가정 위에서 성립하는 최적화이다.

- 최신 JVM 기준으로 synchronized는 적응형 스핀락을 사용한다. "과거 통계를 기반으로 이 락은 보통 금방 풀린다"라고 판단되면 스핀, 안 풀리면 블로킹으로 전환한다.

- OS 커널은 아주 짧은 임계구역을 보호 할 때 스핀락을 쓴다고 한다.

 

일반적인 스핀락의 장점

Context Switch 비용이 없다. 락이 짧은 시간 안에 풀린다면 블로킹보다 훨씬 빠르게 획득할 수 있다.

구현이 단순하다. CAS 루프 하나로 끝난다. -> 대기 큐, 알림, 스케줄러 개입 같은 복잡한 장치가 필요없다.

 

단점

임계구역이 길어지면 손해가 커진다. 

기다리는 동안 CPU를 계속 쓰기 때문에(Like AtomicInteger) 예상보다 락이 오래 잡혀 있으면 그만큼 CPU 자원을 낭비한다.

 

락을 쥔 스레드가 멈추면 최악이다.

락 소유자가 OS 스케줄러에 의해 선점되거나 어떤 이유로든 멈춰버리면, 나머지 스레드들은 아무 진행도 못하고 헛되이 스핀만 돈다.

 

코어가 많고 경쟁이 심할수록 나빠진다.

여러 코어가 동시에 같은 락을 확인하면서 캐시 경합이 발생해 오히려 전체 처리량이 떨어질 수 있다.

 

정리하면, 스핀락은 아래 조건을 동시에 만족할 때만 유리하다.

 

- 임계구역이 아주 짧다 — 대기 시간 자체가 Context Switch 비용보다 작아야 스핀이 이득

- 락 경쟁이 적다 — 대기하는 스레드가 많아지면 스핀 도는 CPU 낭비가 배로 늘어남

- CPU 코어가 충분히 여유롭다 — 스핀 도는 스레드가 다른 유용한 작업의 코어를 뺏지 않아야 함

보유 시간이 예측 가능하다 — " 풀린다" 가정이 맞아야 스핀이 블로킹보다 빠름

 

반대로 아래 상황에서는 스핀락을 쓰면 안 된다.

- 임계구역이 길거나 시간을 예측할 수 없을 때— I/O, 외부 API 호출, DB 쿼리처럼 걸리는 시간이 들쭉날쭉한 작업을 스핀락으로 보호하면, 대기하는 스레드들이 그 시간 내내 CPU를 태우며 헛돈다.

- 경쟁하는 스레드 수가 CPU 코어 수보다 훨씬 많을 때 — 스핀 도는 스레드들끼리 서로 CPU를 뺏고 뺏기면서, 정작 락을 쥐고 실제 작업을 끝내야 할 스레드가 스케줄링을 못 받는 역효과(Priority Inversion과 비슷한 현상)까지 생길 수 있다.

- 단일 코어 환경 — 스핀 도는 스레드가 CPU를 붙잡고 있는 동안, 락을 쥔 스레드는 애초에 실행될 기회조차 못 얻는다. 스핀이 오히려 락 해제를 지연시키는 자기모순적 상황이 된다.

 

이래서 JVM의 synchronized가 "무조건 스핀"이 아니라 적응형(adaptive) 스핀을 쓰는 것이다 — 위 조건이 항상 성립하는 게 아니니, 런타임에 통계를 보고 스핀할지 블로킹할지 그때그때 판단한다.

 

분산락

Redis 분산락을 가장 단순하게 구현하면 아래와 같은 모양이된다.

while (true) {
    val acquired = redisTemplate.opsForValue().setIfAbsent(key, token, ttl)
    if (acquired == true) break
    Thread.sleep(100)  // 될 때까지 계속 다시 물어본다
}

 

SET NX가 성공할 때까지 계속 재시도하는 이 패턴이 정확히 스핀락과 같은 본질(=될 때까지 반복 확인)을 갖는다. 다만 CPU 대신 Redis를 계속 두드린다는 차이가 있을 뿐이다.

 

여기서 중요한 건, 이건 JVM의 적응형 스핀처럼 "짧은 대기를 노리는 의도적 최적화"가 아니라는 점이다. 그냥 "Redis 키가 풀렸는지 다시 확인하는" 가장 단순한 구현 방법이 하필 스핀락과 같은 모양이 된 것뿐이다. 그래서 경쟁이 심해지면(대기하는 클라이언트가 많아지면) 스핀락의 단점이 그대로 드러난다 — 다들 동시에 Redis를 계속 두드리면서 Redis 자체에 불필요한 부하를 준다.(CAS의 경우는 CPU 였지만)

 

이게 바로 Redis 자체는 스핀락이 아니지만(SET NX 원자적으로 시도하고 끝나는 연산이다), Lettuce 구현한 분산락은 스핀락과 유사한 특성을 갖게 되는 이유.

 

Lettuce는 Redis 커맨드를 그대로 실행해주는 얇은 클라이언트다. SET, GET, DEL 같은 워시 명령어를 보내고 응답을 받는 역할만 한다.

- Lettuce는 "락 획득 실패 시 자동으로 해당 채널을 구독하고, 해제 이벤트를 받으면 깨어나서 재시도한다"는 조합 로직을 전혀 제공하지 않고, SET, DEL, SUBSCRIBE는 각각 따로 실행시켜주는 개발 명령어일 뿐이다.

- 이 조합 로직을 직접 만들기 위해서는 구독 타이밍과 SET NX 재시도 사이의 race condition 처리, 여러 후보가 동시에 깨어났을 때의 재경쟁, 타임아웃 처리 등 Redission이 이미 만들어 놓은 걸 처음 부터 다시 구현해야한다.

 

반대로 Redission은 Lettuce와 태생이 다르다! Redission은 java.util.concurrent.locks.Lock 인터페이스를 그대로 구현한 RLock을 제공하는 걸 목표로 만들어진 라이브러리다. (마치 ReetrantLock 처럼)

 

ReentrantLock이 실패하면 스핀 대신 스레드를 park() 시켜서 재우는 것처럼, RLock도 실패하면 진짜로 스레드를 재운다.이를 위해 Redission은 내부적으로 아래와 같이 처리한다.

 

1. 락 실패 시, 그 락 전용 Pub/Sub 채널을 구독한다.

2.스레드를 블로킹 시킨다.(busy-wait이 아니다.) 

3.락을 쥔 쪽이 unlock() 할때, DEL과 동시에 그 채널로 publush한다.

4.대기 중이던 스레드가 그 메시지를 받으면 그제서야 깨어나서 SET NX를 다시 시도한다.

 

이 구조 자체가 "실패하면 계속 반복 확인"이 아니라 "실패하면 알림을 받을 때 까지 진짜로 잔다" 이기 때문에, 정의상 스핀락이 될 수가 없다. (스핀락이 되려면 확인만 반복하고 재우지 않는 구조)

 

테스트 지표 확인

그렇다면 Lettuce 분산락 VS Redission 분산락에 대해서 성능 테스트를 해보자.

시나리오1(동시성 낮음)
- 커머스 환경에서 이벤트를 오픈했다.
- 사용자가 한번에 몰리는 상황이 아니라 각자 편한 시간에 들어와 여유 있게 쿠폰을 받아 간다.
- 실제로 내 요청과 다른 사람의 요청이 거의 겹치지 않는다.
- K6 VUS = 1, 100건 요청, 락 경쟁 거의 없음
- 예상 : Lettuce < Redssion

시나리오2(동시성 중간)
- 커머스 환경에서 이벤트를 오픈했다.
- 오픈과 동시에 푸시 알림을 보고 거의 동시에 들어와 쿠폰 발급을 누른다.
- K6 VUS = 5 내외, 100건 요청, 락 경쟁 중간
- 예상 : Lettuce < Redssion

시나리오3(동시성 매우 높음)
- 커머스 환경에서 이벤트를 오픈했다.
- 쿠폰을 받기 위해서 대기하고 있던 사람들이 동시에 버튼을 누른다.
- K6 VUS = 50 내외, 100건 요청, 락 경쟁 중간
- 예상 : Lettuce < Redssion

 

Lettuce 분산락 테스트 코드

@Service
class LettuceLockCouponService(
    private val redisTemplate: StringRedisTemplate,
    private val couponRepository: CouponRepository,
) {
    private val lockTtl = Duration.ofSeconds(3)
    private val retryInterval = 100L

    fun issue(couponId: Long) {
        val key = "lock:coupon:$couponId"
        val token = UUID.randomUUID().toString()

        while (true) {
            val acquired = redisTemplate.opsForValue().setIfAbsent(key, token, lockTtl) ?: false
            if (acquired) break
            Thread.sleep(retryInterval) // Polling: 스핀락처럼 "얻을 때까지 계속 확인"
        }

        try {
            issueInTransaction(couponId)
        } finally {
            // 내가 건 락만 지운다(다른 스레드가 TTL 만료 후 새로 잡은 락을 실수로 지우지 않도록).
            val currentValue = redisTemplate.opsForValue().get(key)
            if (currentValue == token) {
                redisTemplate.delete(key)
            }
        }
    }

    @Transactional
    fun issueInTransaction(couponId: Long) {
        val coupon = couponRepository.findById(couponId).orElseThrow()
        coupon.issue()
        couponRepository.save(coupon)
    }
}

 

Redission 분산락 테스트 코드

@Service
class RedissonLockCouponService(
    private val redissonClient: RedissonClient,
    private val couponRepository: CouponRepository,
) {
    fun issue(couponId: Long) {
        val lock = redissonClient.getLock("lock:coupon:$couponId")

        // waitTime=5s(최대 대기), leaseTime 은 안 줘서 WatchDog 이 TTL 을 자동 연장하게 한다.
        val acquired = lock.tryLock(5, TimeUnit.SECONDS)
        check(acquired) { "락 획득 실패: coupon=$couponId" }

        try {
            issueInTransaction(couponId)
        } finally {
            if (lock.isHeldByCurrentThread) {
                lock.unlock()
            }
        }
    }

    @Transactional
    fun issueInTransaction(couponId: Long) {
        val coupon = couponRepository.findById(couponId).orElseThrow()
        coupon.issue()
        couponRepository.save(coupon)
    }
}

 

 

테스트 결과

아래의 결과는 k6를 이용해 각 시나리오를 테스트했을 때의 결과이다.

시나리오 전략 avg P50 P95
1. 무경쟁 (VUS=1) Lettuce 1.49ms 1.16ms 3.34ms
1. 무경쟁 (VUS=1) Redisson 1.82ms 1.31ms 2.51ms
2. 중간경쟁 (VUS=5) Lettuce 11.85ms 1.27ms 103.67ms
2. 중간경쟁 (VUS=5) Redisson 4.32ms 3.88ms 6.97ms
3. 고경쟁 (VUS=50) Lettuce 600.23ms 4.32ms 2.61s
3. 고경쟁 (VUS=50) Redisson 33.31ms 36.05ms 50.28ms

 

테스트 결과를 보면 약간? 예상과 빗나간점이 있긴하다.

- 무경쟁 상황에서 Lettuce가 더 빠르다. 

이유를 생각해보면 사실 경쟁이 없기 때문에 우열을 가릴 수 없을것같다. 1ms 내 데이터라 노이즈도 존재하기 때문이다.

 

- 중간 경쟁상황 

대부분의 요청은 Lettuce가 빠르다. 즉, 경쟁 없이 빠르게 끝났기 떄문이다. 다만 P95는 Redission이 훨씬 빠르다.

그 이유는 아무래도 Lettuce는 경쟁이 시작되면 Polling을 위한 대기 시간이 존재하기 때문이다.

 

- 고경쟁 상황

중간 경쟁상황에서 Lettuce가 느렸듯이 역시 Redission이 빠른게 확인됐다.

 

결론, 경쟁이 거의 없을때는 우열이 없지만 경쟁이 생기는 순간부터 Redssion을 이용한 분산락 성능이 매우 좋으며 그 격차는 경쟁이 심할 수록 크다.

 

끝판왕! 적응형 스핀락을 적용한 Redis 분산락

테스트 결과를 보면 Lettuce를 이용한 분산락이 느린 이유는 경쟁이 있을 때 Polling을 위한 대기 시간 때문인것으로 확인된다.

일반적인 상황은 아니지만 아래와 같은 상황이 존재한다면?

- 임계 구역이 길거나 경쟁이 심해지는 순간이 있다면? --> 스핀락을 사용하면 CPU을 긴 임계기간동안 계속 차지하면서 스핀락이 성능 저하를 일으킨다.

- 임계구역이 매우 짧다. --> 커널이 스레드를 재우고,깨우는 비용이 더 비싸다

 

만약, 위 2가지 상황이 섞여서 존재하는 상황에서 분산락을 써야한다면?

synchronized 처럼 적응형 방식을 사용해야한다.

synchronized는 락 별로 과거 이력을 실시간으로 추적해서 스핀을 할지 블로킹을 할지 매번 판단한다.

- 이 락이 최근에 스핀만으로 잘 풀렸는가? -> 스핀락 잡는다.

- 최근에 스핀에서 블로킹으로 된 적이 많은가? -> 블로킹 방식 사용한다.

 

즉, 고정된 정책이 아니라 상황에 따라 바꿔서 락을 잡는다.

 

이를 위해서는 Lettuce와 Redission을 섞어서 사용해야하는데, 그게 불가능하기 때문에 Lettuce 기반 + Pub/Sub 채널을 직접 구현하는 방식으로 진행해보았다.

@Service
class AdaptiveSpinLockCouponService(
    private val redisTemplate: StringRedisTemplate,
    private val listenerContainer: RedisMessageListenerContainer,
    private val couponRepository: CouponRepository,
) {
    private val lockTtl = Duration.ofSeconds(3)
    private val overallTimeoutNanos = Duration.ofSeconds(5).toNanos()

    private val spinBudget = AdaptiveSpinBudget(
        defaultNanos = Duration.ofMillis(5).toNanos(),
        minNanos = 200_000L, // 0.2ms — 완전히 0으로 떨어지지 않게 해서, 경쟁이 풀렸을 때 다시 회복할 여지를 남긴다.
        maxNanos = Duration.ofMillis(20).toNanos(),
    )

    private val releaseScript = DefaultRedisScript(
        """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            redis.call('del', KEYS[1])
            redis.call('publish', KEYS[2], 'released')
            return 1
        else
            return 0
        end
        """.trimIndent(),
        Long::class.java,
    )

    fun issue(couponId: Long) {
        val key = "lock:coupon:adaptive:$couponId"
        val token = UUID.randomUUID().toString()
        val start = System.nanoTime()
        val deadline = start + overallTimeoutNanos
        val budget = spinBudget.current(key)
        val spinDeadline = start + budget

        // 1단계: 이 락 키의 히스토리 기반 스핀 예산만큼 스핀(Busy-Wait).
        while (System.nanoTime() < spinDeadline) {
            if (tryAcquire(key, token)) {
                spinBudget.recordSuccess(key)
                runCriticalSection(couponId, key, token)
                return
            }
            Thread.onSpinWait()
        }
        spinBudget.recordFailure(key)

        // 2단계: 스핀으로 못 잡았다 → 폴링 대신 해제 알림을 구독해서 대기.
        if (waitForReleaseAndAcquire(key, token, deadline)) {
            runCriticalSection(couponId, key, token)
            return
        }

        throw IllegalStateException("락 획득 타임아웃: coupon=$couponId")
    }

    private fun waitForReleaseAndAcquire(
        key: String,
        token: String,
        deadline: Long,
    ): Boolean {
        while (System.nanoTime() < deadline) {
            val latch = CountDownLatch(1)
            val listener = MessageListener { _: Message, _: ByteArray? -> latch.countDown() }
            val topic = ChannelTopic(releaseChannel(key))

            listenerContainer.addMessageListener(listener, topic)
            try {
                // 구독을 먼저 걸고 나서 재확인 — 스핀 종료와 구독 사이에 풀렸을 가능성을 놓치지 않기 위함.
                if (tryAcquire(key, token)) {
                    return true
                }

                val remainingNanos = deadline - System.nanoTime()
                if (remainingNanos <= 0) return false

                // 통보가 영영 안 오는 경우(락 쥔 쪽이 release 없이 죽은 경우)에 대비해, TTL 주기로는 스스로 깨어난다.
                val waitNanos = minOf(remainingNanos, lockTtl.toNanos())
                latch.await(waitNanos, TimeUnit.NANOSECONDS)
            } finally {
                listenerContainer.removeMessageListener(listener, topic)
            }
        }
        return false
    }

    private fun runCriticalSection(
        couponId: Long,
        key: String,
        token: String,
    ) {
        try {
            issueInTransaction(couponId)
        } finally {
            release(key, token)
        }
    }

    private fun tryAcquire(
        key: String,
        token: String,
    ): Boolean = redisTemplate.opsForValue().setIfAbsent(key, token, lockTtl) ?: false

    private fun release(
        key: String,
        token: String,
    ) {
        redisTemplate.execute(releaseScript, listOf(key, releaseChannel(key)), token)
    }

    private fun releaseChannel(key: String): String = "$key:released"

    @Transactional
    fun issueInTransaction(couponId: Long) {
        val coupon = couponRepository.findById(couponId).orElseThrow()
        coupon.issue()
        couponRepository.save(coupon)
    }
}

 

시나리오전략avgmedp95

시나리오 전략 avg P50 P95
1. 무경쟁 (VUS=1) Lettuce 1.49ms 1.16ms 3.34ms
1. 무경쟁 (VUS=1) Redisson 1.82ms 1.31ms 2.51ms
1. 무경쟁 (VUS=1) 적응형(신규) 4.16ms 2.68ms 3.62ms
2. 중간경쟁 (VUS=5) Lettuce 11.85ms 1.27ms 103.67ms
2. 중간경쟁 (VUS=5) Redisson 4.32ms 3.88ms 6.97ms
2. 중간경쟁 (VUS=5) 적응형(신규) 4.76ms 1.93ms 5.51ms
3. 고경쟁 (VUS=50) Lettuce 600.23ms 4.32ms 2.61s
3. 고경쟁 (VUS=50) Redisson 33.31ms 36.05ms 50.28ms
3. 고경쟁 (VUS=50) 적응형(신규) 94.37ms 86.04ms 211.17ms

 

예상했던 바와 같이 중간 경쟁 상황에서 적응형이 가장 빠른 결과를 보였다.

 

 

나의 사례

캐시스탬피드를 피하기 위한 방법으로 Soft TTL을 구현하기 위해 분산락을 사용했었다. 

 

참고)

캐시스템피드를 피하기 위한 Soft TTL