안녕하세요! 밤샘 코딩과 대용량 트래픽 장애 처리가 취미이자 특기인 개발자입니다. 여러분, 혹시 야심 차게 오픈한 선착순 100명 한정 상품 이벤트에서 상품이 100개 이상 과판되어 식은땀을 줄줄 흘려본 경험 있으신가요? 😅
1. 왜 Java의 synchronized나 DB 락으로는 부족할까?
과거 단일 서버 시절에는 자바의 synchronized나 ReentrantLock만으로도 동시성 제어가 가능했습니다. 하지만 요즘 같은 멀티 인스턴스(Scale-out) MSA 환경에서는 각 서버 메모리가 격리되어 있어 로컬 락은 아무런 힘을 쓰지 못하죠.
그렇다고 RDB의 SELECT FOR UPDATE(비관적 락)를 마구 걸자니 데이터베이스 커넥션 풀이 순식간에 고갈되어 서비스 전체가 뻗어버리는 대참사가 발생합니다. 그래서 우리는 인메모리 기반으로 초당 수만 건을 번개처럼 처리하는 Redis 분산 락(Distributed Lock)을 사용해야 합니다.
💡 원포인트 레슨
분산 락을 걸 때 가장 흔히 하는 실수가 바로 '비즈니스 로직 수행 시간보다 락 TTL(Time To Live)이 짧아서 생기는 동시성 파괴'입니다. A 스레드가 작업 중인데 락이 풀려 B 스레드가 진입하고, 나중에 A 스레드가 B 스레드의 락을 멋대로 해제해 버리는 참극을 막아야 합니다.
2. SETNX(Lettuce)의 한계와 Redisson의 구원
Spring Data Redis의 기본 클라이언트인 Lettuce로 SETNX(Set if Not Exists)를 쓰면 스핀 락(Spin Lock) 방식으로 구현하게 됩니다. 락을 획득할 때까지 Redis에 무한 GET/SET 요청을 때려 박는데, 이게 바로 트래픽 폭풍 속에서 Redis를 죽이는 주범입니다.
3. 실무 AOP 기반 Redisson 분산 락 구현 코드
실제 프로덕션 레벨에서는 비즈니스 로직과 락 획득 코드가 섞이지 않도록 Custom Annotation + AOP 구조로 깔끔하게 분리하는 것이 정석입니다.
// 1. 분산 락 커스텀 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // SpEL 표현식 지원 키 (예: #couponId)
long waitTime() default 5L; // 락 획득 대기 시간 (초)
long leaseTime() default 3L; // 락 점유 최대 시간 (초)
}
// 2. Redisson 락 제어 AOP Aspect
@Aspect
@Component@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction; // 트랜잭션 분리용
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String lockKey = CustomSpringELParser.getDynamicValue(
signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()
);
RLock rLock = redissonClient.getLock("LOCK:" + lockKey);
try {
// 락 획득 시도 (waitTime 동안 대기, leaseTime 지나면 자동 해제)
boolean isLocked = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), TimeUnit.SECONDS);
if (!isLocked) {
log.warn("Lock 획득 실패: {}", lockKey);
throw new IllegalStateException("현재 요청이 많아 처리가 지연되고 있습니다.");
}
// 핵심: 락 획득 후 트랜잭션을 별도로 시작해야 커밋 후 안전하게 락이 풀림!
return aopForTransaction.proceed(joinPoint);
} finally {
try {
if (rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
} catch (IllegalMonitorStateException e) {
log.info("이미 만료되어 해제된 락입니다.");
}
}
}
}
4. 치명적 함정: @Transactional과의 순서 싸움
여기서 주니어/미들급 개발자가 가장 많이 겪는 버그가 등장합니다. 바로 @Transactional과 @DistributedLock을 같은 메서드에 나란히 붙였을 때 생기는 참사입니다.
[잘못된 흐름: 락 해제가 DB 커밋보다 먼저 일어남!]
1. Thread A: 락 획득
2. Thread A: @Transactional 트랜잭션 시작
3. Thread A: 재고 1 감소 (메모리상 변경)
4. Thread A: 락 해제 (Unlock) <-- 🔥 여기서 락이 풀림!
5. Thread B: 락 획득 & 트랜잭션 시작 (아직 A의 DB 커밋 전이라 이전 재고 읽음!)
6. Thread A: DB Commit 완료
7. Thread B: 재고 차감 후 Commit -> 동시성 파괴 (Lost Update 발생!)
따라서 반드시 락 획득 -> 트랜잭션 시작 -> 비즈니스 로직 -> 트랜잭션 커밋 -> 락 해제 순서가 보장되도록 TransactionSynchronizationManager를 쓰거나, 위 코드처럼 별도의 트랜잭션 전파 컴포넌트(REQUIRES_NEW)로 묶어 처리해야 합니다.
5. 동시성 제어 기술 스펙 비교 분석
| 구분 | DB 비관적 락 (Pessimistic) | Redis (Lettuce SETNX) | Redis (Redisson Pub/Sub) |
|---|---|---|---|
| 처리 속도 | 느림 (디스크 I/O 및 커넥션 점유) | 보통 (Spin Lock 폴링 부하) | 매우 빠름 (메모리 + 이벤트 알림) |
| 구현 복잡도 | 매우 낮음 (SQL 레벨 지원) | 보통 (직접 재시도 루프 작성) | 낮음 (풍부한 내장 API 제공) |
| 서버 부하 | DB Connection Pool 고갈 위험 | Redis CPU 사용률 급증 위험 | 최소한의 부하로 안정적 |
| 추천 사용처 | 동시 요청이 적고 데이터 정합성이 극도로 중요한 금융 | 간단한 중복 방지 (1회성 클릭 방지) | 선착순 이벤트, 티켓팅, 쿠폰 발급 등 초고트래픽 |
📌 실전 체크리스트
- Redisson 사용: 스핀 락 방식의 부하를 피하고 Pub/Sub 기반 분산 락을 우선 고려하세요.
- 트랜잭션 생명주기 분리: DB 트랜잭션 커밋이 완료된 후 락이 해제되는 아키텍처를 반드시 확보하세요.
- Key 설계: 비즈니스 도메인과 고유 식별자를 조합하여 세밀한(Granular) 락 단위를 만드세요.
- 인프라 고가용성: Redis Cluster 환경에서 마스터 장애 시 락 유실이 우려된다면 Redlock 알고리즘이나 메시지 큐(Kafka) 대기열 조합도 검토해 보세요.
오늘 정리해 드린 분산 락 아키텍처와 트랜잭션 분리 패턴만 확실히 적용하셔도 대규모 트래픽 앞에서 식은땀 흘릴 일은 절대 없을 겁니다. 여러분만의 실무 락 노하우가 있다면 언제든 댓글로 남겨주세요! 즐거운 코딩 되시길 바랍니다! 🚀
아직 댓글이 없습니다. 첫 댓글을 남겨보세요!