brunch

You can make anything
by writing

C.S.Lewis

by 에디의 기술블로그 Feb 15. 2020

레디스 클러스터
Mget 명령은 어떻게 동작하는가?

스프링부트, Lettuce 를 사용해서 Mget 동작방식 분석

이 글에서는, 레디스 클러스터 환경에서 Mget 명령으로 다수키 조회가 어떻게 동작하는지 검토한다. 스프링부트 및 Lettuce 라이브러리를 사용하였고, 성능 모니터링을 위해서 그라파나, 프로메테우스 등을 연동하였다. 애플리케이션, 레디스, 프로메테우스 등 모든 인프라 환경은 쿠버네티스에서 실행한다. 


개인PC에서의 테스트 결과이기 때문에, 실제 서버에서는 다른 결과가 나올수 있다.  


Overview

오랫만에 작성하는 기술블로그 포스팅이다. 필자는, 기술블로그 운영에 대한 실망(회의감)으로, 올해부터 브런치에 글쓰는 것을 자제하고 있다. 글 쓰는 시간이 아깝고, 투자 시간 대비해서 나에게 남는게 많지 않고, 브런치가 기술블로그에 적합하지 않다는 점 등 이런저런 이유로 인해서 브런치를 그만할려고 했다. 하지만, 어쩌다 보니 글을 또 쓰게 되었다. 주말 반나절을 투자해서 작성했지만... 투자한 시간 대비 허접하고 드럽게 재미없는 글이 나왔다. 


관심있는 개발자는 억지로라도 재미있게 읽어주길 바라며... 잘못된 내용은 피드백을 해주길 바란다. 



최근 발생한 서비스 운영 이슈

최근 회사에서 대용량 트래픽으로 인한 서비스 장애 이슈가 발생하였다. 동시접속자의 급증으로 서버로 유입되는 트래픽이 임계치를 넘었고 일부 시스템이 장애가 발생하였다. 사실, 장애가 발생했을 때 특정 원인으로 인해서 장애가 전파되어, 시스템 전반적으로 문제가 발생하는 경우도 발생한다. 하지만, 순간적으로 대용량 트래픽이 유입되면, 시스템 문제가 동시다발적으로 발생하기 때문에, 특정 원인이 전반적인 시스템 장애의 범인(?)이라고 단정하기가 쉽지가 않다. 이 글에서 논하는 주제는, 다수의 원인 중 하나일 뿐이며, 해당 이슈가 시스템 전반적인 장애를 발생했다고 판단하기는 어려운 상황이다.


어쨋든, 회사에는 팀원들에게 내용을 공유하였고, 함께 대안을 검토 중인 상황이다. 외부에 공개되는 이 글에서는 아쉽지만 회사 비즈니스 관련 내용은 전혀 다루지 않을 예정이다. 회사 소스코드 또한 단 한줄도 포함되지 않는다. 회사 업무랑 전혀 상관없이, 일반적인 기술 중심 내용으로 신선하게 글을 작성하였다.  



레디스 클러스터

레디스는 Key-Value 기반의 NoSQL 오픈소스이다. IT업계에서 가장 많이 사용하는 대표적인 캐싱 솔루션이지만, 캐싱 용도 외에 다양한 용도로 사용이 가능하다. 심지어는 RDBMS의 대안으로 사용하는 회사도 있다.(바람직하지는 않지만...) 

레디스는 안정적인 시스템 운영을 위해서 두 가지 기능을 제공하는데 레디스 클러스터와 레디스 센티널이다. 아래 링크를 참고하길 바라며, 이글에서는 자세한 내용은 생략한다.

https://brunch.co.kr/@springboot/218

https://redis.io/topics/cluster-tutorial


레디스 클러스터에 대해서 간략하게 요약하면 아래와 같다. 


총 16384 슬롯에 데이터를 분산해서 저장한다. 클러스터 인프라를 처음 구축할 때 슬롯이 노드에 할당된다. 예를 들어서, 3개의 노드에 클러스터를 구축한다면, A 노드에는 0 to 5460, B 노드에는 5461 to 10922, C노드에는 10923 to 16383 슬롯이 할당된다. 노드의 개수가 많을 수록 한 노드에 할당받는 슬롯의 개수는 적어진다. 노드가 추가되거나, 삭제가 되면 슬롯을 재할당하는 리밸런싱이 필요할 수 있다.

