Spring WebFlux + Redisson: 분산 락 처리 성능 개선하기

Seongyong's PLOG·2026년 2월 14일

WebFlux

목록 보기
3/3
post-thumbnail

문제 배경

이전 글에서 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
    )
    ...
}

이 코드에는 세 가지 문제가 숨어 있습니다.

문제 1: trySetPermits의 불필요한 반복 호출

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의 누적 오버헤드가 발생했습니다.

문제 2: Thundering Herd (우레 떼)

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 연산이 지연됩니다.

문제 3: 무제한 대기자 누적

기존 waitTime 기본값은 60초였습니다.
분당 50개의 요청이 들어오고, 처리량이 분당 ~11개(평균 점유 5.5초 기준)라면:

매 분마다 약 39개의 요청이 대기열에 누적
→ 1분 후: 39개 대기
→ 2분 후: 78개 대기
→ ...
→ Dispatchers.IO 스레드(기본 64개) 전부 블로킹 대기 상태
→ 새로운 코루틴을 디스패치할 스레드가 없음
→ near-deadlock

60초를 기다리다 결국 실패할 요청들이 스레드를 점유한 채 대기하면서,
정작 처리할 수 있는 요청마저 스레드를 할당받지 못하는 악순환이 발생합니다.


해결: 3단계 방어

개선된 acquireLock() 의 흐름은 다음과 같습니다.

요청 도착
  │
  ▼
① maxWaiters 체크 ──── 초과 시 즉시 실패 (fast-fail)
  │
  ▼
② 로컬 세마포어 획득 ── Pod 내 1개만 통과
  │
  ▼
③ trySetPermits 캐싱 ── 최초 1회만 Redis 호출
  │
  ▼
④ Redis 락 획득 ─────── 남은 시간으로 tryAcquire
  │
  ▼
비즈니스 로직 실행
  │
  ▼
⑤ Redis 락 해제 → 로컬 세마포어 해제 → waiterCount 감소

하나씩 살펴보겠습니다.


1단계: trySetPermits 캐싱 — 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이 원자적으로 처리됩니다.

왜 ConcurrentHashMap.newKeySet() 인가?

멀티스레드 환경에서 Set 을 안전하게 쓰는 방법은 여러 가지가 있습니다.

자료구조읽기쓰기단점
HashSet + synchronized전체 락전체 락모든 연산이 직렬화
Collections.synchronizedSet()전체 락전체 락위와 동일 (래퍼일 뿐)
CopyOnWriteArraySetlock-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]
...
  • 읽기 (contains/add에서 존재 확인): volatile 읽기로 lock-free. 락 없이 최신 값 확인
  • 쓰기 (add에서 새 원소 삽입): 해당 버킷의 헤드 노드에만 synchronized. 다른 버킷의 읽기/쓰기를 차단하지 않음

분산 락 키는 ORDER_LOCK:12345 같은 형태로, 서로 다른 키가 서로 다른 버킷에 분산됩니다.
결과적으로 대부분의 연산이 서로 간섭 없이 병렬 처리됩니다.

Pod 재시작 시 동작

Pod가 재시작되면 initializedKeys 는 빈 상태로 초기화됩니다.
이때 trySetPermits(1) 이 다시 호출되지만, Redis에 이미 설정된 키라면 Redisson이 무시합니다.
별도의 만료 처리나 동기화 없이도 자연스럽게 정합성이 유지됩니다.


2단계: 로컬 세마포어 — java.util.concurrent.Semaphore

아이디어

Thundering 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)
}

왜 java.util.concurrent.Semaphore 인가?

동시성을 제어하는 도구는 여러 가지가 있습니다.

도구소유자 개념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() 메서드(일반 함수)에서 직접 사용할 수 없습니다.

computeIfAbsent로 인스턴스 관리

val localSemaphore = localSemaphores.computeIfAbsent(lockKey) { Semaphore(1) }

computeIfAbsent() 는 키가 없을 때만 팩토리를 호출하여 값을 생성합니다.

  • 키가 이미 존재: lock-free 읽기로 기존 Semaphore 반환. 오버헤드 없음
  • 키가 없음: 해당 버킷만 잠금 후 Semaphore 생성. 동일 키에 대해 인스턴스가 하나만 생성됨

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 을 초과하지 않습니다.


3단계: maxWaiters fast-fail — 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)  // 즉시 실패
}

왜 AtomicInteger 인가?

대기자 수를 추적하려면 증가/감소가 원자적이어야 합니다.

// 위험: 일반 Int
var count = 0
count++  // read → increment → write: 3단계, 스레드 간 race condition 발생

