분산 락 Lease 만료 경계에서 중복 보상이 발생하는 레이스 컨디션 재현과 방지

분산 락의 Lease가 만료되는 순간 중단된 작업이 다시 실행되면 두 서버가 같은 보상을 처리할 수 있습니다. Redis TTL만 믿지 않고 fencing token, DB 유니크 제약, 멱등 트랜잭션으로 레이스 컨디션을 재현하고 방지하는 방법을 정리합니다.

✓ 복사 완료!
챕터게임 백엔드 & 데이터베이스
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
분산 락 Lease 만료 경계에서 중복 보상이 발생하는 레이스 컨디션 재현과 방지

핵심 요약

분산 락의 Lease는 작업이 끝날 때까지 보유된다는 보장이 아니라 일정 시간 동안만 유효한 임대 계약입니다. 워커 A가 락을 잡은 뒤 GC 일시 정지나 네트워크 지연으로 멈추면 Lease가 만료되고 워커 B가 같은 락을 획득할 수 있습니다.

이때 A가 다시 실행되어 보상을 기록하면 중복 지급이 발생합니다. 해결책은 락을 유일한 안전장치로 사용하지 않고 fencing token, 데이터베이스의 유니크 제약, 멱등 트랜잭션을 함께 적용하는 것입니다.

Lease 만료 경계에서 왜 중복 보상이 발생하는가?

분산 락을 다음과 같이 구현했다고 가정해 보겠습니다.

SET lock:reward:player-42 worker-a NX PX 5000

NX는 키가 없을 때만 생성하도록 하고 PX 5000은 5,000ms 뒤에 키가 만료되도록 설정합니다. 일반적인 처리 흐름은 다음과 같습니다.

  1. 워커 A가 player-42의 보상 처리 락을 획득합니다.
  2. A가 외부 API 호출이나 데이터베이스 작업을 시작합니다.
  3. A의 프로세스가 Stop-the-World GC, 스케줄러 지연, CPU 부족으로 5초 이상 멈춥니다.
  4. Redis의 Lease가 만료됩니다.
  5. 워커 B가 같은 락을 획득하고 보상을 처리합니다.
  6. A가 다시 실행되어 자신도 보상을 처리합니다.

Lease의 만료 시각은 다음처럼 표현할 수 있습니다.

L=tgrant+TTLL = t_{grant} + TTL

워커 A의 실제 작업 완료 시각이 L보다 늦으면 A가 락을 보유하고 있다고 믿더라도 서버 관점에서는 이미 권한이 사라진 상태입니다. 특히 A는 자신의 로컬 메모리에 락 획득 결과를 보관하므로 Redis의 현재 상태를 자동으로 알 수 없습니다.

레이스 컨디션을 어떻게 재현할까?

재현 테스트에서는 긴 작업을 일부러 삽입하고 Lease TTL보다 긴 지연을 발생시키면 됩니다. 예시는 Node.js와 Redis를 사용하는 테스트 의사 코드입니다.

const TTL_MS = 5000;
const lockKey = 'lock:reward:player-42';

const leaseA = await lock.acquire(lockKey, {
  ttlMs: TTL_MS,
  owner: 'worker-a'
});

if (!leaseA) throw new Error('worker-a failed to acquire lock');

// 실제 환경의 GC 정지, 스레드 블로킹, CPU 포화 등을 모사한다.
await sleep(5100);

const leaseB = await lock.acquire(lockKey, {
  ttlMs: TTL_MS,
  owner: 'worker-b'
});

await Promise.all([
  applyReward('worker-a', leaseA),
  leaseB ? applyReward('worker-b', leaseB) : Promise.resolve()
]);

실행 결과는 다음과 같은 형태가 됩니다.

시점워커 ARedis Lease워커 B
t=0ms락 획득A의 Lease 생성대기
t=100ms보상 처리 시작유효대기
t=5100ms일시 정지 후 복귀A의 Lease 만료락 획득
t=5200ms보상 기록 시도B의 Lease 유효보상 기록
sequenceDiagram
    participant A as Worker A
    participant R as Redis
    participant B as Worker B
    participant DB as PostgreSQL

    A->>R: SET lock:reward:42 NX PX 5000
    R-->>A: OK
    Note over A: GC 또는 스케줄러 정지 5.1초
    B->>R: SET lock:reward:42 NX PX 5000
    R-->>B: OK
    B->>DB: 보상 INSERT
    DB-->>B: 성공
    A->>DB: 보상 INSERT
    DB-->>A: 중복 또는 오래된 작업

