왜 분산 락이 어려운가

단일 프로세스의 뮤텍스와 달리 분산 락은 네트워크 지연, 노드 장애, GC 스톱 같은 변수를 함께 다뤄야 한다. 흔한 실수는 "락을 잡았다"는 사실과 "지금도 여전히 잡고 있다"를 혼동하는 것이다. 클라이언트가 락을 획득한 뒤 긴 STW GC로 멈춰 있는 동안 락 TTL이 만료되면, 다른 클라이언트가 같은 락을 획득한다. 결국 두 프로세스가 임계 구역에 동시 진입한다. 이 문제는 어떤 저장소를 쓰든 근본적으로 존재하며, 해결 방식의 차이가 Redis와 etcd의 트레이드오프를 만든다.

Redis Redlock의 아이디어

Redlock은 N개(보통 5개)의 독립 Redis 인스턴스에 순차적으로 락을 걸고, 과반수(N/2+1)에서 TTL 안에 성공하면 락을 획득한 것으로 본다. 단일 마스터의 장애나 복제 지연으로 인한 락 유실을 완화하려는 설계다.

import time, uuid, redis

nodes = [redis.Redis(host=h) for h in ("r1","r2","r3","r4","r5")]
UNLOCK = """
if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
else return 0 end
"""

def acquire(key, ttl_ms=10000):
    token, start = str(uuid.uuid4()), time.monotonic()
    got = 0
    for n in nodes:
        try:
            if n.set(key, token, nx=True, px=ttl_ms): got += 1
        except redis.RedisError:
            pass
    elapsed = (time.monotonic() - start) * 1000
    valid = ttl_ms - elapsed - 100  # clock drift 여유
    if got >= len(nodes)//2 + 1 and valid > 0:
        return token
    for n in nodes:  # 실패 시 롤백
        try: n.eval(UNLOCK, 1, key, token)
        except redis.RedisError: pass
    return None

해제는 반드시 위 Lua처럼 get+del을 원자적으로 수행해 자기 토큰일 때만 지운다. 그냥 del하면 만료 후 남이 잡은 락을 지울 수 있다.

etcd 리스 기반 락

etcd는 Raft 합의로 선형화 가능한(linearizable) 쓰기를 보장한다. 락은 리스(lease)에 키를 묶어 표현하며, 클라이언트가 주기적으로 KeepAlive로 리스를 갱신한다. 갱신이 끊기면 리스가 만료되고 키가 자동 삭제된다.

package main

import (
    "context"; "time"
    clientv3 "go.etcd.io/etcd/client/v3"
    "go.etcd.io/etcd/client/v3/concurrency"
)

func main() {
    cli, _ := clientv3.New(clientv3.Config{
        Endpoints:   []string{"etcd1:2379", "etcd2:2379", "etcd3:2379"},
        DialTimeout: 5 * time.Second,
    })
    defer cli.Close()

    // TTL 10초 세션. 내부적으로 lease + KeepAlive 관리
    sess, _ := concurrency.NewSession(cli, concurrency.WithTTL(10))
    defer sess.Close()

    mu := concurrency.NewMutex(sess, "/locks/job-42")
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    if err := mu.Lock(ctx); err != nil { return } // 대기 큐에 순서대로 진입
    defer mu.Unlock(context.Background())
    // 임계 구역
}

etcd Mutex는 리비전(revision) 순서로 대기 큐를 만들어 공정성을 제공하고, 세션이 끊기면 리스 만료로 락이 확실히 풀린다.

펜싱 토큰: 공통 안전장치

어느 쪽을 쓰든 GC 정지 문제는 락 저장소만으로 못 막는다. 보호 대상 리소스(DB, 파일 서버)가 단조 증가하는 펜싱 토큰을 검증해야 한다. etcd는 리비전 번호를 그대로 토큰으로 쓸 수 있어 유리하다.

-- 리소스 측에서 오래된 토큰을 거부
UPDATE resource
   SET data = :payload, fence = :token
 WHERE id = :id AND fence < :token;
-- 갱신 행이 0이면 stale lock 이므로 쓰기 거부

비교 정리

항목Redis Redlocketcd 리스
일관성 모델비합의, 과반수 휴리스틱Raft 선형화 보장
안전성 근거시계·타이밍 가정에 의존합의로 상호배제 보장
지연/처리량낮은 지연, 고빈도 락에 유리합의 비용으로 상대적 느림
펜싱 토큰별도 구현 필요리비전 번호 내장
운영독립 노드 5개 관리 부담Raft 클러스터 운영 필요

어떻게 고를 것인가

정확성이 최우선이고 락 획득 빈도가 높지 않다면 etcd(또는 ZooKeeper) 리스가 안전하다. 리비전 기반 펜싱까지 자연스럽게 얻는다. 반대로 초당 수천 건의 짧은 락처럼 성능이 중요하고, 락 실패가 곧바로 데이터 손상으로 이어지지 않는 "효율성" 용도라면 단일 Redis SET NX PX만으로 충분한 경우가 많다.

주의점

Redlock에서 다중 인스턴스 구성은 복잡도만 키우고 근본 안전성은 보장하지 못하니, 상관관계 있는 장애(같은 랙, 같은 존)를 피해야 의미가 있다. etcd는 리스 TTL을 너무 짧게 잡으면 네트워크 순단에 락이 튀고, 너무 길면 장애 감지가 늦다. 양쪽 모두 임계 구역 작업이 TTL을 넘기지 않도록 설계하고, 넘길 위험이 있으면 반드시 펜싱 토큰으로 마지막 방어선을 둬야 한다. 락은 상호배제의 시작일 뿐, 최종 안전성은 리소스 측 검증에서 완성된다.