한 줄 요약: 분산 락의 핵심은 “원자성”과 “소유권 증명”이다. SET NX 한 줄로 락을 걸 수 있지만, 잘못 설계하면 10만 TPS 트래픽 앞에서 이중 결제, 재고 음수, 중복 배송이 동시에 터진다. 락을 거는 것보다 올바르게 해제하는 것이 훨씬 어렵다.


실제 사고: 분산 락이 없거나 잘못 설계되면 어떤 일이 벌어지나

2021년 국내 대형 이커머스 플랫폼에서 선착순 한정 상품 1,000개가 할인 이벤트 시작 3초 만에 3,847건 주문이 생성된 사건이 있었습니다. 재고 차감 로직이 SELECT → 재고 확인 → UPDATE를 트랜잭션 없이 실행했고, 동시 요청 수천 개가 모두 “재고 있음”을 확인한 뒤 차감에 들어갔습니다. 재고가 -2,847개가 됐고, 고객 2,847명에게 취소 문자를 발송하며 보상 쿠폰을 뿌리는 사태로 끝났습니다.

카카오페이에서는 결제 요청 API에 네트워크 타임아웃이 발생했을 때 클라이언트가 동일 요청을 재시도하면서 이중 결제가 발생하는 장애가 보고됐습니다. 서버 측에서 “이미 처리 중인 요청”임을 감지하는 멱등성 키 + 분산 락 조합이 없었고, 두 개의 요청이 동시에 결제 처리 단계까지 진입했습니다. 한 명의 고객이 동일 주문에 대해 두 번 결제되는 결과가 나왔습니다.

배달의민족에서는 라이더 배정 시스템에서 동일 주문에 두 명의 라이더가 동시에 배정되는 문제가 발생했습니다. 배정 알고리즘이 “배정 가능한 라이더 조회 → 배정 처리”를 별개 트랜잭션으로 실행했는데, 두 개의 인스턴스가 동시에 같은 주문을 선택했습니다. 라이더 두 명이 같은 음식을 픽업하러 갔고, 한 명은 헛걸음을 했습니다.

세 사고의 공통 원인은 단 하나입니다. 분산 환경에서 “하나의 자원을 하나의 실행자만 접근한다”는 보장이 없었습니다. 이 글은 이 세 가지 실패를 전부 방어하는 분산 락 시스템을 WHY 중심으로 설계합니다.


1. 설계 의사결정 로드맵

분산 락 시스템을 설계할 때 반드시 결정해야 하는 다섯 가지 선택이 있습니다. 각 결정은 이후 아키텍처 전체를 규정하므로, 근거 없이 고르면 면접에서 즉시 탈락합니다.

1-1. 락 저장소: DB vs Redis vs ZooKeeper vs etcd

후보 장점 단점 언제 적합한가
DB (SELECT FOR UPDATE) 별도 인프라 불필요, 트랜잭션과 통합 락 경합 시 커넥션 고갈, TPS 수천 이상에서 병목 단일 DB, 락 빈도 낮음
Redis (SET NX) 서브밀리초 응답, 클러스터 확장 용이 메모리 데이터라 재시작 시 유실 가능, 단일 노드 장애 고TPS, 단기 락, 캐시 계층 이미 있을 때
ZooKeeper 강한 일관성, Ephemeral 노드로 자동 해제 운영 복잡, 쓰기 TPS 수만 이상 어려움, 레이턴시 높음 리더 선출, 서비스 디스커버리
etcd Raft 기반 강한 일관성, Kubernetes 표준 ZooKeeper와 유사한 제약 인프라 락, 클러스터 설정 관리

우리의 선택: Redis (Redlock 알고리즘)

Redis 락은 교통 신호등과 같습니다. 신호등(락)이 빠르게 전환되어야 차(요청)가 막히지 않습니다. DB 락은 경찰관이 수동으로 교통을 통제하는 것과 같아서, 차가 많아질수록 경찰관(커넥션)이 지쳐 쓰러집니다.

10만 TPS 환경에서 DB 락은 커넥션 풀을 순식간에 고갈시킵니다. Redis는 단일 스레드 이벤트 루프 기반으로 초당 수십만 명령을 처리하며, 서브밀리초 응답을 보장합니다. 단, 단일 Redis 노드 장애 시 락 전체가 사라지는 문제를 Redlock(다중 노드 과반 획득) 으로 해결합니다.


1-2. 락 알고리즘: 단일 노드 SET NX vs Redlock vs Fencing Token

후보 장점 단점 언제 적합한가
단일 노드 SET NX 구현 단순, 빠름 노드 장애 시 모든 락 소실, SPOF 단일 Redis, 장애 허용 가능한 내부 락
Redlock (N개 노드) N/2+1 과반으로 SPOF 제거 구현 복잡, clock drift 주의 프로덕션 고가용성 요구
Fencing Token 스토리지 레벨에서 순서 보장 스토리지가 token을 검증해야 함 최고 수준의 안전성 요구 (금융 결제)

우리의 선택: Redlock + Fencing Token 조합

단순한 재고 차감은 Redlock으로 충분합니다. 그러나 결제 처리처럼 “락을 가졌던 프로세스가 GC pause 동안 TTL이 만료되고, 다른 프로세스가 락을 획득한 뒤, 첫 번째 프로세스가 깨어나서 중복 처리”하는 시나리오는 Fencing Token으로만 막을 수 있습니다. 두 전략을 계층별로 조합합니다.


1-3. 대기 전략: spin vs backoff vs pub/sub

후보 장점 단점 언제 적합한가
Spin Lock 구현 단순, 락 해제 즉시 감지 CPU 소모, Thundering Herd 락 보유 시간 극히 짧을 때 (마이크로초)
Exponential Backoff + Jitter CPU 소모 없음, 부하 분산 락 해제 감지 지연 가능 일반적인 분산 락 대기
pub/sub 대기 (Redisson 방식) 락 해제 즉시 깨어남, 효율적 Redis pub/sub 연결 필요 고TPS, Redisson 라이브러리 사용 시

우리의 선택: Exponential Backoff + Jitter (기본), pub/sub (고성능)


1-4. Lease 관리: 고정 TTL vs Watchdog 연장

후보 장점 단점 언제 적합한가
고정 TTL 구현 단순, 데드락 자동 방지 작업이 TTL 초과 시 락 소실 중간 처리 짧고 예측 가능한 작업
Watchdog 자동 연장 작업 완료 보장 프로세스 죽으면 watchdog도 멈춰 TTL 만료 처리 시간 불확실한 장기 작업

우리의 선택: Watchdog 패턴

Watchdog은 반려견과 같습니다. 주인(락 보유 스레드)이 살아있는 동안만 짖으며(TTL 연장) 경계를 섭니다. 주인이 쓰러지면(스레드 종료) 개도 멈추고, 영역(락)이 자연히 반환됩니다.


1-5. 락 범위: 글로벌 vs 파티션 vs 행 수준

후보 장점 단점 언제 적합한가
글로벌 락 구현 단순 락 하나에 모든 요청 직렬화 전역 설정 변경, 스키마 마이그레이션
파티션 락 병렬성 향상 (N배) 키 설계 필요 사용자별, 주문별 락
행 수준 락 최고 병렬성 락 키 폭발, 관리 복잡 개별 상품 재고, 좌석 예약

우리의 선택: 파티션 락 (resource_type:resource_id 형태)


2. 요구사항 분석 및 규모 추정

기능 요구사항

  • 동일 자원에 대해 동시에 하나의 실행자만 락 획득 가능
  • 락 획득 실패 시 대기 또는 즉시 실패 반환 선택 가능
  • 락 보유 중 프로세스 장애 발생 시 자동 해제 (TTL)
  • 락 재진입 (Reentrant Lock) 지원: 동일 스레드가 이미 보유한 락 재획득 가능
  • 분산 환경 (N개 서버 인스턴스) 에서 정확히 동작

비기능 요구사항

  • 락 획득/해제 레이턴시 p99 < 5ms
  • 가용성 99.99% (연간 52분 이하 장애)
  • 최대 동시 락 보유 수: 100만 개
  • 락 요청 TPS: 10만

규모 추정

락 요청: 100,000 TPS
평균 락 보유 시간: 50ms
동시 락 보유 수: 100,000 × 0.05 = 5,000개 (평균)
피크 동시 락: 5,000 × 20 = 100,000개
Redis 키 크기: 평균 200 bytes × 100,000 = 20MB (매우 작음)
Redlock 노드 수: 5개 (N/2+1 = 3개 과반)

Redis 메모리 관점에서 분산 락은 거의 부하가 없습니다. 병목은 메모리가 아니라 초당 명령 처리 수(OPS)입니다. Redis 단일 노드는 초당 100만 OPS까지 처리 가능하므로, 10만 TPS × 2 (획득+해제) = 20만 OPS로 단일 노드도 충분합니다. 고가용성을 위해 5노드 Redlock을 구성합니다.


3. 고수준 아키텍처

분산 락 시스템을 은행 금고에 비유합니다. 여러 창구(서버 인스턴스)가 동시에 금고를 열려 하지만, 금고 열쇠(락)는 한 번에 한 창구만 가질 수 있습니다. Redlock은 5개 금고 중 3개 이상에서 열쇠를 받아야 진짜 금고를 연 것으로 인정하는 방식입니다.

graph LR
  C["클라이언트"] --> LM["LockManager"]
  LM --> R1["Redis 노드 1"]
  LM --> R2["Redis 노드 2"]
  LM --> R3["Redis 노드 3"]
  LM --> R4["Redis 노드 4"]
  LM --> R5["Redis 노드 5"]

