Redis 분산 락을 믿어도 될까? 락 만료와 Race Condition 분석하기
분산 시스템에서 여러 서버나 프로세스가 동시에 동일한 자원에 접근하는 것을 막기 위해 Redis 분산 락을 도입하는 경우가 많습니다. 구현이 비교적 간단하고 Redis 자체의 단일 스레드 특성 덕분에 원자적 연산이 가능하다는 점이 매력적이죠. 하지만 현실 환경에서는 락이 충분히 안전하지 않을 수 있는 상황이 여럿 존재합니다. 이 글에서는 Redis 분산 락의 기본 동작 방식부터 시작해 락 만료(TTL)와 Race Condition이 어떤 방식으로 문제를 일으킬 수 있는지, 그리고 이를 어떻게 완화할 수 있는지를 구체적으로 살펴봅니다.
Redis 분산 락의 기본 동작 방식
Redis 분산 락의 가장 일반적인 구현 방법은 SET key value NX PX milliseconds 명령을 사용하는 것입니다. NX는 키가 존재하지 않을 때만 값을 설정하도록 하고, PX로 TTL(만료 시간)을 밀리초 단위로 지정합니다. 이 명령이 성공하면 락을 획득한 것이고, 실패하면 이미 다른 클라이언트가 락을 보유 중인 것입니다.
락을 해제할 때는 단순히 키를 삭제하는 것이 아니라, 자신이 설정한 값(주로 UUID 등 고유 식별자)을 확인한 뒤 삭제해야 합니다. 이 과정은 원자적으로 이루어져야 하기 때문에 보통 Lua 스크립트를 함께 사용합니다.
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
이처럼 구현 자체는 간결해 보이지만, 실제 운영 환경에서는 여러 가지 예외 상황이 발생할 수 있습니다.
락 만료(TTL)가 가져오는 문제
분산 락에 TTL을 설정하는 이유는 락을 획득한 클라이언트가 비정상 종료되거나 응답 불가 상태가 되었을 때 락이 영구히 점유되는 것을 막기 위해서입니다. 그런데 이 TTL이 오히려 Race Condition의 원인이 되기도 합니다.
작업 시간이 TTL을 초과하는 경우
가장 흔한 문제는 락을 보유한 클라이언트의 실제 작업 시간이 TTL보다 길어지는 경우입니다. 예를 들어 클라이언트 A가 TTL 5초로 락을 획득했는데, GC(가비지 컬렉션) 중단이나 네트워크 지연, 외부 API 응답 지연 등으로 인해 5초가 지나버리면 Redis는 락을 자동으로 삭제합니다. 이 시점에 클라이언트 B가 동일한 키로 락을 획득할 수 있게 됩니다. 문제는 클라이언트 A가 아직 작업 중이라는 점입니다. 결국 두 클라이언트가 동시에 임계 구역(critical section)을 실행하게 되는 상황이 발생합니다.
이런 상황은 JVM 기반 언어를 사용할 때 특히 조심해야 합니다. Stop-the-world GC는 애플리케이션 실행을 수 초씩 멈출 수 있기 때문입니다.
락 해제 시점의 혼선
또 다른 시나리오는 클라이언트 A가 TTL 만료 직후에 락 해제를 시도하는 경우입니다. 클라이언트 A는 이미 락이 만료된 상태임을 모르고 삭제 명령을 보내는데, 이때 클라이언트 B가 이미 새로운 락을 획득한 상태라면 클라이언트 A의 삭제 명령이 B의 락을 지워버릴 수 있습니다. 앞서 소개한 Lua 스크립트로 값을 검증하면 이 문제는 방지할 수 있습니다. 고유 식별자 값이 일치하지 않으면 삭제하지 않기 때문입니다.
Race Condition의 주요 시나리오
Redis 분산 락을 사용하더라도 Race Condition이 발생할 수 있는 상황은 여러 가지입니다.
단일 Redis 노드의 한계
단일 Redis 인스턴스를 사용하는 경우 Redis 자체가 장애를 겪으면 락 정보가 사라집니다. 만약 클라이언트 A가 락을 보유한 상태에서 Redis가 재시작되면, 다른 클라이언트들이 동일한 키로 락을 새로 획득할 수 있습니다. 클라이언트 A는 여전히 작업 중인데도 말이죠.
또한 Redis Replica 구조에서는 마스터 노드가 락을 설정한 직후 장애가 발생하면, 아직 복제가 이루어지지 않은 상태에서 레플리카가 마스터로 승격될 수 있습니다. 이 경우 레플리카에는 락 정보가 없으므로 다른 클라이언트가 락을 획득할 수 있습니다. 이것이 Redis 공식 문서에서도 설명하는 Master-Replica 기반 단일 구성의 한계입니다.
시계 왜곡(Clock Drift) 문제
분산 환경에서는 각 서버의 시스템 클락이 서로 미묘하게 다를 수 있습니다. Redis의 TTL은 Redis 서버의 시간을 기준으로 만료되지만, 클라이언트 측에서 락의 유효 시간을 계산할 때 자신의 로컬 시계를 참조한다면 오차가 생길 수 있습니다. 특히 여러 Redis 노드를 사용하는 환경에서 각 노드의 시계가 다르다면, 특정 노드에서는 락이 만료된 것으로 보이고 다른 노드에서는 아직 유효한 것으로 판단하는 상황이 발생할 수 있습니다.
네트워크 파티션
네트워크 파티션이 발생하면 클라이언트가 Redis에 락 해제 명령을 보냈음에도 Redis가 이를 수신하지 못하거나, 반대로 락 획득 명령이 실제로는 성공했지만 클라이언트가 타임아웃 응답을 받아 실패로 판단할 수도 있습니다. 이런 모호한 상황에서는 락의 상태를 정확하게 파악하기 어렵습니다.
Redlock 알고리즘과 그 논쟁
Redis 공식 문서에서는 단일 노드의 한계를 극복하기 위해 Redlock 알고리즘을 제안합니다. Redlock은 독립적인 Redis 인스턴스 여러 개(보통 5개)에 동시에 락을 획득하려 시도하고, 과반수 이상의 노드에서 성공했을 때 락을 유효하다고 판단하는 방식입니다.
그러나 Redlock 알고리즘 역시 완전하지는 않습니다. 분산 시스템 전문가인 Martin Kleppmann은 Redlock이 시계 동기화에 의존하며 GC 중단이나 네트워크 지연 상황에서 여전히 안전을 보장하지 못할 수 있다고 주장했습니다. Redlock의 창시자인 Salvatore Sanfilippo(antirez)는 이에 반론을 제시했고, 이 논쟁은 분산 락의 안전성에 관한 중요한 관점을 제공합니다.
결론적으로, 강한 일관성이 필요한 상황이라면 Redis보다 ZooKeeper나 etcd 같은 합의(consensus) 기반 시스템이 더 적합할 수 있습니다.
실용적인 완화 방법
완벽한 분산 락은 존재하지 않지만, 실제 운영 환경에서 안정성을 높이기 위해 적용할 수 있는 방법들이 있습니다.
적절한 TTL 설정과 갱신
작업 시간을 충분히 예측하여 TTL을 여유 있게 설정하는 것이 기본입니다. 만약 작업 시간이 가변적이라면 락 갱신(renewal) 메커니즘을 도입할 수 있습니다. 백그라운드 스레드가 주기적으로 락의 TTL을 연장하는 방식으로, Redisson 같은 라이브러리에서는 이를 watchdog 기능으로 제공합니다.
다만 락 갱신도 만능은 아닙니다. 애플리케이션이 완전히 멈춘 상태(GC, 데드락 등)에서는 갱신 스레드도 동작하지 않을 수 있기 때문입니다. TTL이 만료되어 다른 클라이언트가 락을 획득한 뒤에야 갱신 실패를 인지하는 경우도 있습니다.
고유 식별자로 락 소유권 검증
락을 설정할 때 반드시 UUID나 클라이언트 고유 ID를 값으로 저장하고, 해제 시 Lua 스크립트로 값을 검증한 뒤 삭제해야 합니다. 이 방법은 다른 클라이언트의 락을 실수로 해제하는 문제를 방지합니다.
멱등성(Idempotency) 설계
분산 락 자체만으로 완벽한 보호를 기대하기보다는, 락으로 보호되는 작업 자체를 멱등하게 설계하는 것이 중요합니다. 동일한 작업이 두 번 실행되더라도 최종 결과가 동일하게 유지된다면, 드물게 발생하는 Race Condition의 영향을 최소화할 수 있습니다.
예를 들어 재고 차감 로직에서는 조건부 업데이트(UPDATE ... WHERE stock > 0 AND version = :version 형태의 낙관적 락)를 함께 사용하면 데이터베이스 수준에서도 이중 방어가 가능합니다.
사용 목적에 맞는 도구 선택
분산 락이 필요한 목적을 명확히 구분하는 것도 중요합니다.
- 성능 최적화 목적: 중복 작업을 줄이기 위한 용도라면, 드물게 Race Condition이 발생해도 큰 피해가 없는 경우가 많습니다. Redis 분산 락으로 충분합니다.
- 정확성 보장 목적: 금융 거래, 재고 관리처럼 중복 실행이 절대 허용되지 않는 경우라면 Redis 분산 락만으로는 충분하지 않을 수 있습니다. ZooKeeper, etcd, 또는 데이터베이스 트랜잭션 기반의 락을 검토해야 합니다.
모니터링과 운영 고려사항
분산 락을 운영 환경에 도입할 때는 락 획득 실패 횟수, 락 대기 시간, TTL 만료 전 해제 비율 등을 모니터링하는 것이 좋습니다. 이상 징후를 조기에 파악하면 실제 Race Condition이 발생하기 전에 대응할 수 있습니다.
또한 락 키 네이밍 규칙을 명확히 정의하고, 락의 목적과 TTL 기준을 팀 내에서 문서화해두는 것이 장기적인 운영 안정성에 도움이 됩니다. 개발자마다 락 구현 방식이 다르면, 서로 다른 코드 경로에서 동일한 자원에 접근하면서 예기치 않은 충돌이 발생할 수 있습니다.
정리
Redis 분산 락은 올바르게 사용하면 충분히 실용적인 도구입니다. 하지만 TTL 만료로 인한 락 오버랩, 락 해제 시점의 소유권 혼선, 단일 노드 장애, 시계 왜곡 등 여러 취약 지점이 존재하며, 이를 이해하지 않은 채 사용하면 의도치 않은 Race Condition이 발생할 수 있습니다.
핵심은 Redis 분산 락이 어느 수준의 보장을 제공하는지 정확히 이해하고, 그 한계를 인식하면서 설계하는 것입니다. 락 갱신, 고유 식별자 검증, 멱등성 설계, 추가적인 데이터베이스 수준 방어 등을 함께 적용하면 대부분의 실제 서비스 환경에서 충분히 안정적인 동시성 제어를 구현할 수 있습니다.