특정 키가 어떤 슬롯에 저장되어야 하는지는 레디스의 기본 해싱룰에 의해서 정해진다. 일관되게 적용되기 때문에, 특정 키가 어떤 슬롯에 저장하는지, 조회해야하는지 어디서든 알수 있다. 

a 라는 키가 A 노드에 저장되었다고 가정하자. 만약 B 노드의 redis-cli 에서 a를 조회하면, A노드로 리다이렉트된다. 리다이렉트가 가능한 이유는, B노드에도 a키가 A 노드에 저장되어 있다는 사실을 알수 있기 때문이다. 


6대의 노드를 3마스터 - 3슬레이브 구조로 레디스 클러스터를 구축하였다. 아무 노드에 가서 cluster-nodes 명령어를 실행하면 어떤 노드에 몇번의 슬롯이 할당되어있는지 확인할 수 있다. 

특정 키가 몇번의 슬롯에 할당되는지 확인은 cluster keyslot 으로 가능하다.

"mykey"라는 키는 슬롯번호 14687 이기 때문에, 해당 키는 필자가 구축한 레디스 클러스터 인프라에서 3번마스터노드에 저장된다. 물론, 3번 마스터 노드에 6번 슬레이브 서버가 데이터 복제되기 때문에 데이터는 6번 슬레이브에서도 조회가 가능하다. 


이 글에서는 더이상 상세하게 설명하지 않겠다.


레디스 클러스터 구축해보기

레디스 클러스터를 구축하는 가장 간단한 방법은 쿠버네티스에 실행하는 방법이다. 도커 이미지로 직접 설정해도 되고, helm 차트를 사용해도 된다. 하지만, 쿠버네티스에 익숙하지 않다면 조금 어려울 수 있다. 자세한 방법은 이글에서 생략한다. 필자는 쿠버네티스에 6개의 노드(쿠버네티스 pod)를 실행하였다. 

클라우드 플랫폼에 구축해보는 것도 좋은 방법이다. 하지만, 프리티어 계정이 아니라면 유료이기 때문에 조금 부담스럽다. 또한, 프리티어는 구축에 제한이 있기 때문에 다소 불편함이 있다. 그래도 추천한다면 구글 클라우드 플랫폼이 나이스하게 쿠버네티스 환경으로 구축할 수 있다. 개인적으로 AWS, GCP등 다 사용해봤지만, 쿠버네티스 구축은 GCP(구글 클라우드 플랫폼)이 쓸만하다. 하지만, 프리 계정은 CPU 제한으로 인해서 많은 테스트를 할 수는 없다. 

6개의 노드가 실행중이며 redis-cluster 0~2 는 마스터 노드, 3~5는 슬레이브 노드이다. 

레디스 실시간 모니터링을 위해서 Prometheus 와 Grafana 를 연동하였고 쿠버네티스에서 실행하도록 설정하였다.

레디스 모니터링

추가로, 레디스 모니터링 외에, 스프링부트 Actuator 모니터링을 위한 그라파나 설정도 추가하였다. 

스프링 부트 모니터링


이번에 쿠버네티스에 레디스 인프라, 프로메테우스, 그라파나를 구축하고 스프링부트 앱을 배포해봤는데, 처음임에도 어렵지는 않았고, 생각보다는 괜찮아서 조금 감동하였다. 


쿠버네티스를 좀 더 깊게 공부를 더 해야겠다는 생각이 들었지만, 

기본적인 자바 프로그래밍도 잘 못하는 상황이라서 정신차리고 내일부터는 자바 공부를 해야겠다. 



테스트 클라이언트 애플리케이션

레디스 클러스터에 연동하는 스프링부트 애플리케이션은 심플하게 구축하였다. 역시 쿠버네티스에 올려서 실행한다. 


디펜던시

스프링부트 2.1.12.RELEASE 버전으로 생성한다.

RedisTemplate 를 사용하기 때문에, 스프링 데이터 레디스 디펜던시를 추가한다. 또한, 모니터링을 위해서 Acutator 및 프로메테우스 디펜던시를 추가한다. 

