Published on

Redis 락 경합 줄이기 - Lua+해시태그 튜닝

Authors

서버가 늘어나고 트래픽이 몰리면 Redis 기반 분산락은 가장 먼저 병목이 됩니다. 특히 단일 키에 경쟁이 집중되는 구조(예: inventory:sku:123, coupon:issue:2026-02)에서는 락 획득 실패 재시도가 폭발하고, Redis는 CPU는 남는데도 지연이 튀는 현상이 자주 발생합니다.

이 글에서는 락 자체의 알고리즘을 바꾸기보다, 운영에서 바로 효과가 큰 두 가지 튜닝을 묶어서 설명합니다.

  • Lua로 락 획득·갱신·해제를 원자화해서 RTT와 경합 비용을 줄이기
  • Redis Cluster 해시 태그로 키 라우팅을 통제해, 필요한 원자성을 지키면서도 불필요한 크로스 슬롯 문제를 피하기

또한 “왜 이게 경합을 줄이는가”를 Redis 내부 동작 관점에서 설명하고, 바로 붙여 넣어 쓸 수 있는 스크립트/클라이언트 예시까지 제공합니다.

Redis 락 경합이 커질 때 흔한 증상

다음 패턴이 보이면 대개 락 경합이 커진 상태입니다.

  • P99 지연이 순간적으로 튐(락 획득 실패 후 재시도 폭발)
  • 애플리케이션 CPU는 낮은데 Redis instantaneous_ops_per_sec가 급증
  • SET NX PX를 반복 호출하는 코드에서 네트워크 RTT가 비용의 대부분
  • Redis Cluster에서 멀티키 연산이나 Lua가 CROSSSLOT 에러를 자주 냄

핵심 원인은 간단합니다.

  1. 락 경쟁이 심할수록 실패한 시도가 많아지고, 실패한 시도도 Redis 입장에서는 요청 처리 비용이 듭니다.
  2. 락 구현이 SET + PEXPIRE + DEL 같은 다중 커맨드로 되어 있으면, 실패/성공과 무관하게 RTT가 늘고 경합 창이 커집니다.

여기서 Lua와 해시 태그 튜닝이 들어갈 지점이 생깁니다.

기본 락 구현의 함정: 원자성보다 “왕복 횟수”가 먼저 터진다

가장 단순한 구현은 보통 아래처럼 시작합니다.

// Node.js 예시 (단순화)
await redis.set(lockKey, token, { NX: true, PX: ttlMs });
// ... 임계구역
// 해제는 토큰 확인 후 DEL
const v = await redis.get(lockKey);
if (v === token) await redis.del(lockKey);

문제는 락 해제가 GET + DEL로 쪼개지면서 경쟁 상황에서 레이스가 생기고, 무엇보다 네트워크 왕복이 늘어납니다.

  • 락 경합이 심하면 SET NX 실패가 많아지고
  • 실패할 때마다 클라이언트는 짧은 sleep 후 재시도
  • 재시도 횟수가 늘면서 RTT가 누적

즉, 락이 “정확한가” 이전에 “너무 많이 두드린다”가 먼저 문제를 만듭니다.

Lua로 락을 튜닝하는 핵심: 1) 원자화 2) 재진입 3) 갱신

Lua 스크립트는 Redis에서 단일 스레드로 실행되며, 스크립트 내부의 여러 커맨드는 중간에 끼어들 수 없습니다. 이를 이용하면 다음을 한 번에 처리할 수 있습니다.

  • 락 획득: 비어 있으면 설정
  • 재진입(같은 소유자): 이미 내가 잡은 락이면 카운트 증가
  • TTL 갱신: 재진입/연장 시 TTL 업데이트

이렇게 하면 락 획득/연장 로직이 단일 RTT로 줄고, 임계구역이 길어져도 TTL 관리가 쉬워집니다.

권장 데이터 모델: hash + 카운터

문자열 키에 토큰만 넣는 방식도 가능하지만, 실무에서는 hash가 편합니다.

  • 필드 owner: 소유자 토큰
  • 필드 count: 재진입 카운트

