Redis Locking Patterns

Redis는 여러 application instance가 같은 Redis endpoint를 바라보게 만들 수 있기 때문에, 분산 환경에서 간단한 concurrency coordination 저장소로 자주 쓰인다. 대표적인 방식은 두 가지다.

방식 의미 허용 개수 주 용도
Mutex Lock 특정 resource를 한 실행 주체만 점유 1 중복 실행 방지, 같은 job/resource의 단일 처리
Semaphore 특정 resource에 대해 여러 permit을 관리 N 외부 API 호출 수 제한, worker 전체 동시 실행 수 제한

둘 다 "Redis를 source of truth로 삼아 작업의 진입 여부를 조율한다"는 점은 같지만, 보장하려는 모델이 다르다. Mutex는 exclusive ownership을 다루고, semaphore는 capacity를 다룬다.

Redis 명령어 먼저 보기

이 문서에서 쓰는 Redis 명령어는 많지 않다. 핵심은 "조건부로 key를 만들기", "TTL을 걸기", "여러 Redis 연산을 Lua script 하나로 묶기", "sorted set으로 permit 목록을 관리하기"다.

명령어 의미 이 문서에서의 용도
SET key value NX PX ttl key가 없을 때만 value를 저장하고 TTL을 건다 mutex acquire
GET key key의 현재 value를 읽는다 release/renew 시 owner token 확인
DEL key key를 삭제한다 mutex release
PEXPIRE key ttl key의 TTL을 millisecond 단위로 갱신한다 mutex renew
EVAL Lua script를 Redis 서버에서 실행한다 여러 명령을 atomic하게 묶기
ZADD key score member sorted set에 member를 score와 함께 추가한다 semaphore permit 추가
ZREM key member sorted set에서 member를 제거한다 semaphore release
ZCARD key sorted set의 전체 member 수를 센다 현재 permit 수 확인
ZREMRANGEBYSCORE key min max score 범위에 들어오는 member를 제거한다 만료된 permit 청소
TIME Redis server의 현재 시각을 가져온다 semaphore expiry 판정 기준 시각
INCR key 숫자 key를 1 증가시키고 증가된 값을 반환한다 fencing token 발급

Redis는 단일 command 실행과 Lua script 실행 중에는 다른 command와 interleaving되지 않는다. 그래서 "만료된 permit 제거 -> 개수 확인 -> permit 추가"처럼 중간에 끼어들면 안 되는 절차는 Lua script 하나로 묶는다.

Mutex Lock

Mutex는 하나의 key가 존재하면 잠겨 있고, 없으면 비어 있다고 보는 방식이다.

SET lock:{resource} {owner_id} NX PX {ttl_ms}
  • NX: key가 없을 때만 설정한다.
  • PX: lock에 TTL을 둔다.
  • owner_id: lock을 잡은 주체를 식별하는 token이다.

owner_id는 pod 이름처럼 오래 유지되는 값이 아니라 lock acquire 1회마다 새로 만든 token이어야 한다. 같은 pod 안에서도 여러 요청이 같은 resource lock을 시간차로 잡을 수 있기 때문이다. 보통 pod_id + random_uuid 또는 단순 UUID를 사용한다.

TTL은 holder가 crash되었을 때 lock이 영원히 남는 상황을 막기 위한 safety net이다. 다만 TTL이 있다는 말은 작업이 오래 걸리거나 process pause가 길어지면 lock이 작업 중에 만료될 수도 있다는 뜻이다.

Release

release는 단순 DEL lock:{resource}로 처리하면 위험하다. 내가 잡았던 lock이 이미 만료되고, 다른 instance가 같은 key를 새로 잡은 뒤일 수 있기 때문이다.

따라서 release는 반드시 owner token을 비교한 뒤 지워야 한다.

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

renew도 같은 원칙을 따른다. 현재 key의 value가 내 owner_id일 때만 TTL을 연장해야 한다.

if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0

Watchdog

긴 작업에서는 application 쪽에서 watchdog timer를 돌려 lock TTL을 주기적으로 연장할 수 있다. 예를 들어 TTL이 30초라면 10초마다 renew script를 호출하는 식이다.

여기서 중요한 점은 watchdog loop 자체는 application thread/coroutine이 담당하고, Redis에 보내는 renew 연산은 위의 Lua script처럼 owner token을 검증해야 한다는 것이다. 단순 PEXPIRE lock:{resource} 30000은 위험하다. 이전 holder의 watchdog이 늦게 실행되어 이미 다른 holder가 새로 잡은 lock의 TTL을 연장할 수 있기 때문이다.

watchdog은 lock 유실 가능성을 낮추는 best-effort 장치다. GC pause, scheduler 지연, 네트워크 문제, Redis failover가 있으면 renewal이 늦어질 수 있으므로 최종 정합성은 fencing token이나 DB constraint로 보완해야 한다.