디펜던시에는 빠져있지만, 스프링부트 디펜던시에서 레디스 Lettuce 라이브러리를 기본으로 사용하게 된다. RedisTemplate 는 내부적으로 Lettuce 라이브러리를 사용한다. 이 글에서는 스프링 데이터 레디스는 자세하게 다루지 않고, Lettuce 에 집중해서 소개할 예정이다. 스프링 데이터 공통 프로젝트에서 제공하는 어노테이션 또는 추상화 메서드는 전혀 사용하지 않을 예정이다.


@RedisHash 어노테이션 또는 findAll, save 등의 스프링 데이터 추상화 메서드는 사용하지 않는다.


테스트 샘플 데이터 저장

테스트를 위해서 10만건의 데이터를 저장한다. 색상정보를 저장하는 DTO 클래스를 정의한다. 

레디스 컨피그레이션 설정을 작성한다. 

레디스 기본쓰레드풀을 사용하며, 별도의 커스텀 풀설정을 추가하지는 않았다. Read From Slave(슬레이브 우선 조회)로 설정하였고, 1초의 타임아웃 시간을 설정하였다. 그리고, colorRedisTemplate 라는 RedisTemplate 를 정의하였다. 10만개의 테스트 데이터를 간단한 테스트코드를 실행해서 저장하였다. id 는 1~10만 으로 정의된다.

10만개의 데이터는 3개의 클러스터 마스터 노드에 고르게 분산되어 저장된다. 물론 3개의 슬레이브 노드에서 복제가 된다. 


데이터는 잘 저장이 되었다. redis-cli 에서 확인해봐도 좋다. 도커 컨테이너에서 직접 해봐도 되지만, 필자는 쿠버네티스 대시보드에서 해보겠다.

테스트 애플리케이션 역시 쿠버네티스에서 실행한다. 쿠버 대시보드에서 로그를 확인할 수 있다. 스프링 부트 애플리케이션이 아주 잘 실행되었다. 

추가로, 외부에서 접속 가능하도록 loadBalancer 타입으로 설정하였다. 

아주 간단한 RestController 을 구성하고, 서비스 클래스에서는 RedisTemplate 를 사용해서 mget 요청을 한다. 

다수(2개이상) 키를 조회하면 아래와 같이 간단한 JSON 데이터를 리턴하는 로직이다.


필자는 인프라 전문가가 아니라서, 쿠버네티스 사용에 익숙하지 않다. 혹시라도 잘못된 내용은 알려주길 바란다.


성능 테스트

자, 이제 성능테스트를 해보자. 간단하게 JMeter 에서 부하를 주겠다. 일단, 애플리케이션의 톰캣 쓰레드를 조금 높여보자. 필자의 기억이 맞다면 기본 쓰레드는 200일 것이다. 250으로 늘려서 테스트를 해보자. 


사실, 톰캣 쓰레드 설정만큼 중요한 사실은 레디스의 커넥션풀 설정이다.
레디스 풀 설정이 매우 중요하지만, 일단 레디스 풀 설정은 기본으로 하고 테스트를 하였다. 


성능테스트는 JMeter 를 사용하겠다. 250 동시접속자로 부하를 끝없이 요청하도록 했다.


참고로, JMeter 는 부하를 주는 용도로만 테스트할 예정이며, JMeter 의 성능 결과는 100% 믿지 못하겠다. 실무를 성능테스트를 할 경우가 많은데, 필자의 경험상 JMemter의 수치가 실제 서버에서의 동작 수치와 다른 경우가 꽤 많았다. 그래서 JMeter를 무조건 믿으면 안된다. 필자의 회사에서는 제니퍼 모니터링를 사용하지만, 업무랑 상관없는 이 글에서는 JMeter 결과를 가볍게 참고만 할 예정이다.  


필자의 노트북에서 테스트하기 때문에 실제 서비스 와는 다소 다른 결과가 나올 수 있다. 


두근두근..도키메키..

트래픽 부하를 주면 어떻게 될까?? (장애가 나긴 할텐데 어느정도 수준인지 모니터링 해보자) 


레디스 클러스터 마스터 노드

레디스 클러스터 중 마스터 노드 3대에는 큰 변화가 없다. 애플리케이션에서 Read From Slave 로 동작하기 때문에 모든 요청은 슬레이브 노드에 간다. 