컴포넌트 역할

컴포넌트 역할 핵심 책임
LockManager 락 획득/해제 오케스트레이션 Redlock 알고리즘 실행, Watchdog 시작/종료
Redis 노드 1~5 실제 락 상태 저장 SET NX 원자적 실행, TTL 관리
Watchdog Thread 락 TTL 자동 연장 락 보유 스레드 생존 확인, 주기적 PEXPIRE
FencingTokenStore 단조 증가 토큰 발급 INCR 원자적 실행, 스토리지 레벨 중복 방지
LockEventPublisher 락 이벤트 Kafka 발행 감사 로그, 락 해제 pub/sub 알림

4. 핵심 컴포넌트 상세 설계

4-1. Redis Lua 스크립트: 왜 Lua인가

Redis에서 락을 구현할 때 가장 중요한 원칙은 원자성입니다. 락 획득은 “키가 없으면 세팅한다”는 두 단계를 하나의 원자적 연산으로 묶어야 합니다.

왜 MULTI/EXEC(트랜잭션)이 아닌 Lua인가

Redis MULTI/EXEC는 트랜잭션처럼 보이지만, 실제로는 명령을 큐에 쌓았다가 한 번에 실행하는 “배치 실행”입니다. MULTI/EXEC 블록 안에서는 이전 명령의 결과를 조건으로 분기할 수 없습니다. 예를 들어 “GET 결과가 내 ID와 같을 때만 DEL한다”는 로직은 MULTI/EXEC로 표현할 수 없습니다.

Lua 스크립트는 Redis 내부에서 실행되며, Redis의 단일 스레드 이벤트 루프 특성상 스크립트 실행 도중 다른 명령이 끼어들 수 없습니다. 즉, Lua 스크립트 전체가 하나의 원자적 연산입니다. 조건 분기, 반복문, 연산이 모두 가능합니다.

EVAL vs EVALSHA

EVAL 명령은 매번 스크립트 전체 텍스트를 Redis로 전송합니다. 스크립트가 길수록 네트워크 낭비가 발생합니다. SCRIPT LOAD 명령으로 스크립트를 Redis에 미리 등록하면 SHA1 해시를 반환하는데, 이후 EVALSHA <sha1> ...로 해시만 전송하여 실행합니다. 스크립트가 캐시에 없으면 NOSCRIPT 에러가 반환되므로, 실제 구현에서는 EVALSHA 실패 시 EVAL로 폴백하는 로직을 추가합니다.

redis.call() vs redis.pcall()

redis.call()은 Redis 명령 실행 중 에러가 발생하면 스크립트 전체를 중단하고 에러를 전파합니다. redis.pcall()은 에러를 Lua 테이블로 캡처하여 스크립트 내에서 처리할 수 있습니다. 락 스크립트는 실패를 명확하게 전파해야 하므로 redis.call()을 사용합니다. 에러를 조용히 삼키면 “락을 획득했다고 착각”하는 사태가 발생합니다.

KEYS[] vs ARGV[] 규칙

Redis Cluster에서는 동일한 슬롯(shard)에 있는 키만 하나의 명령으로 접근할 수 있습니다. Lua 스크립트에서 KEYS[1], KEYS[2], ...로 선언된 키는 Redis Cluster가 라우팅 분석에 사용합니다. 키가 아닌 값(락의 소유자 ID, TTL 등)은 반드시 ARGV[]에 넣어야 합니다. KEYS[]에 값을 넣거나 스크립트 내부에서 동적으로 키를 생성하면 Cluster 환경에서 cross-slot 에러가 발생합니다.

락 획득 Lua 스크립트 (한 줄씩 설명)

-- KEYS[1]: 락 키 (예: "lock:order:12345")
-- ARGV[1]: 락 소유자 고유 ID (UUID, 서버ID+스레드ID+타임스탬프 조합)
-- ARGV[2]: 락 TTL (밀리초)
-- 반환값: 1(획득 성공), 0(이미 다른 소유자가 보유)

local result = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])
-- SET: Redis SET 명령
-- KEYS[1]: 락 키 이름
-- ARGV[1]: 락 값 (소유자 ID) — 나중에 "내가 가진 락인지" 검증에 사용
-- 'NX': Not eXists — 키가 존재하지 않을 때만 SET (원자적 CAS)
-- 'PX': Milliseconds — TTL을 밀리초 단위로 지정 (EX는 초 단위, PX가 더 정밀)
-- ARGV[2]: TTL 값 (밀리초)
-- result: "OK" (성공) 또는 false (키 이미 존재)

if result then
  -- SET NX가 성공했다는 것 = 키가 없었고 내가 원자적으로 생성함
  return 1
else
  -- 키가 이미 존재 = 다른 소유자가 락을 보유 중
  return 0
end

NX 옵션인가: 키 존재 확인(EXISTS)과 키 쓰기(SET)를 별개 명령으로 실행하면, 두 명령 사이에 다른 클라이언트가 먼저 키를 생성할 수 있습니다. SET NX는 이 두 단계를 하나의 원자적 명령으로 만들어 Race Condition을 원천 차단합니다.

PX 옵션인가: SET key value NX만 하면 TTL이 없어서 프로세스가 죽어도 키가 영원히 남습니다. PX로 TTL을 설정하면 프로세스 장애 시 락이 자동 만료됩니다. EXPIRE를 별도 명령으로 실행하면 SET NX 성공 후 EXPIRE 실행 전 프로세스가 죽을 경우 TTL 없는 락이 남습니다. SET key value NX PX ttl 단일 명령이 이 문제를 원자적으로 해결합니다.

락 해제 Lua 스크립트 (한 줄씩 설명)

-- KEYS[1]: 락 키
-- ARGV[1]: 해제를 시도하는 소유자 ID
-- 반환값: 1(해제 성공), 0(내 락이 아님 또는 이미 만료)

local owner = redis.call('GET', KEYS[1])
-- GET: 현재 락 키의 값(소유자 ID)을 조회
-- owner: 현재 락 보유자의 ID 문자열, 키 없으면 false

if owner == ARGV[1] then
  -- 내가 발급했던 소유자 ID와 현재 저장된 ID가 일치 = 내 락이 맞음
  redis.call('DEL', KEYS[1])
  -- DEL: 락 키 삭제 = 락 해제
  return 1
  -- 해제 성공 반환
else
  -- owner가 false(키 만료)이거나 다른 소유자 ID = 내 락이 아님
  -- 절대 DEL하면 안 됨! 남의 락을 해제하는 사고 방지
  return 0
end

왜 GET → 비교 → DEL을 Lua로 묶어야 하는가: GET으로 소유자를 확인한 뒤 DEL을 별도 명령으로 보내면, GET과 DEL 사이에 내 락이 TTL로 만료되고 다른 프로세스가 같은 키로 새 락을 획득할 수 있습니다. 이때 DEL을 날리면 남의 락을 삭제하는 재앙이 발생합니다. Lua로 묶으면 GET-비교-DEL이 원자적으로 실행되므로 이 시나리오가 불가능합니다.

Java 구현 예시

public class RedisLockClient {

    private static final String ACQUIRE_SCRIPT =
        "local r = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])\n" +
        "if r then return 1 else return 0 end";

    private static final String RELEASE_SCRIPT =
        "local o = redis.call('GET', KEYS[1])\n" +
        "if o == ARGV[1] then\n" +
        "  redis.call('DEL', KEYS[1])\n" +
        "  return 1\n" +
        "else return 0 end";

    private final RedisClient redis;
    // EVALSHA 캐시: 서버에 스크립트를 등록하고 SHA로 재사용
    private String acquireSha;
    private String releaseSha;

    public void loadScripts() {
        // SCRIPT LOAD로 스크립트를 등록하고 SHA1 해시 반환
        // 이후 EVALSHA <sha> numkeys KEYS ARGV 형태로 실행
        this.acquireSha = redis.scriptLoad(ACQUIRE_SCRIPT);
        this.releaseSha = redis.scriptLoad(RELEASE_SCRIPT);
    }

    public boolean acquire(String key, String ownerId, long ttlMs) {
        try {
            // EVALSHA 먼저 시도, NOSCRIPT 에러 시 EVAL로 폴백
            Long result = redis.evalsha(acquireSha,
                List.of(key),              // KEYS[]
                List.of(ownerId, String.valueOf(ttlMs))  // ARGV[]
            );
            return result != null && result == 1L;
        } catch (RedisNoScriptException e) {
            // 스크립트 캐시 미스: Redis 재시작 후 발생 가능
            loadScripts();
            Long result = redis.eval(ACQUIRE_SCRIPT,
                List.of(key),
                List.of(ownerId, String.valueOf(ttlMs))
            );
            return result != null && result == 1L;
        }
    }

    public boolean release(String key, String ownerId) {
        Long result = redis.evalsha(releaseSha,
            List.of(key),
            List.of(ownerId)
        );
        return result != null && result == 1L;
    }
}

4-2. Redlock 알고리즘: 5노드 과반 획득

단일 Redis 노드로 락을 구현하면 해당 노드가 다운됐을 때 모든 락 정보가 사라집니다. Redis Sentinel/Cluster를 쓰더라도 페일오버(failover) 순간에 두 클라이언트가 동시에 락을 획득하는 문제가 발생할 수 있습니다. (마스터가 다운되기 직전에 락을 썼지만, 레플리카에 아직 복제가 안 된 상태에서 레플리카가 마스터로 승격되는 시나리오)

