@Transactional만 걸면 되는 거 아니야?
서론
동시성 제어를 처음 접했을 때, 이런 생각이 들었다.
“동시성 제어? 그냥 @Transactional 달고 DB에서 Select … FOR UPDATE 처리하면 되는 거 아닌가?”
그렇게 생각하고 단일 데이터베이스 환경에서 비관적 락(Pessimistic Lock)을 걸며 뿌듯해했었다.
하지만 서비스가 확장되고 서버가 여러 대(Multi-Instance)로 분산되는 환경을 겪어보니, DB 락만으로는 커넥션 고갈과 트랜잭션 병목을 감당할 수 없다는 걸 알게 됐다.
이 글은 그런 과정을 통해 내가 직접 겪고 도입한 Redisson 분산 락에 대한 이해와 실무 구현을 담고 있다.
부족한 내용이 있을 수 있지만, 나처럼 막 시작한 사람에게는 도움이 될 거라 생각한다.
📚 왜 Lettuce 대신 Redisson인가?
Redis 기반 락을 구현할 때 Lettuce와 Redisson의 방식 차이를 이해하는 것이 중요합니다.
방식별 비교
1. Lettuce (Spin Lock 방식)
- 락을 획득할 때까지 스핀 락(SETNX 반복 호출)을 시도함
- 락 획득 실패 시 지속적인 Redis 요청으로 CPU 및 네트워크 부하 가중
2. Redisson (Pub/Sub 방식)
- 락이 해제되면 Redis가 대기 중인 클라이언트에게 채널로 메시지를 발행(Publish)함
- 대기 클라이언트는 메시지를 수신했을 때만 락 획득을 재시도하므로 Redis 부하가 극도로 낮음
@Aspect
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class DistributedLockAspect {
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(distributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint, final DistributedLock distributedLock) throws Throwable {
String key = CustomSpringELParser.getDynamicValue(joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
throw new IllegalStateException("락 획득 실패: " + key);
}
return aopForTransaction.proceed(joinPoint);
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
// 이미 타임아웃되어 해제된 락 예외 처리
}
}
}
}
정리하며
- Redisson은 Pub/Sub 구조 덕분에 레디스 서버 부하 없이 안정적인 분산 락을 제공한다.
- AOP 적용 시 ‘락 해제 시점’이 ‘트랜잭션 커밋 시점’보다 뒤에 오도록 AOP Order 우선순위를 세심히 설정해야 한다.
분산 환경에서 커스텀 어노테이션 기반 락을 도입해보니, 비즈니스 로직과 시스템 인프라 로직을 깔끔하게 분리할 수 있게 되었다.
함께 읽으면 좋은 글
- Redis Cache Stampede 현상 극복기: 캐시 적용하면 DB 부하 끝 아닌가?
- JPA N+1 문제와 Fetch Join 실무 고찰: lazy loading이면 다 해결되나?
- PostgreSQL FOR UPDATE SKIP LOCKED: DB 락 경합 없이 고성능 작업 큐 구축하기
공식 문서와 확인 자료
아래 1차 자료를 기준으로 명령 동작과 적용 조건을 다시 확인할 수 있습니다.