Semaphore

Semaphore는 하나의 resource에 대해 최대 N개의 holder를 허용한다. Redis에서는 보통 sorted set을 사용한다.

key: semaphore:{resource}
member: permit_id or owner_id
score: expiry timestamp

member도 acquire 1회마다 고유해야 한다. pod 이름만 member로 쓰면 같은 pod 안의 이전 작업이 나중 작업의 permit을 잘못 release하거나 renew할 수 있다.

acquire는 대략 다음 순서로 동작한다.

  1. 만료된 permit을 제거한다.
  2. 현재 살아 있는 permit 수를 센다.
  3. limit보다 작으면 새 permit을 추가한다.
  4. limit에 도달했으면 실패한다.

이 네 단계는 race condition을 피하기 위해 Lua script 하나로 묶어 atomic하게 처리하는 것이 일반적이다.

local t = redis.call("TIME")
local now_ms = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local expire_at_ms = now_ms + tonumber(ARGV[2])

redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", now_ms)
local count = redis.call("ZCARD", KEYS[1])
if count < tonumber(ARGV[1]) then
  redis.call("ZADD", KEYS[1], expire_at_ms, ARGV[3])
  return 1
end
return 0

여기서 ARGV[1]은 limit, ARGV[2]는 TTL(ms), ARGV[3]은 permit id다. 만료 판정에 필요한 현재 시각은 client가 넘기지 않고 Redis의 TIME 명령으로 얻는다. client 시계를 쓰면 instance 간 clock drift 때문에 어떤 client는 permit을 너무 빨리 만료시키고, 어떤 client는 이미 만료된 permit을 살아 있다고 볼 수 있다.

Redis 7에서는 script effects replication만 지원하므로 write script 안에서 TIME을 쓰는 제약이 크게 줄었다. 오래된 Redis 버전을 대상으로 한다면 TIME 같은 non-deterministic command와 Lua replication mode의 제약을 별도로 확인해야 한다.

release는 해당 permit member를 ZREM으로 제거한다. renew는 해당 member가 존재할 때 score를 새 expiry timestamp로 갱신한다.

Watchdog

Semaphore도 작업 시간이 permit TTL보다 길 수 있으면 watchdog이 필요하다. mutex와 마찬가지로 watchdog loop는 application에서 돌고, Redis에는 permit이 아직 존재할 때만 expiry score를 갱신하는 Lua script를 호출한다.

local exists = redis.call("ZSCORE", KEYS[1], ARGV[1])
if exists then
  local t = redis.call("TIME")
  local now_ms = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
  local expire_at_ms = now_ms + tonumber(ARGV[2])
  return redis.call("ZADD", KEYS[1], expire_at_ms, ARGV[1])
end
return 0

여기서 ARGV[1]은 permit id, ARGV[2]는 TTL(ms)이다. permit id가 아직 sorted set에 있을 때만 score를 새 만료 시각으로 갱신한다.

Semaphore watchdog도 best-effort다. renewal이 늦어 permit이 만료되면 다른 worker가 새 permit을 가져갈 수 있으므로, 같은 작업이 중복 실행되면 안 되는 경우에는 idempotency key나 작업 상태 전이를 별도로 둬야 한다.

둘의 차이

Mutex는 semaphore의 limit이 1인 특수한 형태처럼 볼 수 있지만, Redis 구현에서는 다르게 가져가는 경우가 많다.

  • Mutex는 단일 string key와 SET NX PX만으로 acquire가 가능하다.
  • Semaphore는 permit 여러 개의 개별 expiry를 관리해야 하므로 sorted set이 자연스럽다.
  • Mutex release는 owner token 검증이 핵심이다.
  • Semaphore acquire는 expired permit 정리와 count/add의 atomicity가 핵심이다.

Fencing Token

Redis lock의 owner_id는 Redis key를 안전하게 release/renew하기 위한 token이다. 하지만 보호 대상 리소스까지 안전하게 만드는 token은 아니다.

예를 들어 client A가 lock을 잡은 뒤 긴 GC pause에 걸렸다고 하자. 그 사이 TTL이 만료되고 client B가 lock을 새로 잡아 DB나 외부 시스템에 write했다. 이후 client A가 다시 깨어나 예전 작업을 계속 write하면, Redis 입장에서는 이미 A의 lock이 끝났더라도 보호 대상 리소스는 낡은 write를 받아버릴 수 있다.

이 문제를 막으려면 lock acquire가 성공할 때 단조 증가하는 fencing token을 함께 발급하고, 보호 대상 리소스가 더 낮은 token의 write를 거부해야 한다.

if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
  return redis.call("INCR", KEYS[2])
end
return nil