Redlock은 이 문제를 해결합니다. N개(보통 5개)의 독립적인 Redis 인스턴스에 순차적으로 락 획득을 시도하고, N/2+1개(과반) 이상에서 성공했을 때만 락을 획득한 것으로 간주합니다.

유효 시간 계산

Redlock에서 실제 락 유효 시간은 단순히 설정한 TTL이 아닙니다. 5개 노드에 순차적으로 SET NX를 보내는 동안 시간이 소요됩니다. 획득을 완료한 시점에 이미 일부 TTL이 소진됐습니다.

유효 시간 = 설정 TTL - 획득 소요 시간 - clock drift 보정값
clock drift 보정값 = TTL × 0.01 + 2ms (Redlock 스펙 권장)

예를 들어 TTL 10,000ms, 5개 노드 획득에 50ms 소요, drift 보정 102ms라면, 실제 유효 시간은 9,848ms입니다. 이 유효 시간이 0 이하면 획득 즉시 만료된 것이므로, 이미 획득한 모든 노드에서 즉시 해제하고 실패를 반환해야 합니다.

clock drift 보정이 왜 필요한가

서버 5대의 시스템 시계는 정확히 동기화되지 않습니다. NTP(Network Time Protocol)로 동기화해도 수 밀리초의 오차가 발생할 수 있습니다. 클라이언트가 “락 유효 시간이 100ms 남았다”고 판단하는 순간, 어떤 Redis 노드에서는 이미 만료됐을 수 있습니다. clock drift 보정은 이 불확실성을 감안해 안전 마진을 빼는 것입니다.

public class Redlock {

    private static final int QUORUM = 3;            // N/2+1, N=5
    private static final double CLOCK_DRIFT_FACTOR = 0.01;
    private static final long CLOCK_DRIFT_OFFSET_MS = 2;

    private final List<RedisLockClient> nodes; // 5개 독립 노드

    public Optional<LockResult> acquire(String resource, long ttlMs) {
        String lockId = UUID.randomUUID().toString();
        long start = System.currentTimeMillis();

        int acquired = 0;
        List<RedisLockClient> acquiredNodes = new ArrayList<>();

        // 1단계: 5개 노드에 순차적으로 락 획득 시도
        for (RedisLockClient node : nodes) {
            try {
                if (node.acquire(resource, lockId, ttlMs)) {
                    acquired++;
                    acquiredNodes.add(node);
                }
            } catch (Exception e) {
                // 노드 장애 시 해당 노드 건너뜀 — 과반 달성 여부로 판단
            }
        }

        long elapsed = System.currentTimeMillis() - start;

        // 2단계: 유효 시간 계산
        long drift = (long)(ttlMs * CLOCK_DRIFT_FACTOR) + CLOCK_DRIFT_OFFSET_MS;
        long validityTime = ttlMs - elapsed - drift;

        // 3단계: 과반 획득 AND 유효 시간 양수인 경우만 성공
        if (acquired >= QUORUM && validityTime > 0) {
            return Optional.of(new LockResult(lockId, validityTime, acquiredNodes));
        }

        // 4단계: 과반 미달 또는 유효 시간 소진 — 획득한 모든 노드에서 즉시 해제
        acquiredNodes.forEach(node -> node.release(resource, lockId));
        return Optional.empty();
    }

    public void release(String resource, LockResult lock) {
        // 5개 전체 노드에 해제 시도 (획득 실패한 노드도 포함)
        // 네트워크 지연으로 늦게 도착한 SET NX가 뒤늦게 성공할 수 있으므로
        nodes.forEach(node -> node.release(resource, lock.getLockId()));
    }
}

4-3. Fencing Token: 락 만료 후 지연 실행 방지

Redlock조차 해결하지 못하는 시나리오가 있습니다. 클라이언트 A가 락을 획득하고 작업 중에 Full GC가 발생해 70초 동안 멈췄습니다. 락 TTL이 30초였다면, GC 동안 락이 만료됐고 클라이언트 B가 락을 획득해 작업을 완료했습니다. GC에서 깨어난 클라이언트 A는 자신이 락을 가졌다고 착각하고 작업을 계속 진행합니다. 결과적으로 A와 B가 동일 자원을 동시에 수정했습니다.

Fencing Token은 줄 번호가 있는 대기표와 같습니다. 자원을 보호하는 스토리지가 “이 대기표 번호보다 작은 번호는 받지 않겠다”고 선언하면, 오래된 대기표를 가진 손님은 처리가 거부됩니다.

Fencing Token은 단조 증가(monotonic increasing) 번호입니다. 락을 획득할 때마다 Redis의 INCR 명령으로 전역 카운터를 증가시켜 토큰을 받습니다. 자원(DB, 파일 등)을 수정할 때 이 토큰을 함께 전달하고, 스토리지는 “현재까지 받은 최대 토큰보다 작은 토큰은 거부”하는 로직을 가집니다.

public class FencingTokenStore {

    private final RedisClient redis;
    private static final String TOKEN_KEY = "fencing:token:counter";

    // 락 획득 시 토큰 발급 (원자적 증가)
    public long issueToken() {
        return redis.incr(TOKEN_KEY); // INCR: 원자적 1 증가 후 반환
    }
}

// 스토리지(DB) 레이어에서의 검증
public class OrderRepository {

    public void updateOrder(long orderId, OrderUpdate update, long fencingToken) {
        // last_fencing_token 컬럼을 SELECT FOR UPDATE로 잠근 뒤 토큰 비교
        long currentToken = db.queryForLong(
            "SELECT last_fencing_token FROM orders WHERE id = ? FOR UPDATE",
            orderId
        );

        if (fencingToken <= currentToken) {
            // 오래된 락으로 접근한 것 — 거부
            throw new StaleTokenException(
                "Fencing token " + fencingToken + " <= current " + currentToken
            );
        }

        // 토큰 업데이트와 함께 데이터 수정 (원자적 실행)
        db.update(
            "UPDATE orders SET ..., last_fencing_token = ? WHERE id = ?",
            fencingToken, orderId
        );
    }
}

4-4. Watchdog 패턴: 락 TTL 자동 연장

고정 TTL의 문제는 “작업이 TTL보다 오래 걸릴 때” 락이 중간에 만료된다는 것입니다. TTL을 충분히 크게 잡으면, 프로세스가 죽었을 때 락 해제까지 오랜 시간이 걸려 시스템이 멈춥니다.

Watchdog 패턴은 이 딜레마를 해결합니다. 락 획득 시 TTL은 짧게(예: 30초) 설정하고, 별도 Watchdog 스레드가 락 보유 스레드가 살아있는 동안 주기적으로 PEXPIRE 명령으로 TTL을 갱신합니다. 락 보유 스레드가 종료되면 Watchdog도 멈추고, TTL이 자연히 만료됩니다.

public class WatchdogLock implements AutoCloseable {

    private static final long TTL_MS = 30_000;       // 30초 초기 TTL
    private static final long RENEWAL_INTERVAL_MS = 10_000; // 10초마다 갱신

    private final String lockKey;
    private final String lockId;
    private final RedisLockClient redis;
    private final ScheduledExecutorService watchdogExecutor;
    private ScheduledFuture<?> watchdogTask;
    private volatile boolean released = false;

    public WatchdogLock(String lockKey, String lockId, RedisLockClient redis) {
        this.lockKey = lockKey;
        this.lockId = lockId;
        this.redis = redis;
        this.watchdogExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
            Thread t = new Thread(r, "watchdog-" + lockKey);
            t.setDaemon(true); // JVM 종료 시 자동 종료
            return t;
        });
    }

    public void startWatchdog() {
        // TTL의 1/3 주기로 갱신 (30초 TTL → 10초마다)
        watchdogTask = watchdogExecutor.scheduleAtFixedRate(
            this::renewLease,
            RENEWAL_INTERVAL_MS,
            RENEWAL_INTERVAL_MS,
            TimeUnit.MILLISECONDS
        );
    }

    private void renewLease() {
        if (released) {
            watchdogTask.cancel(false);
            return;
        }
        // PEXPIRE: 밀리초 단위 TTL 갱신. 키가 존재하고 내 소유일 때만
        boolean renewed = redis.renewLease(lockKey, lockId, TTL_MS);
        if (!renewed) {
            // 락이 이미 만료되거나 다른 소유자에게 넘어감
            // 락 보유 스레드에 인터럽트 신호 전송 고려
            watchdogTask.cancel(false);
        }
    }

    @Override
    public void close() {
        released = true;
        if (watchdogTask != null) {
            watchdogTask.cancel(false);
        }
        watchdogExecutor.shutdown();
        redis.release(lockKey, lockId);
    }
}

Redisson 라이브러리의 Watchdog은 이 패턴의 검증된 구현입니다. 기본 TTL 30초, 10초마다 갱신합니다. 락을 보유한 스레드가 종료되면 갱신이 멈추고 30초 이내에 락이 자동 해제됩니다. 프로세스 전체가 죽어도 TTL이 보장이므로 데드락이 발생하지 않습니다.


4-5. 경합 완화: Backoff + Jitter + 락 세분화

Thundering Herd 문제

락 해제 순간에 대기 중이던 수백 개의 스레드가 동시에 깨어나서 락 획득을 시도하면, Redis에 순간적인 스파이크 부하가 발생합니다. 이를 Thundering Herd(우레 떼) 문제라 합니다. 소수만 성공하고 나머지는 다시 대기로 돌아가는 과정이 반복되며 불필요한 부하가 누적됩니다.

