Published on

Redisson Lock, Redis Streams, RabbitMQ 에 대한 개인적인 정리

Authors
  • avatar
    Name
    이건창
    Twitter

Introduction

상황

동시성을 제어하기 위해 Redisson Lock을 활용해 분산락을 구현했다. 그러나 분산락을 사용하면 두 가지 문제점이 존재한다.

  1. 요청 순서를 보장하지 않는다.
  2. 외부 메서드를 호출해 데드락 가능성이 존재한다.

해당 문제를 해결하기 위해 Redis Streams, RabbitMQ direct-Reply-To를 사용해보려 한다.

문제

요청 순서를 보장하기 어려운 문제

Redisson Lock은 잠금을 획득하기 위해 대기한다. 이 때, 잠금 해제 신호를 받으려는 Subscriber 형태로 대기하게 되며 잠금 해제 신호를 받으면 잠금 획득을 시도하기 위한 경쟁이 시작된다. 다음은 두 개 작업이 동일한 잠금을 시도할 때 발생하는 상황이다.

이미지

두 개 작업만 비교했을 때 큰 체감은 안나지만 경쟁이 치열할 수록 잠금 순서는 보장하지 않는다.

외부 메서드를 호출해 데드락 가능성이 존재

문제를 해결하는 방법 중 제일 쉬운 방법은 문제를 예방하는 방법이다. 잠금 장치 획득 후 외부 메서드를 호출하는 순간 제어권을 잃게 되며 데드락 발생 가능성이 존재한다. 잠금 장치를 필요 이상으로 유지하면 동기화도 인한 성능 저하와 데드락의 원인이 된다.

원인

두 가지 고민점으로 접근했다.

  • 잠금 획득 대기를 시간 순으로 획득할 수 있도록 하면 되지 않을까?
  • 작업을 순차적으로 처리하면 잠금 장치 내에서 외부 메서드 호출을 걱정하지 않아도 된다.

첫 번째는 RedissonFairLock 을 적용하면 되지 않을까 생각들 수 있지만 RedissonFairLock 은 스레드 간 공정하게 락을 잡는 방법이지 작업의 순차성을 보장하지 않는다. 또한 RedissonLock 구현체를 사용하면 외부 메서드 호출 위험 해결 할 수 없다.

그래서 외부 메서드 호출 위험을 해결하기 위해 동시성 제어를 작업의 순서를 보장하는 일에만 적용하는 방법으로 구성했다. 구성하는 방법은 Redis streams, RabbitMQ direct-reply-to 를 사용했다.

해결

redis streams 사용

RedissonLock은 Lock 메커니즘으로 동시성을 제어하고 publisher-subscriber 패턴을 사용해 잠금 해제 신호를 알린다. request-reply 패턴은 producer-consumer 패턴으로 동시성을 제어하고 publisher-subscriber 패턴을 사용해 응답을 전달한다. 구현 방법은 다음과 같다.

  • producer-consumer, publisher-subscriber 활용한 request-reply 패턴 적용한다.
  • producer-consumer로는 redis streams, publisher-subscriber로는 redis pub/sub 사용한다.

전체 동작은 다음과 같다.

이미지

해당 방식은 아래 두 가지로 성능 차이가 발생한다.

  • producer-consumer 관리 방법에 따른 성능 차이
  • publisher-subscriber 관리 방법에 따른 성능 차이

producer-consumer 방식에서 mvc 라이브러리를 사용한다면 요청에 따라 대기하는 스레드 수와 레디스 커넥션 수가 늘어난다. 반면 webflux 라이브러리를 사용한다면 대기하는 스레드 수와 레디스 커넥션 수를 줄이고 효율적으로 자원을 분배할 수 있다.

mvcwebflux
drawingdrawing
drawingdrawing
drawingdrawing

요약하면 다음과 같다.

  • 레디스 커넥션 수를 71 -> 30 줄였다.
  • JVM time-waiting 스레드 수를 200 -> 10줄였다.
  • 평균 응답 시간이 5.28ms -> 1.55ms로 줄었다.
  • 최대 응답 시간이 268s -> 62ms로 줄었다.

또한 consumer 에서 작업하는 양에 따라 성능 차이가 발생한다. consumer 가 처리하는 작업 수가 1 개와 300 개인 경우 비교했다.

처리하는 작업 수 1 설정처리하는 작업 수 300 설정
drawingdrawing

요약하면 다음 결과가 발생했다.

  • 평균 응답 시간이 7.87s -> 0.55s로 줄었다.
  • 최대 응답 시간이 10.7s -> 7.60s로 줄었다.

