에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/캐시

[Redis] 분산 락 구현: SETNX, Lua 안전 해제, Redisson watchdog, Redlock 한계

반응형
[Redis] 분산 락 구현: SETNX, Lua 안전 해제, Redisson watchdog, Redlock 한계

[Redis] 분산 락 구현: SETNX, Lua 안전 해제, Redisson watchdog, Redlock 한계

서버가 한 대면 동시성은 자바 락으로 막아요. 그런데 서버가 여러 대면 그 락이 서버 안에서만 통해서 소용없어요. 두 서버가 동시에 같은 재고를 차감하면 음수가 나죠. 이때 모든 서버가 공유하는 락이 필요한데, 그걸 Redis로 구현한 게 분산 락이에요.

가장 단순한 SETNX 락에서 출발해, 안전 해제와 락 만료 문제를 짚고, 실무 표준인 Redisson을 거쳐, 마지막으로 단일 Redis 락의 함정(Redlock 논쟁)까지 따라가요. 원자성과 Lua는 트랜잭션과 Lua 스크립트 글을 먼저 보면 좋아요.

01. 가장 단순한 락, SETNX

분산 락의 기본은 "한 명만 키를 선점하게" 하는 거예요. SET key value NX EX가 이걸 해줘요. NX는 키가 없을 때만 설정하고, EX는 만료를 걸어요.

// 락 획득: 키가 없을 때만 성공
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent("lock:order:1", token, Duration.ofSeconds(10));

if (Boolean.TRUE.equals(acquired)) {
    try {
        // 임계 구역 — 한 명만 들어옴
    } finally {
        // 락 해제
        redisTemplate.delete("lock:order:1");
    }
}

키를 먼저 잡은 한 명만 true를 받아 임계 구역에 들어가요. 나머지는 false를 받고 기다리거나 포기해요. EX로 만료를 거는 게 중요해요. 락을 잡은 서버가 죽어서 해제를 못 해도, 만료되면 자동으로 풀려서 영원히 잠기는 걸 막아요.

02. SETNX의 함정, 남의 락을 풀 수 있어요

위 코드에는 위험한 버그가 숨어 있어요. 해제할 때 그냥 delete를 하는 부분이에요.

Redis 분산 락에서 남의 락을 푸는 사고 타임라인. 서버 A의 작업이 만료보다 길어져 락이 풀리고 B가 새로 잡았는데, 뒤늦게 끝난 A의 delete가 B의 락을 풀어 B와 C가 동시에 임계 구역에 들어간다. 해법은 토큰 확인과 삭제를 Lua로 원자화하는 것

서버 A가 락을 잡았는데 작업이 만료 시간보다 오래 걸렸어요. 그 사이 락이 만료되고 서버 B가 새로 락을 잡았어요. 그런데 뒤늦게 끝난 A가 delete를 하면, B의 락을 풀어버려요. 그러면 또 다른 서버 C가 들어와서 B와 동시에 임계 구역에 있게 돼요. 락이 깨지는 거죠.

그래서 해제는 "내가 건 락이 맞을 때만" 해야 해요. 토큰을 확인하고 지우는 건데, 이 "확인하고 지우기"가 원자적이어야 해요. 확인과 삭제 사이에 만료되면 또 같은 문제가 생기거든요. 그래서 Lua로 묶어요.

-- 내 토큰이 맞을 때만 삭제 (원자적)
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

이게 트랜잭션·Lua 글에서 본 "조건부 원자 연산"의 대표 사례예요. 분산 락의 안전 해제는 Lua 없이는 제대로 안 돼요.

03. 락 만료와 작업 시간

또 하나의 근본 문제는 만료 시간을 정하기 어렵다는 거예요. 너무 짧으면 작업 중에 락이 풀려서 다른 서버가 들어와요. 너무 길면 락 잡은 서버가 죽었을 때 그만큼 오래 잠겨 있어요.

작업 시간이 들쭉날쭉하면 적절한 만료를 정하기가 정말 어려워요. 이상적으로는 "작업하는 동안에는 락이 안 풀리고, 죽으면 곧 풀리는" 건데, 고정 만료로는 둘을 동시에 못 잡아요. 그래서 실무에서는 락을 자동으로 연장해주는 도구를 써요. 그게 Redisson이에요.

04. Redisson, 실무 표준

Redisson은 분산 락을 제대로 구현한 라이브러리예요. 위에서 본 문제들을 알아서 처리해줘요.

RLock lock = redisson.getLock("lock:order:1");
try {
    // 최대 5초 대기, 점유 후 자동 연장
    // tryLock은 InterruptedException을 던져요 — 호출부에서 처리
    if (lock.tryLock(5, TimeUnit.SECONDS)) {
        // 임계 구역
    }
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}
Redisson watchdog 동작. 락을 점유한 동안 백그라운드에서 만료를 자동 연장해 작업이 길어져도 락이 안 풀리고, 서버가 죽으면 연장이 멈춰 남은 만료 후 자동 해제된다

Redisson의 핵심은 watchdog이에요. 락을 잡고 있는 동안 백그라운드에서 만료를 자동 연장해줘요. 그래서 작업이 길어져도 락이 안 풀리고 서버가 죽으면 연장이 멈춰 곧 풀려요. 앞에서 본 만료 시간 딜레마를 해결하는 거예요.

단, watchdog은 leaseTime을 직접 지정하지 않았을 때만 동작해요. tryLock(wait, leaseTime, unit)으로 시간을 박으면 연장 없이 그 시간에 풀려요.