Thundering Herd는 목장 문이 열리는 순간 수백 마리의 말이 동시에 문으로 달려드는 것과 같습니다. 한 마리만 나갈 수 있는데, 나머지는 충돌하며 서로를 방해합니다. 각 말이 랜덤하게 5~50ms 기다린 뒤 시도하면 질서 있게 통과할 수 있습니다.

Exponential Backoff + Jitter

public class LockRetrier {

    private static final long BASE_DELAY_MS = 10;
    private static final long MAX_DELAY_MS = 1_000;
    private static final int MAX_RETRIES = 10;
    private final Random random = new Random();

    public boolean acquireWithRetry(
            RedisLockClient client,
            String key,
            String ownerId,
            long ttlMs) throws InterruptedException {

        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
            if (client.acquire(key, ownerId, ttlMs)) {
                return true; // 획득 성공
            }

            // Exponential Backoff: 2^attempt × baseDelay
            long exponentialDelay = (long)(Math.pow(2, attempt) * BASE_DELAY_MS);
            long cappedDelay = Math.min(exponentialDelay, MAX_DELAY_MS);

            // Full Jitter: [0, cappedDelay) 범위의 랜덤 지연
            // Full Jitter가 Equal Jitter보다 Thundering Herd 분산에 효과적
            long jitter = (long)(random.nextDouble() * cappedDelay);

            Thread.sleep(jitter);
        }

        return false; // MAX_RETRIES 소진
    }
}

왜 Jitter가 필요한가: 순수 Exponential Backoff(10ms, 20ms, 40ms…)는 모든 대기자가 동일한 시점에 재시도합니다. Jitter를 추가하면 각 요청이 서로 다른 시점에 재시도하여 Redis 부하가 시간에 걸쳐 분산됩니다. AWS 아키텍처 블로그에서 Full Jitter 방식이 동일 처리량에서 p99 레이턴시를 최대 60% 줄인다고 밝혔습니다.

Pub/Sub 기반 대기 (Redisson 방식)

graph LR
  W["대기 클라이언트"] --> SUB["SUBSCRIBE 채널"]
  H["락 보유자"] --> REL["락 해제 Lua"]
  REL --> PUB["PUBLISH 채널"]
  PUB --> SUB

Backoff 방식은 락 해제 후 즉시 깨어나지 못하고 다음 재시도 시점까지 기다려야 합니다. Redisson은 락 해제 Lua 스크립트에서 해당 락 채널에 PUBLISH 명령을 실행하고, 대기자들은 SUBSCRIBE로 즉시 알림을 받아 락 획득을 시도합니다. 불필요한 폴링 없이 즉시 반응하므로 레이턴시가 크게 줄어듭니다.

락 세분화 (Fine-Grained Locking)

글로벌 락은 모든 요청을 직렬화합니다. lock:inventory:global 하나로 모든 재고를 관리하면, 상품 A의 주문과 상품 B의 주문이 서로를 기다립니다. 락을 상품 단위로 세분화하면 서로 다른 상품의 주문이 병렬로 처리됩니다.

// 나쁜 예: 글로벌 락
String lockKey = "lock:inventory:global";

// 좋은 예: 상품별 파티션 락
String lockKey = "lock:inventory:" + productId;

// 더 좋은 예: 창고-상품 조합 락 (멀티 창고 환경)
String lockKey = "lock:inventory:" + warehouseId + ":" + productId;

주의할 점은 락 세분화가 지나치면 락 키 수가 폭발하고 관리가 어려워진다는 것입니다. 행 수준 락(주문 ID 단위)은 수억 개의 락 키가 생성될 수 있습니다. 비즈니스 의미 있는 단위(상품, 사용자, 주문)로 세분화하되, 락 키 패턴을 문서화합니다.

데드락 탐지 및 회피

데드락은 두 프로세스가 서로 상대방이 보유한 락을 기다리며 영원히 진행하지 못하는 상태입니다. 분산 락에서는 TTL이 있으므로 순수 데드락은 발생하지 않습니다(TTL 만료 후 자동 해제). 그러나 TTL이 길 경우 사실상 데드락과 동일한 지연이 발생합니다.

방어 전략은 다음과 같습니다. 첫째, 여러 락을 동시에 획득해야 할 때 항상 동일한 순서로 획득합니다. 락 키를 사전순으로 정렬하여 획득하면 순환 대기가 불가능합니다. 둘째, 락 획득 시도에 전체 타임아웃을 설정합니다. 개별 재시도가 아니라 “최대 N초 안에 모든 락을 획득하지 못하면 전부 해제하고 실패”합니다.


4-6. Kafka를 통한 락 이벤트 순서 보장

락 획득/해제/실패 이벤트를 감사(audit) 목적으로 Kafka에 발행할 때, 파티션 키 전략이 순서 보장의 핵심입니다.

Kafka는 동일한 파티션 내에서만 순서를 보장합니다. 락 이벤트를 lock_key를 파티션 키로 사용하면, 동일 락의 획득→해제→재획득 이벤트가 항상 동일 파티션에 쓰여 순서가 보장됩니다.

public class LockEventPublisher {

    private final KafkaProducer<String, LockEvent> producer;
    private static final String TOPIC = "lock-events";

    public void publishAcquired(String lockKey, String lockId, long validityMs) {
        LockEvent event = LockEvent.builder()
            .type(LockEventType.ACQUIRED)
            .lockKey(lockKey)
            .lockId(lockId)
            .validityMs(validityMs)
            .timestamp(Instant.now())
            .build();

        // lockKey를 파티션 키로 사용: 동일 락의 이벤트는 동일 파티션에 순서 보장
        ProducerRecord<String, LockEvent> record =
            new ProducerRecord<>(TOPIC, lockKey, event);

        producer.send(record);
    }
}

Exactly-Once Semantics와 락의 관계

Kafka의 Exactly-Once(정확히 한 번 전달)는 “Kafka 토픽 내에서” 중복 없는 전달을 의미합니다. 분산 락과 Kafka를 조합할 때 주의할 점은, 락을 보유한 채로 Kafka에 메시지를 발행하고 DB를 수정하는 경우입니다. Kafka 발행 성공 후 DB 수정 실패, 또는 DB 수정 성공 후 Kafka 발행 실패 같은 부분 성공이 발생할 수 있습니다.

이를 해결하려면 Transactional Outbox 패턴을 함께 사용합니다. DB 수정과 동시에 같은 트랜잭션으로 outbox 테이블에 이벤트를 INSERT하고, 별도 프로세스가 outbox를 폴링하여 Kafka에 발행합니다. 락은 “자원 접근 직렬화”, Outbox는 “이벤트 발행 신뢰성”이라는 서로 다른 문제를 각각 해결합니다.


5. Redis만으로 못 버틸 때 — 대안 설계 5가지

분산 락 면접에서 “Redis 쓰면 되죠”로 끝내는 답변은 시니어 레벨에서 탈락 신호입니다. Redis가 왜 한계에 부딪히는지, 그 한계를 어떻게 우회하는지를 알아야 합니다.

5-1. Redis Lua 스크립트의 숨겨진 함정

Redis는 단일 스레드로 초당 100만 OPS를 처리하지만, Lua 스크립트는 실행 중 Redis 전체를 블로킹합니다. 단순한 GET-비교-DEL 스크립트는 마이크로초 단위이므로 문제가 없습니다. 그러나 재고 샤드 탐색, 복잡한 조건 분기, 루프가 포함된 Lua가 수십 밀리초 이상 걸리면 그동안 다른 모든 Redis 명령이 대기합니다. 10만 TPS 환경에서 20ms짜리 Lua 스크립트 하나가 그 시간 동안 2,000개의 명령을 뒤로 밀어냅니다.

비유하자면 Redis는 하나의 계산대가 있는 편의점입니다. 앞 손님이 복잡한 계산(긴 Lua)을 하는 동안 뒷줄 전체가 멈춥니다. 계산이 단순할 때는 문제가 없지만, 복잡해지면 전체 처리량이 그 한 손님에 의해 결정됩니다.

Lua 스크립트를 짧게 유지하는 원칙:

  • 스크립트 안에서 루프는 최대 10회 이하
  • 네트워크 I/O 절대 금지 (Lua에서는 불가능하지만, 너무 많은 키 접근도 주의)
  • 복잡한 로직은 애플리케이션 레이어로 올리고, Redis는 원자적 CAS만 담당

5-2. Redlock의 근본적 논쟁 — Kleppmann vs Antirez

Redlock은 프로덕션에 널리 쓰이지만, 분산 시스템 커뮤니티에서 가장 뜨거운 논쟁 주제 중 하나입니다.

Martin Kleppmann의 비판 (2016년, “How to do distributed locking”)

Kleppmann은 “분산 시스템에서 시계(clock)는 믿을 수 없다”는 전제에서 출발합니다. 핵심 논점은 세 가지입니다.

첫째, Redlock은 TTL 기반으로 동작하는데, 서로 다른 서버의 시스템 시계가 동일한 속도로 흐른다고 가정합니다. 그러나 NTP 재조정, 가상 머신의 시계 슬립, 윤초 처리 방식 차이 등으로 수십 밀리초 이상의 오차가 발생할 수 있습니다. TTL이 10초인 락에서 한 노드의 시계가 500ms 빠르게 흐르면, 클라이언트가 “아직 락 유효”라고 판단하는 순간 그 노드에서는 이미 만료됐습니다.

둘째, GC pause, OS 스케줄링 지연, 네트워크 패킷 큐잉으로 프로세스가 임의 시간 동안 멈출 수 있습니다. 이 시간이 TTL을 초과하면 락이 만료됐음에도 클라이언트는 “락을 보유하고 있다”고 착각합니다.