여기서 KEYS[1]은 lock key, KEYS[2]는 token sequence key다. 이 방식은 Redis가 token 순서를 발급하는 역할을 하고, 실제 안전성은 DB나 downstream service가 "이미 처리한 token보다 낮거나 같은 요청은 거부한다"는 규칙을 구현할 때 생긴다.

Redlock 논쟁

단일 Redis instance lock은 Redis failover나 replication lag 상황에서 강한 분산 합의 보장을 제공하지 않는다. Redis 공식 문서에는 여러 독립 Redis master 중 과반수에 lock을 획득하는 Redlock 알고리즘이 소개되어 있다.

다만 Redlock은 유명한 논쟁이 있다. Martin Kleppmann은 lease 기반 lock만으로는 GC pause, network delay, clock drift 상황에서 보호 대상 리소스의 안전성을 보장하기 어렵고, fencing token이 필요하다고 비판했다. Salvatore Sanfilippo(antirez)는 Redlock이 실용적인 failure model 안에서 유용하다고 반박했다.

실무적으로는 이렇게 정리하는 편이 안전하다.

  • 중복 실행이 가끔 허용되는 효율 목적 lock이면 단일 Redis lock도 충분할 수 있다.
  • 정확성이 중요한 write path라면 Redis lock만 믿지 말고 DB constraint, idempotency key, fencing token을 함께 둔다.
  • 강한 coordination이 필요하면 Redis보다 ZooKeeper, etcd, DB transaction 같은 더 강한 일관성 도구가 맞을 수 있다.

Acquire 실패와 대기 전략

lock이나 semaphore acquire에 실패했을 때의 처리 방식도 설계의 일부다.

전략 동작 적합한 경우
즉시 실패 acquire 실패를 caller에게 바로 반환한다 API에서 빠른 실패가 낫거나 caller가 retry 정책을 가진 경우
polling retry 짧은 sleep과 jitter를 두고 재시도한다 구현은 단순하지만 Redis 부하와 thundering herd에 주의
timeout 대기 정해진 시간까지만 재시도한다 사용자 요청처럼 무한 대기가 곤란한 경우
queue 기반 처리 Redis list/stream 등으로 작업을 queueing한다 fairness와 순서가 중요한 background work

기본 lock primitive가 blocking/fairness를 자동으로 제공한다고 가정하면 안 된다. FIFO fairness가 필요하면 lock key 하나만으로 해결하기보다 queue나 stream을 함께 설계하는 편이 낫다.

Lock Striping과의 관계

Lock Striping은 resource 전체를 하나의 global lock으로 막지 않고 여러 stripe로 나누어 contention을 줄이는 패턴이다. Redis mutex/semaphore도 key 설계를 통해 비슷한 효과를 낼 수 있다.

예를 들어 전체 시스템에 하나의 lock:job만 두면 모든 job이 직렬화된다. 반대로 lock:job:{tenant_id} 또는 semaphore:api:{tenant_id}처럼 resource key를 나누면 tenant별로 독립적인 concurrency limit을 줄 수 있다.

주의할 점

Redis 기반 lock은 편리하지만, 일반적으로 최종 정합성 보장 장치가 아니라 coordination 장치로 보는 편이 안전하다.

  • Redis TTL은 crash 복구용이지, 작업 완료를 보장하지 않는다.
  • renewal/watchdog은 best-effort다. GC pause, 네트워크 지연, Redis timeout, failover가 있으면 갱신이 늦을 수 있다.
  • 중요한 DB 변경은 unique constraint, optimistic locking, idempotency key 같은 DB 레벨 방어와 함께 사용해야 한다.
  • Redis memory policy가 eviction을 허용하면 active lock key가 사라질 수 있다. lock 용도 Redis는 noeviction을 고려한다.
  • release/renew는 항상 owner 또는 permit token을 검증해야 한다.
  • semaphore expiry 판정은 client wall clock보다 Redis server time을 기준으로 잡는 편이 안전하다.

언제 쓰나

Mutex가 적절한 경우:

  • 같은 사용자의 동일 요청 중복 처리 방지
  • 같은 batch job이 여러 instance에서 동시에 실행되는 것 방지
  • 특정 resource에 대한 exclusive update 보호

Semaphore가 적절한 경우:

  • 전체 pod를 합쳐 외부 API 호출을 최대 N개로 제한
  • 무거운 background job을 cluster 전체에서 N개만 실행
  • 특정 tenant/resource별 concurrent work 수 제한

참고


이 문서는 사용자가 제공한 설계 초안을 Redis 기반 mutex/semaphore 일반 개념 문서로 재정리한 것입니다. Written by Gemini 3.1-pro-preview via Gemini CLI Edited by GPT-5.5 Codex

🔒 Admin 로그인