sync.Mutexkill -9를 견디지 못한다. OOM, 배포 롤아웃, 노드 재부팅에서도 살아남지 못한다. 프로세스가 종료되는 순간 락은 사라진다. 그 락이 예약된 잡, 데이터 마이그레이션, 리더 선출을 보호하고 있었다면, 이제 두 프로세스가 각자가 유일하게 실행 중이라고 믿게 된다.

이건 뮤텍스의 버그가 아니다. 카테고리 오류다. 프로세스 로컬 락은 클라이언트전체 자원을 보호할 수 없다.

해결책은 분산 리스다: 외부 시스템에 저장된 락으로, Time-To-Live, 고유한 소유자 토큰, 안전한 해제 메커니즘을 갖춘다. Redis, PostgreSQL, etcd, ZooKeeper는 모두 동일한 아이디어를 서로 다른 트레이드오프로 구현한다. 패턴 자체는 단순하다. 엣지 케이스는 그렇지 않다.

분산 리스란 무엇인가?

리스는 외부 스토어가 제공하는 약속으로, 제한된 시간 동안 단 하나의 클라이언트만이 명명된 락을 보유한다는 것을 보장한다. 힙 메모리에 존재하는 뮤텍스와 달리, 리스는 Redis나 PostgreSQL에 산다. 스토어가 지속되므로 프로세스 재시작을 넘어서도 유지된다.

기본 연산은 다음과 같다:

  • Acquire: 키가 아직 존재하지 않을 경우에만 TTL을 갖춘 키를 원자적으로 기록한다.
  • Renew: 락을 여전히 보유하고 있을 때 TTL을 연장한다.
  • Release: 키가 여전히 고유한 토큰을 담고 있을 경우에만 삭제한다.

고유한 토큰이 핵심이다. 이것이 없으면 느린 클라이언트가 크래시 이후 다른 누군가에 의해 재획득된 락을 해제할 수 있다.

TTL이 필수인 이유, 그리고 위험한 이유

클라이언트가 락을 획득한 뒤 죽으면, 락은 결국 스스로 해제되어야 한다. 인간의 개입 없이 이를 달성할 유일한 방법은 만료 시간이다. Redis는 SET key value NX EX 30으로 이를 네이티브하게 지원한다. PostgreSQL 어드바이저리 락은 세션에 묶여 있어 TCP 연결이 닫히면 함께 죽는데, 이는 우아하지만 이식성은 떨어진다.

TTL은 새로운 실패 모드를 도입한다: 작업이 TTL보다 오래 걸리면, 실행 중인 도중 락이 만료된다. 다른 클라이언트가 락을 획득해 동일한 작업을 시작한다. 이제 두 개의 동시 프로세스가 동일한 데이터를 변경하고 있다.

단순한 해결책은 매우 긴 TTL을 두는 것이다. 이는 클라이언트가 락을 획득한 직후 죽을 때까지는 통한다. 남은 TTL은 다른 누구도 인수할 수 없는 강제 다운타임 윈도우가 된다.

올바른 해결책은 하트비트다. 보유자는 백그라운드 고루틴을 생성해 수 초마다 리스를 갱신한다. 보유자가 크래시하면 하트비트가 멈추고 TTL이 만료되어, 새로운 소유자가 수 초 내에 락을 획득할 수 있다. 보유자가 단순히 느린 것이라면, 하트비트가 프로세스가 살아 있는 한 리스를 유지한다.

Go에서 동작하는 Redis 리스

다음은 획득, 갱신, 안전한 해제를 처리하는 완전한 구현이다. 해제 시 Lua 스크립트를 사용하여 토큰이 일치할 때만 삭제가 성공하도록 한다.

package lease

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "sync"
    "time"

    "github.com/redis/go-redis/v9"
)

type Lease struct {
    client     *redis.Client
    key        string
    token      string
    ttl        time.Duration
    renewEvery time.Duration
    stopRenew  chan struct{}
    once       sync.Once
}

func generateToken() string {
    b := make([]byte, 16)
    rand.Read(b)
    return hex.EncodeToString(b)
}

func Acquire(ctx context.Context, client *redis.Client, key string, ttl time.Duration) (*Lease, error) {
    token := generateToken()
    ok, err := client.SetNX(ctx, key, token, ttl).Result()
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, fmt.Errorf("lock already held")
    }

    l := &Lease{
        client:     client,
        key:        key,
        token:      token,
        ttl:        ttl,
        renewEvery: ttl / 3,
        stopRenew:  make(chan struct{}),
    }
    go l.renew()
    return l, nil
}

func (l *Lease) renew() {
    ticker := time.NewTicker(l.renewEvery)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
            script := `
                if redis.call("get", KEYS[1]) == ARGV[1] then
                    return redis.call("pexpire", KEYS[1], ARGV[2])
                else
                    return 0
                end
            `
            l.client.Eval(ctx, script, []string{l.key}, l.token, l.ttl.Milliseconds()).Result()
            cancel()
        case <-l.stopRenew:
            return
        }
    }
}

