본문 바로가기
Backend2026년 9월 3일19분 읽기

분산 락 제대로 쓰기 — Redis Redlock의 함정과 안전한 대안

YS
김영삼
조회 7
분산 락 제대로 쓰기 — Redis Redlock의 함정과 안전한 대안

분산 락은 "효율"용이면 단일 Redis SET NX PX로 충분하고, "정확성"이 걸린 순간부터는 락만으로 부족하다 — 펜싱 토큰이 있어야 안전하다.

Redlock은 이 구분을 흐릴 때 위험해진다. 이 글은 단일 Redis 락, Redlock을 둘러싼 Kleppmann–antirez 논쟁, 펜싱 토큰, 합의 기반(ZooKeeper/etcd) 락, 그리고 락을 아예 없애는 설계까지 실무 관점에서 짚는다.

분산 락은 겉보기엔 mutex랑 똑같이 생겼다. 그래서 다들 처음엔 로컬 스레드 락 쓰듯 갖다 쓴다. 나도 그랬다. 문제는, 프로세스 하나 안에서의 락과 네트워크 너머의 락은 실패 모델 자체가 다르다는 거다. 프로세스가 락을 쥔 채로 GC 때문에 몇 초 멈추면? 락 서버는 그 사실을 모른다. TTL이 만료돼서 다른 워커가 같은 락을 잡는다. 이제 두 놈이 동시에 "나만 락을 가졌다"고 믿는다. 여기서부터 사고가 난다.

그래서 분산 락을 논할 때 제일 먼저 던져야 하는 질문은 "Redis냐 etcd냐"가 아니라 이 락이 깨지면 무슨 일이 벌어지는가다. 답이 "그냥 작업을 두 번 하고 만다" 정도면 가벼운 도구로 충분하다. 답이 "돈이 두 번 빠져나간다"거나 "파일이 덮어써진다"면 얘기가 완전히 달라진다.

방식안전성 근거클럭/타이밍 의존대략적 지연
단일 Redis SET NX PX단일 노드 원자성 (약함)있음밀리초 단위
Redlock (N=5 다수결)과반 노드 (논쟁적)강하게 있음밀리초 단위
ZooKeeper (ephemeral seq)ZAB 합의 + 세션없음(세션 기반)수~수십 ms
etcd (lease + revision)Raft 합의 + lease없음(합의 기반)수~수십 ms
락 없음(멱등성/CAS)데이터 계층에서 보장없음쿼리 1회

지연 수치는 네트워크·구성에 따라 크게 달라지는 일반적 특성 범위다. 벤치마크가 아니라 상대적 감을 위한 표다.

분산 락은 왜 로컬 락처럼 안 되나

한 프로세스 안의 락은 "락을 쥔 스레드가 멈추면 아무도 진행 못 한다"가 성립한다. 락을 쥔 주체와 락 자체가 같은 메모리, 같은 운명 공동체다. 분산 락은 그게 깨진다. 락은 저 멀리 Redis에 있고, 락을 쥔 워커는 언제든 GC 스톱, OS 스케줄러 선점, 네트워크 단절로 임의 시간 동안 얼어붙을 수 있다. 그 사이 TTL은 야속하게 흘러간다.

핵심은 TTL이 없으면 데드락, TTL이 있으면 상호배제 위반 사이의 딜레마다. 워커가 죽어도 락이 영원히 안 풀리면 곤란하니 TTL을 건다. 그런데 워커가 죽은 게 아니라 잠깐 멈춘 거였다면? TTL 만료 후 두 번째 워커가 락을 잡고, 첫 번째 워커가 깨어나 아무 일 없었다는 듯 임계 구역 코드를 실행한다. 락을 "가졌다고 믿는" 주체가 둘이 된다.

이 문제는 어떤 락 구현으로도 완전히 못 없앤다. Redis든 ZooKeeper든 마찬가지다. 왜냐하면 락 획득 시점과 락으로 보호하는 실제 작업 사이에는 항상 시간 간격이 있고, 그 사이 프로세스는 멈출 수 있으니까. 이게 뒤에 나올 펜싱 토큰이 필요한 근본 이유다.

단일 Redis 락 — SET NX PX를 정확히 쓰는 법

단일 Redis 락의 정답은 딱 하나다: SET key 랜덤값 NX PX 밀리초. 여기서 세 가지가 전부 필수다. NX는 키가 없을 때만 설정(원자적 획득), PX는 TTL(워커가 죽어도 자동 해제), 그리고 값에 넣는 고유 랜덤 토큰이 제일 자주 빠뜨리는 부분이다.

