Redisson 분산 락과 트랜잭션 경계

1. 문제 상황과 핵심 요약

  • 직접 구현한 SETNX 락과 Redisson의 차이: Lettuce로 단순 SETNX 락을 직접 구현하면 락 획득 실패 시 폴링하는 구조가 될 수 있다. Redisson의 일반 RLock은 Pub/Sub 통지를 사용해 반복 재시도 요청을 줄인다. Lettuce 자체가 분산 락 알고리즘인 것은 아니며, Redisson도 일반 Lock과 Spin Lock을 별도로 제공한다.
  • 트랜잭션과 락의 해제 순서 문제: @Transactional 메서드 내부에서 락을 해제할 경우, 락 해제 시점(Lock Release)이 DB 커밋 시점(Tx Commit)보다 빨라 찰나의 순간에 경쟁 상태(Race Condition) 및 Lost Update가 발생함.
  • AOP와 독립 트랜잭션을 이용한 해결 예시: 트랜잭션 외부에서 락을 잡고, 락 안에서 별도의 트랜잭션(Propagation.REQUIRES_NEW)을 완료한 뒤 락을 해제하는 구조를 사용할 수 있다. REQUIRES_NEW는 독립 물리 트랜잭션이 필요한 경우의 선택지이며 유일한 해법은 아니다.

2. 분산 락과 트랜잭션 경계

2.1 Lettuce vs Redisson 동작 방식 비교

구분Lettuce로 직접 구현한 SETNX 폴링 락Redisson RLock (Pub/Sub)
동작 방식락을 획득할 때까지 지속적으로 Redis에 SETNX 요청 전송락이 해제될 때 Redis Channel 메시지(Pub/Sub)를 수신하여 대기 중인 스레드가 락 재시도
부하 영향스레드가 많을수록 Redis CPU 및 네트워크 I/O 병목 심화Redis 부하 최소화 및 효율적인 락 대기 queue 관리
적합성단순한 원자적 조건 확인을 직접 구성할 때재진입 락·대기 통지·watchdog 등 고수준 락 기능이 필요할 때

2.2 버그 발생 원인: Lock Release vs Transaction Commit

[잘못된 순서 (문제 상황)]
Thread A: |--- Lock Acquire ---|--- DB Update ---|--- Lock Release ---| ... (Tx Commit) ... |
Thread B:                                                             |--- Lock Acquire ---|--- DB Read (커밋 전 데이터) ---| (Lost Update 발생!)
  • 원인: Spring 트랜잭션 프록시는 대상 메서드가 반환된 뒤 커밋을 수행한다. 따라서 @Transactional 메서드 내부에서 락을 해제하면, 락 해제가 외부 트랜잭션 커밋보다 먼저 일어나는 구간이 생길 수 있다.
  • 결과: 이 구간에 Thread B가 락을 획득하면 DB 격리 수준과 갱신 방식에 따라 구 데이터(Stale Data)를 읽거나 Lost Update와 같은 경쟁 문제가 발생할 수 있다.

3. 구현 예시: AOP + REQUIRES_NEW

[올바른 순서 (정상 상황)]
AOP Aspect: |--- Lock Acquire ---| [Tx Start (REQUIRES_NEW) -> DB Update -> Tx Commit] |--- Lock Release ---|

3.1 커스텀 어노테이션 정의

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key(); // Lock 이름
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    long waitTime() default 5L; // Lock 획득 대기 시간
    long leaseTime() default 3L; // Lock 보유 시간
}

3.2 별도 트랜잭션 전파를 위한 컴포넌트

@Component
public class AopForTransaction {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

3.3 Redisson 락 제어 AOP Aspect

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAspect {
 
    private static final String REDISSON_LOCK_PREFIX = "LOCK:";
 
    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;
 
    @Around("@annotation(distributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint, final DistributedLock distributedLock) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(
                signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
 
        RLock rLock = redissonClient.getLock(key);
 
        boolean acquired = false;
        try {
            acquired = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!acquired) {
                throw new IllegalStateException("락 획득 실패 - Key: " + key);
            }
 
            // 락 획득 후 새로운 트랜잭션 시작 및 완료까지 보장
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("인터럽트 발생", e);
        } finally {
            if (acquired && rLock.isHeldByCurrentThread()) {
                rLock.unlock(); // 실제로 획득했고 현재 스레드가 소유한 락만 해제
            }
        }
    }
}