// 안전: AtomicInteger
val count = AtomicInteger(0)
count.incrementAndGet()  // CAS 기반 원자 연산

AtomicIntegerCAS(Compare-And-Swap) 을 사용합니다.
CAS는 "현재 값이 예상한 값과 같으면 새 값으로 교체"하는 CPU 레벨 원자 연산입니다.

CAS 동작:
1. 현재 값 읽기: 5
2. 새 값 계산: 6
3. CAS(expected=5, new=6)
   - 성공: 다른 스레드가 건드리지 않았으므로 5→6 교체
   - 실패: 다른 스레드가 이미 5→7로 바꿨음 → 1번부터 재시도 (스핀)

synchronized 와의 차이:

  • synchronized: 락 획득 실패 시 스레드를 블로킹 (컨텍스트 스위칭 비용)
  • CAS: 실패 시 즉시 재시도 (스핀). 경합이 낮으면 대부분 한 번에 성공

대기자 수 추적은 경합이 낮고, 연산이 가벼운 케이스이므로 CAS가 적합합니다.

근사치여도 안전한 이유

멀티스레드 환경에서 incrementAndGet()비교 사이에 다른 스레드도 증가시킬 수 있습니다.
최악의 경우 maxWaiters + 1~2 명이 통과할 수 있지만, 이는 안전합니다.

  • maxWaiters는 "정확히 10명만 허용"이 아니라, "대략 10명 수준에서 제한"이 목적
  • 1~2명 더 통과해도 스레드 고갈에는 영향 없음
  • 정확한 카운트가 필요했다면 synchronized 블록이 필요하지만, 그만한 비용을 치를 이유가 없음

LockResource 확장과 릴리스 보장

LockResource

LockResource 에 로컬 세마포어와 대기자 카운트 참조를 추가하여, 해제 시 한 곳에서 정리합니다.

private data class LockResource(
    val semaphore: RPermitExpirableSemaphore,
    val permitId: String,
    val lockKey: String,
    val localSemaphore: Semaphore,      // 추가
    val waiterCount: AtomicInteger       // 추가
)

releaseLock

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() 을 통과하므로,
어떤 반환 타입이든 리소스 누수 없이 안전하게 해제됩니다.


부하 테스트 결과

현실적 부하 (200~600ms 점유, 50req/min, 2분)

실제 비즈니스 로직의 락 점유 시간에 가까운 조건입니다.

지표기존개선
성공률23%100%
평균 대기 시간56,000ms2ms
최대 동시 대기 수64+1
Redis 오류발생0건

이론적 처리량(150/min)이 유입(50/min)보다 충분히 크므로,
요청이 도착하면 거의 즉시(2ms) 락을 획득합니다.

극한 부하 (1~10초 점유, 50req/min, 10분)

의도적으로 처리량(10.9/min)보다 유입(50/min)이 훨씬 높은 상황입니다.

지표기존개선
성공률23%21%
평균 대기 시간56,000ms4,279ms
최대 동시 대기 수64+ (스레드 고갈)6 (안정)
Redis 오류/타임아웃발생0건
대기자 추이계속 누적4~6으로 일정

성공률이 비슷한 이유는 물리적 한계 때문입니다.
처리량 10.9/min으로 50/min을 소화할 수 없으므로, 어떤 최적화를 해도 ~22%가 한계입니다.

하지만 핵심 차이는:

  • 기존: 56초 대기 → 실패. 그 동안 스레드 점유. 스레드 고갈. 연쇄 장애
  • 개선: 4초 대기 → 실패. 대기자 6명 이내 유지. 스레드 여유. 시스템 안정

정리

문제원인해결핵심 기술
불필요한 Redis 호출trySetPermits 매번 실행키별 초기화 캐싱ConcurrentHashMap.newKeySet()
Thundering Herd모든 대기자가 동시에 Redis 접근Pod 내 1개만 통과Semaphore + ConcurrentHashMap
스레드 고갈무제한 대기자 누적maxWaiters 초과 시 즉시 실패AtomicInteger (CAS)
긴 실패 대기waitTime 60초5초로 단축fast-fail

공통적으로 ConcurrentHashMap 이 키별 자원 관리의 중심 역할을 합니다.
lock-free 읽기와 버킷 단위 쓰기 잠금으로, 분산 락이 관리하는 수십~수백 개의 서로 다른 키에 대해
각각 독립적인 세마포어, 카운터, 초기화 상태를 효율적으로 유지합니다.

한 줄 정리: "Redis에 가기 전에 JVM 안에서 먼저 정리하자"

profile
성용의 프로그래밍 블로그

0개의 댓글