셋째, 이런 시나리오에서 Redlock은 “두 클라이언트가 동시에 락을 보유한다”는 안전성(safety) 위반을 막지 못합니다. Kleppmann의 결론은 명확합니다: “safety가 critical하면 Redlock 대신 Fencing Token을 쓰거나, ZooKeeper/etcd처럼 강한 일관성을 보장하는 시스템을 써야 한다.”

Antirez(Salvatore Sanfilippo)의 반박

Redis 창시자 Antirez는 “Kleppmann이 상정하는 시나리오는 현실에서 극히 드물다”고 반박했습니다. NTP는 보통 수 밀리초 이내로 동기화되고, GC pause가 TTL을 초과할 만큼 길어지는 경우는 JVM 튜닝으로 방지할 수 있습니다. Redlock이 요구하는 안전성은 “완벽한 이론적 보장”이 아니라 “현실에서 충분한 수준의 보장”이며, 이는 대부분의 비즈니스 요구사항을 만족시킨다는 입장입니다.

결론: 어떤 락을 써야 하는가

graph LR
  Q["Safety가 절대적인가?"] --> Y["Yes: 금융/의료"]
  Q --> N["No: 재고/캐시/작업 중복방지"]
  Y --> ZK["ZooKeeper / etcd + Fencing Token"]
  N --> RL["Redlock + Watchdog"]

“절대로 중복 처리가 발생하면 안 된다”는 요건에는 Redlock만으로 부족합니다. Fencing Token을 반드시 함께 사용하거나, ZooKeeper/etcd로 전환해야 합니다.


5-3. 대안 1: DB Advisory Lock — 추가 인프라 없이

언제 적합한가: TPS 1,000 이하, 이미 PostgreSQL/MySQL이 있고 추가 인프라 도입이 부담스러울 때. 스타트업 초기나 내부 배치 작업에 최적입니다.

비유하자면 DB Advisory Lock은 은행 대여금고 직원에게 “203호 금고 작업 중”이라는 포스트잇을 붙여달라고 부탁하는 것입니다. 간단하고 별도 도구가 필요 없지만, 직원(커넥션)이 그 포스트잇을 들고 서 있어야 합니다.

// PostgreSQL pg_advisory_lock 예시
public class DbAdvisoryLock {

    private final DataSource dataSource;

    public boolean tryLock(long lockId, long timeoutMs) throws SQLException {
        try (Connection conn = dataSource.getConnection()) {
            // pg_try_advisory_lock: 즉시 획득 또는 false 반환 (블로킹 없음)
            // pg_advisory_lock: 획득할 때까지 블로킹
            PreparedStatement ps = conn.prepareStatement(
                "SELECT pg_try_advisory_lock(?)"
            );
            ps.setLong(1, lockId);
            ResultSet rs = ps.executeQuery();
            rs.next();
            return rs.getBoolean(1);
        }
    }

    public void unlock(long lockId) throws SQLException {
        try (Connection conn = dataSource.getConnection()) {
            PreparedStatement ps = conn.prepareStatement(
                "SELECT pg_advisory_unlock(?)"
            );
            ps.setLong(1, lockId);
            ps.executeQuery();
        }
    }
}

// MySQL GET_LOCK 예시
// SELECT GET_LOCK('lock:order:12345', 0);   -- 0: 타임아웃 없이 즉시 시도
// SELECT RELEASE_LOCK('lock:order:12345');

한계: DB 커넥션 풀 고갈이 치명적입니다. 락 대기 중인 스레드는 DB 커넥션을 점유합니다. 100개짜리 커넥션 풀에서 80개가 락 대기 상태라면 나머지 20개로 일반 쿼리를 처리해야 합니다. TPS가 높아질수록 이 문제가 빠르게 악화됩니다.

⚠️ 이 선택의 한계: TPS가 1,000을 넘기 시작하면 커넥션 고갈 징후(커넥션 대기 p99 상승)가 먼저 나타납니다. 이때가 Redis 락으로 전환할 신호입니다.

🔄 탈출 전략: Advisory Lock의 락 ID를 resource_type:resource_id의 해시값으로 정의하고, 나중에 Redis 락으로 교체할 때 인터페이스만 바꾸면 되도록 DistributedLock 인터페이스로 추상화해두는 것이 좋습니다.


5-4. 대안 2: ZooKeeper Ephemeral Node — 세션 기반 자동 해제

원리: ZooKeeper에서 클라이언트가 /locks/order-12345 아래에 Ephemeral Sequential 노드(lock-0000001, lock-0000002, …)를 생성합니다. 가장 작은 번호를 가진 노드의 소유자가 락을 획득합니다. 그 다음 번호 노드의 소유자는 바로 앞 번호 노드의 삭제를 Watch합니다. 락을 보유한 클라이언트가 죽으면 ZooKeeper 세션이 만료되고, Ephemeral 노드가 자동으로 삭제됩니다. TTL이 아니라 세션 연결로 생명주기를 관리합니다.

장점: clock drift 문제가 없습니다. 클라이언트가 죽으면 시계와 무관하게 즉시 락이 해제됩니다. Sequential 노드 번호로 FIFO 순서가 보장됩니다(공정 락). Watcher로 폴링 없이 즉시 알림을 받습니다.

단점: ZooKeeper 클러스터(보통 3~5노드) 운영 부담이 큽니다. ZAB(ZooKeeper Atomic Broadcast) 합의 프로토콜 특성상 쓰기 TPS가 약 1만 이하로 제한됩니다. 락 획득/해제 레이턴시가 수 밀리초~수십 밀리초로 Redis(서브밀리초)보다 10~100배 느립니다.

// Apache Curator 라이브러리 사용 (ZooKeeper 클라이언트 표준)
public class ZooKeeperDistributedLock {

    private final CuratorFramework client;
    private final InterProcessMutex mutex;

    public ZooKeeperDistributedLock(CuratorFramework client, String lockPath) {
        this.client = client;
        // InterProcessMutex: Curator가 구현한 분산 상호 배제 락
        // 내부적으로 Ephemeral Sequential 노드 패턴 사용
        this.mutex = new InterProcessMutex(client, lockPath);
    }

    public boolean tryLock(long timeoutMs) throws Exception {
        // acquire: 지정 시간 안에 락 획득 시도
        // 세션 만료 시 자동 해제 보장
        return mutex.acquire(timeoutMs, TimeUnit.MILLISECONDS);
    }

    public void unlock() throws Exception {
        mutex.release();
    }
}

5-5. 대안 3: etcd Lease + Revision — Raft 합의로 강한 일관성

원리: etcd는 Raft 합의 알고리즘으로 동작합니다. 클라이언트가 Lease(임대)를 생성하면 TTL이 있는 세션이 만들어집니다. 이 Lease에 키를 연결하면, Lease가 만료될 때 키도 함께 삭제됩니다. Revision(전역 단조 증가 버전 번호)을 Fencing Token으로 사용할 수 있습니다.

장점: Raft 합의로 강한 일관성(Strong Consistency)이 보장됩니다. 클러스터 과반이 살아있는 한 데이터 손실이 없습니다. Kubernetes가 모든 클러스터 상태를 etcd에 저장하므로, K8s 환경에서는 별도 인프라 없이 etcd를 재활용할 수 있습니다. Revision이 Fencing Token 역할을 자연스럽게 수행합니다.

단점: ZooKeeper와 마찬가지로 쓰기 TPS가 약 1만 이하입니다. K8s etcd를 애플리케이션 락에 공유하면 K8s 제어 플레인에 영향을 줄 수 있으므로 별도 etcd 클러스터 권장입니다.

// etcd Java 클라이언트 (jetcd) 예시
public class EtcdDistributedLock {

    private final Lock lockClient;
    private final Lease leaseClient;

    public Optional<LockResponse> tryLock(String lockName, long ttlSeconds)
            throws Exception {
        // 1단계: Lease 생성 (TTL 기반 자동 만료)
        LeaseGrantResponse leaseGrant = leaseClient
            .grant(ttlSeconds)
            .get();
        long leaseId = leaseGrant.getID();

        // 2단계: Lease에 연결된 락 획득
        // Lock 명령은 내부적으로 etcd 트랜잭션으로 원자적 실행
        LockResponse lockResponse = lockClient
            .lock(ByteSequence.from(lockName, StandardCharsets.UTF_8), leaseId)
            .get();

        // lockResponse.getHeader().getRevision() == Fencing Token
        return Optional.of(lockResponse);
    }

    public void keepAlive(long leaseId) {
        // Lease TTL 연장 (Watchdog 패턴과 동일한 역할)
        leaseClient.keepAliveOnce(leaseId);
    }
}

5-6. 대안 4: Lock-Free 접근 — 락 자체를 없애는 것이 최선

가장 중요한 대안입니다. 락을 어떻게 더 잘 쓸지 고민하기 전에, “락이 정말 필요한가?”를 먼저 질문해야 합니다.

비유하자면 분산 락은 화장실 열쇠입니다. 열쇠를 누가 가질지 싸우는 대신, 화장실을 여러 개 만들거나(샤딩), 줄을 서는 방식 자체를 바꾸는(이벤트 큐) 것이 근본적 해결입니다.

CAS (Compare-And-Swap) 기반 Optimistic Locking

락을 잡는 대신, “현재 상태를 확인하고 내가 기대하는 값과 같을 때만 업데이트”합니다. 충돌이 발생하면 재시도합니다. DB의 UPDATE ... WHERE version = ? 패턴이 대표적입니다.

