스케줄러가 두 대의 WAS에서 동시에 돈다거나, 재고 차감 배치가 겹쳐 돌면서 같은 주문을 두 번 처리하는 사고를 한 번쯤은 겪는다. 이럴 때 가장 먼저 손이 가는 게 Redis 분산 락이다. 문서 예제만 보면 SET 한 줄로 끝날 것 같은데, 막상 운영에 올려보면 만료와 페일오버 구간에서 이상하게 락이 두 번 잡히는 상황을 만난다. 이 글은 그 지점들을 하나씩 짚는다.
결론부터 말하면, 단일 Redis에서는 SET key value NX PX로 락을 잡고 값을 비교하는 Lua 스크립트로 해제하면 대부분의 실무 요구를 만족한다. 다만 이 방식은 비동기 복제와 페일오버 앞에서 상호 배제가 깨질 수 있고, 그걸 근본적으로 막으려면 락만으로는 부족해 펜싱 토큰(fencing token)을 보호 대상 쪽에서 검증해야 한다. 여러 마스터에 거는 Redlock, 그리고 Java에서 자주 쓰는 Redisson의 선택 기준까지 아래에서 차례로 살펴본다.
분산 락이 무엇이고 왜 어려운가
분산 락은 여러 프로세스가 공유 자원을 서로 배타적으로 다루도록, 한 시점에 하나의 클라이언트만 자원을 잡게 하는 장치다. 단일 JVM 안이라면 synchronized나 ReentrantLock으로 끝나지만, WAS가 여러 대로 늘어나면 그 경계를 넘는 공용 저장소가 필요하다. Redis가 자주 쓰이는 이유는 빠르고, 키에 만료(TTL)를 걸 수 있어 락을 잡은 클라이언트가 죽어도 자원이 영영 잠기지 않기 때문이다.
Redis 공식 문서는 분산 락이 갖춰야 할 최소 조건을 세 가지로 정리한다.
- 상호 배제(safety): 한 시점에 한 클라이언트만 락을 보유한다.
- 데드락 없음(liveness A): 락을 쥔 클라이언트가 죽거나 분단돼도 결국 다시 획득할 수 있다.
- 내결함성(liveness B): Redis 노드의 과반이 살아 있으면 획득과 해제가 가능하다.
단일 인스턴스에서 락 획득: SET NX PX
락 획득은 명령 한 줄이다. 공식 문서 예제를 그대로 옮긴다.
SET resource_name my_random_value NX PX 30000
NX는 키가 없을 때만 설정하라는 옵션이고, PX 30000은 30000밀리초(30초) 뒤 자동 만료다. 여기서 두 옵션이 핵심이다. NX가 없으면 이미 잡힌 락을 덮어써 상호 배제가 깨지고, PX가 없으면 클라이언트가 죽었을 때 락이 영원히 남는다.
값으로 넣는 my_random_value는 모든 클라이언트와 모든 요청에 걸쳐 유일해야 한다. 이 값이 왜 필요한지는 해제 단계에서 드러난다. 문서는 /dev/urandom에서 20바이트를 뽑는 방식을 권한다. Java라면 이 정도로 충분하다.
String token = UUID.randomUUID().toString();
String res = jedis.set("resource_name", token,
SetParams.setParams().nx().px(30000));
// res 가 "OK" 면 획득 성공, null 이면 이미 누군가 잡고 있음
해제는 반드시 원자적으로: 값 비교 후 삭제
여기서 처음 걸린다. 해제할 때 그냥 DEL을 쓰면 안 된다. 왜냐하면 내가 잡은 락이 이미 만료돼 사라지고, 그 사이 다른 클라이언트가 같은 키로 락을 새로 잡았을 수 있기 때문이다. 이 상태에서 내가 DEL을 날리면 남의 락을 지워버린다.
그래서 "값이 내가 넣은 것과 같을 때만 지운다"를 하나의 원자적 연산으로 처리한다. 공식 문서가 제시하는 Lua 스크립트다.
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
Java에서는 eval로 넘긴다.
String lua =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
Object r = jedis.eval(lua,
Collections.singletonList("resource_name"),
Collections.singletonList(token));
// r == 1 이면 내가 잡은 락을 정상 해제, 0 이면 이미 만료됐거나 남의 락
GET 후 DEL을 애플리케이션 코드에서 두 번에 나눠 호출하면 그 사이에 만료와 재획득이 끼어들 수 있어 이 보장이 깨진다. 반드시 서버 측에서 한 번에 처리해야 한다.
참고로 Redis 8.4부터는 같은 동작을 하는 DELEX key IFEQ my_random_value 명령이 추가됐다. 8.4 미만이라면 위 Lua 스크립트를 쓴다. 버전은 저장소 상태에 따라 다를 수 있으니 redis-server --version으로 먼저 확인하는 게 좋다.
만료(TTL)가 만드는 함정
TTL은 데드락을 막아주지만 동시에 위험을 만든다. 락을 30초로 잡았는데 실제 작업이 GC 스톱더월드, 네트워크 지연, 스왑 등으로 30초를 넘기면 어떻게 되나. 락이 만료돼 다른 클라이언트가 같은 자원의 락을 잡는다. 그러면 두 클라이언트가 동시에 자원을 건드린다. 앞의 값 비교 해제는 "남의 락을 지우는" 사고만 막을 뿐, 이 동시 접근 자체를 막지는 못한다.
대응은 두 갈래다. 작업을 작은 단계로 쪼개 TTL을 짧게 잡고, 작업이 길어지면 락을 연장(extend)한다. 연장도 해제와 같은 원리로 "키가 있고 값이 내 것일 때만 TTL을 늘리는" Lua 스크립트를 쓴다. 다만 문서도 명시하듯, 락을 잡은 프로세스가 살아 있는 한 락이 유지된다고 가정해서는 안 된다. 연장은 안전을 완성하는 장치가 아니라 확률을 낮추는 장치다.
왜 복제본을 붙여도 안전하지 않은가
단일 마스터는 단일 장애점이다. 그래서 복제본을 붙이고 싶어지는데, 여기서 상호 배제가 깨진다. Redis 복제는 비동기이기 때문이다. 문서가 드는 경쟁 조건은 이렇다.
- 클라이언트 A가 마스터에서 락을 획득한다.
- 그 쓰기가 복제본에 전달되기 전에 마스터가 죽는다.
- 복제본이 마스터로 승격된다.
- 클라이언트 B가 같은 자원의 락을 획득한다. 상호 배제 위반.
가끔 겹치는 정도를 감당할 수 있는 작업이라면 복제 기반 단일 마스터로도 충분하다. 하지만 정확성이 중요하다면 이 구조만으로는 부족하다.
Redlock: 여러 독립 마스터에 과반으로 건다
Redlock은 이 페일오버 문제를 풀려는 알고리즘이다. 복제 없이 서로 독립된 N개의 마스터(문서 예시는 N=5)를 두고, 과반에서 락을 잡아야 획득으로 친다. 순서는 이렇다.
- 현재 시각(밀리초)을 기록한다.
- 동일한 키와 값으로 N개 인스턴스에 병렬로 락을 시도한다. 각 시도에는 짧은 타임아웃을 둔다(자동 해제 10초라면 5~50ms 수준).
- 과반(3개 이상) 획득에 성공했고, 걸린 시간이 락 유효시간보다 짧으면 획득으로 본다.
- 유효시간은 초기 TTL에서 획득에 쓴 시간을 뺀 값이다.
- 실패하면 잡았다고 믿는 것까지 포함해 모든 인스턴스의 락을 즉시 해제한다.
운영 부담은 솔직히 크다. 서로 독립적으로 죽도록 물리적으로 분리된 마스터 5대를 별도로 굴려야 한다. 재시작 안전성을 위해선 fsync=always나 지연 재시작(delayed restart)까지 고려해야 한다. 대부분의 서비스에서 이 비용은 과하다.
펜싱 토큰: 락만으로는 못 막는 것
Martin Kleppmann이 Redlock 분석에서 지적한 핵심이 펜싱 토큰이다. 락을 아무리 잘 구현해도, 락을 잡은 클라이언트가 GC나 지연으로 멈춰 있는 동안 락이 만료되고 다른 클라이언트가 락을 잡은 뒤, 멈춰 있던 첫 클라이언트가 깨어나 자기가 아직 락을 쥐고 있다고 믿고 쓰기를 날리는 상황은 어떤 락 알고리즘으로도 막을 수 없다.
해법은 락 획득 때마다 단조 증가하는 토큰을 함께 발급하고, 실제 자원을 쓰는 쪽(DB, 스토리지 등)이 자기가 받은 마지막 토큰보다 작은 토큰의 쓰기를 거부하는 것이다. 멈췄다 깨어난 클라이언트는 오래된 토큰을 들고 오므로 걸러진다. 중요한 건 이 검증을 보호 대상 쪽에서 해야 한다는 점이다. 락 서버가 토큰을 주는 것만으로는 부족하다.
여기서 흔한 오해가 있다. "Redis에서 INCR로 정수 하나 뽑아 토큰으로 쓰면 되지 않나"인데, 단일 Redis에서 INCR로 만든 토큰은 앞에서 설명한 바로 그 비동기 복제와 페일오버 상황에서 위험하다. 마스터가 죽고 복제가 덜 된 복제본이 승격되면 카운터가 되감겨(중복 또는 역전) 발급되어, 단조 증가라는 전제가 깨진다. Kleppmann이 지적한 한계가 이 지점이다. 그래서 진짜 정확성이 필요하면 토큰 생성 자체를 강한 일관성을 보장하는 저장소(예: ZooKeeper의 zxid, 또는 트랜잭션이 보장되는 RDB 시퀀스)에 맡기는 편이 맞다.
Redis 문서의 일관성 관련 주의사항도 같은 맥락이다. 첫째, 오래 걸리는 작업이라면 펜싱 토큰을 구현하라. 둘째, Redis의 TTL 만료는 단조 시계(monotonic clock)를 쓰지 않으므로, 벽시계가 틀어지면 락이 둘 이상에게 잡힐 수 있다. NTP를 제대로 맞추고 관리자가 시간을 수동으로 바꾸지 못하게 해 완화할 수는 있지만 완전히 없앨 수는 없다.
Java라면 Redisson: RLock과 RFencedLock
직접 SET NX PX와 Lua를 조립하는 대신, Java에서는 Redisson을 쓰면 획득, 해제, 연장, 재진입까지 검증된 구현을 그대로 가져올 수 있다. 기본 사용은 java.util.concurrent.locks.Lock 규약을 따른다.
RLock lock = redisson.getLock("resource_name");
// 최대 100초 대기, 획득 후 10초 뒤 자동 해제
if (lock.tryLock(100, 10, TimeUnit.SECONDS)) {
try {
// 임계 구역
} finally {
lock.unlock();
}
}
leaseTime을 지정하면 그 시간 뒤 자동 해제된다. 락 소유 스레드만 unlock()할 수 있고, 아니면 IllegalMonitorStateException이 난다.
다중 인스턴스는 이제 RedissonRedLock이 아니다
예전 자료에는 여러 마스터에 걸친 Redlock을 RedissonRedLock으로 구현하라는 안내가 많다. 지금은 이 클래스가 deprecated 상태다. 공식 Javadoc(3.46.0 기준)에 "Superseded by RLock and RFencedLock objects"라고 명시돼 있다. 대체 방향은 두 가지다.
- 복제본 동기화 검증을 켠 RLock: Redisson은 락 획득 후 연결된 복제본에 전파됐는지 확인한다. 관련 설정
checkLockSyncedSlaves는 기본값이 켜짐이고,slavesSyncTimeout기본값은 1000ms다. 시간 안에 복제본이 확인해주지 않으면 락을 해제하고 획득을 실패 처리한다. 5노드 쿼럼 없이도 앞서 본 페일오버 경쟁 조건을 상당 부분 닫는다. - RFencedLock: 획득할 때마다 단조 증가하는 펜싱 토큰을 돌려준다. 앞에서 강조한 펜싱 토큰을 전용으로 제공하는 API다.
RFencedLock lock = redisson.getFencedLock("resource_name");
Long token = lock.lockAndGetToken();
try {
// 보호 대상(DB 등)에 token 을 함께 넘겨,
// 마지막으로 받은 token 보다 작으면 거부하도록 검증
} finally {
lock.unlock();
}
// 이후 token 은 lock.getToken() 으로도 조회
다만 이 경우도 토큰 검증은 보호 대상 쪽 책임이다. RFencedLock은 토큰을 발급하고 관리해줄 뿐, 오래된 토큰의 쓰기를 실제로 막는 건 자원을 쓰는 시스템이 해야 한다.
정리: 무엇을 언제 쓰나
가끔 락이 겹쳐도 되는 작업(중복 실행이 멱등하거나 비용이 낮은 경우)이라면 단일 Redis에 SET NX PX + Lua 해제로 충분하다. 가장 단순하고 대부분 여기서 끝난다.
Java 스택이고 재진입, 자동 연장, 복제본 동기화 검증까지 검증된 구현을 원하면 Redisson의 RLock을 쓴다. RedissonRedLock은 deprecated이므로 새로 짜는 코드에서는 쓰지 않는다.
멈췄다 깨어난 프로세스의 뒤늦은 쓰기까지 반드시 막아야 하는 정확성 요구가 있으면, 락만으로 끝내지 말고 펜싱 토큰을 보호 대상에서 검증하도록 설계한다. Redisson이면 RFencedLock이 시작점이지만, 토큰의 단조 증가 자체가 정말 중요하다면 그 생성은 강한 일관성 저장소에 맡기는 것을 고려한다. 여러 독립 마스터로 상호 배제를 강화하고 싶다면 Redlock 알고리즘이 답이지만, 5노드 운영 비용과 재시작 안전성 부담을 감수할 값어치가 있는지 먼저 따져봐야 한다.