레디스 클러스터 슬레이브 노드

슬레이브 노드에는 높은 트래픽이 발생한다. 

그라파나 - 레디스

mget 요청 역시 급증하였다. 

그라파나 - 레디스


애플리케이션 모니터링

많은 트래픽이 유입되면 액티브 쓰레드가 급증하게 된다. 당연한 얘기지만, 요청이 많으면 그만큼 애플리케이션이 많은 작업을 하게 된다. 

톰캣 쓰레드에서 설정한 250개의 쓰레드 전후의 성능을 보인다. 즉, 애플리케이션은 할수 있는 최선으로 열일했다. 그라파나 데이터가 이상하다고 생각된다면, 프로메테우스 대시보드에서 직접 보는것도 괜찮다. 프로메테우스 역시 간단하게 쿠버네티스에서 실행하고, 로컬에서 9090포트로 접속하게 설정하였다. 

프로메테우스는 자주 볼일이 없어서 필자가 잘 모른다. 대충 넘어가겠다. 

지금 보는 애플리케이션의 메트릭 정보는, 스프링 부트 Actuator 기능으로 수집된 데이터이다. 스프링 부트 Actuator 를 사용하기 때문에 http 접속으로 실시간으로 확인이 가능하지만, 별도의 저장소에 데이터를 수집해서 보는게 좋다.  


어쩃든, 트래픽 부하로 인해서 많은 액티브 쓰레드가 발생하였다. 쓰레드는 대부분 레디스 클러스터에 데이터를 조회하는 로직에서 사용할 것이다. 


또한, 모든 요청을 처리하지 못해서 애플리케이션에 장애가 많이 발생하는 상황이다.


어떤 로직에서 부하가 발생하는지 좀 더 자세히 살펴봐야겠다. 


쓰레드 덤프를 확인해보자

쓰레드 덤프를 확인하는 방법은 많다. 필자는 이번 글에서는 스프링부트 Actuator 에서 제공하는 쓰레드 덤프를 확인하였다. 쓰레드 덤프에서 액티브 쓰레드 대부분이 비슷한 로직에서 걸려 있는 것을 확인할 수 있다. 

필자는 Jstack 를 사용해서 분석하는 방법에 익숙하기 때문에 Jstack 명령어로 쓰레드 덤프를 보고 싶었는데, 도커 컨테이너 안에서 Jstack 명령어가 실행이 잘 되지 않았다. 방법을 아는 개발자는 알려주길 바란다...ㅠㅠ

구글링을 잠시 해보니 도커의 openjdk 때문이라는 글도 있던데... 귀찮아서 더이상 알아보진 않았다.


또한 스프링부트 Actuator 에서 제공하는 쓰레드 덤프를 분석해주는 도구가 있을 것으로 추측된다. 알고 있는 개발자는 알려주길 바란다. 필자는 눈으로 한줄한줄 보는 무식한 방법으로 진행하였다. 



어쨋든, 결론은 쓰레드 덤프 분석 결과, Lettuce 코어 라이브러리의 PipeRedisFuture 라는 녀석에서 걸린다는 것을 확인할 수 있다. 하지만, 쓰레드 덤프에 보이는 액티브 쓰레드가 정말 문제를 일으키는게 맞는지 확신할수는 없다. Lettuce 를 만든 개발자에게 직접 물어보고 싶지만, 영어를 잘 못해서 물어볼수가 없다. 


어쩔수 없지만, Lettuce 코어 라이브러리의 소스코드를 직접 까서 보도록 하자... 


블로그 포스팅을 짧게 작성하고 마무리할려고 했는데... 어쩌다 보니 글이 또 길어지고 있다...   


Lettuce Core 라이브러리

디버깅을 해가면서 소스코드 한줄한줄 따라가보았다. 소스를 따라가다 보면 Lettuce 라이브러리의 io.lettuce.core.cluster 패키지의 RedisAdvancedClusterAsyncCommandsImpl 클래스에 도착하게 된다. 

참고로, CompletableFuture 에 대해서 전혀 모르는 개발자는, 필자의 예전 글을 읽어보길 바란다. 

https://brunch.co.kr/@springboot/267