func (l *Lease) Release(ctx context.Context) error {
    l.once.Do(func() { close(l.stopRenew) })
    script := `
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `
    res, err := l.client.Eval(ctx, script, []string{l.key}, l.token).Int64()
    if err != nil {
        return err
    }
    if res == 0 {
        return fmt.Errorf("lock was lost or stolen")
    }
    return nil
}

사용법:

lease, err := lease.Acquire(ctx, redisClient, "job:invoice-generation", 10*time.Second)
if err != nil {
    // Another instance is running the job.
    return
}
defer lease.Release(ctx)

// Do the work. If this takes 30 seconds, the heartbeat keeps the lease alive.
generateInvoices()

Lua 스크립트는 필수적인데, Redis는 compare-and-delete를 단일 네이티브 명령으로 지원하지 않기 때문이다. 이것이 없으면 해제가 재획득과 race condition에 빠져 다른 누군가의 락을 삭제할 수 있다.

무너지는 지점: 펜싱 토큰 문제

완벽한 TTL과 갱신이 있더라도, Martin Kleppmann이 Redlock 비판에서 지적한 미묘한 race condition이 존재한다. 상상해 보자:

  1. 클라이언트 A가 리스를 획득한다.
  2. 클라이언트 A가 45초 동안 멈춘다(GC stop-the-world, VM 일시 중지, CPU 스로틀링).
  3. 리스가 만료된다.
  4. 클라이언트 B가 리스를 획득한다.
  5. 클라이언트 A가 다시 시작되어 데이터베이스에 기록한다.
  6. 클라이언트 B가 데이터베이스에 기록한다.

두 클라이언트 모두 자신이 락을 보유하고 있다고 믿었다. 둘 다 공유 상태를 변경했다.

TTL은 영구적인 데드락을 막지만, 지연된 프로세스를 막을 수는 없다. 해결책은 fencing token이다: 락 보유자가 모든 쓰기에 붙이는 단조 증가 숫자나 UUID다. 스토리지 레이어는 오래된 토큰을 가진 쓰기를 거부한다.

실무에서는 이것이 데이터베이스 테이블에 lock_version 컬럼이 필요하다는 뜻이거나, blob 스토어에 조건부 쓰기가 필요하다는 뜻이다. 대부분의 애플리케이션은 이 단계를 건너뛰는데, 락킹 레이어가 아닌 데이터 레이어를 변경해야 하기 때문이다. 이는 합리적인 트레이드오프지만, 트레이드오프다. 스스로 그 트레이드오프를 하고 있다는 것을 알아야 한다.

고려할 만한 대안들

PostgreSQL advisory locks는 세션 범위다. TCP 연결이 닫히면 락이 자동으로 해제된다. TTL 관리도 없고 시계 왜곡도 없다. 단점은 단일 데이터베이스 연결에 묶여 있어 커넥션 풀이나 멀티 리전 환경에서는 잘 작동하지 않는다는 점이다.

etcd leases는 이 문제를 위해 정확히 설계되었다. TTL, 자동 취소, 리스가 죽었을 때의 watch 기반 알림을 지원한다. 이미 Kubernetes를 실행 중이라면 etcd를 갖고 있는 셈이다. API는 Redis보다 장황하지만, 의미 체계는 더 깔끔하다.

ZooKeeper ephemeral sequential nodes는 고전적인 해결책이다. CAP 이론 하에서 CP(consistent and partition-tolerant)이므로 시계 왜곡 문제를 완전히 제거한다. 또한 Redis보다 느리고 운영 부담도 더 크다.

시도하지 않은 것

관계형 데이터베이스 위에 커스텀 컨센서스 프로토콜을 구현하지는 않았다. 모든 팀은 결국 이것을 시도한다: INSERT ... ON CONFLICTlast_heartbeat 컬럼을 가진 locks 테이블을 크론 잡으로 청소하는 방식이다. 해피 패스에서는 작동한다. 하지만 경쟁 상황에서는 무너지는데, MVCC 데이터베이스가 충돌하는 쓰기를 직렬화하기 때문이며, 락 획득이 전체 시스템의 병목이 된다. 올바른 도구를 사용하라.

TTL 선택하기

너무 짧으면: 하트비트가 과도해지고, 느린 GC pause 하나만으로도 리스를 잃을 수 있다.

너무 길면: 죽은 보유자가 전체 TTL 동안 페일오버를 막는다.

좋은 시작점은 TTL 10초, 3초마다 하트비트를 보내는 것이다. 관측된 GC pause와 네트워크 지연 시간을 기반으로 거기서부터 튜닝하라. p99 하트비트 지연 시간을 측정하라. 500 ms라면 TTL은 최소 한 자릿수 이상 커야 한다.

직접 해보기

지금 sync.Mutex를 사용해 백그라운드 잡을 보호하고 있다면, Redis나 PostgreSQL 기반의 리스드 락으로 교체하라. 위 구현부터 시작하라. lease_acquired, lease_lost, heartbeat_latency에 대한 메트릭을 추가하라. 장기 실행 잡 중에 배포하고 두 번째 인스턴스가 충돌하는 대신 정중히 기다리는 것을 본다면, 카테고리 오류가 수정된 것이다.