// JPA @Version을 이용한 Optimistic Locking
@Entity
public class Inventory {

    @Id
    private Long productId;

    private int quantity;

    @Version  // JPA가 자동으로 version 컬럼 관리
    private long version;
}

@Service
public class InventoryService {

    @Retryable(
        value = ObjectOptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 10, multiplier = 2)
    )
    @Transactional
    public void decreaseInventory(Long productId, int amount) {
        Inventory inventory = inventoryRepository.findById(productId)
            .orElseThrow();

        if (inventory.getQuantity() < amount) {
            throw new InsufficientInventoryException();
        }

        // version 불일치 시 ObjectOptimisticLockingFailureException 발생
        // JPA가 내부적으로: UPDATE inventory SET quantity=?, version=version+1
        //                   WHERE id=? AND version=?  실행
        inventory.setQuantity(inventory.getQuantity() - amount);
        // 충돌 발생 시 @Retryable이 최대 3회 재시도
    }
}

언제 Lock-Free가 더 나은가:

조건 Lock-Free 적합 Lock 필요
충돌률 5% 이하 30% 이상
작업 범위 DB 단일 테이블 외부 API 호출 포함
작업 특성 읽기 많고 쓰기 드문 쓰기 위주, 동시 수정 빈번
재시도 비용 낮음 (DB 쿼리 한 번) 높음 (결제 API 재호출 불가)

언제 Lock이 필수인가: 결제 API 호출처럼 외부 시스템과 통신이 포함된 작업은 Optimistic Locking만으로 방어할 수 없습니다. DB 버전 충돌을 감지했을 때 이미 외부 결제 API는 호출된 후이기 때문입니다. 이 경우 반드시 분산 락으로 외부 시스템 진입 자체를 직렬화해야 합니다.

충돌률이 30%를 넘어서면 Optimistic Locking은 오히려 역효과입니다. 대부분의 요청이 재시도를 반복하며 DB 부하만 높아집니다. 이 경우 락을 사용하거나 요청 자체를 큐로 직렬화하는 것이 낫습니다.


5-7. 대안 5: 락 샤딩으로 경합 분산

하나의 글로벌 락에 모든 요청이 몰리는 것 자체가 설계 문제입니다. 재고 100개를 10개 버킷으로 분할하면 각 버킷에 1/10의 요청만 몰립니다.

graph LR
  REQ["주문 요청"] --> HASH["버킷 선택 Lua"]
  HASH --> B0["버킷 0: 재고 10"]
  HASH --> B1["버킷 1: 재고 10"]
  HASH --> B2["버킷 N: 재고 10"]
-- 재고 샤드 선택 + 원자적 차감 Lua 스크립트
-- KEYS[1..N]: 각 샤드의 재고 키 (예: "inventory:12345:shard:0" ~ ":9")
-- ARGV[1]: 차감할 수량
-- ARGV[2]: 총 샤드 수

local shards = tonumber(ARGV[2])
local amount = tonumber(ARGV[1])

-- 랜덤 샤드부터 시작하여 재고 있는 샤드 탐색
local start = math.random(shards) - 1
for i = 0, shards - 1 do
    local idx = (start + i) % shards + 1  -- 1-based index
    local current = tonumber(redis.call('GET', KEYS[idx]) or '0')
    if current >= amount then
        redis.call('DECRBY', KEYS[idx], amount)
        return idx  -- 성공한 샤드 번호 반환
    end
end
return -1  -- 전체 샤드 재고 부족

애플리케이션에서는 각 샤드에 개별 락을 걸어 동시 차감을 방지하거나, DECRBY 자체의 원자성(값이 음수가 되면 복구)을 활용하여 락 없이 구현할 수 있습니다. 샤드가 10개면 락 경합이 이론적으로 10분의 1로 줄어듭니다.


6. 기존 설계 결정의 한계와 탈출 전략

6-1. Redis SET NX 선택의 한계

⚠️ 이 선택의 한계: 단일 Redis 노드 장애 시 모든 락 정보가 사라집니다. 노드 재시작 후 “모든 락이 해제됐다”고 잘못 판단한 클라이언트들이 동시에 락 획득에 성공하면, 이중 처리가 발생합니다. Redis AOF(Append Only File) 영속성을 켜도 재시작 직전의 락은 복구되지 않습니다.

🔄 탈출 전략:

  • 즉시: Circuit Breaker를 추가하여 Redis 장애 감지 시 DB Advisory Lock으로 자동 폴백
  • 중기: Redlock 5노드 구성으로 전환하여 단일 노드 장애를 견딤
  • 장기: Fencing Token 도입으로 “락이 있다고 착각”하는 상황 자체를 방어

6-2. Watchdog가 GC Pause로 갱신 못할 때

⚠️ 이 선택의 한계: JVM Full GC가 30초 이상 발생하면 Watchdog 스레드도 멈춥니다. TTL이 30초라면 GC 동안 락이 만료됩니다. Watchdog이 깨어났을 때 renewLease()를 호출하면 이미 다른 클라이언트가 같은 키로 새 락을 획득한 상황일 수 있습니다. renewLease()는 소유자 ID를 검증하는 Lua 스크립트를 써야 하므로, 갱신 실패를 감지하고 현재 작업을 즉시 중단해야 합니다.

private void renewLease() {
    if (released) return;
    boolean renewed = redis.renewLease(lockKey, lockId, TTL_MS);
    if (!renewed) {
        // 락 만료: 현재 작업 스레드에 인터럽트 신호
        // 작업 스레드는 InterruptedException을 잡아 안전하게 롤백
        ownerThread.interrupt();
        watchdogTask.cancel(false);
    }
}

🔄 탈출 전략: G1GC MaxGCPauseMillis를 200ms 이하로 튜닝하고, Full GC가 1초 이상 발생하면 JVM 힙 크기 재조정을 검토합니다. 최후 방어선은 Fencing Token입니다. GC pause로 락이 만료됐더라도 오래된 토큰으로 DB를 수정하려 하면 StaleTokenException이 발생합니다.

6-3. Backoff + Jitter가 충분하지 않을 때

⚠️ 이 선택의 한계: 한정 판매처럼 초당 수만 명이 단일 자원을 노리는 경우, Backoff + Jitter만으로는 경합 자체를 줄이지 못합니다. 대기자가 줄어드는 것이 아니라 “언제 재시도할지 랜덤화”할 뿐입니다. 결국 모든 요청이 최대 재시도를 소진하고 실패를 반환합니다.

🔄 탈출 전략: 재시도 횟수가 평균 5회를 넘기 시작하는 시점이 대기 큐 전환 신호입니다. 이 경우 락 획득 실패 즉시 에러를 반환하는 대신, 요청을 대기열(Redis Sorted Set 또는 Kafka)에 넣고 선착순으로 처리합니다. 사용자에게는 “대기 중 (현재 N번째)” 메시지를 보여주면 UX도 개선됩니다.

graph LR
  REQ["락 획득 실패"] --> CNT["재시도 5회 이상?"]
  CNT --> Y["Yes: 대기열 삽입"]
  CNT --> N["No: Backoff 재시도"]
  Y --> Q["Redis Sorted Set 큐"]
  Q --> PROC["순서대로 처리"]

7. 오버엔지니어링 경고 — TPS별 적정 기술 선택

“분산 락을 설계할 수 있다는 것”과 “분산 락이 필요하다는 것”은 완전히 다른 이야기입니다. 시니어 개발자는 Redis Redlock을 능숙하게 구현하면서도, TPS 100짜리 서비스에는 절대 쓰지 않습니다.

7-1. TPS 기준 적정 기술 선택표

TPS 범위 적정 기술 근거
100 이하 synchronized 키워드 또는 DB 트랜잭션 단일 JVM 내 동시성으로 충분. Redis 락은 네트워크 왕복 비용이 더 큼
1,000 이하 DB Advisory Lock (pg_advisory_lock) 이미 있는 DB 활용. 커넥션 풀 여유로움
10,000 이하 Redis 단일 노드 SET NX + Watchdog Redlock의 5노드 오버헤드가 아직 불필요
10,000~100,000 Redis Redlock 5노드 + Fencing Token 고가용성 필요. 결제 등 safety critical
100,000 이상 Lock-Free 설계 우선, 락은 최후 수단 락 자체가 처리량 상한선. 샤딩/CAS/큐 우선

7-2. “락을 잡아야 한다는 생각 자체를 먼저 의심하라”

많은 경우 분산 락이 필요하다고 느끼는 상황은 실제로 락이 아닌 다른 방식으로 해결 가능합니다.

문제 재정의로 락이 불필요해지는 예시:

  • “재고 차감 시 음수가 되면 안 된다” → DECR 원자성 + 음수 복구 로직으로 락 불필요
  • “같은 사용자가 동시에 두 번 주문하면 안 된다” → 멱등성 키(요청 ID) + UNIQUE 제약으로 락 불필요
  • “같은 쿠폰을 두 번 쓰면 안 된다” → UPDATE ... WHERE used = false의 원자적 실행으로 락 불필요
  • “배치 작업이 여러 인스턴스에서 동시에 실행되면 안 된다” → DB SELECT ... FOR UPDATE SKIP LOCKED로 작업 큐 구현

락이 진짜 필요한 경우는 외부 시스템 호출이 포함된 작업을 직렬화해야 할 때, 또는 여러 자원을 원자적으로 묶어야 할 때입니다. 그 외 대부분의 상황은 DB 제약조건, 원자적 명령, 멱등성으로 해결됩니다.


8. 비용 분석 — 기술 선택의 경제학