CompletableFuture 에 대해서 전혀 모른다면 이후 나오는 내용을 이해하지 못할 가능성이 100%이다. 

(필자 또한 개발 초보라서 이해가 잘 안된다.) 


어쨋든 클래스의 mget 메서드를 보자. 

한줄씩 읽어보자. 


(1) mget 요청을 하는 Keys (레디스 키의 리스트) 를 슬롯 기준으로 나누는 작업을 한다. 2개 이상의 키가 같은 슬롯에 저장되어 있다면 1개의 파티션에 같이 설정된다. 만약 100개의 키 중에서 딱2개만 같은 슬롯을 사용하고 나머지 98개의 키는 전부 다른 슬롯을 사용한다면 partitioned 의 size 는 몇이 될까? 정답은 99가 될 것이다. partition 하는 메서드는 SlotHash 클래스의 static 메서드로 제공한다. 

클라이언트(라이브러리)에서 레디스의 SlotHash 개념을 그대로 사용한다는 것을 알 수 있다. 즉, 특정 키가 몇번의 슬롯에 저장되는지 Lettuce 라이브러리(클라이언트 레벨)에서도 알 수 있다는 사실이다. 


(2) 만약 모든 키가 동일 슬롯에 할당되어 있다면, 바로 레디스 서버에 mget 요청을 보낸다.  


하지만, 애플리케이션에서 별도의 작업을 하지 않는다면, 키는 여기저기 수많은 슬롯에 분산되어서 저장이 될 것이다. 레디스 클러스터에서 슬롯의 개수는 16000개가 넘는다. 동일 슬롯에 저장될 가능성은 매우 낮다. 그래서 100개 키를 동시에 조회할 경우 100개의 키가 모두 다른 슬롯에 저장되어 있을 확률이 크다. 


(3) 데이터가 저장되어 있는 모든 슬롯 리스트로 설정하며, 실제 명령어를 수행하는 작업의 excutions 를 초기화한다. excutions 는 HashMap 자료구조에 RedisFuture 가 저장이 되는데, RedisFuture 는 인터페이스이고, 구현체로 AsyncCommand 라는 클래스로 구현된다. 해당 클래스는 CompletableFuture 를 상속받는다. 


중요!! 레디스에 요청하는 각 작업은 CompletableFuture 로 실행된다.


(4) executions 에 CompletableFuture 구현체를 저장한다. partitioned 개수만큼 셋팅하게 되는데, 만약 100개의 키가 모두 다른 슬롯이라면 100개의 CompletableFuture 구현체가 설정되게 된다. 


메서드를 계속 살펴보자. 쓰레드 덤프에서 보였던 PipelineRedisFuture 라는 인스턴스 객체를 생성하는 로직이 나온다. 


(5) 해당 생성자의 매개변수로 executions 와 함수형 인터페이스 매개변수를 전달한다. executions 는 CompletableFuture 의 조합으로 구성되며, ojbectPipelinedRedisFuture -> {} 로 전달된 구문 (6)은 executions 가 최종적으로 모두 정상적으로 수행이 된 이후에 실행이 되는 구문이다. 


그럼, PipelineRedisFuture 생성자 코드를 보자. CompletableFuture 의 allOf 메서드를 사용하였다. 

allOf 는 모든 CompletableFuture 가 종료될 때까지 대기하는 구문이다. 즉, 모든 CompletableFuture 가 실행이 될때까지 기다리는 로직이다. 모든 CompletableFuture 가 정상적으로 수행된 이후에 thenRun 이 실행이 되는데, 이때 converter 로 전달되는 함수형 매개변수가 실행이 된다. 위에서 설명한 (6) 이 실행된다.


(6) 은 레디스에 각각 요청한 mget 의 결과를 취합하는 작업으로 보인다. 



Lettuce 코드를 분석한 결과, 레디스 클러스터 환경에서의 mget 요청은 매우 주의가 필요하다. 이유는 같은 슬롯에 해당 되는 다수키만 동시에 조회할 수 있기 때문이다. 서비스(비즈니스)로직에서 mget 요청에 100개의 키를 보내지만, 100개 모두 다른 슬롯에 할당되어있다면 


내부적으로는 결국 mget 을 100번 요청하게 되는 꼴이다. 


