fragile and resilient

Storage

[Redisson] Pub/Sub 기반 Redis Distributed Lock

Green Lawn 2025. 3. 13. 00:54

분산락(Distributed Lock)은 분산 환경에서 공유 리소스에 대한 동시 접근을 제어하기 위한 동기화 메커니즘이다. 이를 통해 임계 영역에서 발생할 수 있는 경쟁 조건(Race Condition)을 방지할 수 있다.
 
Redis를 활용한 분산 락 방법에는 대표적으로 Spin Lock 방식과 Pub/Sub 기반 Lock 방식이 있다. 
Spin Lock 방식은 Redis의 SETNX(Set if Not Exists) 명령어를 사용하여 락을 획득하는 방법이다. 락이 이미 존재하면 일정 시간 동안 락이 해제되기를 기다리면서 주기적으로 Redis에 요청을 보내 락 해제 여부를 확인한다. 구글링하면 많은 예제들이 존재하므로 어렵지 않게 구현할 수 있을 것이다.
 
해당 글은 Redis를 활용한 분산락 구현 방법에 대한 포스팅은 아니고, 
Redisson(Java, Netty 기반으로 구현된 레디스 클라이언트)의 Pub/Sub 기능을 활용한 Lock(RedissonLock) 구현 로직이 아이디어도 좋고 흥미로워서 정리하고자 한다.


Redisson 공식문서에 나와있는 Pub/Sub 에 대한 내용을 정리해보면 아래와 같다.

  • Pub/Sub 패턴은 Publisher와 Subscriber가 직접적으로 연결되지 않고, 특정 채널(Topic)을 통해 메시지를 주고받는 방식이다.
  • Pub/Sub 패턴에서는 메시지가 특정 토픽에 발행되며, 이를 구독하는 모든 구독자가 메시지를 받는다.

위 내용을 읽고 나면 기억 저편에 있던 Observer 패턴이 떠오른다.
Observer 패턴 또한 아래 코드와 같이 데이터를 사용하는 곳에서 데이터를 pull하는 방식이 아니라, 데이터 소유 주체에서 데이터를 사용하는 쪽으로 데이터를 push 하는 방식이기 때문이다.

public class ObserverPatternTest {

    static class IntObservable extends Observable implements Runnable  {

        @Override
        public void run() {
            for (int data = 1; data <= 10; data++) {
                setChanged();
                notifyObservers(data);
            }
        }
    }

    public static void main(String[] args) {
        Observer observer = new Observer() {

            @Override
            public void update(Observable o, Object arg) {
                System.out.println("observer1 data: " + arg);
            }
        };

        Observer observer2 = new Observer() {

            @Override
            public void update(Observable o, Object arg) {
                System.out.println("observer2 data: " + arg);
            }
        };

        IntObservable intObservable = new IntObservable();
        intObservable.addObserver(observer);
        intObservable.addObserver(observer2);

        intObservable.run();
    }
}

 
하지만 공식문서에서는 Pub/Sub 패턴과 Observer 패턴은 다르다고 설명하고 있다.
Observer 패턴에서는 주체와 옵저버가 서로를 인식하고 있는 반면, Pub/Sub 패턴에서는 발행자와 구독자가 서로를 인식하지 않아도 동작할 수 있기 때문에 더 결합도가 낮고 확장에 용이하다고 한다. 
Redis 명령어를 기반으로 이해해보면, 

PUBLISH world hello

world라는 채널에 메시지를 publish하면,

SUBSCRIBE world

world 채널을 구독하고 있는 여러 클라이언트은 hello라는 메시지를 수신받을 수 있는 것이다.
즉, 채널을 중심으로 메시지를 주고 받는 방식이다.


분산락 내용으로 돌아와서 Redisson의 RedissonLock은 위의 Redis Pub/Sub 기능을 활용하여 Lock 기능을 제공한다고 이해하면 된다. 
락을 점유하는 RedissonLock의 tryLock 메서드부터 보면 된다. 메서드가 길기 때문에 핵심적인 로직만 나눠서 살펴보겠다. 