TTL은 키 자체에 PEXPIRE로 부여합니다.

락 획득 Lua (재진입 포함)

아래 스크립트는 다음 규칙을 가집니다.

  • 키가 없으면 생성하고 owner=token, count=1, TTL 설정 후 성공
  • 키가 있고 owner가 동일하면 count 증가 + TTL 갱신 후 성공
  • 키가 있고 owner가 다르면 실패
-- KEYS[1] = lock key
-- ARGV[1] = token (owner id)
-- ARGV[2] = ttl ms

local key = KEYS[1]
local token = ARGV[1]
local ttl = tonumber(ARGV[2])

if redis.call('EXISTS', key) == 0 then
  redis.call('HSET', key, 'owner', token, 'count', 1)
  redis.call('PEXPIRE', key, ttl)
  return 1
end

local owner = redis.call('HGET', key, 'owner')
if owner == token then
  redis.call('HINCRBY', key, 'count', 1)
  redis.call('PEXPIRE', key, ttl)
  return 1
end

return 0

이 스크립트의 효과는 단순합니다.

  • 락 획득 시도당 Redis 왕복이 1회
  • 재진입/연장도 1회
  • 해제 로직도 Lua로 원자화 가능

락 해제 Lua (카운트 감소 후 0이면 삭제)

-- KEYS[1] = lock key
-- ARGV[1] = token

local key = KEYS[1]
local token = ARGV[1]

if redis.call('EXISTS', key) == 0 then
  return 1
end

local owner = redis.call('HGET', key, 'owner')
if owner ~= token then
  return 0
end

local c = tonumber(redis.call('HINCRBY', key, 'count', -1))
if c <= 0 then
  redis.call('DEL', key)
end

return 1

여기까지가 “Lua로 경합 비용을 줄이는” 첫 번째 축입니다.

  • 성공/실패 여부와 무관하게 요청당 비용을 줄이고
  • 레이스를 없애 재시도 폭발을 완화합니다.

Redis Cluster에서 해시 태그가 필요한 이유

Redis Cluster는 키를 슬롯으로 매핑하고 슬롯을 노드에 분배합니다. 문제는 아래 두 가지입니다.

  1. 멀티키 연산(예: EVAL에서 여러 키를 다루거나, MGET, MULTI에서 여러 키)이 서로 다른 슬롯이면 CROSSSLOT 에러가 납니다.
  2. 락과 상태 키(예: lockdata)를 함께 다뤄야 하는 경우, 키가 서로 다른 노드로 가면 원자적 처리 자체가 불가능해집니다.

해시 태그는 키 이름 중 {...} 구간만 해시 계산에 사용하게 만드는 기능입니다. 이를 이용하면 서로 다른 키라도 같은 슬롯으로 강제할 수 있습니다.

  • order:{123}:lock
  • order:{123}:state

위 두 키는 {123}이 동일하므로 같은 슬롯으로 라우팅됩니다.

튜닝 포인트: “너무 넓은 태그”는 핫스팟을 만든다

해시 태그는 편리하지만, 태그를 잘못 잡으면 경합이 더 커집니다.

  • 나쁜 예: lock:{global}:coupon 처럼 모든 락을 한 슬롯에 몰아넣기
  • 나쁜 예: lock:{shop}:* 처럼 태그 단위가 너무 커서 특정 샵 트래픽이 몰릴 때 단일 노드가 터짐

따라서 태그는 원자성이 필요한 최소 단위로 잡아야 합니다.

  • 재고 락이면 skuId 단위: inv:{sku123}:lock
  • 주문 락이면 orderId 단위: order:{o987}:lock
  • 사용자 단위 제한이면 userId 단위: rate:{u55}:lock

즉, 해시 태그는 “같이 묶어야 하는 것만 묶고, 나머지는 흩뿌린다”가 원칙입니다.

Lua + 해시 태그를 같이 쓰는 설계 패턴