4. 실무 주의사항 및 트러블슈팅

  1. Spring AOP Self-Invocation(자기 호출) 이슈
    • 동일 클래스 내의 메서드가 @Transactional을 호출하면 프록시 객체를 거치지 않아 AOP가 적용되지 않음.
    • 따라서 트랜잭션을 전파 계층(AopForTransaction)으로 분리하거나 서비스 계층을 분리해야 함.
  2. 트랜잭션 커넥션 풀 고갈 주의
    • Propagation.REQUIRES_NEW 사용 시 부모 트랜잭션과 자식 트랜잭션이 각각 DB 커넥션을 점유할 수 있음.
    • 고정 공식으로 풀 크기를 단정하지 않는다. 외부 트랜잭션이 커넥션을 유지한 채 내부 REQUIRES_NEW가 추가 커넥션을 요청할 수 있으므로, 예상 동시성·중첩 깊이·타임아웃과 실제 부하 측정을 기준으로 여유 용량을 잡아야 한다.
  3. LeaseTime 설정 최적화 및 Watchdog 활용
    • Business Logic 처리 시간보다 leaseTime이 짧으면 작업 도중 락이 풀려 동시성 결함 발생.
    • 비동기 작업 시간이 유동적이라면 Redisson의 Watchdog 기능(leaseTime 미지정 시 30초 기본 락 갱신)을 사용하는 것도 검토.
  4. 락 획득·소유 확인 후 해제
    • tryLock()이 실패한 경우에는 unlock()을 호출하지 않는다.
    • 현재 스레드가 락을 소유하지 않으면 IllegalMonitorStateException이 발생하므로 isHeldByCurrentThread()를 함께 확인한다.

5. 면접 대비 핵심 Q&A

Q1. Lettuce 대신 Redisson을 선택한 이유는 무엇인가요?

A1. Lettuce는 Redis 클라이언트이며 자체적으로 특정 분산 락 알고리즘을 강제하지 않습니다. Lettuce로 SETNX 락을 직접 구현하면 락 획득 실패 시 반복 폴링하는 구조가 될 수 있습니다. 반면 Redisson의 일반 RLock은 Redis의 Pub/Sub 메커니즘으로 락 해제 통지를 받아 대기 중 반복 요청을 줄이고, 재진입·watchdog 등의 기능을 제공합니다. Redisson의 Spin Lock은 일반 Lock과 구분되는 별도 기능입니다.

Q2. 분산락을 사용할 때 @Transactional과 함께 쓰면 발생하는 문제와 해결책은 무엇인가요?

A2. 분산락 해제 시점이 트랜잭션 커밋 시점보다 빠르면, 락이 해제된 직후 다른 스레드가 락을 잡고 아직 DB에 반영되지 않은 구 데이터를 조회하는 **Lost Update(경쟁 상태)**가 발생할 수 있습니다.
이를 해결하려면 락 획득 -> 트랜잭션 시작 -> 비즈니스 로직 수행 및 DB 커밋 -> 락 해제 순서가 보장되어야 합니다. Spring에서는 AOP와 Propagation.REQUIRES_NEW를 조합해 독립 물리 트랜잭션을 만드는 방법을 사용할 수 있지만, 이는 트랜잭션 구조와 커넥션 점유를 검토해 선택할 해법입니다. TransactionTemplate이나 트랜잭션 동기화를 이용해 커밋 후 락 해제 순서를 보장하는 대안도 함께 검토할 수 있습니다.


6. 관련 키워드

Redis · Redisson · Lettuce · Distributed Lock · Concurrency Control · Spring AOP · Transaction Boundary · REQUIRES_NEW · Lost Update