Distributed Lock을 구현하는 여러 방법과 우리의 선택
Neon • Backend Engineer
안녕하세요. 채널톡 백엔드 엔지니어 네온입니다. 😊 우리는 임계 구역의 상호 배제를 보장하기 위하여 Lock을 사용합니다. 임계 구역을 하나의 서버가 접근하는 경우 언어에서 제공하는 Lock 기능(Java의 경우 synchronized 등)을 활용할 수 있지만, 여러 대의 서버에서 임계 구역을 접근하는 경우에는 임계 구역의 상호 배제를 보장하는 다른 방법이 필요하며 이를 Distributed Lock(분산 락)이라고 부릅니다.
채널의 메시징 환경은 대부분의 경우 DynamoDB의 Optimistic locking으로도 무리 없는 구조로 구성되어 있지만 외부 메신저의 연동 과정에서 별도의 Distributed Lock이 필요한 상황이 발생하여, Distributed Lock을 구현하는 여러 방법을 찾아보고 우리의 상황에서 가장 적절한 방법을 선택하게 되었습니다.
일반적으로 Distributed Lock의 구현 방법은 크게 세 가지로 나뉩니다.
Redis SETNXEX 명령어 사용
SETNX는 key가 존재하면 SET이 실패하고, key가 없으면 SET이 성공합니다. 해당 명령어는 key가 존재하는지 확인, 존재하지 않으면 값을 설정하는 과정에서 원자성이 보장됩니다. 따라서 SET의 성공 여부를 Lock 획득의 성공 여부로 간주하면 Distributed Lock을 간단하게 구현할 수 있습니다. 여기에 각종 장애 상황으로 인한 Deadlock 방지를 위해, EX 명령어를 추가로 활용하여 Lock의 Timeout 설정도 가능합니다. 가장 무난한 구현 방식으로, 후술할 Publish/Subscribe 기능을 이용하여 락의 재시도를 최소화할 수 있어 성능과 안정성 두 마리 토끼를 잡을 수 있습니다.
SQL DB Lock을 활용
SELECT ~ FOR UPDATE 등의 row lock, 혹은 MYSQL의 경우 USER-LEVEL Lock 등을 활용하는 방법입니다. 대부분의 서비스에서는 RDB를 운용하고 있으므로, 별도의 Redis 인스턴스를 운용하고 있지 않을 경우 인프라의 부담 없이 활용해볼 수 있다는 장점이 있으나 lock timeout을 지원하지 않아 서버에서 별도로 timeout을 scheduling 해줘야 하는 등 관리포인트가 많고, spin lock을 활용해야 하여 SQL connection에 부담이 많이 가며 Redis에 비해서는 무겁다는 단점이 있습니다.
ZooKeeper 활용
Kafka 등에서 활용되는 분산서버 관리 시스템으로, 각 클라이언트의 Session 및 Heartbeat 체킹 등의 서버 로직을 응용하여 고가용성을 보장합니다. 결제 등 Lock의 성능보다 안정성이 매우 중요한 경우 고려할만한 방법입니다. 그러나 ZooKeeper 자체가 많이 무겁고, 성능 튜닝을 위한 러닝커브가 있으며(herd effect로 인하여 Lock을 경쟁하는 클라이언트가 많을 경우 성능이 크게 저하되어, 이를 위한 대책이 필요합니다), 고가용성을 보장하기 위해서는 필연적으로 ZooKeeper를 Clustering 해야하므로 별도 인프라가 필요하며 많이 무겁습니다.
채널팀은 이미 캐싱 및 소켓 용도로 Redis를 활용하고 있고, Distributed Lock 사용 예정 대상의 부하가 적지 않을 것으로 예상하여 Redis를 이용하여 Distributed Lock을 구현하기로 결정하였습니다.
저희는 SETNXEX와 더불어, Lock 획득을 실패했을 때 스핀락을 활용하는 대신, Redis Pub/Sub를 이용하여 Lock 획득 재시도를 최소화하여 Redis에 가해지는 부담을 최소화했습니다. 크게 tryAcquire, Release, Subscribe, Retry로 나누어집니다.
tryAcquire(Lock의 획득)
SETNX 명령어를 이용하여, Lock이 존재하는지 확인, 존재하지 않으면 획득하는 과정을 Timeout을 포함하여 원자적으로 실행합니다.
Key에 해당하는 value는 클라이언트마다 달라야 합니다.
별도의 value 구분이 없다면 Timeout된 Lock을 다른 클라이언트가 점유한 상황에서, 다른 클라이언트의 Lock을 해제할 수 있기 때문입니다.
Release(Lock의 해제)
value를 확인하여, 해당 클라이언트가 획득한 Lock이 맞으면 value를 제거합니다.
해당 key를 Subscribe하고 있는 클라이언트들에게 Lock의 해제를 알려주기 위해 메세지를 Publish합니다.
동시성 이슈를 피하기 위해 전부 원자적으로 실행되어야 하며, 이를 위해 LUA Script를 활용합니다.
Retry 및 Subscribe(Lock 획득 실패 시, Lock이 풀릴때까지 대기 후 재시도)
Lock을 기다리고 있는 클라이언트는 Publish된 메세지가 올 때마다 Lock 획득을 재시도합니다.
이때 Pattern Subscribe를 이용하여, 각 프로세스가 아닌 인스턴스마다 Connection을 유지하여 부담을 줄였습니다.
네트워크 및 인스턴스 장애, GC 등 발생시 Lock이 Timeout되어 Message가 누락될 수 있습니다.
따라서, lock timeout이 지나도 Message가 오지 않을 때는 Lock의 획득을 재시도합니다.
Single Redis에 의존하여 결함 허용성(Fault Tolerance)이 부족합니다.
일시적인 Timeout, 혹은 Redis 인스턴스의 장애로 Lock이 동작하지 않을 때 Lock 없이 실행할 건지, 실행을 안 할 건지, 혹은 일정 시간 이후 재시도 할 것인지에 대한 결정이 필요합니다.
그러나 결함 허용성을 위해서 Redis를 Clustering 하는것은 득보다 실이 크다고 판단하였습니다.
Master-Replica 구성 시, Master-Replica 동기화 중 Master에 장애가 발생하면 Lock이 유실되는 것은 동일합니다.
이를 해결하기 위하여 Redis Cluster에서 Distributed Lock을 활용할 때는 Redlock 알고리즘을 많이 활용하고 있습니다.
Redlock 알고리즘은,
현재 시간을 지정하고
n개의 Redis에 전부 lock 획득을 시도하여
Lock 자체의 Timeout 대비 짧은 시간 (5~50ms) 내에 과반수의 Redis에서 잠금이 획득되면, Lock이 획득된것으로 간주하고
과반수가 획득하지 못하면 전부 잠금을 해제합니다
Single Redis 대비 성능 손해가 극심하고, Redis Cluster들의 시스템 시간이 틀어지면 장애가 발생하는 메커니즘으로, 관리 포인트가 또 생기게 됩니다. 가용성이 중요한 상황이라면, Redis Cluster보다는 Zookeeper Cluster를 활용하는 것이 더 올바른 방향이고, Zookeeper는 많이 무겁습니다.
또한 순서 보장이 되지 않습니다. 순서 보장을 위해서는 추가적인 성능 희생이 필요하고, 현재 상황에서는 필요하지 않아 구현하지 않았습니다.
저희는 이미 Redis를 활용하고 있고, Distributed Lock에 부하가 적지 않을것으로 예상되어, Single Redis를 이용하여 Distributed Lock을 구현하였습니다. 그러나 모든 상황에서 Redis를 이용한 Distributed Lock이 최적은 아닙니다. 대부분의 엔지니어링이 그렇듯 상황에 따른 Trade-off가 중요하며, 인프라 운용 상황, 요구 가용성 및 예상 부하, 순서 보장 여부 등에 따라 최적의 구현 방법을 선택하시길 바랍니다!
We Make a Future Classic Product
채널팀과 함께 성장하고 싶은 분을 기다립니다