테스트는 반드시 다음 상황도 포함해야 합니다.

  • 작업 중간에 Lease 갱신이 실패하는 경우
  • 네트워크가 끊겼다가 복구되는 경우
  • 워커가 처리 완료 직전에 종료되는 경우
  • 같은 보상 요청이 여러 메시지로 전달되는 경우
  • 데이터베이스 커밋 성공 후 응답만 유실되는 경우

Lease 갱신만으로 충분하지 않은 이유

많은 구현은 작업 중간에 PEXPIRE를 호출해 Lease를 연장합니다. 하지만 갱신 요청 자체가 늦게 도착하거나 워커가 이미 권한을 잃은 뒤 실행될 수 있습니다.

또한 Lease를 삭제할 때는 단순히 DEL lockKey를 호출하면 안 됩니다. A의 Lease가 만료된 뒤 B가 새 Lease를 획득했는데 A가 늦게 DEL을 실행하면 B의 락까지 삭제할 수 있습니다. 삭제는 소유자 토큰을 확인하는 Lua 스크립트로 원자적으로 처리해야 합니다.

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

그러나 안전한 해제는 잘못된 삭제를 막을 뿐입니다. 이미 Lease가 만료된 워커 A가 데이터베이스에 쓰는 문제까지 해결하지는 못합니다.

Fencing Token이란 무엇인가?

Fencing token은 락을 획득할 때마다 단조 증가하는 숫자를 발급하고 데이터베이스나 외부 리소스에 함께 전달하는 방식입니다.

예를 들어 워커 A가 fence_token=41, 워커 B가 fence_token=42를 받았다고 하겠습니다. A가 늦게 복귀하더라도 저장소는 41을 가진 요청을 오래된 작업으로 판단하고 거부할 수 있습니다.

핵심 조건은 다음과 같습니다.

accept(request)  ⟺  request.fence_token≥resource.last_fence_tokenaccept(request) \iff request.fence\_token \geq resource.last\_fence\_token

새 작업이 더 높은 토큰을 사용하면 리소스의 최신 토큰을 갱신합니다. 이미 더 높은 토큰이 기록된 뒤 도착한 낮은 토큰의 요청은 처리하지 않습니다.

구성 요소역할
Lease동시에 실행되는 작업 수를 줄이는 최적화 장치
Fencing token오래된 워커의 쓰기를 거부하는 권한 버전
Unique constraint같은 보상 이벤트의 중복 삽입 방지
Idempotent transaction재시도와 중복 메시지를 안전하게 처리
Outbox 또는 이벤트 로그커밋과 후속 발행의 불일치 보완

Fencing Token을 어떻게 데이터베이스에 적용할까?

PostgreSQL에서는 보상 이벤트의 자연 키와 마지막 fencing token을 함께 관리할 수 있습니다.

CREATE TABLE reward_claim (
    claim_id         TEXT PRIMARY KEY,
    player_id        BIGINT NOT NULL,
    reward_event_id  TEXT NOT NULL,
    fence_token      BIGINT NOT NULL,
    status           TEXT NOT NULL,
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (player_id, reward_event_id)
);

보상 원장에도 중복 방지용 유니크 키를 둡니다.

CREATE TABLE reward_ledger (
    id               BIGSERIAL PRIMARY KEY,
    player_id        BIGINT NOT NULL,
    reward_event_id  TEXT NOT NULL,
    amount           INTEGER NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (player_id, reward_event_id)
);

처리 트랜잭션에서는 fencing token이 현재 작업보다 낮은지 확인하고 보상 원장을 삽입합니다.

BEGIN;

UPDATE reward_claim
SET fence_token = $3,
    status = 'processing',
    updated_at = now()
WHERE claim_id = $1
  AND fence_token < $3
  AND status <> 'applied';

INSERT INTO reward_ledger (player_id, reward_event_id, amount)
VALUES ($4, $5, $6)
ON CONFLICT (player_id, reward_event_id) DO NOTHING;

UPDATE reward_claim
SET status = 'applied',
    updated_at = now()
WHERE claim_id = $1
  AND fence_token = $3;

COMMIT;

실제 코드에서는 첫 번째 UPDATE의 영향을 받은 행 수를 확인해야 합니다. 영향받은 행이 0이면 이미 더 높은 토큰이 처리했거나 보상이 완료된 것이므로 작업을 중단합니다.

