Redis를 이용한 분산 락(Distributed Lock) 구현: 3대 플랫폼 실전 가이드
서버가 한 대라면 Java의 synchronized나 Go의 sync.Mutex 같은 메모리 락으로 동시 실행을 막을 수 있습니다. 하지만 서버가 여러 대로 늘어나면 각 서버의 메모리 락은 서로를 알지 못합니다. 이때 모든 서버가 함께 바라보는 Redis에 "지금 누가 작업 중인지"를 기록해 순서를 정하는 방법이 Redis 분산 락입니다. 이 글은 올바른 구현 방법과 함께, 분산 락이 보장하지 못하는 부분까지 차분히 정리합니다.
1. 비유로 이해하기
도서관 스터디룸 열쇠를 생각해 봅시다. 열쇠를 빌리면 이름표를 달고, 대여 시간(예: 2시간)이 지나면 자동으로 반납된 것으로 처리됩니다. 대여 시간이 있어야 열쇠를 들고 사라진 사람 때문에 방이 영원히 잠기지 않습니다. 이름표가 있어야 시간이 지나 다른 사람이 빌려 간 열쇠를 엉뚱한 사람이 반납해 버리는 일을 막을 수 있습니다. Redis 분산 락에서 대여 시간은 TTL, 이름표는 고유 토큰입니다.
2. 구체적인 시나리오
주문이 결제되면 외부 물류 API로 배송 요청을 보내는 작업이 있고, 서버 3대가 같은 주문 이벤트를 받을 수 있는 구조라고 가정합니다. 락이 없으면 같은 주문에 배송 요청이 여러 번 나갈 수 있습니다. 주문 번호로 락을 잡고, 락을 얻은 서버만 요청을 보내게 하면 중복을 크게 줄일 수 있습니다. 다만 뒤에서 설명하듯 락만으로 중복을 "완벽히" 막을 수는 없으므로 외부 호출 쪽에도 멱등 처리를 두는 것이 좋습니다.
3. 올바른 획득과 해제
Redis 공식 문서가 안내하는 단일 인스턴스 락의 기본형은 다음과 같습니다.
SET lock:order:1001 <무작위 토큰> NX PX 30000
NX: 키가 없을 때만 저장합니다. 이미 누가 잡고 있으면 실패합니다.PX 30000: 30초 뒤 자동 만료됩니다. 락을 잡은 서버가 죽어도 영원히 잠기지 않습니다.- 토큰: 요청마다 새로 만든 무작위 값입니다. 해제할 때 "내 락인지" 확인하는 데 씁니다.
예전에는 SETNX 후 EXPIRE를 따로 호출하는 코드가 많았지만, 두 명령 사이에 서버가 죽으면 만료 없는 락이 남습니다. 한 번의 SET ... NX PX로 처리해야 합니다.
해제할 때 그냥 DEL을 하면 안 됩니다. 내 작업이 TTL보다 오래 걸려 락이 이미 만료되고 다른 서버가 새로 잡은 상태라면, 남의 락을 지워 버리기 때문입니다. 그래서 "값이 내 토큰과 같을 때만 삭제"를 원자적으로 처리하는 Lua 스크립트를 씁니다. Redis 문서에 따르면 Redis 8.4부터는 DELEX key IFEQ 토큰 명령으로도 같은 일을 할 수 있습니다.
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
4. 언어별 예시
Go (go-redis v9)
var unlockScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0`)
func WithLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration, job func() error) error {
token := uuid.NewString()
ok, err := rdb.SetNX(ctx, key, token, ttl).Result() // SET key token NX PX
if err != nil {
return err
}
if !ok {
return ErrLockBusy
}
defer unlockScript.Run(context.Background(), rdb, []string{key}, token)
return job()
}
NestJS (ioredis)
const token = randomUUID(); // node:crypto
const ok = await this.redis.set(key, token, 'PX', ttlMs, 'NX');
if (ok !== 'OK') throw new ConflictException();
try {
return await job();
} finally {
await this.redis.eval(UNLOCK_SCRIPT, 1, key, token);
}
Spring Boot (Redisson)
val lock = redissonClient.getLock("lock:order:$orderId")
if (!lock.tryLock(3, TimeUnit.SECONDS)) throw LockBusyException()
try {
job()
} finally {
if (lock.isHeldByCurrentThread) lock.unlock()
}
Redisson은 대기 중인 요청에 pub/sub으로 해제를 알려 주고, 토큰 확인도 내부에서 처리합니다. tryLock(대기시간, 단위)처럼 임대 시간(leaseTime)을 생략하면 watchdog이 락을 가진 인스턴스가 살아 있는 동안 만료 시간을 계속 연장합니다(기본 30초). 반대로 tryLock(대기, 임대, 단위)로 임대 시간을 직접 지정하면 watchdog이 동작하지 않아 그 시간이 지나면 작업 중이어도 락이 풀립니다. 또한 RLock은 잠근 스레드만 해제할 수 있으므로, 코루틴처럼 실행 스레드가 바뀌는 코드에서는 주의해야 합니다.
5. 분산 락이 보장하지 못하는 것
TTL 기반 락에는 피할 수 없는 약점이 있습니다. 락을 잡은 서버가 GC 일시 정지나 네트워크 지연으로 TTL보다 오래 멈추면, 락은 만료되어 다른 서버에 넘어가는데 원래 서버는 이를 모른 채 작업을 이어 갈 수 있습니다. 또한 Redis 복제는 비동기이므로, 락을 기록한 마스터가 복제 전에 죽고 레플리카가 승격되면 락이 사라질 수 있습니다. Redis 문서도 이 장애 조치 시나리오를 안전성 위반 사례로 설명합니다.
여러 독립 Redis 노드의 과반수에서 락을 잡는 Redlock 알고리즘이 제안되어 있지만, Martin Kleppmann은 시간 가정에 의존하는 점과 fencing 토큰이 없다는 점을 들어 정확성이 중요한 용도에는 적합하지 않다고 비판했고, Redis 창시자 antirez가 반론을 낸 바 있습니다. Redis 문서 역시 정확성이 중요하다면 fencing 토큰을 구현하라고 권합니다. fencing 토큰은 락을 얻을 때마다 증가하는 번호를 받아, 데이터를 쓰는 쪽에서 더 작은 번호의 요청을 거절하는 방식입니다.
UPDATE shipment SET status = 'REQUESTED', lock_token = :token
WHERE order_id = :orderId AND lock_token < :token;
예를 들어 락을 얻을 때 Redis INCR로 번호를 함께 발급받고, 위처럼 DB에 쓸 때 조건으로 검사합니다. 멈춰 있다가 뒤늦게 깨어난 서버는 이미 더 큰 번호가 기록되어 있으므로 영향받은 행이 0이 되어 쓰기가 거절됩니다. 이 방식은 데이터를 저장하는 쪽이 번호를 검사할 수 있어야 한다는 전제가 있어, 외부 API처럼 검사할 수 없는 대상에는 그 API가 제공하는 멱등성 키를 함께 사용하는 것이 현실적입니다.
| 락의 목적 | 예시 | 권장 방식 |
|---|---|---|
| 효율 (중복 작업 줄이기) | 캐시 갱신, 리포트 생성 | 단일 Redis 락으로 충분 |
| 정확성 (데이터 손상 방지) | 잔액 변경, 재고 차감 | DB 트랜잭션·제약 조건 + fencing 토큰 |
6. 언제 쓰고, 언제 안 써도 되나
- 쓰기 좋은 경우: 여러 서버가 같은 외부 작업을 중복 실행하지 않게 하고 싶을 때, 캐시 스탬피드를 줄이기 위해 갱신을 한 서버에 맡기고 싶을 때.
- 안 써도 되는 경우: 보호 대상이 DB 행이라면 행 락이나 낙관적 락, 원자적 UPDATE가 더 단순하고 안전합니다. Redis가 없다면 DB 기반 분산 락도 선택지입니다.
7. 자주 하는 실수
- 토큰 확인 없이 DEL로 해제: 다른 서버의 락을 지울 수 있습니다.
- SETNX와 EXPIRE를 따로 호출: 중간에 장애가 나면 만료 없는 락이 남습니다.
- TTL을 작업 시간보다 짧게 설정: 작업 중에 락이 풀립니다. 작업 시간을 측정해 여유 있게 정하거나 연장 기능을 씁니다.
- 락을 유일한 안전장치로 사용: 중요한 쓰기에는 DB 제약 조건이나 멱등성 키를 함께 둡니다.
8. 정리
- 획득은
SET key token NX PX ttl한 번으로 합니다. - 해제는 토큰을 확인하는 Lua 스크립트(또는 Redis 8.4+의 DELEX)로 합니다.
- Redisson은 임대 시간을 지정하면 watchdog이 꺼진다는 점을 기억합니다.
- 분산 락은 "대부분의 경우" 중복을 막아 주는 도구이며, 정확성은 DB와 fencing 토큰으로 보완합니다.