Spring MVC 같은 동기 트랜잭션 환경에서는 @Transactional
바깥에서 분산 락을 잡고, finally 블록에서 분산 락을 해제하면 큰 문제가 없습니다.
하지만 WebFlux 환경에서는 이야기가 달라집니다.
try-finally
에서 락을 해제하면, 분산 락 획득과 트랜잭션 실행이 서로 다른 컨텍스트에서 실행되기에 트랜잭션 커밋 전에 락이 풀려버릴 위험이 있습니다. 위와 같은 이유로 다른 요청이 같은 자원에 접근해서 데이터 정합성이 깨질 수 있는 상황이 발생합니다.
WebFlux 환경에서는 Reactive Streams 라이프사이클에 맞춰 락을 관리해야 합니다.
그래서 Mono.usingWhen
/ Flux.usingWhen
패턴을 적용했습니다.
이렇게 하면, 락 해제가 항상 트랜잭션 커밋 이후에 일어나도록 보장할 수 있습니다.
Mono
와 Flux
차이Mono<T>
-> 0 또는 1개의 결과를 발행하는 PublisherFlux<T>
-> 0개 이상 N개의 결과를 발행하는 PublisherusingWhen
은 두 타입에 대해 각각 오버로드(overload) 되어 있습니다.
Mono.usingWhen(resource, use, release)
-> Mono<R>
Flux.usingWhen(resource, use, release)
-> Flux<R>
위를 통해, joinPoint.proceed()
결과가 Mono
인지 Flux
인지에 따라 맞는 오버로드를 써야 컴파일러가 타입을 인식할 수 있습니다.
Kotlin/Java 에서 Publisher<T>
라는 공통 부모 타입은 있지만, Publisher.usingWhen(...)
같은 추상화된 API는 존재하지 않습니다.
따라서 Mono
/Flux
를 한 번에 처리하려면
is Mono<*>
/ is Flux<*>
)fun <T> usingWhenPublisher(...)
)이렇게 해야 합니다. 아래 코드에서 when 으로 분기한 이유는 첫 번째 방법을 택한 것입니다.
WebFlux에서 Mono
나 Flux
는 Publisher입니다.
코드에 Mono.just(...)
를 쓴다고 해서 곧바로 실행되는 게 아니라, 구독(subscribe)이 발생해야 체인이 동작합니다.
실제 실행은 이 순서로 진행됩니다.
subscribe()
호출 -> 체인 실행 시작onNext()
-> 데이터 발행 (중간 오퍼레이터를 실행)onComplete
/ onError
/ onCancel
-> 체인 종료Mono.usingWhen(resource, use, release)
는 리소스를 안전하게 쓰기 위한 Reactor 도구입니다.
acquireLock
)joinPoint.proceed()
)위를 이용하여서
acquireLock()
은 Reactor 체인이 시작될 때 실행됩니다. (= 락 획득 시점)releaseLock()
은 Reactor 체인이 끝날 때 실행됩니다. (= 락 해제 시점)@Around("@annotation(redisDistributedLock)")
fun around(joinPoint: ProceedingJoinPoint, redisDistributedLock: DistributedLock): Any? {
val result = joinPoint.proceed()
return when (result) {
is Mono<*> -> {
Mono.usingWhen(
acquireLock(redisDistributedLock, joinPoint), // 락 획득
{ result as Mono<Any> }, // 비즈니스 로직 실행
{ res -> releaseLock(res) }, // 정상 종료 시 해제
{ res, _ -> releaseLock(res) }, // 에러 발생 시 해제
{ res -> releaseLock(res) } // 취소 시 해제
)
}
is Flux<*> -> {
Flux.usingWhen(
acquireLock(redisDistributedLock, joinPoint),
{ result as Flux<Any> },
{ res -> releaseLock(res) },
{ res, _ -> releaseLock(res) },
{ res -> releaseLock(res) }
)
}
else -> result
}
}
여기서 중요한 건 분산 락 획득(acquireLock) 과 해제(releaseLock) 시점이 Reactor 체인의 시작과 종료에 정확히 맞물려 있다는 점입니다.
락 획득 시점은 체인 시작 시점과 동기화됨
-> 누군가 subscribe()
해서 체인이 실행되기 전까지는 락을 안 잡음
락 해제 시점은 체인 종료 시점과 동기화됨
-> 체인이 onComplete
, onError
, onCancel
로 끝날 때 반드시 해제됨
핵심은 락이 Reactor 체인의 라이프사이클과 1:1로 연결된다는 것입니다.
그래서 트랜잭션 커밋이 끝나고 체인이 종료될 때만 락이 풀림을 보장을 할 수 있습니다.
한 줄 정리: "락은 체인이 시작될 때 획득되고, 체인이 끝날 때 해제된다"
WebFlux 환경에서 분산 락을 안전하게 다루려면, 단순히 try-finally
로는 부족합니다.
Reactive 체인의 생명주기에 맞춰 자원을 관리해야 하며, usingWhen
패턴이 이를 깔끔하게 해결해 줍니다.
다음 글에서는 Redisson 락 해제 방식(forceUnlock vs threadId 기반 unlock) 의 차이점을 다루겠습니다.