개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

Redis 분산 락 — 동시 요청 충돌 방지

2026년 2월 12일
RedisDistributed Lock분산 락동시성원자성

결제 요청이 동시에 두 번 들어왔습니다. 두 서버가 동시에 재고를 확인하고 모두 "재고 있음"을 보고 결제를 진행합니다. 결과적으로 재고가 1개인데 2개가 팔립니다. 분산 락은 이런 상황을 막습니다.

왜 데이터베이스 락이 부족한가

단일 서버에서는 언어나 데이터베이스의 Lock으로 충분합니다. 그러나 서버가 여러 대인 분산 환경에서는 서버 간 공유 락이 필요합니다. Redis는 모든 서버가 접근하는 공유 저장소이므로 락 관리에 적합합니다.

기본 구현 — SET NX EX

# 락 획득 시도
# NX: 키가 없을 때만 SET
# EX 30: 30초 후 자동 해제
SET lock:payment:order:1000 "server-1" NX EX 30

# 성공: OK 반환 → 락 획득
# 실패: nil 반환 → 이미 다른 서버가 락 보유

NX와 EX를 하나의 명령으로 처리하는 것이 핵심입니다. 두 단계로 나누면 중간에 장애가 발생할 때 락이 영원히 남을 수 있습니다.

락 해제

락을 건 서버만 해제해야 합니다. 다른 서버의 락을 실수로 해제하면 안 됩니다.

# 잘못된 방법: 값 확인과 삭제 사이에 틈이 있음
GET lock:payment:order:1000   # "server-1" 확인
DEL lock:payment:order:1000   # 삭제 (사이에 다른 서버가 락을 가져가면?)

# 올바른 방법: Lua 스크립트로 원자적 처리
EVAL "
  if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
  else
    return 0
  end
" 1 lock:payment:order:1000 "server-1"

Lua 스크립트: Redis에서 여러 명령을 원자적으로 실행하는 방법. 스크립트 내 명령은 중간에 끼어들 수 없어 확인-삭제를 안전하게 처리합니다.

Node.js 구현 예시

class RedisLock {
  private readonly lockValue = `${process.pid}-${Date.now()}`;

  async acquire(key: string, ttlSeconds: number): Promise<boolean> {
    const result = await redis.set(
      `lock:${key}`,
      this.lockValue,
      "NX",
      "EX",
      ttlSeconds
    );
    return result === "OK";
  }

  async release(key: string): Promise<boolean> {
    const script = `
      if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('DEL', KEYS[1])
      else
        return 0
      end
    `;
    const result = await redis.eval(script, 1, `lock:${key}`, this.lockValue);
    return result === 1;
  }
}

// 사용
const lock = new RedisLock();

async function processPayment(orderId: string) {
  const acquired = await lock.acquire(`payment:${orderId}`, 30);
  if (!acquired) {
    throw new Error("이미 처리 중인 주문입니다");
  }

  try {
    await doPayment(orderId);
  } finally {
    await lock.release(`payment:${orderId}`);
  }
}

락 획득 재시도

락을 즉시 획득 못 했을 때 잠시 기다렸다가 재시도합니다.

async function acquireWithRetry(
  key: string,
  ttl: number,
  maxRetries = 10,
  retryDelay = 100
): Promise<boolean> {
  for (let i = 0; i < maxRetries; i++) {
    const acquired = await lock.acquire(key, ttl);
    if (acquired) return true;
    await sleep(retryDelay + Math.random() * 50);  // 지터 추가
  }
  return false;
}

지터(Jitter): 재시도 간격에 랜덤 값을 더하는 기법. 모든 서버가 정확히 같은 시간에 재시도하면 다시 충돌이 발생하므로 이를 분산시킵니다.

Redlock 알고리즘

단일 Redis 서버에 장애가 나면 락이 없어집니다. 높은 가용성이 필요하다면 여러 Redis 서버에 걸쳐 락을 거는 Redlock 알고리즘을 씁니다.

서버 A, B, C, D, E (5대)

락 획득:
1. 현재 시간 기록
2. 5대 모두에 SET NX EX 시도
3. 과반수(3대 이상)에 성공하면 → 락 획득
4. 성공 시간이 TTL보다 짧아야 유효

락 해제:
1. 5대 모두에 Lua 스크립트로 삭제

과반수 서버에 락이 걸렸으므로 1~2대가 장애여도 안전합니다. ioredis-redlock 같은 라이브러리가 이 알고리즘을 구현해 제공합니다.

주의 사항

TTL 설정

락 TTL이 너무 짧으면 작업이 끝나기 전에 락이 풀립니다. 너무 길면 장애 시 락이 오래 유지됩니다. 작업 예상 시간의 2~3배를 권장합니다.

락 연장

작업이 길어질 것 같으면 락을 연장합니다.

// 락 보유 중에 만료 시간 연장
async function extendLock(key: string, ttl: number) {
  const script = `
    if redis.call('GET', KEYS[1]) == ARGV[1] then
      return redis.call('EXPIRE', KEYS[1], ARGV[2])
    else
      return 0
    end
  `;
  return redis.eval(script, 1, `lock:${key}`, lockValue, ttl);
}

분산 락은 강력하지만 오남용하면 시스템이 느려집니다. 진짜로 동시 실행을 막아야 하는 경우에만 사용합니다.

← 이전 글Redis 모니터링 — INFO, SLOWLOG, LATENCY로 상태 파악하기