mget "1", "2", "3", "4", "5" 이런식으로 보내는게 아니라

mget "1", mget "2", mget "3" 이렇게 개별적으로 요청하게 된다. 


물론, CompletableFuture 를 사용했기 때문에 해당 작업들은 병렬로 실행된다. 순차실행이었다면 아마 응답속도는 미친듯이 느렸을 것이다. Lettuce 에서는 CompletableFuture 를 사용해서 병렬프로그래밍을 매우 멋지게 구현한 것으로 보인다. 하지만, CompletableFuture 로 병렬 프로그래밍을 잘 구현했음에도 불구하고 결국에는 모든 CompletableFuture 가 끝날때까지 기다려야 한다. 


하나의 작업이라도 끝나지 않는다면 블록킹(대기)현상이 발생하게 된다. 쓰레드 덤프에서 확인했던 액티브 쓰레드가 해당 로직에서 대기중인것으로 확인이 된다. 


필자의 의견을 입증할 수 있는 다른 의견을 찾아보자. 스프링 문서를 보자.  

https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#cluster


When all keys map to the same slot, the native driver library automatically serves cross-slot requests, such as MGET. However, once this is not the case, RedisClusterConnection executes multiple parallel GET commands against the slot-serving nodes and again returns an accumulated result. This is less performant than the single-slot execution and, therefore, should be used with care. If in doubt, consider pinning keys to the same slot by providing a prefix in curly brackets, such as {my-prefix}.thing1 and {my-prefix}.thing2, which will both map to the same slot number. The following example shows cross-slot request handling


스프링 문서를 읽어보면, 

모든 키가 동일 슬롯에 저장되지 않는다면, 다중 병렬 Get 명령을 수행하고 누적 결과를 반환하게 된다. 단일 슬롯에 조회하는 것보다 성능이 안좋기 때문에, 주의해야 한다는 의견이다. 대안으로는 동일한 슬롯에 저장할 수 있도록 키를 설정하는 방법에 대해서 가이드를 제시하고 있다. 


실무에서는 동일 슬롯에 키를 저장할 수 있는 상황이 아니라면, 해당 방법은 대안이 되지 못한다.


대안

일단 당장 생각나는 방법은 아래와 같다. 


Mget 조회가 필요한 데이터를 동일 슬롯에 저장한다.

싱글노드(센티널) 인프라 환경으로 변경한다.


싱글노드 인프라 환경으로 구성하고, 클라이언트에서 해싱룰을 구현해서 데이터를 분산해서 저장한다면, 레디스 클러스터를 사용하지 않고, 효율적으로 인프라 구성이 될것으로 생각이 되지만, 이것도 쉽지 않은 작업이다. 



반전

같은 OS환경에서 싱글노드 인프라를 구성해서 동일한 부하테스트 진행하였으나, 기대했던 것보다는 성능이 좋지 않게 나왔다. 그래서 조금 실망하고 있다. 내가 뭘 잘못한 걸까? 내 가설에 문제가 있나? 반나절동안 뭐한거지? 

조심스러운 추측으로는, 아마도 필자의 노트북 성능이 250TPS 수준 까지만 가능한 상황이라서 성능 차이를 비교하기 어려운 상황인것 같다. TPS 5000 이상 유입되는 트래픽 환경에서는 싱글노드의 조합으로 구축한 레디스 인프라가, 레디스 클러스터 인프라보다는 성능이 좋을 것이라는 개인적인 생각이다. 


하지만 실제로 경험해보기 전에는 확답을 하기 어렵다. 회사에서 팀원들과 같이 고민을 해봐야겠다.



글 마무리

글을 급하게 마무리한다. 결론없는 글이라서 많이 아쉽지만... 오늘은 글을 그만 써야겠다.



해당 글은, 

회사 비즈니스 관련 내용은 전부 제외하였고 실제로 회사에서는 변수가 너무 많습니다. 

즉, 회사에서는 전혀 다른 결과가 나올 수 있습니다. 


추가로 여기까지 읽은 개발자는 거의 없겠지만 

혹시라도, 레디스 인프라 및 자바 백엔드 전문가가 있다면, 잘못된 내용에 대해서 피드백을 부탁드립니다...




브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari