- Published on
Redis 락 경합 줄이기 - Lua+해시태그 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 늘어나고 트래픽이 몰리면 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에러를 자주 냄
핵심 원인은 간단합니다.
- 락 경쟁이 심할수록 실패한 시도가 많아지고, 실패한 시도도 Redis 입장에서는 요청 처리 비용이 듭니다.
- 락 구현이
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는 키를 슬롯으로 매핑하고 슬롯을 노드에 분배합니다. 문제는 아래 두 가지입니다.
- 멀티키 연산(예:
EVAL에서 여러 키를 다루거나,MGET,MULTI에서 여러 키)이 서로 다른 슬롯이면CROSSSLOT에러가 납니다. - 락과 상태 키(예:
lock과data)를 함께 다뤄야 하는 경우, 키가 서로 다른 노드로 가면 원자적 처리 자체가 불가능해집니다.
해시 태그는 키 이름 중 {...} 구간만 해시 계산에 사용하게 만드는 기능입니다. 이를 이용하면 서로 다른 키라도 같은 슬롯으로 강제할 수 있습니다.
order:{123}:lockorder:{123}:state
위 두 키는 {123}이 동일하므로 같은 슬롯으로 라우팅됩니다.
튜닝 포인트: “너무 넓은 태그”는 핫스팟을 만든다
해시 태그는 편리하지만, 태그를 잘못 잡으면 경합이 더 커집니다.
- 나쁜 예:
lock:{global}:coupon처럼 모든 락을 한 슬롯에 몰아넣기 - 나쁜 예:
lock:{shop}:*처럼 태그 단위가 너무 커서 특정 샵 트래픽이 몰릴 때 단일 노드가 터짐
따라서 태그는 원자성이 필요한 최소 단위로 잡아야 합니다.
- 재고 락이면
skuId단위:inv:{sku123}:lock - 주문 락이면
orderId단위:order:{o987}:lock - 사용자 단위 제한이면
userId단위:rate:{u55}:lock
즉, 해시 태그는 “같이 묶어야 하는 것만 묶고, 나머지는 흩뿌린다”가 원칙입니다.
Lua + 해시 태그를 같이 쓰는 설계 패턴
경합을 줄이는 실전 패턴은 보통 아래 형태로 수렴합니다.
- 임계구역에서 함께 다뤄야 하는 키들을 같은 태그로 묶는다
- 락 키는
hash로 만들고 Lua로 획득/해제/연장을 1 RTT로 만든다 - 실패 시 재시도는 지수 백오프 + 지터로 폭발을 막는다
예시: 주문 단위 락과 주문 상태 키를 같은 슬롯에 배치
키 설계:
- 락:
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 LOAD 후 EVALSHA로 호출하는 편이 일반적입니다.
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와 네트워크에서 덜어내고, 시스템 전체를 더 예측 가능하게 만드는 데 가장 실용적인 방법입니다.