경합을 줄이는 실전 패턴은 보통 아래 형태로 수렴합니다.

  1. 임계구역에서 함께 다뤄야 하는 키들을 같은 태그로 묶는다
  2. 락 키는 hash로 만들고 Lua로 획득/해제/연장을 1 RTT로 만든다
  3. 실패 시 재시도는 지수 백오프 + 지터로 폭발을 막는다

예시: 주문 단위 락과 주문 상태 키를 같은 슬롯에 배치

키 설계:

  • 락: order:{orderId}:lock
  • 상태: order:{orderId}:state
  • 이벤트 중복 방지: order:{orderId}:dedup

이렇게 하면 해당 주문 처리에 필요한 Lua(또는 트랜잭션성 작업)를 한 노드에서 처리할 수 있습니다.

Lua에서 상태 갱신까지 함께 처리하기

락만 잡고 나서 상태를 업데이트하면, 결국 락 획득 후에도 추가 RTT가 생깁니다. 가능하면 “락 획득 성공 시 상태까지 갱신”을 한 번에 처리해 경합 창을 줄일 수 있습니다.

아래는 예시로, 락을 획득한 경우에만 상태 키를 HSET하고 TTL을 맞추는 형태입니다.

-- KEYS[1] = lock key (order:{id}:lock)
-- KEYS[2] = state key (order:{id}:state)
-- ARGV[1] = token
-- ARGV[2] = lock ttl ms
-- ARGV[3] = state field
-- ARGV[4] = state value
-- ARGV[5] = state ttl ms

local lockKey = KEYS[1]
local stateKey = KEYS[2]
local token = ARGV[1]
local lockTtl = tonumber(ARGV[2])

if redis.call('EXISTS', lockKey) == 0 then
  redis.call('HSET', lockKey, 'owner', token, 'count', 1)
  redis.call('PEXPIRE', lockKey, lockTtl)
else
  local owner = redis.call('HGET', lockKey, 'owner')
  if owner ~= token then
    return 0
  end
  redis.call('HINCRBY', lockKey, 'count', 1)
  redis.call('PEXPIRE', lockKey, lockTtl)
end

redis.call('HSET', stateKey, ARGV[3], ARGV[4])
redis.call('PEXPIRE', stateKey, tonumber(ARGV[5]))
return 1

주의할 점은 명확합니다.

  • KEYS[1]KEYS[2]는 반드시 같은 슬롯이어야 합니다.
  • 그래서 order:{orderId}:lock / order:{orderId}:state 처럼 동일 태그를 강제합니다.

이 방식은 “락을 잡았는데 상태 갱신이 늦어져서 다른 워커가 재시도 폭발” 같은 상황을 줄이는 데 효과가 큽니다.

클라이언트 구현 팁: EVALSHA 캐시 + 백오프

Lua를 운영에서 쓸 때는 EVAL을 매번 보내기보다 SCRIPT LOADEVALSHA로 호출하는 편이 일반적입니다.

Node.js (ioredis) 예시

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

const acquireLua = `
local key = KEYS[1]
local token = ARGV[1]
local ttl = tonumber(ARGV[2])

if redis.call('EXISTS', key) == 0 then
  redis.call('HSET', key, 'owner', token, 'count', 1)
  redis.call('PEXPIRE', key, ttl)
  return 1
end

local owner = redis.call('HGET', key, 'owner')
if owner == token then
  redis.call('HINCRBY', key, 'count', 1)
  redis.call('PEXPIRE', key, ttl)
  return 1
end

return 0
`;

const acquireSha = await redis.script('LOAD', acquireLua);

async function sleep(ms) {
  return new Promise(r => setTimeout(r, ms));
}

function jitter(baseMs) {
  return Math.floor(baseMs / 2 + Math.random() * baseMs);
}

