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