Redis 분산 락 완벽 가이드: Redisson 아키텍처 분석 및 동시성 이슈 해결
1. 서론
지난 포스팅에서 우리는 JPA의 낙관적 락과 비관적 락을 통해 데이터베이스 레벨에서 동시성 문제를 해결하는 방법을 배웠습니다. 하지만 서비스의 규모가 커져 서버가 한 대가 아닌 여러 대(Scale-out)가 되는 순간, 자바의 synchronized 키워드나 단일 애플리케이션 내부의 락은 무용지물이 됩니다. 각 서버의 메모리가 독립적이기 때문입니다.
물론 데이터베이스의 비관적 락(Pessimistic Lock)을 사용하면 서버가 여러 대여도 동시성을 제어할 수 있습니다. 하지만 DB 락은 디스크 I/O를 동반하므로 속도가 느리고, 트래픽이 몰릴 경우 DB 커넥션 풀이 고갈되어 서비스 전체가 마비될 위험이 있습니다.
이때 등장하는 구세주가 바로 Redis를 활용한 분산 락(Distributed Lock)입니다. 인메모리 DB인 Redis의 빠른 속도를 활용하여 여러 서버가 공통된 락을 획득하고 반납하게 함으로써, 고성능을 유지하면서도 데이터 정합성을 지킬 수 있습니다. 오늘은 자바 진영에서 Redis 클라이언트로 많이 쓰이는 Lettuce와 Redisson의 락 구현 방식을 비교하고, 왜 실무에서는 Redisson이 표준으로 자리 잡았는지, 그리고 구현 시 주의해야 할 결정적인 트랜잭션 문제까지 심도 있게 알아보겠습니다.
2. 본론
1. 왜 Lettuce가 아닌 Redisson인가? (Spin Lock vs Pub/Sub)
스프링 부트에서 spring-boot-starter-data-redis를 추가하면 기본적으로 Lettuce 클라이언트가 사용됩니다. Lettuce로도 분산 락을 구현할 수 있습니다. Redis의 SETNX (Set if Not Exists) 명령어를 활용하여, 키가 없을 때만 값을 세팅하는 방식으로 락 획득을 시도하면 됩니다.
하지만 Lettuce 방식에는 치명적인 단점이 있습니다. 바로 스핀 락(Spin Lock) 방식이라는 점입니다.
스핀 락은 락을 획득하지 못한 스레드가 “락 다 썼니?”, “지금은 비었니?”라고 끊임없이 Redis에게 물어보는(Retry) 방식입니다.
- Redis 부하 증가: 락을 기다리는 스레드가 많아질수록 Redis에는 엄청난 양의 요청이 쏟아집니다. 이는 Redis 전체의 성능 저하를 일으킵니다.
- 개발 복잡도: 개발자가 직접 재시도 로직(Retry logic)과 타임아웃을 구현해야 합니다.
반면, Redisson은 펍섭(Pub/Sub) 방식을 사용하여 이 문제를 우아하게 해결합니다.
- 구독과 알림: 락을 점유 중인 스레드가 작업을 마치고 락을 해제할 때, 대기 중인 스레드들에게 “이제 락 비었어!”라고 채널을 통해 알림(Publish)을 보냅니다.
- 부하 감소: 대기 중인 스레드들은 락이 해제되었다는 신호를 받을 때까지 얌전히 기다립니다(Subscribe). 불필요한 조회 요청을 보내지 않으므로 Redis의 부하가 거의 없습니다.
- 타임아웃 지원: 락 획득 대기 시간(
waitTime)과 락 점유 시간(leaseTime)을 API 단에서 직관적으로 설정할 수 있습니다.
2. Redisson의 핵심 기능, Watchdog (락 만료 방지)
분산 락을 사용할 때 가장 무서운 상황 중 하나는 “작업이 끝나기도 전에 락이 만료되는 것“입니다.
예를 들어, 락의 유효 시간(Lease Time)을 10초로 설정했는데, 로직 수행에 15초가 걸린다면 어떻게 될까요? 10초가 지난 시점에 Redis는 락을 강제로 해제해 버리고, 다른 스레드가 락을 획득하게 됩니다. 결국 두 스레드가 동시에 데이터를 수정하는 동시성 이슈가 재발합니다.
Redisson은 이를 방지하기 위해 왓치독(Watchdog)이라는 기능을 제공합니다. 사용자가 별도로 leaseTime을 설정하지 않으면(기본값 -1), Redisson은 기본 30초의 락 만료 시간을 설정하고 백그라운드에서 주기적으로 락의 남은 시간을 확인합니다. 작업이 아직 끝나지 않았다면 락의 만료 시간을 자동으로 연장해 줍니다. 덕분에 개발자는 “락 시간을 몇 초로 잡아야 안전할까?”라는 고민에서 해방될 수 있습니다.
3. Spring Boot에서 Redisson 분산 락 구현하기
이제 실무 코드를 작성해 보겠습니다. Redisson 의존성을 추가한 후, AOP(Aspect Oriented Programming)를 활용하여 비즈니스 로직과 락 처리 로직을 분리하는 것이 가장 깔끔한 패턴입니다.
1. 커스텀 어노테이션 생성
Java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // 락의 이름 (고유 키)
TimeUnit timeUnit() default TimeUnit.SECONDS;
long waitTime() default 5L; // 락 획득 대기 시간
long leaseTime() default 3L; // 락 점유 시간
}
2. AOP 기반 락 처리 (Aspect)
Java
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.example.annotation.DistributedLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
DistributedLock distributedLock = signature.getMethod().getAnnotation(DistributedLock.class);
// 락 키 생성 (유니크해야 함)
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
// 락 획득 시도
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
return false; // 획득 실패
}
// [핵심] 트랜잭션 분리
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock(); // 락 해제
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}", method.getName(), key);
}
}
}
}
4. 실무 주의사항 – 트랜잭션 커밋과 락 해제의 타이밍
위 코드에서 aopForTransaction.proceed(joinPoint)라는 별도의 클래스를 호출한 이유가 무엇일까요? 이는 분산 락 구현 시 가장 많이 실수하는 ‘데이터베이스 트랜잭션 커밋과 락 해제 시점의 불일치’ 문제를 해결하기 위함입니다.
만약 @Transactional과 락 해제 로직이 같은 메서드 레벨에 있다면 다음과 같은 순서로 실행됩니다.
- 락 획득
- 트랜잭션 시작
- 비즈니스 로직 수행
- 락 해제 (finally 블록)
- 트랜잭션 커밋 (Spring AOP 종료 시점)
문제는 4번과 5번 사이의 미세한 틈입니다. 락은 해제되었지만 아직 DB에 데이터가 커밋되지 않은 찰나의 순간에, 대기하던 다른 스레드가 락을 획득하고 데이터를 조회합니다. 이때 DB에는 아직 이전 트랜잭션의 결과가 반영되지 않았으므로, 과거의 데이터(Old Data)를 읽게 되어 데이터 정합성이 깨지게 됩니다.
이를 해결하기 위해선 반드시 “락 내부에서 트랜잭션이 시작되고, 트랜잭션이 완전히 커밋된 후에 락이 해제“되어야 합니다. 따라서 별도의 클래스나 메서드로 트랜잭션 범위를 분리하거나, TransactionalEventListener 등을 활용하여 커밋 이후에 락을 풀도록 설계해야 합니다.
3. 결론
Redisson을 활용한 분산 락은 멀티 서버 환경에서 동시성을 제어하는 가장 강력하고 표준적인 방법입니다.
Lettuce의 스핀 락 방식이 가진 Redis 부하 문제를 Redisson의 Pub/Sub 방식으로 해결할 수 있으며, Watchdog 기능을 통해 락 만료 시간에 대한 불안감을 해소할 수 있습니다. 하지만 아무리 좋은 도구라도 잘못 사용하면 독이 됩니다. 특히 트랜잭션의 범위와 락의 범위를 정확히 이해하지 못하면, 락을 걸었음에도 불구하고 데이터가 꼬이는 미스터리한 버그를 마주하게 될 것입니다.
오늘 소개한 AOP 패턴과 트랜잭션 분리 전략을 여러분의 프로젝트에 적용해 보십시오. 단순히 “동시성을 해결했다”는 것을 넘어, 시스템의 아키텍처를 이해하고 제어할 수 있는 진정한 백엔드 엔지니어로서의 역량을 증명할 수 있을 것입니다.
동시성 제어까지 마쳤으니, 이제 대용량 트래픽을 효율적으로 처리하기 위한 “Redis 캐싱 전략: Look Aside 패턴과 Write Back 패턴의 차이 및 @Cacheable 실무 적용“에 대해 알아볼까요? DB 부하를 줄이는 가장 효과적인 방법입니다.