# 획득: 무조건 NX + PX + 고유값을 한 번에 (원자적)
SET lock:order:42 "a1b2c3-uuid-랜덤" NX PX 30000
# => OK 면 락 획득, nil 이면 실패

# 해제: 절대 그냥 DEL 하지 말 것
# 내 값이 맞을 때만 지우는 Lua 스크립트 (compare-and-delete)
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:42 "a1b2c3-uuid-랜덤"

왜 해제를 Lua로 하느냐. 그냥 DEL lock:order:42를 하면, 내 락이 이미 TTL로 만료되고 다른 워커가 같은 키로 새 락을 잡은 뒤에 내가 뒤늦게 DEL을 날리면 남의 락을 지워버린다. 그래서 "값이 내 것일 때만 지운다"를 원자적으로 해야 하고, GET-비교-DEL을 원자화하려면 Lua 스크립트가 유일한 방법이다. 이거 문서에는 있는데 실무 코드에선 절반이 그냥 DEL 쓴다.

참고 TTL을 너무 짧게 잡으면 작업이 안 끝났는데 락이 풀린다. 그렇다고 넉넉하게 5분 잡으면 워커가 진짜 죽었을 때 5분간 아무도 진입 못 한다. 실무에선 작업 예상 시간의 넉넉한 배수로 TTL을 잡고, 오래 걸리는 작업은 워치독(주기적으로 TTL을 연장하는 백그라운드 스레드)을 붙인다. Redisson의 lock()이 기본 30초 TTL에 10초마다 갱신하는 게 이 패턴이다.

단일 Redis 락의 진짜 약점은 코드가 아니라 토폴로지다. 마스터 하나에 락을 썼는데, 그 마스터가 복제본에 아직 전파하기 전에 죽고 복제본이 승격되면? 새 마스터엔 그 락이 없다. 두 클라이언트가 동시에 락을 잡는다. Redis 복제는 비동기라 이건 구조적 문제다. 바로 이 지점을 메우려고 나온 게 Redlock이다.

Redlock이란 무엇이고 왜 논쟁이 됐나

Redlock은 antirez(Redis 창시자)가 제안한 알고리즘으로, 서로 독립적인 Redis 마스터 N개(보통 5개)에 순차적으로 같은 락을 걸고, 과반(3개 이상)에서 성공하면 락을 획득한 것으로 본다. 복제본 승격 문제를 다수결로 우회하자는 발상이다. 획득에 든 시간을 빼서 유효 TTL을 계산하고, 실패하면 잡은 것들을 전부 해제한다.

2016년 Martin Kleppmann(『데이터 중심 애플리케이션 설계』 저자)이 "How to do distributed locking"에서 강하게 반박하면서 유명한 논쟁이 됐다. 요지는 두 가지다.

  • 타이밍 가정에 의존한다. Redlock의 안전성은 프로세스 일시정지·네트워크 지연·클럭 오차가 모두 TTL보다 작다는 가정 위에 서 있다. GC 스톱이 길거나, NTP가 시계를 점프시키면 두 클라이언트가 동시에 락을 가질 수 있다. 즉 비동기 모델에서 안전하지 않다.
  • 락만으로는 정확성을 못 지킨다. 설령 Redlock이 완벽해도, 락 획득 후 스토리지에 쓰기까지의 프로세스 멈춤은 못 막는다. 진짜 해법은 펜싱 토큰이다.

antirez는 "Is Redlock safe?"로 반박했다. 클럭 점프는 운영으로 관리 가능하고, 실무 대부분의 상황에서 Redlock은 단일 인스턴스보다 안전하다는 논지였다. 둘 다 부분적으로 맞다. 내 정리는 이렇다.

Kleppmann의 분류가 실무의 핵심이다 — 락의 용도를 효율(efficiency)정확성(correctness)으로 나눠라. 효율용(중복 작업을 줄이려는 것, 가끔 두 번 해도 무해)이면 단일 Redis로 충분하고 Redlock까지 갈 필요도 없다. 정확성용(두 번 실행되면 데이터가 깨짐)이면 Redlock을 써도 부족하고 펜싱 토큰이 필요하다. 즉 Redlock은 "단일 Redis로는 불안하지만 펜싱 토큰까진 안 쓰고 싶은" 애매한 중간 지대를 노리는데, Kleppmann은 그 중간 지대가 실은 없다고 보는 것이다.