async function applyReward(input: {
  claimId: string;
  playerId: number;
  rewardEventId: string;
  amount: number;
  fenceToken: bigint;
}) {
  return db.transaction(async (tx) => {
    const updated = await tx.oneOrNone(
      `UPDATE reward_claim
       SET fence_token = $2, status = 'processing', updated_at = now()
       WHERE claim_id = $1
         AND fence_token < $2
         AND status <> 'applied'
       RETURNING claim_id`,
      [input.claimId, input.fenceToken]
    );

    if (!updated) {
      return { applied: false, reason: 'stale-or-already-applied' };
    }

    const ledger = await tx.oneOrNone(
      `INSERT INTO reward_ledger (player_id, reward_event_id, amount)
       VALUES ($1, $2, $3)
       ON CONFLICT (player_id, reward_event_id) DO NOTHING
       RETURNING id`,
      [input.playerId, input.rewardEventId, input.amount]
    );

    await tx.none(
      `UPDATE reward_claim
       SET status = 'applied', updated_at = now()
       WHERE claim_id = $1 AND fence_token = $2`,
      [input.claimId, input.fenceToken]
    );

    return {
      applied: Boolean(ledger),
      reason: ledger ? 'applied' : 'duplicate-event'
    };
  });
}

이 구조에서 A가 Lease 만료 후 복귀해도 두 겹의 방어가 작동합니다.

  1. B가 더 높은 fencing token으로 먼저 처리하면 A의 낮은 토큰 갱신이 실패합니다.
  2. 두 요청이 모두 원장 삽입에 도달하더라도 UNIQUE (player_id, reward_event_id)가 중복 삽입을 막습니다.

보상 지급에는 왜 멱등 키가 필요한가?

분산 시스템에서는 응답 유실과 재시도가 정상적으로 발생합니다. 서버가 보상을 지급하고 응답을 보내기 직전에 연결이 끊기면 클라이언트나 메시지 소비자는 같은 요청을 다시 보낼 수 있습니다.

따라서 보상 요청마다 reward_event_id 같은 멱등 키를 만들어야 합니다. 플레이어 ID만 키로 사용하면 서로 다른 보상 이벤트를 잘못 합칠 수 있으므로 이벤트의 의미를 포함한 식별자가 필요합니다.

좋은 멱등 키의 예시는 다음과 같습니다.

reward_event_id = season-2026-week-34:player-42:rank-3

반면 다음과 같은 값은 안전하지 않습니다.

reward_event_id = random UUID generated on every retry

재시도마다 새 UUID를 생성하면 데이터베이스는 서로 다른 보상으로 인식합니다. 멱등 키는 최초 요청과 모든 재시도에서 동일해야 합니다.

외부 결제나 아이템 서버까지 어떻게 보호할까?

데이터베이스 원장만 보호해도 외부 시스템에서 중복 효과가 발생하면 문제가 남습니다. 예를 들어 다음 순서에서는 DB 커밋과 외부 아이템 지급 사이에 장애가 발생할 수 있습니다.

  1. DB에 보상 지급 기록을 커밋합니다.
  2. 아이템 서버 호출 전에 워커가 종료됩니다.
  3. 재시도 시 이미 지급된 기록 때문에 외부 호출을 생략하면 실제 아이템이 지급되지 않습니다.

이 경우 보상 원장과 Outbox 이벤트를 같은 DB 트랜잭션에서 커밋하고 별도 퍼블리셔가 외부 시스템에 재시도하는 패턴을 사용할 수 있습니다.

외부 시스템도 다음 조건을 지원해야 합니다.

  • 동일한 reward_event_id를 멱등 키로 받기
  • 이미 처리한 키에 대해 같은 결과 반환하기
  • 요청의 fencing token 또는 발급 버전 검증하기
  • 처리 결과를 조회할 수 있는 상태 API 제공하기

외부 시스템이 fencing token을 이해하지 못한다면 DB를 최종 권위로 두고 외부 호출을 Outbox와 상태 머신으로 관리해야 합니다. 단순한 분산 락만으로 여러 시스템의 원자성을 만들 수는 없습니다.

안전한 보상 처리 구현 순서

1단계: 경계를 정의한다

어떤 작업이 하나의 보상 이벤트인지 먼저 정의합니다.

  • 플레이어 ID
  • 시즌 또는 콘텐츠 ID
  • 순위나 퀘스트 ID
  • 보상 종류
  • 지급 버전

이 값으로 재시도에도 변하지 않는 reward_event_id를 생성합니다.

2단계: Lease와 fencing token을 분리한다

