Spring WebFlux + Redisson: 트랜잭션과 분산 락 해제 타이밍 문제 해결하기

Seongyong's PLOG·2025년 9월 27일
7

WebFlux

목록 보기
1/1
post-thumbnail

문제 배경

Spring MVC 같은 동기 트랜잭션 환경에서는 @Transactional 바깥에서 분산 락을 잡고, finally 블록에서 분산 락을 해제하면 큰 문제가 없습니다.
하지만 WebFlux 환경에서는 이야기가 달라집니다.

  • 요청은 논블로킹(Non-blocking) 으로 처리됩니다.
  • DB 트랜잭션 커밋 시점은 Reactor 체인 안에서 비동기적으로 완료됩니다.
  • 단순히 try-finally 에서 락을 해제하면, 분산 락 획득과 트랜잭션 실행이 서로 다른 컨텍스트에서 실행되기에 트랜잭션 커밋 전에 락이 풀려버릴 위험이 있습니다.

위와 같은 이유로 다른 요청이 같은 자원에 접근해서 데이터 정합성이 깨질 수 있는 상황이 발생합니다.


핵심 아이디어

WebFlux 환경에서는 Reactive Streams 라이프사이클에 맞춰 락을 관리해야 합니다.

그래서 Mono.usingWhen / Flux.usingWhen 패턴을 적용했습니다.

  1. 리소스 획득: Reactive 체인이 시작될 때 Redisson 분산 락 획득
  2. 비즈니스 로직 실행: DB 트랜잭션 포함 비즈니스 로직 진행
  3. 정상 종료 / 에러 / 취소: 체인 종료 시점에 맞춰 락 해제

이렇게 하면, 락 해제가 항상 트랜잭션 커밋 이후에 일어나도록 보장할 수 있습니다.


MonoFlux 차이

  • Mono<T> -> 0 또는 1개의 결과를 발행하는 Publisher
  • Flux<T> -> 0개 이상 N개의 결과를 발행하는 Publisher

usingWhen은 두 타입에 대해 각각 오버로드(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를 한 번에 처리하려면

  • 런타임 캐스팅을 통해 when 분기 처리 (is Mono<*> / is Flux<*>)
  • 아니면 공통 래퍼 메서드를 따로 작성 (fun <T> usingWhenPublisher(...))

이렇게 해야 합니다. 아래 코드에서 when 으로 분기한 이유는 첫 번째 방법을 택한 것입니다.


Reactor 체인의 실행 흐름

WebFlux에서 MonoFluxPublisher입니다.
코드에 Mono.just(...)를 쓴다고 해서 곧바로 실행되는 게 아니라, 구독(subscribe)이 발생해야 체인이 동작합니다.

실제 실행은 이 순서로 진행됩니다.

  1. subscribe() 호출 -> 체인 실행 시작
  2. onNext() -> 데이터 발행 (중간 오퍼레이터를 실행)
  3. onComplete / onError / onCancel -> 체인 종료

usingWhen 패턴의 동작

Mono.usingWhen(resource, use, release) 는 리소스를 안전하게 쓰기 위한 Reactor 도구입니다.

  • resource: 체인 시작 시 실행 (예: acquireLock)
  • use: 비즈니스 로직 실행 (joinPoint.proceed())
  • release: 체인 종료 시점에 실행 (정상 완료, 에러, 취소 모두 처리됨)

위를 이용하여서

  • 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 비동기 흐름과 자연스럽게 통합
  • 에러 / 취소 상황에서도 누락 없이 안전하게 해제

결론

WebFlux 환경에서 분산 락을 안전하게 다루려면, 단순히 try-finally 로는 부족합니다.
Reactive 체인의 생명주기에 맞춰 자원을 관리해야 하며, usingWhen 패턴이 이를 깔끔하게 해결해 줍니다.


다음 글에서는 Redisson 락 해제 방식(forceUnlock vs threadId 기반 unlock) 의 차이점을 다루겠습니다.

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

0개의 댓글