8-1. 월 비용 비교 (AWS ap-northeast-2 기준, 2026년 1분기)

구성 인스턴스 월 비용 쓰기 TPS 락 레이턴시 p50 운영 난이도
DB만 (Advisory Lock) RDS PostgreSQL r7g.large (이미 있음) +$0 (추가 없음) ~1,000 2~10ms 낮음
Redis 단일 노드 ElastiCache r7g.medium ~$70/월 ~50,000 <1ms 낮음
Redis Redlock 5노드 ElastiCache r7g.medium × 5 ~$350/월 ~50,000 2~5ms 중간
ZooKeeper 3노드 EC2 c7g.large × 3 ~$200/월 ~10,000 5~20ms 높음
etcd 3노드 EC2 c7g.large × 3 ~$200/월 ~10,000 5~20ms 중간

DB Advisory Lock은 이미 DB가 있다면 추가 비용이 0입니다. Redis Redlock은 월 350달러로 ZooKeeper/etcd 대비 비슷한 비용에 훨씬 높은 TPS를 처리합니다. ZooKeeper는 쓰기 TPS 상한이 낮음에도 EC2 직접 운영이므로 운영 부담이 가장 큽니다.

8-2. 운영 복잡도 — “누가 운영하는가”가 결정한다

graph LR
  T1["DevOps팀 있음"] --> ZK["ZooKeeper / etcd 고려"]
  T2["DevOps팀 없음"] --> DB["DB Advisory Lock 우선"]
  T2 --> RC["Redis (관리형 ElastiCache)"]
  ZK --> HA["강한 일관성 필요하면 채택"]
  RC --> COST["추가 비용 감수 시 채택"]
항목 DB Advisory Lock Redis ElastiCache ZooKeeper/etcd
운영팀 요건 없음 (DBA면 충분) 없음 (AWS 관리형) DevOps 필수
장애 대응 복잡도 낮음 낮음 (AWS SLA) 높음
버전 업그레이드 RDS 자동 패치 ElastiCache 자동 수동, 중단 위험
모니터링 지표 DB 슬로우쿼리 ElastiCache 메트릭 ZK/etcd 전용 메트릭
결론 스타트업, 초기 서비스 성장기 대부분 대기업, K8s 운영팀

ElastiCache는 AWS가 패치, 페일오버, 백업을 모두 처리하므로 별도 DevOps 없이도 운영 가능합니다. ZooKeeper는 앙상블(3~5노드) 구성, ZAB 프로토콜 모니터링, 노드 교체 절차가 복잡하므로 전담 인프라 엔지니어가 없다면 도입을 권장하지 않습니다.


9. 극한 시나리오 3개

시나리오 1: GC Pause로 인한 락 만료 후 지연 실행

발생 조건: Java 서버 A가 재고 차감 락을 획득(TTL 5초), GC pause로 7초 동안 멈춤. 5초 후 락 TTL 만료, 서버 B가 동일 락 획득 및 재고 차감 완료. GC에서 깨어난 서버 A가 자신이 아직 락을 가졌다고 착각하고 재고를 한 번 더 차감.

피해: 재고 2중 차감. 상품 재고가 음수가 되고 초과 판매 발생.

수치: Java GC pause는 G1GC 기준 p99 50ms이지만, Old Gen 풀 컬렉션 시 수 초 ~ 수십 초까지 발생 가능. 재고 1개 상품에서 이 시나리오가 100번 발생하면 재고 -100개, 100명에게 취소 문자 발송.

graph LR
  A["서버A GC pause"] --> EXP["락 TTL 만료"]
  EXP --> B["서버B 락 획득"]
  B --> DEC1["재고 차감 1"]
  A --> DEC2["재고 차감 2 (중복)"]
  DEC2 --> NEG["재고 음수"]

대응: Fencing Token 적용. 서버 A의 토큰(예: 42)은 서버 B의 토큰(43)보다 작으므로, DB가 서버 A의 요청을 StaleTokenException으로 거부합니다. 재고 차감 쿼리에 WHERE last_token < 43 조건을 추가하는 것만으로 이 시나리오 전체를 방어합니다.


시나리오 2: Redlock에서 5개 노드 중 3개 동시 장애

발생 조건: Redlock 5노드 구성에서 네트워크 파티션으로 3개 노드가 동시에 응답 불가. 과반(N/2+1=3) 달성 불가능.

피해: 모든 락 획득이 실패하여 시스템 전체가 처리 불가 상태. 10만 TPS 요청이 전부 “락 획득 실패” 에러 반환.

수치: 5노드 중 3개 다운 = 40% 이상의 Redis 노드 동시 장애. 일반적인 데이터센터에서 이 수준의 동시 장애는 매우 드물지만, 의존하는 공유 네트워크 스위치 장애 시 발생 가능.

대응: 3단계 방어 전략을 구사합니다. 첫째, 락 획득 실패 시 즉시 에러를 반환하지 않고 Fallback을 실행합니다. 주문 처리의 경우 DB SELECT FOR UPDATE로 전환(성능 저하는 있지만 동작 유지). 둘째, Redlock 노드를 서로 다른 AZ(가용영역)에 배치하여 단일 AZ 장애가 과반 달성을 막지 않도록 구성합니다(예: AZ-A에 2개, AZ-B에 2개, AZ-C에 1개). 셋째, Circuit Breaker를 적용하여 락 서비스 전체 장애 시 자동으로 DB 락 모드로 전환합니다.


시나리오 3: 락 경합 Thundering Herd로 Redis CPU 100%

발생 조건: 인기 상품 한정 판매 시작. 10만 명의 사용자가 동시에 동일 재고 락을 요청. Jitter 없이 순수 Exponential Backoff를 사용해 모든 대기자가 동일한 간격으로 재시도.

피해: 락 해제 후 10만 개의 재시도 요청이 수백 밀리초 간격으로 동기화되어 Redis CPU가 스파이크. Redis 단일 스레드 특성상 처리 큐가 폭증하고 레이턴시가 초 단위로 증가.

수치: Redis는 단일 스레드에서 초당 100만 명령을 처리합니다. 10만 개의 동기화된 재시도가 1ms 내에 몰리면 100ms 분량의 명령이 순간 도착, 평균 레이턴시가 1ms → 100ms로 100배 증가.

graph LR
  REL["락 해제"] --> T1["t=10ms 재시도"]
  REL --> T2["t=10ms 재시도"]
  T1 --> SPIKE["Redis CPU 스파이크"]
  T2 --> SPIKE
  SPIKE --> SLOW["레이턴시 100배"]

대응: Full Jitter 적용으로 재시도를 [0, 1000ms) 구간에 균등 분산. 1ms 안에 몰리던 10만 요청이 1,000ms에 걸쳐 균등 분산되어 Redis로의 순간 부하가 100분의 1로 줄어듭니다. 추가로 락 세분화를 통해 인기 상품을 샤드 번호로 분할(lock:inventory:12345:shard:3)하여 락 자체의 경합을 줄입니다.


10. 실무 실수 Top 5

순위 실수 발생 결과 올바른 방법
1 SET key value NXEXPIRE 별도 실행 SET 성공 후 EXPIRE 전 프로세스 죽으면 TTL 없는 영구 락 SET key value NX PX ttl 단일 명령
2 락 해제 시 소유자 검증 없이 DEL TTL 만료 후 남의 락을 삭제 → 이중 진입 허용 GET-비교-DEL Lua 스크립트
3 락 ID로 Thread.currentThread().getId() 사용 JVM 재시작 시 동일 ID 재사용 → 소유자 오인식 UUID + 서버 인스턴스 ID 조합
4 재시도 없이 락 획득 실패 즉시 에러 반환 순간 경합에도 사용자에게 에러 노출 Backoff + Jitter + 최대 재시도
5 글로벌 단일 락으로 모든 자원 보호 트래픽 증가 시 처리량이 선형이 아닌 지수적으로 저하 자원별 파티션 락 설계

11. Phase 1→4 진화

Phase 구성 월 비용 (AWS 기준) 주요 기능
Phase 1 단일 Redis (ElastiCache r7g.medium) + SET NX $50 기본 락, 고정 TTL
Phase 2 Redis 3노드 Redlock (r7g.medium × 3) + Watchdog $150 고가용성, 자동 연장
Phase 3 Redis 5노드 Redlock (r7g.large × 5) + Fencing Token + pub/sub $500 결제급 안전성, Thundering Herd 방어
Phase 4 Phase 3 + Kafka 이벤트 파이프라인 + Circuit Breaker + DB 폴백 $800 완전 고가용성, 감사 로그, 장애 자동 복구

Phase 1은 스타트업 초기 트래픽에 적합합니다. 단일 Redis 장애 시 서비스 일시 중단은 허용하되, 복구 후 데이터 정합성이 보장되도록 멱등성 키를 함께 구현합니다. Phase 3부터는 결제 처리처럼 “절대 중복이 없어야 하는” 자원에 적용합니다. Phase 4는 10만 TPS를 넘는 대형 서비스 또는 규제 업종(금융, 의료)에서 감사 요건을 만족하기 위한 구성입니다.


12. 핵심 메트릭

메트릭 정상 범위 경고 임계값 장애 임계값 의미
락 획득 레이턴시 p50 < 1ms 5ms 20ms Redis 응답 속도
락 획득 레이턴시 p99 < 5ms 20ms 100ms 꼬리 레이턴시, 경합 정도
락 획득 성공률 > 95% < 90% < 70% 경합 수준 또는 Redis 장애
Redlock 과반 달성률 > 99% < 97% < 95% Redis 노드 가용성
Watchdog 갱신 실패율 0% 0.1% 1% Redis 연결 불안정
락 강제 만료 수 (TTL 소진) 0/분 10/분 50/분 프로세스 장애 또는 TTL 너무 짧음
평균 재시도 횟수 < 2회 5회 8회 락 경합 수준