tryAcquire 메서드의 반환된 ttl이 null 이면 lock 획득에 성공하는 것으로 나와있다.
ttl은 tryLockInnerAsync 에서 반환하고 있는데 Lua Script 실행 결과로 내용은 아래와 같다.

 

  1. 요청한 락 이름 키가 redis에 존재하지 않으면 락 생성 및 threadId 필드의 값을 1로 설정하고 null 리턴
  2. 같은 스레드가 요청한 경우에는 락 획득을 허용하고 TTL을 연장하고 null 리턴
  3. 다른 스레드에서 이미 락을 점유한 경우에는 남은 TTL을 반환

- > 정리하면 락 획득에 성공하면 null 리턴, 획득하지 못하면 남은 TTL 값을 리턴한다.
 
위에서 TTL을 리턴 받았다면(다른 스레드가 락 점유 중) 현재 스레드는 Lock Name에 해당하는 채널에 구독한다.

핵심 로직인 subscribe(threadId) 메서드를 따라가면 많은 로직들을 거치게 되는데, 몇 가지 중요한 부분만 정리하고자 한다.

 subscribe의 반환 값은 Lock 채널에 해당하는 RedissonLockEntry 을 CompletableFuture로 감싼 형태이다.

여기서 promise에는 LockPubSub 이 담기게 되는데, LockPubSub 내부의 onMessage 메서드를 보면 UNLOCK_MESSAGE(락 점유 해제)에 해당하는 메시지가 오면 latch Semaphore를 release 하는 것을 확인할 수 있다. 

 
중요한 내용이므로 해당 부분을 기억하고 다시 RedissonLock 클래스로 돌아가면, 
Lock 채널이 완료된 다음에는 while 문을 돌면서 Lock 해제를 기다리는 것을 알 수 있다.

여기서 빨강 박스 부분이 중요하다.
commandExecutor.getNow(subscribeFuture).getLatch() 여기서 latch는 위에서 설명했던 세마포어이다.
LockPubSub 에서 UNLOCK_MESSAGE(락 점유 해제)를 받으면 세마포어의 permit을 release하는 것을 볼 수 있었다.(value.getLatch().release();) 
 
이후 Semaphore 의 tryAcquire 을 호출하여 permit이 날때까지 ttl 만큼이나 waitTime만큼 대기하다가, 다시 while 문을 타면서 Lock 점유 시도를 하는 방식으로 동작하는 것이다.
 
정리해보면 아래와 같다.

  1. Pub/Sub 방식인 RedissonLock은 여러 스레드에서 동일한 Lock 을 점유하려고 하는 경우에는 Lock Name에 해당하는 채널을 생성하여 구독하고,
  2. Redis에 계속 Lock을 획득할 수 있는지 요청하는 것이 아니라 Semaphore 통해 구독 해제 메시지가 올 때까지 대기하거나 지정된 시간만큼(TTL 또는 waitTime) 해당 스레드가 스케줄링에서 제외되어 대기 상태(dormant)가 된다.

언뜻보면 Lock을 얻기 위해 Redis에 지속적으로 요청하는 Spin Lock 보다 당연히 레디스에 부하가 적겠다는 생각이 들 수 있지만,
어느 기술이 그렇듯 모든 상황에서 항상 좋은 것은 어렵기 때문에 운영하는 프로젝트의 특성을 고려하고 성능 테스트를 진행해 보아야 한다. 
 

정리

  • RedissonLock 은 Redis Pub/Sub 기능과 Semaphore, Completablefuture 를 활용하여 Redis에 가해지는 부하를 줄이고 비동기로 동작할 수 있도록 구현되어있다. > 이 부분에 대한 고민이 매우 재밌음!
  • Spin Lock 방식과 Pub/Sub 기반 Lock 방식 둘 중 더 좋은 것은? 운영하는 프로젝트의 특성을 고려하고 성능 테스트를 진행하여 선택해야 한다.

 
References
https://redisson.pro/docs
https://github.com/redisson/redisson/tree/master
https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/concurrent/Semaphore.java