publisher-subscriber 는 응답 채널 구성 방식에 성능 차이가 발생한다. 각 요청마다 pub-sub 채널을 운영하면 채널 연결할 커넥션을 열고 닫는 작업을 해야 한다. 반면 모든 요청을 동일한 채널을 사용하면 커넥션 수는 유지되지만 모든 subscriber 에게 메시지를 전달해야 한다.

하나의 커넥션 만 생성요청당 커넥션 생성
drawingdrawing
drawingdrawing

요약하면 다음과 같다.

  • 레디스 커넥션 수 중 pub/sub 수가 1 -> 35 개로 늘었다.
  • 레디스 커넥션 수 중 pub/sub 커넥션이 아닌 커넥션 수가 60 -> 34 개로 줄었다.
  • 평균 응답 시간이 7.49s -> 0.55s로 줄었다.
  • 최대 응답 시간이 20.1s -> 7.6s로 줄었다.

그럼 레디스 분산락과 해당 방식을 불특정 다수 거래자로 작업 처리했을 때 다음과 같은 차이를 확인했다.

Redisson LockRedis streams + Redis pub/sub
drawingdrawing
  • 평균 응답 시간이 0.40s -> 0.55s로 늘었다.
  • 최대 응답 시간이 3.6s -> 7.6s로 늘었다.

예상과는 다르게 성능이 더 떨어진 모습을 확인했다. 차이나는 이유는 Redis streams + Redis pub/sub 방식은 모든 요청에 순차 처리를 진행하고 있어 병렬 처리를 수행할 수 없기 때문이다. 반면 분산락은 동시성 제어 영역을 좁혀 연관 없다면 병렬 처리를 수행하고 있다.

그럼 특정 거래자 작업이 집중되면 어떤 현상이 발생하는지 파악했다. 작업은 간단하다. (발급처 -> B), (B -> C), (C -> 사용쳐) 순으로 거래를 진행한다. 그럼 발생할 문제는 B와 C의 잔액 계산시 데이터 누적으로 레이턴시 증가하는 현상과 특정 데이터 접근에 대한 경쟁이 치열하다.

Redisson LockRedis streams + Redis pub/sub
drawingdrawing
drawingdrawing
  • 평균 응답 시간이 7.83s -> 3.18s로 줄었다.
  • 실패 건수가 258에서 0 건으로 줄었다.

Redisson Lock 은 동시성 제어 영역을 좁혀 레이턴시를 줄이는 방법이라 특정 데이터 경쟁에 취약했다. 반면 Redis streams 는 모든 요청 순차 처리만 진행하기 때문에 특정 데이터 경쟁에 취약하지 않았다.

rabbitmq direct-reply-to 사용

rabbitmq 에서 제공하는 direct-reply-to 기능을 사용해 소비한 결과를 생산자에게 전달해 사용자에게 결과를 전파했다. 문서는 다음 문서 참고했다.

Spring amqp 버전 2.0 이전에는 각 요청에 대해 새 소비자를 생성하고 응답이 수신되거나 시간이 초과되면 소비자를 취소했지만, DirectReplyToMessageListenerContainer를 사용하여 소비자를 재사용할 수 있다. 응답을 전달하는 일은 달라지지 않지만 답장이 다른 발신자에게 전송될 위험이 없다는게 특징이다.AsyncRabbitTemplate 은 답장하는 경우 선택 옵션 없이 DirectReplyToContainer 사용한다.

전체 동작은 다음과 같다.

이미지

해당 방식은 다음처럼 성능 차이가 발생한다.

  • Sync Rabbit Template vs Async Rabbit Template 따른 성능 차이
  • Channel 수에 따른 성능차이

문서에서 Async 로직을 제공했다. 전송하고 반환 받는 경우 CompletableFuture 를 사용해 블록킹되는 상황을 줄일 수 있었다.

Sync Rabbit TemplateAsync Rabbit Template
drawingdrawing
drawingdrawing

요약하면 다음 결과가 발생했다.

  • 평균 응답 시간이 2.37s -> 2.33s로 줄었다.
  • 최대 응답 시간이 9.16s -> 5.93s로 줄었다.

그럼 레디스 분산락과 해당 방식을 불특정 다수 거래자로 작업 처리했을 때 다음과 같은 차이를 확인했다.

Redisson LockRabbitmq direct-reply-to
drawingdrawing
  • 평균 응답 시간이 0.40s -> 2.33s로 늘었다.
  • 최대 응답 시간이 3.6s -> 5.93s로 늘었다.

해당 방식도 마찬가지로 모든 요청에 순차 처리를 진행하고 있어 병렬 처리를 수행할 수 없기 때문에 분산락 성능을 따라갈 수 없다.