또 안전 해제와 재진입(같은 스레드가 락을 여러 번 잡기)도 지원해요. 내부 구현이 전부 Lua로 원자적으로 처리돼 있어요. 그래서 직접 SETNX로 짜기보다 Redisson을 쓰는 게 실무 표준이에요.

Redisson은 상황별 락도 줘요. 순서를 보장해야 하면 공정 락(getFairLock), 읽기는 동시에 쓰기는 단독으로 하려면 읽기-쓰기 락(getReadWriteLock)을 써요. 한 자원에 읽기가 많고 쓰기가 드물면 읽기-쓰기 락으로 읽기 동시성을 살릴 수 있어요.

락을 못 잡았을 때의 전략

락 기반 코드에서 정작 중요한 건 "락을 못 잡았을 때 어떻게 하냐"예요. 세 가지 중 하나를 정해요. 짧게 기다렸다 포기하고 에러를 주거나(tryLock 타임아웃), 잠깐 기다렸다 재시도하거나, 아예 안 기다리고 바로 다른 처리를 해요. 무한정 기다리면 그 자체가 장애로 번지니, 대기 시간 상한을 꼭 두는 게 좋아요.

05. 분산 락의 함정, Redlock 논쟁

여기서 멈추면 안 돼요. 분산 락에는 근본적인 한계가 있어요. 단일 Redis로 거는 락은 그 Redis가 죽으면 같이 사라져요. 복제본이 있어도, 락 정보가 복제되기 전에 마스터가 죽으면 락이 유실될 수 있어요.

이걸 풀려고 여러 Redis에 동시에 락을 거는 Redlock 알고리즘이 나왔는데, 이것도 안전하지 않다는 유명한 논쟁이 있어요(Martin Kleppmann의 비판이 대표적이에요).

GC pause와 fencing token. 락을 잡은 서버 A가 GC로 멈춘 사이 락이 만료되고 B가 새 락을 잡았는데, 깨어난 A는 자기가 락 주인인 줄 알고 쓰기를 시도한다. 저장소가 단조 증가하는 fencing token을 비교해 오래된 토큰의 쓰기를 거부해야 안전하다

핵심 지적은 이거예요. 락을 잡은 서버가 GC로 멈췄다 깨어나면, 그 사이 락이 만료되고 다른 서버가 락을 잡았을 수 있어요. 깨어난 서버는 자기가 여전히 락을 가졌다고 착각하고 임계 구역 작업을 해버려요. 락 시스템이 아무리 완벽해도, 락을 쓰는 쪽이 멈췄다 깨어나는 것까지는 못 막아요.

그래서 "락만으로는 정확히 한 번을 보장 못 한다"가 결론이에요. 정말 정합성이 중요하면 fencing token(락마다 증가하는 번호를 발급해, 저장소가 오래된 토큰의 쓰기를 거부)을 같이 써요. 분산 락은 만능이 아니라는 걸 알고 쓰는 게 중요해요.

06. 그래서 언제 쓰나요

락을 먼저 떠올리지 않는 게 핵심이에요. 분산 락은 비싸고 완벽하지도 않아서 "락 없이 풀 수 있나"부터 봐요. 재고 차감은 락 대신 Lua 원자 연산이나 DECR로 풀리고 중복 실행이 문제면 멱등성 설계가 더 단순해요. 원자 연산도 멱등성도 안 되는 "여러 단계를 한 서버만 순서대로 해야 하는" 경우에만 분산 락을 꺼내요.
  • 원자 연산으로 충분하면 — 재고 차감 같은 건 락 대신 Lua 원자 연산이나 DECR로 풀려요. 락보다 훨씬 가벼워요.
  • 멱등성으로 충분하면 — 중복 실행이 문제면 락보다 멱등성 설계가 단순해요.
  • 그래도 임계 구역이 필요하면 — 여러 단계를 한 서버만 순서대로 해야 하는 경우엔 분산 락을 써요. 단 위 한계를 감안해서요.

07. 자주 만나는 문제

락을 풀었는데 다른 작업이 깨져요

남의 락을 delete로 푼 거예요. 토큰 확인 후 삭제를 Lua로 원자적으로 하거나, Redisson을 써요.

작업 중에 락이 풀려요

만료가 작업 시간보다 짧은 거예요. Redisson의 watchdog 자동 연장을 쓰면 작업 동안 안 풀려요.

락이 영원히 안 풀려요

만료(EX) 없이 락을 건 거예요. 락 잡은 서버가 죽으면 영영 잠겨요. 반드시 만료를 같이 걸어요.

락을 꺼내기 전에

Redis 분산 락은 여러 서버가 공유하는 락이에요. SET NX EX로 선점하되 안전 해제는 토큰 확인과 삭제를 Lua로 원자화하고 만료 딜레마는 Redisson의 watchdog으로 풀어요. 다만 단일 Redis 락은 완벽하지 않아서(Redlock 논쟁), 정합성이 핵심이면 fencing token을 같이 써야 해요.

그래서 코드를 짜기 전에 어디로 풀지부터 갈라요. 재고 차감처럼 한 값을 조건부로 바꾸는 일이면 락 대신 Lua 원자 연산이나 DECR로 풀어요. 중복 실행이 문제면 멱등성 설계가 더 단순해요. 원자 연산도 멱등성도 안 되는 "여러 단계를 한 서버만 순서대로 해야 하는" 경우에만 분산 락을 꺼내요. 이 원자성의 뿌리는 트랜잭션·Lua에 있고 다음은 캐시가 동시에 만료될 때의 트래픽 폭주를 다루는 캐시 스탬피드예요.

출처: Redis — Distributed Locks · Redisson — Distributed Locks · Martin Kleppmann — How to do distributed locking

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."