Redis 분산 락 — Lettuce, Redisson, Redlock
분산 락 개요
여러 서버(프로세스)가 동일한 공유 자원에 동시에 접근할 때, 오직 하나의 프로세스만 자원을 점유하도록 보장하는 메커니즘이다. 단일 JVM 환경에서는 synchronized나 ReentrantLock으로 해결되지만, 멀티 인스턴스 환경에서는 외부 저장소 기반의 락이 필요하다.
Redis가 분산 락 저장소로 널리 쓰이는 이유:
| 특성 | 설명 |
|---|---|
| 싱글 스레드 | 명령어가 순차 실행되므로 race condition 없음 |
| 원자적 명령어 | SET NX EX, EVAL(Lua) 등으로 atomic 락 획득 가능 |
| TTL 지원 | 락에 만료 시간을 설정하여 데드락 방지 |
| 고성능 | 인메모리 기반으로 지연시간이 매우 낮음 |
Lettuce 기반 분산 락
Spring Boot 기본 Redis 클라이언트인 Lettuce를 사용한 직접 구현 방식이다.
SET NX EX + Lua 구조
락 획득: SET key value NX EX ttl
락 해제: Lua 스크립트 (GET → 비교 → DEL 원자 실행)
락 획득 구현
@Component
public class LettuceDistributedLock {
private final StringRedisTemplate redisTemplate;
public LettuceDistributedLock(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 락 획득 시도
* @param lockKey 락 키
* @param lockValue 고유 식별자 (UUID 권장)
* @param ttlSeconds TTL (초)
* @return 락 획득 성공 여부
*/
public boolean tryLock(String lockKey, String lockValue, long ttlSeconds) {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, ttlSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 락 해제 — Lua 스크립트로 원자 실행
* GET → 값 비교 → DEL 을 하나의 명령으로 처리
*/
public boolean releaseLock(String lockKey, String lockValue) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(lockKey),
lockValue
);
return Long.valueOf(1L).equals(result);
}
}
사용 예시
@Service
public class OrderService {
private final LettuceDistributedLock lock;
public void processOrder(Long orderId) {
String lockKey = "order:lock:" + orderId;
String lockValue = UUID.randomUUID().toString();
boolean acquired = lock.tryLock(lockKey, lockValue, 30);
if (!acquired) {
throw new LockAcquisitionException("락 획득 실패: " + orderId);
}
try {
// 임계 영역 로직
doProcess(orderId);
} finally {
lock.releaseLock(lockKey, lockValue);
}
}
}
스핀락 방식 (재시도)
Lettuce는 Pub/Sub 기반 대기를 제공하지 않으므로, 재시도가 필요하면 스핀락을 직접 구현해야 한다.
public boolean tryLockWithRetry(String lockKey, String lockValue,
long ttlSeconds, long waitMillis) throws InterruptedException {
long deadline = System.currentTimeMillis() + waitMillis;
while (System.currentTimeMillis() < deadline) {
if (tryLock(lockKey, lockValue, ttlSeconds)) {
return true;
}
// 100ms 대기 후 재시도 — CPU 낭비 발생
Thread.sleep(100);
}
return false;
}
문제점: Thread.sleep() 간격 동안 CPU를 낭비하지는 않지만, 락 해제 이벤트를 즉시 감지하지 못해 지연이 발생한다. 이 문제를 Redisson은 Pub/Sub으로 해결한다.
Redisson 분산 락
Redisson은 Redis 기반 Java 클라이언트로, 다양한 분산 동기화 프리미티브를 제공한다.
의존성 추가
<!-- Maven -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.0</version>
</dependency>
// Gradle
implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'
기본 설정
# application.yml
spring:
redis:
host: localhost
port: 6379
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setConnectionMinimumIdleSize(1)
.setConnectionPoolSize(10);
return Redisson.create(config);
}
}
Redisson 락 종류
1. RLock — 기본 분산 락
가장 기본적인 분산 락이다. 재진입(reentrant) 가능하며, Watchdog이 TTL을 자동 연장한다.
RLock lock = redissonClient.getLock("order:lock:" + orderId);
try {
// waitTime: 락 대기 최대 시간, leaseTime: 락 보유 최대 시간
boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (acquired) {
processOrder(orderId);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
leaseTime을 -1로 설정하면 Watchdog이 자동으로 TTL을 연장한다.
// leaseTime 생략 → Watchdog 활성화 (기본 30초마다 갱신)
lock.lock(); // 무기한 보유, Watchdog이 TTL 연장
Watchdog 동작 원리:
- 기본 TTL: 30초 (
lockWatchdogTimeout) - TTL의 1/3 시점마다 갱신 → 즉, 10초마다 30초로 리셋
- 락 해제(
unlock()) 시 Watchdog 자동 중단
// Watchdog 타임아웃 커스터마이즈
Config config = new Config();
config.setLockWatchdogTimeout(60000); // 60초
2. FairLock — 공정 락
락 획득 순서를 요청 순서대로 보장한다. 내부적으로 대기 큐를 Redis에 저장한다.
RLock fairLock = redissonClient.getFairLock("fairLock:resource");
try {
// 먼저 요청한 스레드가 먼저 락을 획득
fairLock.lock();
doWork();
} finally {
fairLock.unlock();
}
동작 원리:
- 락 획득 시도 시 대기 큐에 자신을 등록
- 현재 락 보유자가 해제하면 큐의 첫 번째 대기자에게 알림
- 순서 보장으로 starvation 방지
단점: 일반 RLock보다 오버헤드가 크고, 처리량이 낮다.
3. MultiLock — 복수 락 동시 획득
여러 자원에 대한 락을 하나의 원자적 연산처럼 획득한다.
RLock lock1 = redissonClient.getLock("lock:resource1");
RLock lock2 = redissonClient.getLock("lock:resource2");
RLock lock3 = redissonClient.getLock("lock:resource3");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2, lock3);
try {
// 세 락을 모두 획득해야 진행
multiLock.lock();
doWork();
} finally {
multiLock.unlock();
}
사용 사례: 계좌 이체 시 출금 계좌 + 입금 계좌 동시 락킹으로 데드락 없이 처리.
내부 동작:
- 모든 락을 순서대로 획득 시도
- 하나라도 실패하면 이미 획득한 락을 모두 해제 후 재시도
- 데드락 방지를 위해 랜덤 백오프 적용
4. RedLock — 다중 Redis 인스턴스 기반 락
단일 Redis 인스턴스의 단일 장애점 문제를 해결하기 위해 여러 독립 Redis 마스터에 걸쳐 락을 획득한다.
RLock lock1 = redissonClient1.getLock("redlock:key"); // Redis #1
RLock lock2 = redissonClient2.getLock("redlock:key"); // Redis #2
RLock lock3 = redissonClient3.getLock("redlock:key"); // Redis #3
RLock lock4 = redissonClient4.getLock("redlock:key"); // Redis #4
RLock lock5 = redissonClient5.getLock("redlock:key"); // Redis #5
RLock redLock = redissonClient1.getRedLock(lock1, lock2, lock3, lock4, lock5);
try {
boolean acquired = redLock.tryLock(10, 30, TimeUnit.SECONDS);
if (acquired) {
doCriticalWork();
}
} finally {
redLock.unlock();
}
Redlock 알고리즘:
1. 현재 시각 T1 기록
2. N개 Redis 마스터에 동시에 SET NX PX ttl 시도
3. N/2 + 1 이상 성공하고, 총 소요 시간 < TTL 이면 락 획득 성공
4. 유효 TTL = 초기 TTL - (현재시각 - T1) - 클럭 드리프트 보정값
5. 실패 시 모든 노드에서 락 해제
Redlock 논쟁:
- Martin Kleppmann: GC pause, 시계 점프 시 안전성 보장 불가
- Antirez(Redis 창시자): 실용적으로 충분하다고 반박
- 결론: 완벽한 분산 합의가 필요하면 ZooKeeper/etcd 사용
5. ReadWriteLock — 읽기/쓰기 락
여러 스레드의 동시 읽기를 허용하고, 쓰기는 배타적으로 처리한다.
RReadWriteLock rwLock = redissonClient.getReadWriteLock("rw:resource");
// 읽기 락 — 여러 스레드 동시 획득 가능
RLock readLock = rwLock.readLock();
readLock.lock();
try {
return readData();
} finally {
readLock.unlock();
}
// 쓰기 락 — 배타적, 다른 읽기/쓰기 락이 없을 때만 획득 가능
RLock writeLock = rwLock.writeLock();
writeLock.lock();
try {
writeData();
} finally {
writeLock.unlock();
}
동작 규칙:
| 요청 \ 현재 상태 | 읽기 락 보유 | 쓰기 락 보유 |
|---|---|---|
| 읽기 락 요청 | 허용 | 대기 |
| 쓰기 락 요청 | 대기 | 대기 |
6. Semaphore — 허용 개수 제한
동시에 접근 가능한 스레드 수를 제한한다.
RSemaphore semaphore = redissonClient.getSemaphore("semaphore:api");
semaphore.trySetPermits(10); // 최대 10개 허용
// 퍼밋 획득
boolean acquired = semaphore.tryAcquire(1, 5, TimeUnit.SECONDS);
try {
callExternalApi();
} finally {
semaphore.release();
}
사용 사례: 외부 API 동시 호출 수 제한, DB 커넥션 풀 제어.
7. CountDownLatch — 완료 대기
여러 작업이 모두 완료될 때까지 대기하는 분산 카운터다.
RCountDownLatch latch = redissonClient.getCountDownLatch("latch:batch");
latch.trySetCount(3); // 3개 작업 완료 대기
// 워커 1, 2, 3 각각 완료 시
latch.countDown();
// 오케스트레이터에서 대기
latch.await(); // 3개 모두 countDown() 되면 진행
Pub/Sub 기반 대기 vs 스핀락
Redisson의 가장 큰 장점 중 하나는 락 대기 방식이다.
Lettuce (스핀락)
- 불필요한 Redis 명령어 반복 실행
- 슬랙(sleep) 간격만큼 대기 지연 발생
- 많은 스레드가 경쟁 시 Redis에 부하
Redisson (Pub/Sub)
- 락 해제 시 즉시 알림 수신
- 불필요한 Redis 명령어 없음
- 대기 스레드 수에 관계없이 Redis 부하 일정
내부 채널명: redisson_lock__channel:{lockKey}
Watchdog TTL 자동 연장
Redisson은 leaseTime을 지정하지 않으면 Watchdog을 통해 락 TTL을 자동으로 연장한다.
주의: leaseTime을 명시적으로 설정하면 Watchdog이 비활성화된다.
// Watchdog 활성화 (leaseTime 없음)
lock.lock();
lock.tryLock(10, TimeUnit.SECONDS); // waitTime만 지정
// Watchdog 비활성화 (leaseTime 명시)
lock.tryLock(10, 30, TimeUnit.SECONDS); // 30초 후 강제 해제
Lettuce vs Redisson 비교
| 항목 | Lettuce (직접 구현) | Redisson |
|---|---|---|
| 설정 복잡도 | 낮음 (Spring Boot 기본) | 중간 (추가 의존성) |
| 락 대기 방식 | 스핀락 (폴링) | Pub/Sub (이벤트) |
| Watchdog | 없음 (직접 구현 필요) | 내장 |
| 재진입 지원 | 없음 (직접 구현 필요) | 기본 지원 |
| 락 종류 | 단일 (SET NX) | RLock, FairLock, MultiLock, RedLock, RW, Semaphore, Latch |
| Redis 부하 | 스핀락 시 높음 | 이벤트 기반으로 낮음 |
| TTL 연장 | 직접 구현 필요 | Watchdog 자동 처리 |
| 클러스터 지원 | 기본 지원 | 기본 지원 |
| Redlock | 직접 구현 필요 | 내장 (getRedLock) |
| 코드량 | 많음 | 적음 |
| 성능 | 단순 락은 약간 빠름 | 다양한 시나리오에서 안정적 |
| 적합 환경 | 단순한 락, 최소 의존성 | 복잡한 동기화, 프로덕션 |
분산 환경 문제 시나리오와 해결
시나리오 1: 락 보유 중 프로세스 크래시
해결: TTL이 반드시 설정되어 있어야 한다. TTL 없이 키만 남으면 데드락이다.
시나리오 2: 작업 시간이 TTL 초과
해결 1: Lua 스크립트로 자신의 value인지 확인 후 삭제
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
해결 2: Redisson Watchdog으로 TTL 자동 연장
해결 3: 작업 시작 전 남은 TTL 확인
Long ttl = redisTemplate.getExpire(lockKey, TimeUnit.MILLISECONDS);
if (ttl != null && ttl < MINIMUM_WORK_TIME_MS) {
throw new LockExpiredException("락 TTL 부족, 작업 포기");
}
시나리오 3: 비동기 복제 중 마스터 장애
해결 1: Redlock (과반수 노드에 락)
해결 2: WAIT 명령으로 동기 복제 강제
// 락 획득 후 복제 확인
redisTemplate.execute((RedisCallback<Long>) conn ->
conn.wait(1, 100)); // 레플리카 1개 이상, 100ms 내 동기화 확인
해결 3: ZooKeeper/etcd (CP 시스템) 사용
시나리오 4: GC Stop-the-World
해결: Fencing Token 패턴
// 락 획득 시 단조 증가 토큰 발급
long fencingToken = redisTemplate.opsForValue()
.increment("fencing:token:resource");
// 공유 자원(DB)에서 토큰 검증
UPDATE shared_resource
SET data = ?, last_token = ?
WHERE id = ? AND last_token < ? -- 오래된 토큰이면 무시
정리
- 간단한 락이 필요하면 Lettuce + SET NX EX + Lua 해제로 충분하다.
- 프로덕션 환경에서는 Redisson을 사용해 Watchdog, Pub/Sub 대기, 재진입을 자동으로 얻는다.
- 단일 Redis 노드는 마스터 장애 시 락이 유실될 수 있다. 고가용성이 필요하면 Redlock을 검토한다.
- Watchdog은 leaseTime을 지정하지 않을 때만 활성화된다.
- 락 해제는 반드시 Lua 스크립트로 — GET/DEL을 분리하면 race condition이 생긴다.
- 완벽한 분산 락은 없다. 최종 방어선은 DB 레벨 멱등성과 Fencing Token이다.