본문으로 건너뛰기
개발 머꼬
개발 노트Redis
hohyeon.dev27

Redis 락을 DEL로 풀었다가 남의 락을 지운 이유

  • #Engineering Note
  • #Redis

문제 발생

배치가 중복 실행되지 않도록 Redis 락을 썼습니다.

const ok = await redis.set("lock:report", "1", "NX", "PX", 30000);
if (!ok) return;
await generateReport();          // 가끔 30초를 넘긴다
await redis.del("lock:report");  // 여기서 사고

리포트 생성이 30초를 넘긴 날, 두 프로세스가 동시에 돌았습니다.

원인 분석

락이 만료된 뒤의 DEL은 남의 락을 지웁니다.

  1. A가 락을 잡는다(만료 30초)
  2. A의 작업이 35초 걸린다 → 30초에 락이 자동 만료
  3. B가 락을 새로 잡는다
  4. A가 작업을 끝내고 DEL lock:reportB의 락이 사라진다
  5. C가 락을 잡는다 → B와 C가 동시에 실행

Redis 문서가 이 문제를 정확히 설명하며 해법을 제시합니다 — 고정 문자열 대신 추측할 수 없는 큰 랜덤 문자열(토큰)을 설정하고, DEL로 해제하는 대신 값이 일치할 때만 키를 지우는 스크립트를 보내라는 것입니다. 그 이유도 적혀 있습니다 — 만료 시간이 지난 뒤 클라이언트가 락을 해제하려다 나중에 락을 획득한 다른 클라이언트가 만든 키를 지우는 일을 막기 위해서입니다.

문서가 제시하는 해제 스크립트가 이것입니다.

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

해결 방안

  1. 토큰을 값으로 넣습니다.
const token = crypto.randomUUID();
const ok = await redis.set("lock:report", token, "NX", "PX", 30000);
  1. 해제는 Lua로 원자적으로 합니다. 문서 안내대로 EVAL <script> 1 lock:report <token> 형태로 호출합니다. "GET 해서 같으면 DEL"을 두 번의 명령으로 하면 그 사이에 똑같은 경합이 생깁니다.

  2. 만료 시간을 작업 시간보다 넉넉히 잡거나 갱신합니다. 작업이 길어질 수 있으면 진행 중에 주기적으로 TTL을 연장하고, 연장에 실패하면 작업을 중단합니다.

  3. 락이 없어도 안전하도록 만듭니다. 이 방식은 단일 인스턴스 기준이고, 문서 자신도 더 강한 보장이 필요하면 Redlock을 검토하라고 안내합니다. 그보다 나은 접근은 작업 자체를 멱등하게 만드는 것입니다 — DB unique 제약으로 중복 결과를 거부하면 락이 실패해도 데이터는 깨지지 않습니다.

  4. SET이 기존 TTL을 지운다는 점도 기억합니다. 문서 그대로 성공한 SET에서는 이전에 설정된 TTL이 폐기됩니다. 값을 갱신하면서 만료를 유지하려면 KEEPTTL이 필요합니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.