Redis - Redisson 분산 락
작성일: 2025.11.06
지난 포스팅에서는 Redis를 캐시 저장소로 사용하여 성능을 높이는 법을 다뤘다. 하지만 Redis의 능력은 단순히 데이터를 저장하는 데서 그치지 않는다.
이번 글을 쓰게 된 계기는 단순했다. "쇼핑몰의 타임 세일이나 수강 신청 같은 초고밀도 트래픽 상황에서, 서버는 어떻게 정확히 재고를 0으로 맞추는 걸까?"
단순히 DB 트랜잭션만 믿고 있다가는 큰코다칠 수 있는 이 **"동시성 문제"**를, Redis의 분산 락(Distributed Lock) 기능을 이용해 직접 구현하고 해결해 본 과정을 공유한다.
"선착순 100명 한정 판매!"
이런 이벤트가 열렸을 때, 100명이 샀는데 재고가 0이 아니라 70이 남아있다면? 혹은 100개만 팔아야 하는데 105명이 결제에 성공했다면? 회사는 막대한 손해를 보거나 신뢰를 잃게 된다.
이 문제를 해결하기 위해 Redisson을 활용한 분산 락(Distributed Lock) 을 구현해 보자.
1. 문제 상황: 동시성 이슈란?
서버가 요청을 순서대로 하나씩 처리하면 문제가 없지만, 동시에 수천 개의 요청(Thread)이 들어오면 이야기가 달라진다.
- 시나리오: 재고가 100개인 상품을 유저 A와 유저 B가 동시에 구매한다.
- 기대 결과: A 구매(99개) -> B 구매(98개) -> 남은 재고 98개.
- 실제 결과(Race Condition):
- A가 재고를 읽음 (100개)
- B가 재고를 읽음 (100개) - A가 줄이기 전에 B도 읽어버림!
- A가 1개를 줄여서 저장 (99개)
- B가 1개를 줄여서 저장 (99개)
- 결과: 두 명이 샀는데 재고는 1개만 줄어듦.
이것이 바로 **경쟁 상태(Race Condition)**다. 서버가 한 대라면 Java의 synchronized로 막을 수 있지만, 서버가 여러 대(Scale-out)라면 모든 서버가 공통으로 바라보는 락(Lock) 장치가 필요하다. 그게 바로 Redis다.
2. ⚔왜 Lettuce가 아니라 Redisson인가?
Spring Data Redis의 기본 라이브러리인 Lettuce로도 락을 구현할 수 있다(SETNX 명령어 사용). 하지만 실무에서는 Redisson을 주로 사용한다.
| 특징 | Lettuce (Spin Lock) | Redisson (Pub/Sub) |
|---|---|---|
| 방식 | 락을 얻을 때까지 계속 Redis에게 "락 줘!" 물어봄 (Polling) | 락이 해제되면 "가져가!"라고 알려줌 (이벤트 기반) |
| 부하 | 요청이 많으면 Redis 부하 급증 (Spin Lock) | 대기 중일 때 Redis 부하 거의 없음 |
| 구현 | 재시도 로직, 타임아웃 등을 직접 짜야 함 | 락 획득 대기 시간, 만료 시간 등 기능 제공 |
결론: 트래픽이 많은 환경에서는 Redis 부하를 줄여주는 Redisson이 정답이다.
3. 실전 구현: AOP로 우아하게 처리하기
비즈니스 로직마다 락을 걸고 푸는 코드를 넣으면(tryLock, unlock) 코드가 매우 지저분해진다. 커스텀 어노테이션을 만들어 락 로직을 깔끔하게 분리해 보자.
3-1. @DistributedLock 어노테이션 생성
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
// 락의 이름 (Key)
String key();
// 락의 시간 단위
TimeUnit timeUnit() default TimeUnit.SECONDS;
// 락을 기다리는 시간 (default 5s)
long waitTime() default 5L;
// 락을 임대하는 시간 (default 3s)
long leaseTime() default 3L;
}
3-2. AOP 설정 (Aspect)
어노테이션이 붙은 메서드를 가로채서 락을 걸고 -> 로직 실행 -> 락 해제를 수행하는 AOP 클래스다.
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RedissonLockAop {
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.example.demo.annotation.DistributedLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
DistributedLock distributedLock = signature.getMethod().getAnnotation(DistributedLock.class);
// 락 키 생성 (예: product:lock:1)
String key = "product:lock:" + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
// 1. 락 획득 시도 (waitTime 동안 대기, leaseTime 지나면 자동 해제)
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
return false; // 락 획득 실패 시 처리
}
// 2. 비즈니스 로직 실행 (별도 트랜잭션으로 분리)
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
// 3. 락 해제 (반드시 finally에서!)
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}", key, e.getMessage());
}
}
}
}
@Transactional과 락을 같은 메서드에 쓰면, 트랜잭션이 커밋되기 전에 락이 먼저 풀려버리는 문제가 발생할 수 있다.
이를 막기 위해 AopForTransaction 클래스를 따로 만들어 트랜잭션이 확실히 끝난 뒤에 락이 풀리도록 전파 수준(REQUIRES_NEW)을 조정하거나 AOP 순서를 제어해야 한다.
3-3. 서비스 코드 적용
이제 서비스 코드는 어노테이션 한 줄이면 끝난다.
@Service
@RequiredArgsConstructor
public class ProductService {
// ... repository 등 주입
// 키값은 파라미터 productId를 사용 (SpEL)
@DistributedLock(key = "#productId")
public void decreaseStock(Long productId, Long quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("상품 없음"));
product.decrease(quantity); // 재고 차감 (내부 로직)
// Dirty Checking으로 자동 저장됨
}
}
4. 테스트 코드로 검증하기
말로만 되면 소용없다. 동시에 100명이 1개씩 주문하는 상황을 테스트 코드로 검증해 보자.
4-1. 테스트 시나리오
- 초기 재고: 100개
- 스레드 개수: 100개 (동시 요청)
- 기대 결과: 재고 0개
@SpringBootTest
class StockConcurrencyTest {
@Autowired ProductService productService;
@Autowired ProductRepository productRepository;
@Test
@DisplayName("동시에 100명이 주문하면 재고가 0개가 되어야 한다.")
void decrease_concurrency_test() throws InterruptedException {
// given
int threadCount = 100;
// 멀티스레드 환경을 위한 ExecutorService
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 모든 스레드가 끝날 때까지 기다리는 Latch
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
productService.decreaseStock(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await(); // 100명 끝날 때까지 대기
// then
Product product = productRepository.findById(1L).orElseThrow();
// 락이 없다면 0이 아니라 50~70 정도가 나옴 (실패)
// 락이 있다면 정확히 0이 나옴 (성공)
assertEquals(0L, product.getStock());
}
}
4-2. 결과 비교
<img src="/docs/be/redis/test-fail.png" alt="테스트 실패 화면" width="100%"/> ▲ 락 적용 전: 100명이 샀는데 재고가 89개나 남았다. (대참사)
<img src="/docs/be/redis/test-success.png" alt="테스트 성공 화면" width="100%"/> ▲ Redisson 락 적용 후: 정확하게 재고가 0개가 되었다. (편안-)
분산 환경에서의 동시성 제어는 선택이 아닌 필수다. Redisson을 활용하면 Redis의 부하를 최소화하면서도 강력한 락 기능을 구현할 수 있다. 특히 AOP를 활용해 비즈니스 로직과 락 로직을 분리하면 코드의 가독성과 유지보수성까지 잡을 수 있다는 점을 기억하자!
---
### 🛠️ 추가 설명: 실습을 위해 필요한 파일들
MDX에 언급된 코드 중 `AopForTransaction`이나 `CustomSpringELParser`는 실제로 구현해야 돌아갑니다. 아래 코드를 프로젝트에 추가해 주세요.
**1. `AopForTransaction.java`** (트랜잭션 분리용)
```java
@Component
public class AopForTransaction {
// 트랜잭션을 별도로 수행하기 위해 REQUIRES_NEW 설정
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
2. CustomSpringELParser.java (키 값 파싱용)
public class CustomSpringELParser {
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}