13. 실제 장애 사례

사례 1: Watchdog 없이 대용량 이미지 처리 락 획득

이미지 리사이징 서비스에서 동일 원본 이미지를 중복 처리하지 않도록 Redis 락을 사용했습니다. 이미지 크기에 따라 처리 시간이 0.5초~30초까지 다양했는데, 락 TTL을 5초로 고정했습니다. 5초가 넘는 이미지 처리 중 락이 만료되어 두 번째 서버가 같은 이미지를 처리하기 시작했고, 두 서버가 동시에 같은 경로에 파일을 쓰면서 이미지가 corrupted됐습니다. Watchdog 패턴 도입으로 처리 시간에 무관하게 락이 유지되어 해결됐습니다.

사례 2: 분산 환경에서 Thread ID를 락 ID로 사용

마이크로서비스 환경에서 서버 인스턴스 5대가 동일 Redis를 공유하고, 락 ID로 Thread-42 같은 스레드 이름을 사용했습니다. 서버 인스턴스 1의 Thread-42와 서버 인스턴스 3의 Thread-42가 충돌하여, 서버 1의 Thread-42가 서버 3의 Thread-42가 보유한 락을 해제하는 사고가 발생했습니다. UUID + 서버 인스턴스 고유 ID 조합으로 수정하여 해결됐습니다.

사례 3: Redlock 없이 Redis Sentinel 사용 중 페일오버 락 소실

Redis Sentinel 구성(마스터 1 + 레플리카 2)에서 마스터가 락 데이터를 레플리카에 복제하기 전에 다운됐습니다. Sentinel이 레플리카를 새 마스터로 승격시켰지만, 해당 락 정보는 복제되지 않아 소실됐습니다. 두 번째 서버가 락을 획득하고 결제를 처리했고, 첫 번째 서버가 페일오버 후 재시도하면서 이중 결제가 발생했습니다. Redlock 5노드 구성으로 전환하여 단일 마스터 페일오버가 전체 락 소실로 이어지지 않도록 구조를 변경했습니다.


14. 확장 포인트

재진입 락 (Reentrant Lock)

동일 스레드가 이미 보유한 락을 다시 획득하려 할 때 데드락 없이 처리하려면 재진입 지원이 필요합니다. Redis Hash를 사용하여 HSET lock:key lockId 1 (재진입 카운터), HINCRBY lock:key lockId 1 (재진입 시 증가), HINCRBY lock:key lockId -1 (해제 시 감소), 카운터가 0이면 키 삭제 방식으로 구현합니다.

Read-Write 락

읽기 작업이 많고 쓰기가 드문 경우, 여러 읽기 스레드가 동시에 락을 보유하고 쓰기 스레드만 배타적으로 보유하는 RW 락이 성능상 유리합니다. Redis에서는 readers 카운터와 writer 플래그를 조합한 Lua 스크립트로 구현할 수 있으며, Redisson의 RReadWriteLock이 검증된 구현입니다.

공정 락 (Fair Lock)

대기 큐 없는 락은 먼저 기다린 스레드가 나중에 획득하는 starvation이 발생할 수 있습니다. Redis Sorted Set을 대기 큐로 사용하여(ZADD lock:queue score threadId, score = 요청 타임스탬프) FIFO 순서를 보장하는 공정 락을 구현할 수 있습니다. 단, 구현 복잡도가 높아지므로 starvation이 실제 문제가 되는 경우에만 도입합니다.


15. 면접 포인트

Q1. SET NX와 SETNX의 차이는 무엇이며, 어떤 것을 써야 하나요? `SETNX key value`는 Redis 2.6.12 이전의 구버전 명령입니다. 이 명령은 NX(키 없을 때 SET) 기능만 있고, **TTL을 원자적으로 설정할 수 없습니다**. `SETNX` 후 `EXPIRE`를 별도로 실행해야 하는데, 그 사이에 프로세스가 죽으면 TTL 없는 영구 락이 생성됩니다. `SET key value NX PX ttl`은 Redis 2.6.12+에서 도입된 확장 SET 명령으로, NX와 PX(밀리초 TTL)를 하나의 원자적 명령으로 실행합니다. 반드시 `SET ... NX PX`를 사용해야 합니다. `SETNX`는 분산 락 구현에 사용해서는 안 됩니다.
Q2. Redlock은 완벽한가요? 비판적인 시각은 무엇인가요? Redlock은 Martin Kleppmann(분산 시스템 전문가, "Designing Data-Intensive Applications" 저자)으로부터 강한 비판을 받았습니다. 핵심 논점은 다음과 같습니다. 첫째, 분산 시스템에서 시스템 시계(clock)를 신뢰할 수 없는데 Redlock은 TTL 기반으로 동작합니다. clock drift, NTP 조정, 가상 머신 시계 슬립 등으로 노드별 시계가 달라질 수 있습니다. 둘째, GC pause, 네트워크 패킷 지연 등으로 프로세스가 락을 보유한 채 임의 시간 동안 멈출 수 있습니다. Redlock은 이 경우 락 안전성을 보장하지 못합니다. Redis 창시자 Salvatore Sanfilippo(antirez)는 Redlock이 "목적에 맞게 설계됐으며, 완벽하지 않더라도 실용적인 수준의 안전성을 제공한다"고 반박했습니다. 결론: "절대적 정확성"이 필요한 금융 결제에는 Redlock + Fencing Token 조합이나 ZooKeeper/etcd를 사용하고, "높은 성능 + 충분한 안전성"이 필요한 일반적인 분산 조율에는 Redlock이 적합합니다.
Q3. 10만 TPS에서 하나의 자원에 경합이 집중되면 어떻게 처리하나요? 단일 자원에 10만 TPS의 경합이 집중되는 것 자체가 설계 문제입니다. 여러 계층의 대응이 필요합니다. **1단계: 락 앞단에서 트래픽 차단** Rate Limiter로 자원별 초당 요청 수를 제한합니다. Redis에 락을 시도하기 전에 "이 자원에 대한 초당 요청이 1,000개를 넘으면 나머지는 즉시 대기열에 삽입"하는 로직을 추가합니다. **2단계: 전체 카운터 기반 재고 관리** 개별 락 없이 Redis `DECR` 명령으로 원자적 재고 차감을 구현할 수 있습니다. `DECR inventory:12345`는 락 없이 원자적으로 동작하며, 반환값이 0 이상이면 차감 성공, 음수면 `INCR`로 복구 후 실패 처리합니다. 초당 수십만 TPS를 처리할 수 있습니다. **3단계: 세그멘테이션** 재고를 여러 샤드로 분할(`inventory:12345:shard:0` ~ `shard:9`)하여 각 샤드별 독립 락을 사용합니다. 요청을 랜덤하게 샤드에 분산시켜 단일 락 경합을 1/N로 줄입니다.
Q4. 락 없이 멱등성만으로 중복 처리를 방지할 수 있지 않나요? 멱등성 키(idempotency key)는 동일 요청의 재시도를 방지하는 훌륭한 방법이지만, 분산 락과는 해결하는 문제가 다릅니다. **멱등성 키가 해결하는 문제**: 동일 클라이언트의 동일 요청이 네트워크 재시도로 여러 번 서버에 도달하는 경우. "같은 요청을 두 번 처리하지 않는다." **분산 락이 해결하는 문제**: 서로 다른 요청이 동시에 공유 자원을 수정하려는 경우. "재고 100개를 동시에 차감하는 1,000개의 요청에서 정확히 100개만 성공한다." 멱등성 키만으로는 서로 다른 사용자가 동시에 동일 상품을 주문하는 경합을 막을 수 없습니다. 실제로는 멱등성 키(클라이언트 재시도 방지)와 분산 락(동시 접근 직렬화)을 함께 사용하는 것이 올바릅니다.
Q5. ZooKeeper의 Ephemeral 노드 방식과 Redis Redlock의 가장 큰 차이는 무엇인가요? **ZooKeeper Ephemeral 노드** ZooKeeper에서 클라이언트가 `/locks/resource` Ephemeral Sequential 노드를 생성하면, 클라이언트의 세션이 끊기는 순간 노드가 자동으로 삭제됩니다. TTL이 아니라 "세션 연결"로 락의 생명주기를 관리합니다. 클라이언트 프로세스가 죽으면 ZooKeeper 세션이 끊기고, 즉시 락이 해제됩니다. Clock drift 문제가 없습니다. Watcher 메커니즘으로 락 해제 즉시 알림을 받아 poll 없이 다음 대기자가 획득합니다. FIFO 순서(Sequential 노드 번호 기반)로 공정성이 보장됩니다. 단점은 레이턴시입니다. ZooKeeper의 쓰기 레이턴시는 Raft/ZAB 합의 프로토콜로 인해 수 밀리초 ~ 수십 밀리초입니다. Redis의 서브밀리초와 비교하면 10~100배 느립니다. 또한 ZooKeeper는 운영 복잡도가 높고, 초당 수만 이상의 락 TPS에서 병목이 됩니다. **정리**: 락 빈도가 낮고 강한 일관성이 최우선이면 ZooKeeper, 고TPS와 낮은 레이턴시가 우선이면 Redis Redlock입니다.

16. 함께 읽으면 좋은 글

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →