정산 배치를 서버 두 대에 띄웠더니 같은 작업이 두 번 돌았고, 레디스 락을 붙이자는 이야기가 나온 상황이라고 해 볼게요. 결론부터 말하면 락이 잠깐 깨졌을 때 생기는 피해가 "같은 일을 두 번 함" 정도라면 레디스 한 대에 SET NX PX 로 충분하고, 돈·재고처럼 두 번 쓰면 틀어지는 데이터라면 락 종류보다 "쓰는 쪽이 오래된 요청을 거절하는 장치(펜싱 토큰·조건부 UPDATE)"를 먼저 만들어야 해요. Redlock 처럼 노드를 늘리는 방법은 락 서버가 죽는 문제를 줄일 뿐, 락을 쥔 프로세스가 멈추는 문제는 풀지 못해요.
이 글은 락이 내부에서 어떻게 잡히는지보다 어떤 방식을 언제 고르고, TTL 은 몇으로 잡고, 운영에서 락이 이상할 때 무엇부터 보는지를 다뤄요. 기준은 레디스 공식 문서의 분산 락 패턴과 레디슨(Redisson)·PostgreSQL·etcd 공식 문서이고, 가용성과 명령 수는 모두 직접 계산한 예시예요(2026-10-28 확인).
레디스 분산 락 판단표, 효율용과 정확성용부터 나누기
방식을 고르기 전에 한 가지만 물어요. 락이 깨져 두 서버가 동시에 돌면 무슨 일이 생기나요? 마틴 클레프만(Martin Kleppmann)의 글 How to do distributed locking은 락을 쓰는 이유를 효율(efficiency)과 정확성(correctness) 둘로 나누고, 둘 중 어느 쪽인지 분명히 하라고 권해요. 같은 메일이 두 번 나가거나 같은 리포트를 두 번 계산하는 정도면 효율이고, 잔액이 두 번 빠지거나 파일이 덮어써지면 정확성이에요.
| 상황 | 고를 방식 | 장애 때 생기는 일 | 운영 부담 |
|---|---|---|---|
| 크론·배치 중복 실행 줄이기, 캐시 재계산 한 번만 | 레디스 1대 SET NX PX + 값 비교 풀기 | 레디스 재시작·장애 조치 때 드물게 둘 다 실행 | 낮음 |
| 위와 같은데 레디스가 이미 센티널·클러스터 | 같은 방식 + 복제 확인(레디슨 기본값) | 복제 전 장애 조치 창은 줄지만 0은 아님 | 낮음 |
| 작업이 DB 한 곳의 행만 바꿈 | DB 행 잠금·advisory lock·UNIQUE 제약 | DB 트랜잭션이 보장, 별도 락 서버 불필요 | 가장 낮음 |
| 정확성이 걸리고 저장소가 조건부 쓰기 가능 | 아무 락 + 펜싱 토큰(조건부 UPDATE) | 늦게 깨어난 쪽의 쓰기가 거절됨 | 중간 |
| 정확성이 걸리고 토큰 발급까지 믿어야 함 | etcd·ZooKeeper 같은 합의 시스템 + 펜싱 | 합의 시스템이 순서 보장 | 높음 |
| 효율용인데 레디스 한 대의 장애가 자주 문제 | Redlock(독립 마스터 3~5대) | 클라이언트 멈춤·시계 점프에는 여전히 취약 | 높음 |
판단표에서 Redlock 이 정확성 칸에 없는 이유는 클레프만의 지적 때문이에요. Redlock 은 증가하는 번호를 만들어 주지 않아서 펜싱 토큰을 얻을 수 없고, 그래서 정확성이 걸린 곳에는 맞지 않는다는 주장이에요. 레디스 창시자 안티레즈(antirez)는 Is Redlock safe? 에서 반박했지만, 공식 문서도 지금은 두 글을 함께 걸고 "일관성과 정확성이 중요하면 펜싱 토큰을 구현하라"고 적어요. 실무에서는 논쟁의 승패보다 정확성이 걸리면 저장소 쪽 검사가 있어야 한다는 공통 결론만 가져가면 돼요.
락이 정말 필요한지 먼저 따질 것
락 서버를 붙이기 전에 아래 셋으로 끝나는지 봐요. 끝나면 락은 필요 없어요.
- UNIQUE 제약: "같은 주문에 정산 행이 하나만" 이라면
(order_id)유일 제약이 두 번째 INSERT 를 막아요. 락이 깨져도 DB 가 막아 주니 가장 튼튼해요. - 조건부 UPDATE:
UPDATE job SET status='RUNNING' WHERE id=? AND status='READY'처럼 상태 전이를 한 문장으로 하면, 영향받은 행이 1인 서버만 일을 가져가요. 이 모양이 DDD 애그리거트 경계 글에서 다룬 낙관적 잠금과 같은 원리예요. - 멱등 키: 외부 결제·메일 API 처럼 막을 방법이 없는 곳은 요청마다 고유 키를 붙여 두 번 와도 한 번만 처리되게 만들어요. 상대 시스템이 멱등 키를 지원하는지 확인해요.
여러 서버에 같은 크론을 걸어 둔 경우라면 "실행 서버를 하나로 정하기"가 가장 싼 해결일 때도 많아요. 크론 시각과 타임존 함정은 크론 표현식 읽는 법에 따로 정리했어요.
TTL 몇으로 잡나, 작업 시간과 크래시 대기 시간으로 계산
레디스 락의 TTL 은 두 방향으로 당겨요. 너무 짧으면 일하는 도중 만료돼 다른 서버가 들어오고, 너무 길면 락을 쥔 서버가 죽었을 때 그 시간만큼 아무도 일을 못 해요. 공식 문서는 TTL 을 락 유효 시간(lock validity time)이라 부르고, 이것이 자동 해제 시간이자 일을 끝내야 하는 시간이라고 설명해요. 그래서 TTL 은 아래 두 숫자 사이에서 골라요.
- 아래 한계: 작업 시간 p99 + GC·네트워크 지연 여유. 평균이 아니라 꼬리 지연을 봐요.
- 위 한계: 락 주인이 죽었을 때 다른 서버가 기다려도 되는 최대 시간.
| 작업 모양 | 예시 수치 | 권장 TTL 방식 | 크래시 뒤 최대 대기 |
|---|---|---|---|
| 짧고 일정한 작업 | p99 2초 | 고정 TTL 10초 | 10초 |
| 중간, 편차 작음 | p99 40초 | 고정 TTL 120초 (p99 × 3) | 2분 |
| 길고 편차 큼 | p50 1분, p99 15분 | 짧은 TTL 30초 + 10초마다 연장 | 30초 |
| 끝을 알 수 없음 | 스트리밍 소비자 | 연장 + 연장 횟수·총시간 상한 | 30초 + 상한 |
세 번째 줄이 연장을 쓰는 이유예요. 고정 TTL 로 p99 15분을 덮으려면 TTL 을 30분 넘게 잡아야 하고, 그러면 서버가 죽을 때마다 정산이 30분 멈춰요. 짧은 TTL 을 계속 늘리면 살아 있는 동안만 락이 유지되고, 죽으면 30초 안에 풀려요. 공식 문서도 작업이 작은 단계로 나뉘면 짧은 유효 시간에 연장을 더하라고 하고, 연장은 값이 아직 내 것일 때만 TTL 을 늘리는 Lua 스크립트로 하라고 해요.
연장 주기는 "TTL 의 3분의 1" 정도로 잡으면 연장 명령이 한 번 실패해도 한 번 더 기회가 있어요. 예시 계산이에요. TTL 30초에 10초마다 연장하면 연장 하나가 응답 타임아웃으로 실패해도 다음 연장까지 20초가 남아요. 15초마다면 남는 시간이 0초에 가까워 한 번의 실패가 곧 만료예요.
레디슨 워치독 기본값 읽기
자바에서 레디슨 락 문서대로 lock() 을 leaseTime 없이 부르면 워치독이 락 만료를 연장해요. 레디슨 설정 문서에서 lockWatchdogTimeout 기본값은 30,000밀리초이고, 워치독이 연장하지 못하면 그 시간 뒤 락이 풀려요. 즉 위 표 세 번째 줄과 같은 동작이 기본이에요. 반대로 leaseTime 을 직접 주면 워치독이 꺼지고 그 시간이 고정 TTL 이 돼요. 작업이 그보다 오래 걸리면 도중에 풀려요.
같은 설정 문서에 checkLockSyncedSlaves(기본 true)와 slavesSyncTimeout(기본 1,000밀리초)도 있어요. 락을 잡은 뒤 레플리카까지 복제됐는지 확인하고, 시간 안에 확인이 안 되면 락을 풀고 실패로 돌려요. 마스터가 락을 받자마자 죽어 레플리카가 승격되는 창을 줄이는 장치예요. 레디슨 문서는 RedLock 객체를 폐기(deprecated)로 표시하고 RLock·RFencedLock 을 쓰라고 안내해요.
Redlock 노드 수별 가용성과 명령 수 직접 계산
Redlock 을 검토하는 이유는 대개 "레디스 한 대가 죽으면 락도 죽는다"예요. 그렇다면 얻는 것(가용성)과 내는 것(명령 수·노드 운영)을 숫자로 놓고 비교해요. 노드마다 1년에 0.1% 시간 동안 독립적으로 멈춘다고 가정하고, 과반이 살아 있어야 락을 잡을 수 있다는 조건으로 계산했어요.
1대에서 3대로 갈 때 효과가 가장 커요. 3대는 1대가 죽어도 버티고, 5대는 2대까지 버텨요. 공식 문서가 노드를 서로 다른 컴퓨터나 가상 머신에 두라고 하는 이유가 독립 가정을 지키기 위해서예요. 같은 호스트에 컨테이너 다섯 개를 띄우면 계산상의 이득이 사라져요.
내는 쪽은 명령 수예요. 60초 걸리는 작업 하나를 TTL 30초, 10초마다 연장으로 돌린다고 할 때 레디스로 보내는 명령을 세면 이래요.
경합으로 재시도하면 실패한 시도마다 잡기와 풀기가 노드 수만큼 더 붙어요
표로 보기
| 구분 | 잡기 | 연장 | 풀기 |
|---|---|---|---|
| 레디스 1대 | 1 | 5 | 1 |
| 독립 마스터 3대 | 3 | 15 | 3 |
| 독립 마스터 5대 | 5 | 25 | 5 |
명령은 병렬로 보내니 지연이 다섯 배가 되지는 않지만, 가장 느린 노드의 응답이 전체 지연을 정해요. 그리고 Redlock 은 실패한 시도에서도 모든 노드에 풀기를 보내야 해서, 경합이 심한 키에서는 명령 수가 빠르게 늘어요.
Redlock 남은 유효 시간 예시
Redlock 은 걸린 시간을 빼고 남은 시간만 유효하다고 봐요. 공식 문서의 안전성 논증은 가장 먼저 만료되는 키도 TTL − (T2 − T1) − 시계 오차 동안 살아 있다는 계산에 기대요. TTL 10초에 시계 오차를 2밀리초로 놓은 예시예요.
| 상황 | 걸린 시간 | 남은 유효 시간 | 판단 |
|---|---|---|---|
| 5대 모두 빠르게 응답 | 4ms | 9,994ms | 획득 |
| 1대가 응답 대기 50ms 로 타임아웃, 4대 성공 | 50ms | 9,948ms | 획득(과반 4) |
| 3대가 타임아웃, 2대 성공 | 50ms | — | 실패, 5대 모두 풀기 |
| 큰 지연으로 걸린 시간 10,100ms | 10,100ms | 음수 | 실패, 5대 모두 풀기 |
노드별 응답 대기 시간을 짧게 두는 이유가 두 번째 줄이에요. 공식 문서의 예는 자동 해제 10초일 때 5~50밀리초 범위예요. 대기를 몇 초로 두면 죽은 노드 하나 때문에 남은 유효 시간이 크게 줄어요.
레디스 운영 설정에서 락이 사라지는 경우
락 코드를 맞게 써도 레디스 설정 때문에 락 키가 사라지면 두 서버가 동시에 들어와요. 도입할 때 아래 네 가지를 확인해요.
메모리 축출 정책. 레디스 eviction 문서에서 volatile-lru·volatile-ttl 같은 volatile-* 정책은 만료 시간이 있는 키를 지워요. 락 키는 늘 TTL 이 있으니 축출 후보예요. allkeys-* 정책도 당연히 지울 수 있어요. 캐시와 락을 한 인스턴스에 두고 maxmemory 에 닿으면 락이 조용히 사라져요. 락 전용 인스턴스를 두거나 noeviction 으로 두는 편이 안전해요. noeviction 은 메모리가 차면 새 쓰기를 에러로 돌려서, 락이 사라지는 대신 잡기가 실패로 드러나요.
redis-cli CONFIG GET maxmemory-policy # 정책 확인
redis-cli INFO stats | grep evicted_keys # 축출된 키 누적 수
redis-cli INFO memory | grep used_memory_human
비동기 복제와 장애 조치. 레디스 클러스터 문서는 비동기 복제 때문에 클라이언트에 OK 를 준 쓰기도 장애 조치 중에 잃을 수 있다고 적어요. 센티널도 같은 구조예요. WAIT 명령으로 레플리카 복제를 기다릴 수는 있지만, 문서는 WAIT 이 레디스를 강한 일관성 저장소로 만들지 않고 실제 데이터 안전을 높이는 최선 노력이라고 적어요.
영속성과 재시작. 공식 문서는 기본값인 초당 fsync 에서는 정전 뒤 재시작하면 락 키가 없을 수 있다고 해요. 대안은 fsync=always(성능 비용) 또는 크래시한 노드를 최대 TTL 보다 조금 더 쉬게 했다가 돌려보내는 지연 재시작이에요. 쿠버네티스처럼 즉시 재시작하는 환경이라면 이 점이 그대로 적용돼요.
시계. 공식 문서는 레디스가 TTL 만료에 단조 시계를 쓰지 않아 벽시계 변경이 락을 둘에게 줄 수 있다고 적고, 관리자의 수동 시간 변경을 막고 NTP 를 제대로 설정하라고 권해요.
펜싱 토큰 구현, 저장소에서 오래된 쓰기 거절하기
정확성용 락이라면 여기가 핵심이에요. 락을 줄 때 증가하는 번호를 함께 주고, 저장소는 지금까지 받은 가장 큰 번호보다 작은 번호의 쓰기를 거절해요. 저장소가 관계형 DB 라면 칼럼 하나와 조건 하나로 끝나요.
-- 정산 결과 테이블에 마지막으로 반영한 토큰을 함께 저장
ALTER TABLE settlement ADD COLUMN fence_token BIGINT NOT NULL DEFAULT 0;
-- 쓰기: 내 토큰이 더 클 때만 반영
UPDATE settlement
SET amount = :amount, status = 'DONE', fence_token = :token
WHERE settle_date = :date
AND fence_token < :token;
-- 영향받은 행이 0 이면 더 새 토큰을 가진 쪽이 이미 썼다는 뜻 → 내 결과를 버림
토큰은 어디서 받든 계속 커지기만 해야 해요. 선택지별로 정리하면 이래요.
| 토큰 발급처 | 단조 증가가 깨지는 경우 | 맞는 상황 |
|---|---|---|
레디스 INCR 카운터 | 장애 조치로 최근 증가분을 잃으면 같은 번호 재발급 | 효율용 + 가벼운 이중 방어 |
레디슨 RFencedLock | 같은 레디스 복제 한계를 따름 | 자바, 저장소가 토큰 검사 가능 |
| DB 시퀀스 | 저장소와 같은 DB 라면 사실상 없음 | 저장소가 그 DB 일 때 |
| etcd 리비전 | 합의로 순서 보장 | 여러 저장소에 걸친 정확성 |
etcd 문서는 클러스터 상태가 바뀔 때마다 단조 증가하는 리비전 번호를 붙이고, 클레프만이 말한 펜싱 토큰을 etcd 에서는 이 리비전으로 해석한다고 적어요. 레디슨 문서도 RFencedLock 이 획득마다 단조 증가 토큰을 돌려주며, 토큰을 검사할 수 있는 외부 시스템에 쓰는 락이라면 펜싱 락을 쓰라고 해요.
락 값 자체는 펜싱 토큰과 달라요. 락 값은 "내가 건 락인지" 확인하는 용도라서 유일하기만 하면 되고 순서는 필요 없어요. 공식 문서는 /dev/urandom 20바이트를 예로 들어요. UUID v4 도 무작위 122비트라 충분하고, 테스트할 때는 UUID 생성기로 값을 만들어 붙여 봐도 돼요. 무작위 비트 수와 충돌 확률은 UUID v4·v7 구조 글에 계산해 뒀어요.
DB 하나로 끝나면 advisory lock
작업이 PostgreSQL 한 곳 안에서 끝난다면 레디스를 추가하지 않고 PostgreSQL advisory lock을 쓸 수 있어요. 문서에 따르면 세션 수준 advisory lock 은 직접 풀거나 세션이 끝날 때까지 유지되고, 트랜잭션을 롤백해도 풀리지 않아요. 커넥션 풀을 쓰면 세션이 풀 안에서 계속 살아 있으니, 풀지 않고 반납하면 다음 요청이 그 락을 물려받아요. 그래서 풀 환경에서는 트랜잭션이 끝나면 자동으로 풀리는 pg_try_advisory_xact_lock 이 실수가 적어요. 락과 데이터가 같은 트랜잭션에 있어 TTL·시계 문제도 없어요.
레디스 락 장애 확인 순서, 증상별 체크리스트
운영에서 락이 이상하다는 신고는 대개 세 가지 모양이에요. 증상별로 확인 순서를 정해 두면 원인을 빨리 좁힐 수 있어요.
락이 안 풀려요. redis-cli PTTL <키> 가 -1 이면 TTL 없이 잡힌 키예요. 옛 코드의 SETNX 뒤 EXPIRE 사이에서 죽었거나 TTL 을 빠뜨린 경우예요. SET 명령 문서대로 NX 와 PX 를 한 명령에 넣었는지 확인해요. TTL 이 계속 리셋되면 연장이 상한 없이 도는 거예요. 풀기 스크립트가 0 을 돌려주는 로그가 있다면 이미 만료된 뒤 풀려 한 것이니, 그 작업은 TTL 을 넘겼다는 증거예요.
둘이 같이 돌았어요. 먼저 작업 시간 로그와 TTL 을 비교해요. 작업이 TTL 보다 길었다면 설정 문제예요. 아니라면 GC 로그에서 그 시각의 멈춤 시간, 센티널·클러스터의 장애 조치 이벤트, evicted_keys 증가, NTP 보정 기록 순으로 봐요. 어느 원인이든 같은 일이 다시 생길 수 있으니, 재발 방지는 원인 수정과 함께 저장소 쪽 펜싱이나 UNIQUE 제약을 더하는 거예요.
획득 실패가 늘어요. 키 하나에 요청이 몰리면 락 범위를 좁혀요(lock:settle 대신 lock:settle:{가맹점ID}). 고정 간격 재시도는 서버들이 같은 박자로 부딪히게 하니 무작위 지연을 섞어요. 공식 문서도 실패 뒤 무작위 지연으로 재시도하라고 해요. 응답 타임아웃이 너무 짧으면 레디스에서는 성공했는데 클라이언트는 실패로 보고 키만 남는 경우가 생겨요. 이때 잡기 실패 처리에서 같은 값으로 풀기를 한 번 보내면 TTL 을 기다리지 않아도 돼요.
도입 전에 따질 것, 비용과 한계
레디스 락을 붙이기 전에 아래를 표로 확인해요. 하나라도 "모름"이면 도입 전에 정해야 해요.
| 확인할 것 | 왜 필요한가 | 정하는 방법 |
|---|---|---|
| 효율용인가 정확성용인가 | 방식이 갈림 | 락이 깨지면 생길 피해를 한 줄로 적기 |
| 작업 시간 p99 | TTL 아래 한계 | 기존 로그에서 측정 |
| 크래시 뒤 허용 대기 | TTL 위 한계 | 업무 담당자와 합의 |
| 락 키 이름 규칙 | 범위·충돌 | lock:<작업>:<자원ID> 같은 접두사 |
| 레디스 축출 정책 | 락 소실 | 락 전용 인스턴스 또는 noeviction |
| 풀기 방식 | 남의 락 삭제 방지 | Lua 또는 레디스 8.4+ DELEX IFEQ |
| 저장소 펜싱 가능 여부 | 정확성의 실제 보장 | 토큰 칼럼·조건부 UPDATE 설계 |
풀기는 값이 같을 때만 지워야 해요. DELEX 명령은 레디스 8.4.0 부터 있어서, 운영 레디스 버전이 그보다 낮거나 호환 서비스를 쓰면 Lua 스크립트로 같은 일을 해요. 버전을 섞어 쓰는 환경이라면 Lua 쪽으로 통일하는 편이 단순해요.
자주 묻는 질문
레디스 분산 락 TTL 은 몇 초로 하나요?
작업 시간 p99 보다 길고, 락 주인이 죽었을 때 기다려도 되는 시간보다 짧게 잡아요. p99 40초에 2분 대기가 괜찮으면 고정 TTL 120초, 작업 편차가 크면 TTL 30초에 10초마다 연장하는 방식이 맞아요. 연장에는 횟수나 총시간 상한을 둬요.
Redlock 을 쓰면 정확성이 보장되나요?
Redlock 은 레디스 노드 장애에 대한 대책이고, 락을 쥔 클라이언트가 GC 등으로 멈추는 문제는 막지 못해요. 클레프만은 펜싱 토큰을 만들 수 없다는 이유로 정확성 용도에 반대했고, 공식 문서도 정확성이 중요하면 펜싱 토큰을 구현하라고 적어요. 정확성은 저장소 쪽 검사로 지켜요.
센티널이나 클러스터를 쓰면 레디스 락이 안전한가요?
락 저장소의 가용성은 올라가지만 안전성은 그대로예요. 복제가 비동기라 락을 쓴 직후 장애 조치가 일어나면 새 마스터에 락이 없을 수 있어요. 레디슨의 복제 확인(checkLockSyncedSlaves)이 그 창을 줄이지만, 효율용 락으로 보고 쓰는 편이 맞아요.
펜싱 토큰은 어디에 저장하나요?
보호하는 데이터 옆에 저장해요. DB 라면 대상 행에 fence_token 칼럼을 두고 WHERE fence_token < :token 조건으로 업데이트해요. 영향받은 행이 0 이면 더 새 토큰을 가진 쪽이 이미 썼다는 뜻이니 결과를 버려요.
DB 가 하나뿐인데 레디스 락이 필요한가요?
대부분 필요 없어요. UNIQUE 제약, 조건부 UPDATE, SELECT … FOR UPDATE, PostgreSQL 의 트랜잭션 수준 advisory lock 으로 해결되면 락과 데이터가 같은 트랜잭션 안에 있어 TTL·시계 문제가 없어요. 레디스 락은 DB 밖의 작업까지 묶어야 할 때 검토해요.
정리
- 먼저 효율용인지 정확성용인지 나눠요. 효율용은 레디스 1대에
SET NX PX와 값 비교 풀기로 충분해요. - 정확성용이면 저장소가 작은 토큰을 거절하는 펜싱이 실제 안전장치예요. DB 한 곳에서 끝나면 DB 락·제약이 더 단순해요.
- TTL 은 작업 p99 와 크래시 허용 대기 사이에서 고르고, 편차가 크면 짧은 TTL 에 TTL 3분의 1 주기 연장을 더해요.
- Redlock 은 3대만으로도 가용성이 크게 오르지만 명령 수와 운영 부담이 노드 수만큼 늘고, 클라이언트 멈춤은 막지 못해요.
- 운영에서는 축출 정책, 비동기 복제, 재시작, 시계를 확인하고, 잡기 실패·락 보유 시간 p99·풀기 결과 0 을 지표로 남겨요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 레디스 분산 락 원리 — SETNX·Redlock·펜싱 토큰에서 도식과 코드로 단계별로 풀었어요.