
이전 글에서 WebFlux 환경에서 분산 락을 Reactive 체인의 라이프사이클에 맞춰 안전하게 관리하는 방법을 다뤘습니다.
락 획득과 해제 시점이 트랜잭션 커밋과 정확히 맞물리게 만들었고, 기능적으로는 문제가 없었습니다.
하지만 부하 테스트를 통해 성능 측면에서 3가지 병목이 드러났습니다.
| 지표 | 부하 테스트 결과 |
|---|---|
| 성공률 | 23% (77% 실패) |
| 평균 대기 시간 | 56초 |
| 최대 동시 대기 수 | 64+ (Dispatchers.IO 스레드 고갈) |
| Redis 오류/타임아웃 | 발생 |
50req/min, 10분 기준으로 측정한 수치입니다.
기능은 정상이지만, 실 서비스 부하에서는 대부분의 요청이 타임아웃으로 실패하는 상황이었습니다.
기존 acquireLock() 의 흐름은 단순했습니다.
private fun acquireLock(...): LockResource {
val lockKey = resolveLockKey(distributedLock, joinPoint)
val semaphore = redissonClient.getPermitExpirableSemaphore(lockKey)
semaphore.trySetPermits(1) // ① 매번 Redis 호출
val permitId = semaphore.tryAcquire( // ② 모든 요청이 직접 Redis로
distributedLock.waitTime, // ③ 60초 동안 무한 대기
distributedLock.leaseTime,
TimeUnit.SECONDS
)
...
}
이 코드에는 세 가지 문제가 숨어 있습니다.
trySetPermits(1) 은 세마포어의 허가(permit) 수를 설정하는 메서드입니다.
이미 설정되어 있으면 무시되지만, Redisson 내부적으로는 매번 Redis에 Lua 스크립트를 전송합니다.
요청 1: trySetPermits(1) → Redis Lua 실행 → 설정됨 ✓
요청 2: trySetPermits(1) → Redis Lua 실행 → 이미 설정됨, 무시
요청 3: trySetPermits(1) → Redis Lua 실행 → 이미 설정됨, 무시
...
요청 N: trySetPermits(1) → Redis Lua 실행 → 이미 설정됨, 무시
결과적으로 모든 요청에 대해 의미 없는 Redis 왕복(round-trip) 이 발생합니다.
500회 반복 측정 결과, 이 불필요한 호출만으로 수백 ms의 누적 오버헤드가 발생했습니다.
Redisson의 PermitExpirableSemaphore 는 내부적으로 Pub/Sub을 사용합니다.
누군가 락을 해제하면 Redis가 대기 중인 모든 클라이언트에 알림을 보내고,
알림을 받은 모든 클라이언트가 동시에 tryAcquire Lua 스크립트를 실행합니다.
락 해제 이벤트 발생!
├─ 대기자 1: tryAcquire Lua 실행 → 성공 ✓
├─ 대기자 2: tryAcquire Lua 실행 → 실패 (이미 1이 가져감)
├─ 대기자 3: tryAcquire Lua 실행 → 실패
├─ ...
└─ 대기자 50: tryAcquire Lua 실행 → 실패
허가는 1개인데 50개의 Lua 스크립트가 동시에 실행됩니다.
49개는 반드시 실패할 연산인데도 Redis CPU를 소모합니다.
이것이 반복되면 Redis의 single-threaded 특성상 다른 모든 Redis 연산이 지연됩니다.
기존 waitTime 기본값은 60초였습니다.
분당 50개의 요청이 들어오고, 처리량이 분당 ~11개(평균 점유 5.5초 기준)라면:
매 분마다 약 39개의 요청이 대기열에 누적
→ 1분 후: 39개 대기
→ 2분 후: 78개 대기
→ ...
→ Dispatchers.IO 스레드(기본 64개) 전부 블로킹 대기 상태
→ 새로운 코루틴을 디스패치할 스레드가 없음
→ near-deadlock
60초를 기다리다 결국 실패할 요청들이 스레드를 점유한 채 대기하면서,
정작 처리할 수 있는 요청마저 스레드를 할당받지 못하는 악순환이 발생합니다.
개선된 acquireLock() 의 흐름은 다음과 같습니다.
요청 도착
│
▼
① maxWaiters 체크 ──── 초과 시 즉시 실패 (fast-fail)
│
▼
② 로컬 세마포어 획득 ── Pod 내 1개만 통과
│
▼
③ trySetPermits 캐싱 ── 최초 1회만 Redis 호출
│
▼
④ Redis 락 획득 ─────── 남은 시간으로 tryAcquire
│
▼
비즈니스 로직 실행
│
▼
⑤ Redis 락 해제 → 로컬 세마포어 해제 → waiterCount 감소
하나씩 살펴보겠습니다.
ConcurrentHashMap.newKeySet()trySetPermits(1) 은 키당 한 번만 호출하면 됩니다.
"이 키는 이미 초기화했다"는 사실을 JVM 메모리에 캐싱하면 됩니다.
// trySetPermits 중복 호출 방지 캐시
private val initializedKeys: MutableSet<String> = ConcurrentHashMap.newKeySet()
// acquireLock() 내부
if (initializedKeys.add(lockKey)) { // 새 원소면 true, 이미 있으면 false
semaphore.trySetPermits(1) // true일 때만 Redis 호출
}
Set.add() 는 원소가 새로 추가되면 true, 이미 존재하면 false 를 반환합니다.
이 한 줄로 check-and-set이 원자적으로 처리됩니다.
멀티스레드 환경에서 Set 을 안전하게 쓰는 방법은 여러 가지가 있습니다.
| 자료구조 | 읽기 | 쓰기 | 단점 |
|---|---|---|---|
HashSet + synchronized | 전체 락 | 전체 락 | 모든 연산이 직렬화 |
Collections.synchronizedSet() | 전체 락 | 전체 락 | 위와 동일 (래퍼일 뿐) |
CopyOnWriteArraySet | lock-free | 배열 전체 복사 | 쓰기 시 O(n) 메모리 + GC |
ConcurrentHashMap.newKeySet() | lock-free | 버킷 단위 락 | - |
ConcurrentHashMap 은 Java 8부터 내부적으로 CAS(Compare-And-Swap) + 버킷별 synchronized 를 사용합니다.
ConcurrentHashMap 내부 구조 (개념)
버킷 0: [keyA] ←── 이 버킷에 쓰기 시, 이 버킷만 잠금
버킷 1: [keyB, keyC]
버킷 2: (비어있음) ←── 읽기는 항상 lock-free
버킷 3: [keyD]
...
volatile 읽기로 lock-free. 락 없이 최신 값 확인synchronized. 다른 버킷의 읽기/쓰기를 차단하지 않음분산 락 키는 ORDER_LOCK:12345 같은 형태로, 서로 다른 키가 서로 다른 버킷에 분산됩니다.
결과적으로 대부분의 연산이 서로 간섭 없이 병렬 처리됩니다.
Pod가 재시작되면 initializedKeys 는 빈 상태로 초기화됩니다.
이때 trySetPermits(1) 이 다시 호출되지만, Redis에 이미 설정된 키라면 Redisson이 무시합니다.
별도의 만료 처리나 동기화 없이도 자연스럽게 정합성이 유지됩니다.
java.util.concurrent.SemaphoreThundering Herd의 근본 원인은 여러 요청이 동시에 Redis에 도달하는 것입니다.
어차피 허가는 1개이므로, Pod 안에서 1개만 통과시키면 나머지는 Redis까지 갈 필요가 없습니다.
기존:
요청 1 ──→ Redis tryAcquire (Lua) ──→ 성공
요청 2 ──→ Redis tryAcquire (Lua) ──→ 실패 (대기)
요청 3 ──→ Redis tryAcquire (Lua) ──→ 실패 (대기)
...
요청 50 ─→ Redis tryAcquire (Lua) ──→ 실패 (대기)
→ Redis에 50개 Lua 스크립트 동시 실행
개선:
요청 1 ──→ [로컬 세마포어 통과] ──→ Redis tryAcquire ──→ 성공
요청 2 ──→ [로컬 세마포어 대기] (JVM 내부)
요청 3 ──→ [로컬 세마포어 대기] (JVM 내부)
...
요청 50 ─→ [로컬 세마포어 대기] (JVM 내부)
→ Redis에는 항상 1개만 도달
// 로컬 세마포어: 키별 1개 요청만 Redis 도달
private val localSemaphores = ConcurrentHashMap<String, Semaphore>()
// acquireLock() 내부
val localSemaphore = localSemaphores.computeIfAbsent(lockKey) { Semaphore(1) }
val localAcquired = localSemaphore.tryAcquire(waitTimeMs, TimeUnit.MILLISECONDS)
if (!localAcquired) {
// 로컬 대기 중 타임아웃
throw Exception(ErrorCode.Common.LOCK_FAILURE)
}
동시성을 제어하는 도구는 여러 가지가 있습니다.
| 도구 | 소유자 개념 | timeout 지원 | 크로스 스레드 해제 | 코루틴 호환 |
|---|---|---|---|---|
synchronized | 있음 (스레드) | 없음 | 불가 | 불가 |
ReentrantLock | 있음 (스레드) | 있음 | 불가 | 불가 |
Semaphore | 없음 | 있음 | 가능 | 불가 |
Kotlin Mutex | 없음 | 있음 | 가능 | 전용 |
여기서 핵심은 "소유자 개념이 없다" 는 점입니다.
ReentrantLock 은 락을 획득한 스레드만 해제할 수 있습니다.
하지만 우리 코드에서는 AOP가 락을 획득하고, Mono.doFinally 또는 wrappedContinuation 에서 해제합니다.
이 두 지점이 같은 스레드에서 실행된다는 보장이 없습니다.
AOP around() [Thread-1] → acquireLock() → 로컬 세마포어 획득
↓
비즈니스 로직 (suspend, 스레드 전환 가능)
↓
Mono.doFinally [Thread-3] → releaseLock() → 로컬 세마포어 해제
Semaphore 는 어떤 스레드에서든 release() 를 호출할 수 있으므로 이 패턴에 적합합니다.
Kotlin Mutex 는 코루틴 전용이라, AOP의 around() 메서드(일반 함수)에서 직접 사용할 수 없습니다.
val localSemaphore = localSemaphores.computeIfAbsent(lockKey) { Semaphore(1) }
computeIfAbsent() 는 키가 없을 때만 팩토리를 호출하여 값을 생성합니다.
putIfAbsent(key, Semaphore(1)) 와의 차이:
// putIfAbsent: 항상 Semaphore 객체를 먼저 생성한 뒤, 이미 있으면 버림
localSemaphores.putIfAbsent(lockKey, Semaphore(1)) // 불필요한 객체 생성 가능
// computeIfAbsent: 키가 없을 때만 팩토리 호출
localSemaphores.computeIfAbsent(lockKey) { Semaphore(1) } // 필요할 때만 생성
로컬 세마포어에서 대기한 시간을 차감하여, Redis 락 획득에 남은 시간만 사용합니다.
val waitTimeMs = distributedLock.waitTime * 1000
val startTime = System.currentTimeMillis()
// 로컬 세마포어 대기 (여기서 시간이 소모됨)
localSemaphore.tryAcquire(waitTimeMs, TimeUnit.MILLISECONDS)
// 남은 시간 계산
val elapsedMs = System.currentTimeMillis() - startTime
val remainingMs = waitTimeMs - elapsedMs
// Redis 락은 남은 시간으로만 시도
semaphore.tryAcquire(remainingMs, leaseTime, TimeUnit.MILLISECONDS)
이렇게 하면 전체 대기 시간이 waitTime 을 초과하지 않습니다.
AtomicInteger로컬 세마포어가 Redis 부하를 줄여주지만, 대기자 자체가 무한히 쌓이는 문제는 해결하지 못합니다.
60초(기존 waitTime) 동안 대기하는 요청들이 스레드를 점유하면 Dispatchers.IO 가 고갈됩니다.
해결 방법은 단순합니다: "너무 많이 기다리고 있으면, 줄 서지 말고 바로 돌아가라."
// 키별 대기자 수 추적
private val waiterCounts = ConcurrentHashMap<String, AtomicInteger>()
// acquireLock() 최상단
val waiterCount = waiterCounts.computeIfAbsent(lockKey) { AtomicInteger(0) }
val currentWaiters = waiterCount.incrementAndGet()
if (currentWaiters > distributedLock.maxWaiters) {
waiterCount.decrementAndGet()
throw Exception(ErrorCode.Common.LOCK_FAILURE) // 즉시 실패
}
대기자 수를 추적하려면 증가/감소가 원자적이어야 합니다.
// 위험: 일반 Int
var count = 0
count++ // read → increment → write: 3단계, 스레드 간 race condition 발생
// 안전: AtomicInteger
val count = AtomicInteger(0)
count.incrementAndGet() // CAS 기반 원자 연산
AtomicInteger 는 CAS(Compare-And-Swap) 을 사용합니다.
CAS는 "현재 값이 예상한 값과 같으면 새 값으로 교체"하는 CPU 레벨 원자 연산입니다.
CAS 동작:
1. 현재 값 읽기: 5
2. 새 값 계산: 6
3. CAS(expected=5, new=6)
- 성공: 다른 스레드가 건드리지 않았으므로 5→6 교체
- 실패: 다른 스레드가 이미 5→7로 바꿨음 → 1번부터 재시도 (스핀)
synchronized 와의 차이:
synchronized: 락 획득 실패 시 스레드를 블로킹 (컨텍스트 스위칭 비용)대기자 수 추적은 경합이 낮고, 연산이 가벼운 케이스이므로 CAS가 적합합니다.
멀티스레드 환경에서 incrementAndGet() → 비교 사이에 다른 스레드도 증가시킬 수 있습니다.
최악의 경우 maxWaiters + 1~2 명이 통과할 수 있지만, 이는 안전합니다.
synchronized 블록이 필요하지만, 그만한 비용을 치를 이유가 없음LockResource 에 로컬 세마포어와 대기자 카운트 참조를 추가하여, 해제 시 한 곳에서 정리합니다.
private data class LockResource(
val semaphore: RPermitExpirableSemaphore,
val permitId: String,
val lockKey: String,
val localSemaphore: Semaphore, // 추가
val waiterCount: AtomicInteger // 추가
)
private fun releaseLock(lockResource: LockResource) {
try {
lockResource.semaphore.release(lockResource.permitId) // Redis 해제
} catch (e: Exception) {
logger.warn("Failed to release lock: ${lockResource.lockKey}", e)
} finally {
lockResource.localSemaphore.release() // 로컬 세마포어 해제
lockResource.waiterCount.decrementAndGet() // 대기자 수 감소
}
}
finally 블록에 배치하여 Redis 해제가 실패하더라도 로컬 리소스는 반드시 정리됩니다.
이전 글에서 설명한 Mono/Flux/COROUTINE_SUSPENDED 모든 경로가 이 releaseLock() 을 통과하므로,
어떤 반환 타입이든 리소스 누수 없이 안전하게 해제됩니다.
실제 비즈니스 로직의 락 점유 시간에 가까운 조건입니다.
| 지표 | 기존 | 개선 |
|---|---|---|
| 성공률 | 23% | 100% |
| 평균 대기 시간 | 56,000ms | 2ms |
| 최대 동시 대기 수 | 64+ | 1 |
| Redis 오류 | 발생 | 0건 |
이론적 처리량(150/min)이 유입(50/min)보다 충분히 크므로,
요청이 도착하면 거의 즉시(2ms) 락을 획득합니다.
의도적으로 처리량(10.9/min)보다 유입(50/min)이 훨씬 높은 상황입니다.
| 지표 | 기존 | 개선 |
|---|---|---|
| 성공률 | 23% | 21% |
| 평균 대기 시간 | 56,000ms | 4,279ms |
| 최대 동시 대기 수 | 64+ (스레드 고갈) | 6 (안정) |
| Redis 오류/타임아웃 | 발생 | 0건 |
| 대기자 추이 | 계속 누적 | 4~6으로 일정 |
성공률이 비슷한 이유는 물리적 한계 때문입니다.
처리량 10.9/min으로 50/min을 소화할 수 없으므로, 어떤 최적화를 해도 ~22%가 한계입니다.
하지만 핵심 차이는:
| 문제 | 원인 | 해결 | 핵심 기술 |
|---|---|---|---|
| 불필요한 Redis 호출 | trySetPermits 매번 실행 | 키별 초기화 캐싱 | ConcurrentHashMap.newKeySet() |
| Thundering Herd | 모든 대기자가 동시에 Redis 접근 | Pod 내 1개만 통과 | Semaphore + ConcurrentHashMap |
| 스레드 고갈 | 무제한 대기자 누적 | maxWaiters 초과 시 즉시 실패 | AtomicInteger (CAS) |
| 긴 실패 대기 | waitTime 60초 | 5초로 단축 | fast-fail |
공통적으로 ConcurrentHashMap 이 키별 자원 관리의 중심 역할을 합니다.
lock-free 읽기와 버킷 단위 쓰기 잠금으로, 분산 락이 관리하는 수십~수백 개의 서로 다른 키에 대해
각각 독립적인 세마포어, 카운터, 초기화 상태를 효율적으로 유지합니다.
한 줄 정리: "Redis에 가기 전에 JVM 안에서 먼저 정리하자"