export async function acquireLock(orderId, token, ttlMs, maxWaitMs = 2000) {
  const lockKey = `order:{${orderId}}:lock`;
  const start = Date.now();
  let backoff = 10;

  while (Date.now() - start < maxWaitMs) {
    const ok = await redis.evalsha(acquireSha, 1, lockKey, token, String(ttlMs));
    if (ok === 1) return true;

    await sleep(jitter(backoff));
    backoff = Math.min(backoff * 2, 200);
  }
  return false;
}

여기서 경합을 줄이는 포인트는 백오프입니다.

  • 고정 sleep(5ms) 같은 방식은 동시성 높을 때 락 키를 “동기화된 주기”로 두드려서 더 악화됩니다.
  • 지수 백오프 + 지터는 요청을 분산시켜 Redis와 애플리케이션 모두의 스파이크를 줄입니다.

이 접근은 외부 API 재시도에도 동일하게 적용되며, 재시도 설계는 OpenAI 429·rate_limit 재시도·백오프 설계 가이드에서 더 체계적으로 다룬 적이 있습니다.

해시 태그 튜닝 체크리스트

해시 태그는 “원자성 확보”와 “부하 분산” 사이에서 균형을 잡는 작업입니다.

1) 태그 범위를 최소화

  • 주문 처리면 {orderId}
  • 결제면 {paymentId} 또는 {orderId}
  • 재고면 {skuId}

“같이 처리해야 하는 키들”만 같은 태그로 묶고, 그 외는 분리합니다.

2) 태그가 핫스팟이 되는지 관측

  • 특정 태그 값에 트래픽이 몰리면 해당 슬롯이 있는 노드가 과부하
  • 키스페이스 이벤트나 모니터링에서 특정 prefix가 과도하게 등장

가능하면 락을 “정말 필요한 곳”에만 두고, 임계구역을 짧게 유지하세요.

3) 락 키 TTL과 워커 처리 시간의 관계를 명시

  • TTL이 너무 짧으면 작업 중 만료로 동시 실행이 발생
  • TTL이 너무 길면 장애 시 복구가 느림

실무에서는 작업 시간 P99를 기준으로 TTL을 잡고, 장시간 작업이면 주기적 연장(heartbeat)을 넣습니다. 연장도 Lua로 1 RTT로 만들 수 있습니다.

운영에서 자주 터지는 함정과 대응

함정 A: 락 해제에서 토큰 검증을 빼먹음

토큰 검증 없이 DEL하면 다른 워커의 락을 지워버릴 수 있습니다. 반드시 Lua로 소유자 확인 후 해제하세요.

함정 B: Redis Cluster에서 키 태그 불일치로 CROSSSLOT

Lua에서 KEYS를 2개 이상 쓰는 순간, 키가 같은 슬롯이 아니면 실패합니다. 키 설계 단계에서 {...} 태그를 먼저 확정해야 합니다.

함정 C: 재시도 폭발로 다운스트림까지 연쇄 장애

락 획득 실패가 곧바로 재시도로 이어지면, Redis뿐 아니라 DB/메시지큐에도 연쇄적으로 부하가 전달됩니다. 이 문제는 분산 트랜잭션/사가에서도 자주 재현되며, 중복 실행과 보상 처리까지 고려하면 복잡도가 급상승합니다. 관련해서는 MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결 글도 함께 보면 설계 감이 빨리 잡힙니다.

결론: 경합을 줄이는 가장 현실적인 조합

Redis 락 경합을 줄이는 데 가장 즉효인 조합은 다음입니다.

  • Lua로 락 획득/해제/연장을 원자화해서 요청당 비용과 레이스를 제거
  • Redis Cluster 해시 태그로 필요한 키만 같은 슬롯에 배치해 원자성을 확보하되, 태그 범위를 최소화해 핫스팟을 피함
  • 지수 백오프 + 지터로 재시도 폭발을 제어

락은 결국 “동시성의 비용을 어디서 치를지”를 정하는 장치입니다. Lua와 해시 태그 튜닝은 그 비용을 Redis와 네트워크에서 덜어내고, 시스템 전체를 더 예측 가능하게 만드는 데 가장 실용적인 방법입니다.