개인적으로는, 정확성이 걸린 곳에 Redlock을 쓰느니 차라리 etcd/ZooKeeper로 가거나 락 자체를 없애는 설계를 택한다. Redlock은 노드 5개 운영 비용을 치르면서도 논쟁의 여지를 남기기 때문이다. 반대로 "크론이 두 서버에서 중복 실행되는 것만 웬만하면 막자" 같은 효율 목적이면 단일 Redis SET NX PX 한 줄로 끝낸다.

펜싱 토큰 — 락이 배신해도 데이터를 지키는 법

펜싱 토큰은 락을 획득할 때마다 단조 증가하는 숫자를 함께 발급받고, 보호 대상 리소스(스토리지 등)가 그 숫자를 검사해서 더 오래된 토큰의 쓰기를 거부하는 기법이다. 락이 잘못돼서 두 클라이언트가 동시에 진입해도, 리소스 계층이 마지막 심판을 본다.

시나리오로 보면 명확하다. 클라이언트 A가 토큰 33으로 락을 잡고 GC로 멈춘다. TTL 만료 후 클라이언트 B가 토큰 34로 락을 잡고 write(토큰=34)를 한다. 스토리지는 34를 기록한다. 그 뒤 A가 깨어나 write(토큰=33)을 시도한다. 스토리지는 "이미 34를 봤다, 33은 오래됐다"며 거부한다. 락은 실패했지만 데이터는 안 깨진다.

# 리소스 서버 측 의사코드 — 펜싱 검사
def write(payload, token):
    # 지금까지 본 최대 토큰보다 작거나 같으면 거부
    if token <= storage.last_seen_token:
        raise StaleTokenError(f"stale fencing token {token}")
    storage.last_seen_token = token   # 원자적 CAS 로 갱신해야 함
    storage.apply(payload)

문제는, 펜싱 토큰이 통하려면 리소스 서버가 토큰을 이해하고 거부할 수 있어야 한다는 점이다. S3처럼 임의의 단조 토큰 검사를 지원 안 하는 저장소라면 이 패턴을 그대로 못 쓴다(요즘 S3의 조건부 쓰기, DynamoDB 조건식, RDB의 WHERE version = ? 같은 걸로 흉내는 낸다). 그래서 현실에선 펜싱 토큰이 낙관적 동시성 제어(버전 컬럼)와 사실상 같은 것으로 수렴한다.

그리고 여기서 Redlock의 또 다른 약점이 드러난다. Redlock에는 단조 증가 토큰을 발급하는 메커니즘이 없다. 5개 노드에 흩어진 락에서 전역적으로 증가하는 번호를 안전하게 뽑아내기 어렵다. 반면 ZooKeeper와 etcd는 이 토큰을 공짜로 준다 — 그게 다음 절이다.

ZooKeeper와 etcd — 합의 기반 락과 공짜 펜싱 토큰

정확성이 걸린 락이라면 답은 합의(consensus) 기반 시스템이다. ZooKeeper(ZAB)와 etcd(Raft)는 과반 합의로 상태를 확정하므로 비동기 네트워크에서도 "누가 락을 가졌나"에 대해 단일 진실을 유지한다. 그리고 둘 다 펜싱 토큰으로 쓸 단조 증가 값을 내장한다.

ZooKeeper는 임시 순차 노드(ephemeral sequential znode)로 락을 구현한다. 락을 원하는 클라이언트가 /lock/lock- 아래 순차 노드를 만들면 lock-0000000017처럼 번호가 붙는다. 가장 작은 번호가 락 주인이고, 나머지는 바로 앞 노드를 watch한다(herd effect 방지). 클라이언트 세션이 끊기면 임시 노드가 자동 삭제돼 락이 풀린다 — TTL 추측이 아니라 세션 생존으로 판정한다는 게 핵심 차이다. 그리고 노드의 zxid(또는 순차 번호)를 펜싱 토큰으로 쓸 수 있다.

etcd는 lease와 revision으로 락을 만든다. lease(TTL)에 묶인 키를 만들고, 클라이언트가 살아있는 동안 lease를 keep-alive로 갱신한다. etcd의 모든 쓰기는 전역 단조 증가하는 revision을 받는데, 이게 그대로 훌륭한 펜싱 토큰이 된다.

