Redis SETNX 분산 락 — TTL·소유권·원자적 해제
몇 분마다 외부 API 를 호출해 데이터를 받아 오는 작업을 앱 안에 스케줄러로 넣어 뒀고, 그 앱이 파드 세 개로 떠 있으면 스케줄러도 셋이 되니 같은 시각에 같은 작업이 세 번 돕니다.
알림을 보내는 작업이면 사용자가 같은 알림을 세 번 받고 정산이면 같은 건이 세 번 잡히니, 한 번만 돌아야 하는 작업에서는 그대로 사고가 됩니다.
파드 하나만 실행하게 하려면 서로 “내가 지금 한다” 를 알릴 자리가 필요한데, 파드들이 공통으로
보고 있는 게 Redis 라 여기에 락을 겁니다. 먼저 잡은 파드만 작업을 돌리고 나머지는 그냥 넘어가는
식이라 SETNX 하나면 되는 만큼 원리는 간단한데, 그에 비해 잘못 짜기 쉽습니다.
SETNX — 키가 없을 때만 SET
SETNX 는 “SET if Not eXists” 로, 키가 없을 때만 값을 넣습니다. 지금은 같은 이름의 명령보다 SET 의 nx 옵션을 쓰는 편인데, TTL 까지 한 번에 같이 걸 수 있기
때문입니다. 이걸로 “내가 먼저 락을
잡았다” 를 표현하고, 이미 있으면 다른 파드가 실행 중이라는 뜻이니 건너뜁니다.
acquired = redis_client.set(
lock_key,
lock_value,
nx=True, # 키 없을 때만 SET
ex=ttl_seconds, # TTL도 같이 (원자적)
)
if not acquired:
return # 다른 Pod가 실행 중 → 건너뜀
nx 와 ex 는 한 번의 set 으로 같이 줘야 하는데, SET 따로 EXPIRE 따로 하면 그 사이에 Pod 가
죽어서 TTL 이 안 걸릴 수 있습니다.
TTL이 없으면 데드락
락을 잡은 Pod 가 작업 도중 크래시하면 락 해제 코드가 안 돌아서 락이 영구히 남고, 그 뒤로는 아무 Pod 도 그 작업을 못 하는 데드락이 됩니다.
TTL 을 걸면 크래시해도 그 시간 뒤 락이 자동으로 풀리니, 작업이 아무리 길어도 안 넘길 시간(예: 5분)으로 잡습니다.
단순한 값이 남의 락을 해제
락 값을 그냥 "1" 같은 걸로 두면 이런 사고가 납니다.
파드 A가 락 획득 → 작업 중 멈춤(느려짐)
TTL 만료 → 락 자동 해제
파드 B가 락 획득 (같은 값 "1")
Pod A가 깨어나서 "내 락 해제" → 실제로는 Pod B의 락을 지움
파드 C가 락 획득 → 파드 B와 동시 실행
값이 같은 탓에 A 가 B 의 락을 자기 것으로 착각하고 지우니, 락 값에는 UUID 를 씁니다. 해제할 때 “이 값이 내가 넣은 UUID 인가” 를 확인하고 맞을 때만 지웁니다.
lock_value = str(uuid.uuid4()) # 내 락임을 식별
파드마다 다른 값이 들어가니 “이 락이 내 것인가” 를 물어볼 수 있게 됩니다.
다만 UUID 는 남의 락을 지우는 것까지만 막습니다. TTL 이 작업보다 먼저 끝나서 둘이 같이 도는 건 TTL 을 넉넉히 잡거나 하트비트로 연장해서 따로 다뤄야 합니다.
GET 후 DEL 사이의 경합
“내 락인지 확인하고 지운다” 를 이렇게 짜면 안 됩니다.
GET lock_key # 내 UUID 맞나 확인
DEL lock_key # 맞으면 삭제
GET 과 DEL 은 별개 명령이라, 그 사이에 다른 클라이언트가 값을 바꿀 수 있습니다. 확인은 통과했어도 지우는 순간엔 이미 남의 락일 수 있습니다.
Lua 스크립트로 묶으면 Redis 서버에서 원자적으로 실행돼서 확인과 삭제 사이에 아무도 못 끼어듭니다.
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
KEYS 로 락 키를, ARGV 로 내 UUID 를 넘깁니다. 값이 다르면 0 을 돌려주고 아무것도 안 지우니
남의 락은 건드리지 않습니다.
Redis 가 죽었을 때 — 멈출지 단독으로 돌릴지
락을 걸 Redis 자체가 죽으면 어떻게 할지도 정해 둬야 합니다. 락을 못 잡았을 때 모든 스케줄을 같이 멈출지, 아니면 그냥 단독으로 돌게 둘지입니다.
작업 성격에 따라 갈립니다. 정산처럼 두 번 돌면 안 되는 작업이면 멈추는 쪽이 맞고 다시 돌아도 결과가 같은 정리 작업이면 Redis 연결 실패 시 단독 실행을 허용하는 fallback 을 둘 수 있습니다.
어느 쪽이든 근본적으로는 Redis 를 Sentinel 이나 Cluster 로 HA 구성해서 장애 자체를 줄이는 게 먼저입니다.
락은 잡는 것보다 푸는 쪽이 어려움
SETNX 로 잡는 부분은 한 줄이라 헷갈릴 게 없고 문제가 생기는 자리는 전부 푸는 쪽입니다.
크래시로 못 푸는 경우를 TTL 이 막고, 남의 락을 푸는 경우를 UUID 가 막고, 확인과 삭제 사이에
끼어드는 경우를 Lua 스크립트가 막습니다.
직접 짠 분산 락을 볼 일이 생기면 이 세 가지가 다 있는지부터 확인하면 됩니다. set(nx=True, ex=...) 한 번으로 잡고 있는지, 락 값이 UUID 인지, 해제가 Lua 로 묶여 있는지입니다.
댓글 0