그럼 특정 거래자 작업이 집중되면 어떤 현상이 발생하는지 파악해보겠다. 작업은 마찬가지다. (발급처 -> B), (B -> C), (C -> 사용쳐) 순으로 거래를 진행한다.

Redisson LockRabbitmq direct-reply-to
drawingdrawing
drawingdrawing
  • 평균 응답 시간이 7.83s -> 8.57s로 줄었다.
  • 실패 건수가 258에서 4 건으로 줄었다.

성능 향상이 높다고는 못하지만 실패 건수는 줄었다. Rabbitmq request-reply 패턴에서 응답을 제공할 때 사용하는 채널 수가 200으로 한정되는 문제로 메시지 양이 일정 범위 넘어서면서 병목 현상이 발생한다. 채널 수를 제어하면 성능 상에서 일정 수준이상 높일 수 있어보인다.

결론

생각과는 다른 결과

잠금 순서가 보장되지 않아서 이슈가 발생해야 하는데 발생하지 않았다. 원인 파악해보니 Redisson Lock 사용시 인스턴스 하나만 운영하면 순서를 보장하기 때문이다. Redisson Lock은 치열한 경쟁을 막기 위해 세마포어를 활용해 순차적으로 구독하도록 구현한다.

private CompletableFuture<PubSubConnectionEntry> subscribe(PubSubType type, Codec codec, ChannelName channelName,
                                                           MasterSlaveEntry entry, ClientConnectionsEntry clientEntry,
                                                           RedisPubSubListener<?>... listeners) {
    CompletableFuture<PubSubConnectionEntry> promise = new CompletableFuture<>();
    AsyncSemaphore lock = getSemaphore(channelName);
    //...
    lock.acquire().thenAccept(r -> {
        //...
    });
    return promise;
}

일반 적인 세마포어는 락을 획득하기 위해 그 시점에서 대기하지만 AsyncSemaphore는 락을 사용하는 시점에 대기한다. 이 때 대기하는 작업은 listeners라는 Queue에 대기하며 자신 차례가 올때까지 대기한다.

public final class AsyncSemaphore {
    private final Queue<CompletableFuture<Void>> listeners = new ConcurrentLinkedQueue<>();
    //...

    public CompletableFuture<Void> acquire() {
        CompletableFuture<Void> future = new CompletableFuture<>();
        listeners.add(future);
        tryForkAndRun();
        return future;
    }
    //...
}

순서가 보장되지 않음을 파악하기 위해서는 멀티 인스턴스로 가용해야 한다. 멀티 인스턴스로 확인하지 않아 의도한 결과가 나오지 않았다.

변경 후 장단점 고민

이것저것 시도해봤지만 큰 결실이 없는 결과를 만들었다. 결국 동시성 제어는 동시성 영역을 좁혀 병렬처리를 높이는데 주안점을 둬야하고 그렇지 못한 경우는 시스템 성능에 좌우함을 경험했다. 구현하면서 다음과 같은 장단점도 발견했다.

  • 변경 후 장점
    • 특정 데이터에 경쟁이 많아져도 일정한 레이턴시를 유지한다.
    • 외부 메서드 호출 위험이 사라진다.
  • 변경 후 단점
    • 특정 데이터 경쟁이 없는 시점에는 분산락이 월등하게 빠르다.
    • 분산락 구현은 동시성 제어가 특정 계좌 두 개에 국한되지만 메시지 브로커 사용 이후 모든 요청이 순차 처리되어 레이턴시가 증가한다.
    • 분산락이 아닌 경우 동시성 제어 영역을 좁히기 위해서는 두 개 계좌에 한정해서 순차 처리를 보장하면 되는데 구현이 어렵다.

발생할 수 있는 이슈 고려

메시지 브로커를 사용하면 다음과 같은 이슈가 고려할 수 있었다. 메시지 생산 시점에서 발생하는 에러는 클라이언트에 전파할 수 있지만 소비하는 시점에 발생하는 에러나 중간 통신하는 부분에서 발생하는 에러는 어떻게 처리할지 고민 해볼 필요가 있다.

  • 메시지 전송되지 않은 경우
  • 메시지는 전송됐지만 처리되지 못한 경우
  • 메시지는 처리됐지만 응답이 전달되지 못한 경우
  • 메시지는 처리됐지만 응답 대기 시간을 넘어선 경우

응답이 전달되지 않은 방식과 마찬가지로 해당 요청이 정상적으로 처리됐는지 확인하거나 이전 요청을 롤백하는 방식을 강구해야 한다.