# etcd 락 (Go, clientv3/concurrency)
cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})

# 세션: 기본 TTL 60초, 백그라운드에서 자동 keep-alive
session, _ := concurrency.NewSession(cli, concurrency.WithTTL(15))
defer session.Close()

mu := concurrency.NewMutex(session, "/lock/order-42")
if err := mu.Lock(context.TODO()); err != nil { panic(err) }

# mu.Header().Revision 을 펜싱 토큰으로 사용 (전역 단조 증가)
fencingToken := mu.Header().Revision
doWork(fencingToken)

mu.Unlock(context.TODO())
주의 합의 기반이라고 타이밍 문제가 마법처럼 사라지는 건 아니다. etcd·ZooKeeper도 클라이언트가 락을 쥔 채 멈추면 세션 만료 후 다른 클라이언트가 락을 잡는다. 차이는 거기서 발급된 펜싱 토큰으로 리소스 계층이 stale 쓰기를 거부할 수 있다는 것. 락 시스템이 좋아도 리소스 쪽에서 토큰을 검사 안 하면 결국 똑같이 깨진다. 락은 절반, 펜싱 검사가 나머지 절반이다.

비용은 지연과 운영이다. Redis 락은 왕복 한두 번이면 끝나지만, 합의 시스템은 과반 노드 왕복이 필요해 상대적으로 느리고, 클러스터를 홀수 노드(3 또는 5)로 굴려야 한다. 그래서 "초당 수만 번 짧게 잡는 효율용 락"에는 과하고, "실행되면 안 되는 일이 실행되면 안 되는 정확성용 락"에는 값어치를 한다.

세 방식 비교 — 무엇을 언제 고르나

한 줄 요약: 효율이면 단일 Redis, 정확성이면 합의 시스템 + 펜싱 토큰, 가능하면 락 자체를 없애기. Redlock은 특정 애매한 지대에서만 의미가 있고, 그 지대는 대개 재설계로 없앨 수 있다.

기준단일 RedisRedlocketcd/ZooKeeper
적합 용도효율(중복 방지)효율(가용성 강화)정확성
펜싱 토큰직접 구현 필요사실상 어려움내장(revision/zxid)
복제 승격 안전취약부분적강함
운영 부담낮음노드 5개클러스터 운영
클럭 의존있음강함없음

JVM 생태계라면 Redisson이 단일/Redlock 락에 워치독까지 잘 싸놨고, Go라면 etcd concurrency 패키지가 표준에 가깝다. 도구 고르기 전에 위 표의 "적합 용도" 칸에서 내 상황이 어디인지부터 못 박는 걸 권한다.

언제 분산 락이 아예 필요 없나

가장 좋은 분산 락은 안 쓰는 락이다. 락은 병목이자 장애 지점이고, 위에서 봤듯 완벽한 안전도 못 준다. 실무에서 락 요구의 상당수는 아래 넷 중 하나로 사라진다.

  • 멱등성(idempotency) — 작업이 여러 번 실행돼도 결과가 같게 설계한다. 결제 요청에 Idempotency-Key를 붙이고 서버가 중복을 흡수하면, "한 번만 실행"을 강제할 락이 필요 없어진다. 분산 시스템에서 exactly-once는 신기루라, 대개 at-least-once + 멱등성이 정답이다.
  • 낙관적 동시성 제어(CAS/버전)UPDATE ... SET version = version + 1 WHERE id = ? AND version = ? 한 방이면 락 없이도 lost update를 막는다. 이게 사실상 펜싱 토큰의 DB판이다. 충돌이 드물면 락보다 훨씬 빠르다.
  • 유니크 제약 / 원자적 연산 — DB의 UNIQUE 인덱스, INSERT ... ON CONFLICT, Redis의 INCR·SETNX 같은 단일 원자 연산으로 끝나는 일에 굳이 별도 락을 씌우지 마라.
  • 단일 라이터 파티셔닝 — 리소스를 키로 샤딩해 특정 키는 항상 한 컨슈머만 처리하게 만든다(Kafka 파티션이 대표적). 경쟁 자체가 사라지므로 락이 필요 없다. 가장 확장성 좋은 패턴이다.