Lease는 중복 실행을 줄이는 용도로 사용하고 fencing token은 실제 쓰기 권한 검증에 사용합니다. 토큰은 발급될 때마다 증가해야 하며 워커의 로컬 시계가 아니라 신뢰할 수 있는 저장소에서 관리해야 합니다.

3단계: DB의 멱등 제약을 추가한다

보상 원장에 UNIQUE (player_id, reward_event_id)를 설정합니다. 애플리케이션 코드의 SELECT 후 INSERT만으로 중복을 막으면 두 요청이 동시에 같은 결과를 읽고 각각 삽입할 수 있습니다.

4단계: 조건부 쓰기를 트랜잭션으로 묶는다

fencing token 검증, 보상 원장 기록, Outbox 삽입을 하나의 트랜잭션으로 처리합니다. 조건부 UPDATE가 실패하면 오래된 작업으로 간주하고 성공 응답 대신 재처리 불필요 상태를 반환합니다.

5단계: 장애 주입 테스트를 추가한다

TTL을 5,000ms로 설정하고 다음 지연을 의도적으로 주입합니다.

  • 5,001ms 이상의 워커 정지
  • Lease 갱신 패킷 지연
  • DB 커밋 직후 프로세스 종료
  • 외부 API 응답 유실
  • 동일 메시지의 동시 소비

검증할 불변식은 단순합니다.

count(player_id,reward_event_id)≤1count(player\_id, reward\_event\_id) \leq 1

그리고 보상 금액이나 아이템 수량이 누적되는 시스템이라면 다음 조건도 확인해야 합니다.

ledger_sum(event)=expected_reward(event)ledger\_sum(event) = expected\_reward(event)

Redis 락만 사용해도 되는 경우는 언제인가?

작업이 중복되어도 결과에 영향이 없는 캐시 갱신, 통계 재계산, 임시 파일 생성처럼 멱등성이 이미 보장된 작업이라면 Redis Lease만으로 충분할 수 있습니다.

반대로 다음 작업은 락만으로 보호해서는 안 됩니다.

작업필요한 보호 장치
게임 재화 지급멱등 키, DB 유니크 제약, fencing token
결제 승인결제 제공자의 멱등 키, 상태 조회, 원장
아이템 지급이벤트 ID, 중복 방지 키, 재처리 상태
시즌 보상 확정버전 검증, 트랜잭션, 원장
랭킹 보상 메시지 발행Outbox, 중복 소비 방지, 재시도 정책

분산 락은 상호 배제를 시도하는 장치이지 데이터 무결성을 보장하는 최종 수단이 아닙니다.

자주 묻는 질문 (FAQ)

Redis의 TTL을 충분히 길게 설정하면 문제가 사라지나요?

아닙니다. TTL을 늘리면 발생 확률은 낮아질 수 있지만 GC 정지, 장애 복구, 네트워크 지연보다 짧으면 같은 문제가 다시 발생합니다. TTL과 별개로 fencing token과 멱등 제약이 필요합니다.

Redlock을 사용하면 중복 보상을 막을 수 있나요?

Redlock은 락 획득 안정성을 높이는 데 도움을 줄 수 있지만 Lease가 만료된 워커의 늦은 쓰기를 자동으로 차단하지는 않습니다. 보상을 저장하는 리소스 계층에서 fencing token이나 멱등 제약을 검증해야 합니다.

fencing token은 DB 시퀀스로 발급해도 되나요?

가능합니다. 중요한 조건은 토큰이 작업마다 단조 증가하고 저장소가 최신 토큰을 기준으로 낮은 토큰의 쓰기를 거부하는 것입니다. 토큰 생성과 실제 락 소유권의 일관성이 필요하므로 이미 사용 중인 락 서비스가 토큰 발급을 지원하는지도 확인해야 합니다.

정리

Lease 만료 경계의 레이스 컨디션은 드문 예외가 아니라 분산 시스템에서 자연스럽게 발생하는 장애 모델입니다. 워커가 락을 잡았다는 사실만으로 작업의 쓰기 권한이 끝까지 유지된다고 가정하면 중복 보상이나 오래된 상태 덮어쓰기가 발생합니다.

실전에서는 Lease로 동시 실행을 줄이고, fencing token으로 오래된 워커를 거부하며, DB 유니크 제약과 멱등 트랜잭션으로 중복 효과를 차단하는 세 겹의 방어선을 구성해야 합니다. 여기에 Outbox와 장애 주입 테스트를 더하면 메시지 재전달과 응답 유실까지 포함한 보상 처리 흐름을 검증할 수 있습니다.