이 글에서 얻는 것
- 분산 잠금이 왜 필요한지 이해합니다.
- Redis로 분산 잠금을 구현할 수 있습니다.
- Redisson으로 안전한 분산 잠금을 사용할 수 있습니다.
- 동시성 문제를 해결할 수 있습니다.
0) 분산 환경에서 synchronized는 통하지 않는다
문제 상황
// ❌ 단일 서버에서는 동작
@Service
public class CouponService {
private int remainingCoupons = 100;
public synchronized void issueCoupon(Long userId) {
if (remainingCoupons > 0) {
remainingCoupons--;
// 쿠폰 발급...
}
}
}
// 문제: 서버가 2대 이상이면?
// 서버 A: remainingCoupons = 1
// 서버 B: remainingCoupons = 1
// 동시에 2명이 발급 → 중복 발급!
해결: 분산 잠금
서버 A → Redis Lock 획득 → 작업 → 해제
서버 B → Lock 대기 → 획득 → 작업 → 해제
공유 자원(Redis)을 이용한 동기화!
1) Redis SETNX를 이용한 기본 구현
1-1) SETNX (SET if Not eXists)
# Lock 획득 시도
SETNX lock:coupon 1
# 1: 성공 (Lock 획득)
# 0: 실패 (이미 Lock 존재)
# TTL 설정 (데드락 방지)
EXPIRE lock:coupon 10
# Lock 해제
DEL lock:coupon
1-2) Java 구현
@Service
public class CouponService {
@Autowired
private StringRedisTemplate redisTemplate;
public void issueCoupon(Long userId) {
String lockKey = "lock:coupon";
String lockValue = UUID.randomUUID().toString();
try {
// 1. Lock 획득 시도 (10초 TTL)
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10));
if (Boolean.FALSE.equals(acquired)) {
throw new LockAcquisitionException("다른 요청이 처리 중입니다.");
}
// 2. 작업 수행
int remaining = getRemainingCoupons();
if (remaining > 0) {
decrementCoupons();
saveCouponIssue(userId);
}
} finally {
// 3. Lock 해제 (본인이 획득한 Lock만 해제)
String currentValue = redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
redisTemplate.delete(lockKey);
}
}
}
}
문제점:
- Lock 획득 실패 시 재시도 로직 필요
- TTL 내에 작업이 끝나지 않으면 Lock 자동 해제
- Lock 해제가 원자적이지 않음
2) Redisson으로 안전한 분산 잠금
2-1) 의존성
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'
}
2-2) 설정
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(5);
return Redisson.create(config);
}
}
2-3) 사용
@Service
public class CouponService {
@Autowired
private RedissonClient redissonClient;
public void issueCoupon(Long userId) {
RLock lock = redissonClient.getLock("lock:coupon");
try {
// Lock 획득 (대기 시간: 10초, 자동 해제: 30초)
boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!acquired) {
throw new LockAcquisitionException("Lock을 획득할 수 없습니다.");
}
// 작업 수행
int remaining = getRemainingCoupons();
if (remaining > 0) {
decrementCoupons();
saveCouponIssue(userId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Lock 획득 중 인터럽트 발생", e);
} finally {
// Lock 해제 (본인이 획득한 Lock만 해제)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Redisson의 장점:
- Watch Dog: TTL 자동 연장
- 재시도 로직 내장
- 원자적인 Lock 해제
- 다양한 Lock 타입 지원
3) Lock 타입
3-1) ReentrantLock (재진입 가능)
RLock lock = redissonClient.getLock("myLock");
lock.lock();
try {
// 같은 스레드에서 다시 lock() 가능
nestedMethod(); // 내부에서 lock() 호출해도 OK
} finally {
lock.unlock();
}
3-2) FairLock (공정한 순서)
// 먼저 요청한 순서대로 Lock 획득
RLock fairLock = redissonClient.getFairLock("fairLock");
fairLock.lock();
try {
// 작업
} finally {
fairLock.unlock();
}
3-3) MultiLock (여러 Lock 동시 획득)
RLock lock1 = redissonClient.getLock("lock:user:123");
RLock lock2 = redissonClient.getLock("lock:order:456");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2);
multiLock.lock();
try {
// 두 Lock을 모두 획득한 상태에서 작업
} finally {
multiLock.unlock();
}
3-4) ReadWriteLock (읽기/쓰기 분리)
RReadWriteLock rwLock = redissonClient.getReadWriteLock("rwLock");
// 읽기 Lock (여러 스레드 동시 가능)
RLock readLock = rwLock.readLock();
readLock.lock();
try {
String data = readData();
} finally {
readLock.unlock();
}
// 쓰기 Lock (배타적)
RLock writeLock = rwLock.writeLock();
writeLock.lock();
try {
writeData();
} finally {
writeLock.unlock();
}
4) AOP로 Lock 추상화
4-1) 애노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // Lock 키
long waitTime() default 5; // 대기 시간 (초)
long leaseTime() default 10; // 자동 해제 시간 (초)
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
4-2) AOP 구현
@Aspect
@Component
@Slf4j
public class DistributedLockAspect {
@Autowired
private RedissonClient redissonClient;
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
String lockKey = distributedLock.key();
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!acquired) {
log.warn("Failed to acquire lock: {}", lockKey);
throw new LockAcquisitionException("Lock을 획득할 수 없습니다: " + lockKey);
}
log.info("Lock acquired: {}", lockKey);
return joinPoint.proceed();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("Lock released: {}", lockKey);
}
}
}
}
4-3) 사용
@Service
public class CouponService {
@DistributedLock(key = "lock:coupon", waitTime = 10, leaseTime = 30)
public void issueCoupon(Long userId) {
// Lock 자동 획득/해제
int remaining = getRemainingCoupons();
if (remaining > 0) {
decrementCoupons();
saveCouponIssue(userId);
}
}
}
5) 실전 패턴
5-1) 재고 차감 (동시성 제어)
@Service
public class InventoryService {
@DistributedLock(key = "'lock:inventory:' + #productId")
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow();
if (product.getStock() < quantity) {
throw new InsufficientStockException();
}
product.decreaseStock(quantity);
productRepository.save(product);
}
}
5-2) 중복 방지 (Idempotency)
@Service
public class PaymentService {
@DistributedLock(key = "'lock:payment:' + #orderId")
public PaymentResult processPayment(Long orderId) {
// 같은 주문에 대한 동시 결제 요청 방지
Payment existing = paymentRepository.findByOrderId(orderId);
if (existing != null) {
return PaymentResult.alreadyProcessed(existing);
}
Payment payment = paymentGateway.process(orderId);
paymentRepository.save(payment);
return PaymentResult.success(payment);
}
}
6) 주의사항
⚠️ 1. 데드락 방지
// ❌ 데드락 위험
Thread A: lock1 획득 → lock2 대기
Thread B: lock2 획득 → lock1 대기
// ✅ 해결: TTL 설정
lock.tryLock(10, 30, TimeUnit.SECONDS);
⚠️ 2. Lock 해제 보장
// ❌ 나쁜 예
lock.lock();
doSomething();
lock.unlock(); // 예외 발생 시 해제 안 됨!
// ✅ 좋은 예
lock.lock();
try {
doSomething();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
⚠️ 3. Lock 범위 최소화
// ❌ Lock 범위가 너무 넓음
lock.lock();
try {
// DB 조회 (느림)
// 외부 API 호출 (느림)
// 실제 작업
} finally {
lock.unlock();
}
// ✅ 필요한 부분만 Lock
// DB 조회
// 외부 API 호출
lock.lock();
try {
// 실제 작업만
} finally {
lock.unlock();
}
요약
- 분산 환경에서는 synchronized 사용 불가
- Redis SETNX로 기본 분산 잠금 구현
- Redisson으로 안전하고 편리한 분산 잠금
- AOP로 Lock 추상화 가능
- TTL 설정으로 데드락 방지 필수
다음 단계
- Redis 고급:
/learning/deep-dive/deep-dive-redis-advanced/ - 동시성 제어:
/learning/deep-dive/deep-dive-concurrency-control/ - 분산 트랜잭션:
/learning/deep-dive/deep-dive-distributed-transactions/
💬 댓글