내 경험칙은 이렇다. "락을 걸어야 하나?" 싶으면 먼저 "이 작업을 멱등하게 만들 수 있나?", "버전 컬럼으로 충돌을 감지할 수 있나?", "키로 파티셔닝할 수 있나?"를 차례로 묻는다. 셋 다 아니오일 때만 분산 락으로 간다. 그리고 그 락이 정확성용이면 반드시 펜싱 토큰을 붙인다. 이 순서만 지켜도 사고의 대부분은 안 난다.

자주 묻는 질문

단일 Redis 락과 Redlock 중 뭘 써야 하나요?

락이 효율 목적(중복 실행을 줄이면 되고, 드물게 두 번 실행돼도 데이터가 안 깨짐)이면 단일 Redis의 SET NX PX로 충분합니다. Redlock은 Redis 가용성을 높이려는 효율용 강화판일 뿐이고, 정확성이 걸린 곳에는 Redlock도 부족합니다. 정확성이 필요하면 etcd/ZooKeeper + 펜싱 토큰으로 가세요.

Kleppmann의 Redlock 비판, 결국 Redlock은 못 쓰는 건가요?

"쓰면 안 된다"가 아니라 "정확성 보장 용도로는 믿지 말라"가 정확한 해석입니다. Redlock은 프로세스 멈춤·네트워크 지연·클럭 오차가 TTL보다 작다는 타이밍 가정에 의존하는데, 이 가정은 비동기 시스템에서 항상 성립하지 않습니다. 효율용으로는 실용적일 수 있지만, 두 번 실행되면 안 되는 작업이라면 펜싱 토큰 없는 어떤 락도 부족합니다.

펜싱 토큰은 꼭 있어야 하나요?

락 실패 시 데이터가 손상되는 정확성용 락이라면 사실상 필수입니다. 락 획득과 실제 쓰기 사이의 프로세스 멈춤은 어떤 락도 못 막기 때문에, 리소스 계층이 단조 증가 토큰을 검사해 오래된 쓰기를 거부해야 마지막 안전선이 생깁니다. 다만 리소스 저장소가 토큰(또는 버전) 검사를 지원해야 하며, 실무에선 DB 버전 컬럼(낙관적 락)으로 같은 효과를 냅니다.

TTL은 얼마로 잡아야 하나요?

정답 숫자는 없고 트레이드오프입니다. 너무 짧으면 작업 도중 락이 풀리고, 너무 길면 워커가 죽었을 때 오래 막힙니다. 작업 예상 시간의 넉넉한 배수로 잡되, 오래 걸리는 작업엔 워치독(주기적 TTL 연장)을 붙이세요. Redisson은 기본 30초 리스에 10초마다 갱신하는 방식으로 이 문제를 다룹니다. TTL로 정확성을 지키려 하지 말고, 정확성은 펜싱 토큰에 맡기세요.

etcd와 ZooKeeper 중에는 뭐가 낫나요?

둘 다 합의 기반이라 안전성 수준은 비슷합니다. 이미 Kubernetes 등으로 etcd가 있고 Go 생태계라면 clientv3/concurrency가 자연스럽고, Kafka·Hadoop 계열이나 JVM 환경에 ZooKeeper가 이미 있다면 Curator 레시피(InterProcessMutex)가 검증돼 있습니다. 둘 다 펜싱 토큰(etcd revision, ZooKeeper zxid/순차번호)을 제공하니, 새로 도입하는 스택과의 궁합으로 고르면 됩니다.

그냥 DB의 SELECT FOR UPDATE로 락을 걸면 안 되나요?

되고, 오히려 좋은 선택일 때가 많습니다. 이미 트랜잭션 안에서 특정 행을 보호하려는 거라면 SELECT ... FOR UPDATE(비관적 락)나 버전 컬럼(낙관적 락)이 별도 락 인프라 없이 정확성을 줍니다. 트랜잭션 커밋/롤백과 락 생명주기가 묶여서 관리도 단순합니다. 다만 여러 서비스가 DB 밖 리소스를 조율해야 하거나 락 경쟁이 심해 DB에 부담이 될 때 전용 분산 락으로 넘어가면 됩니다.

분산 락을 쓰면 exactly-once 실행이 보장되나요?

아니요. 분산 시스템에서 exactly-once 실행은 사실상 불가능합니다. 락은 상호배제를 도울 뿐, 프로세스 멈춤·부분 실패 때문에 "정확히 한 번"은 보장 못 합니다. 현실적인 목표는 at-least-once 실행 + 멱등한 연산이며, 이 조합이 대부분의 정확성 요구를 락보다 안전하고 단순하게 해결합니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록