순수 자료구조로서의 Redis Streams
원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기
Redis 5에서 “Streams”라는 이름으로 도입된 새로운 Redis 자료구조는 커뮤니티에서 꽤 큰 관심을 불러일으켰다. 조만간 프로덕션 유스케이스를 가진 사용자들과 이야기를 나누며 커뮤니티 설문조사를 진행하고, 그 내용을 블로그에 정리하고 싶다. 오늘은 다른 이야기를 해보려 한다. 많은 사용자가 Streams를 Kafka(TM)와 유사한 유스케이스를 해결하는 수단으로만 생각하고 있다는 느낌이 들기 시작했다. 사실 이 자료구조는 생산자와 소비자를 전제로 한 메시징 환경에서도 동작하도록 설계된 것이 맞지만, Redis Streams가 그 용도로만 유용하다고 생각하는 것은 엄청나게 축소된 시각이다. 스트리밍은 시스템을 설계할 때 큰 성공을 거둘 수 있는 훌륭한 패턴이자 “멘탈 모델”이지만, Redis Streams는 다른 대부분의 Redis 자료구조와 마찬가지로 훨씬 더 일반적이며, 서로 전혀 관련 없는 수많은 문제를 모델링하는 데 사용할 수 있다. 그래서 이번 글에서는 블로킹 연산이나 컨슈머 그룹 등 메시징과 관련된 부분은 완전히 배제하고, 순수한 자료구조로서의 Streams에만 집중하려 한다.
Streams는 스테로이드를 맞은 CSV 파일이다
일련의 구조화된 데이터 항목들을 기록하고 싶은데 데이터베이스는 어차피 과하다고 판단했다면, 이렇게 말할 수도 있다. 그냥 파일을 append only 모드로 열고, 매 행을 CSV(Comma Separated Value) 항목으로 기록하자는 것이다.
(open data.csv in append only) time=1553096724033,cpu_temp=23.4,load=2.3 time=1553096725029,cpu_temp=23.2,load=2.1
간단해 보이고, 사람들은 예전부터 지금까지 이렇게 해왔다. 자신이 무엇을 하고 있는지 안다면 꽤 견고한 패턴이다. 하지만 이것을 메모리에 옮기면 어떻게 될까? 메모리는 append only 파일보다 훨씬 강력하며, 이런 CSV 파일이 가진 한계를 자동으로 제거할 수 있다.
- 여기서는 범위 질의를 하기가 어렵다(비효율적이다).
- 중복 정보가 너무 많다. 모든 항목에서 시간은 거의 동일하고 필드도 중복된다. 동시에 이를 제거하면 다른 필드 집합으로 바꾸고 싶을 때 포맷의 유연성이 떨어진다.
- 항목의 오프셋은 파일 내 바이트 오프셋에 불과하다. 파일 구조를 바꾸면 오프셋이 틀어지므로, 여기에는 진정한 의미의 기본 ID라는 개념이 없다. 항목들은 사실상 어떤 식으로든 고유하게 식별되지 않는 셈이다.
- 항목을 삭제할 수 없고, 로그를 다시 쓰지 않는 한 유효하지 않다고 표시만 할 뿐 가비지 컬렉션을 할 수 없다. 로그를 다시 쓰는 작업은 여러 가지 이유로 골치 아프며, 피할 수 있다면 피하는 것이 좋다.
그럼에도 이런 CSV 항목들의 로그는 어떤 면에서는 훌륭하다. 고정된 구조가 없고 필드가 바뀔 수 있으며, 생성하기도 매우 간단하고 전체적으로 꽤 간결하기 때문이다. Redis Streams의 아이디어는 이러한 장점은 유지하면서 한계를 극복하는 것이었다. 그 결과 Redis Sorted Set과 매우 유사한 하이브리드 자료구조가 탄생했다. 겉으로 보기에는 근본적인 자료구조처럼 느껴지지만, 내부적으로는 그런 효과를 내기 위해 여러 표현 방식을 함께 사용한다.
Streams 101 (이미 Redis Streams 기초를 알고 있다면 건너뛰어도 된다)
Redis Streams는 델타 압축된 매크로 노드들이 radix 트리로 연결된 형태로 표현된다. 그 효과는 임의의 항목을 매우 빠르게 탐색하고, 필요하면 범위를 가져오며, 오래된 항목을 제거해 크기가 제한된 스트림을 만드는 등의 작업을 할 수 있다는 것이다. 그럼에도 프로그래머에게 제공되는 인터페이스는 CSV 파일과 매우 유사하다.
> XADD mystream * cpu-temp 23.4 load 2.3 "1553097561402-0" > XADD mystream * cpu-temp 23.2 load 2.1 "1553097568315-0"
위 예시에서 볼 수 있듯이 XADD 명령은 항목 ID를 자동으로 생성해 반환하며, 이 ID는 단조롭게 증가하고 <time>-<counter> 두 부분으로 구성된다. time은 밀리초 단위이며, counter는 같은 밀리초 안에 생성된 항목들에 대해 증가한다.
따라서 “append only CSV 파일”이라는 아이디어 위에 추가된 첫 번째 추상화는, XADD의 ID 인자로 별표를 사용했기 때문에 서버로부터 항목 ID를 공짜로 얻는다는 점이다. 이 ID는 스트림 안의 특정 항목을 가리키는 데 유용할 뿐만 아니라, 항목이 스트림에 추가된 시간과도 연결되어 있다. 실제로 XRANGE를 이용하면 범위 질의를 수행하거나 단일 항목을 가져올 수 있다.
> XRANGE mystream 1553097561402-0 1553097561402-0
1) 1) "1553097561402-0"
2) 1) "cpu-temp"
2) "23.4"
3) "load"
4) "2.3"이 경우에는 단일 요소를 식별하기 위해 범위의 시작과 끝에 동일한 ID를 사용했다. 하지만 어떤 범위든 사용할 수 있으며, COUNT 인자로 결과 수를 제한할 수도 있다. 마찬가지로 범위로 전체 ID를 지정할 필요 없이, ID의 밀리초 단위 유닉스 시간 부분만 사용해 특정 시간 범위에 속한 요소들을 가져올 수도 있다.
> XRANGE mystream 1553097560000 1553097570000
1) 1) "1553097561402-0"
2) 1) "cpu-temp"
2) "23.4"
3) "load"
4) "2.3"
2) 1) "1553097568315-0"
2) 1) "cpu-temp"
2) "23.2"
3) "load"
4) "2.1"지금 당장 Streams API를 더 보여줄 필요는 없다. 그 부분은 Redis 문서를 참고하면 된다. 일단 지금은 XADD로 데이터를 추가하고 XRANGE(그리고 XREAD도)로 범위를 조회하는(하려는 일에 따라 다르다) 그 사용 패턴에만 집중하고, 왜 Streams가 자료구조로서 그토록 강력한지 살펴보자.
다만 Redis Streams와 그 API에 대해 더 알고 싶다면, 다음 튜토리얼을 꼭 확인하기 바란다: https://redis.io/topics/streams-intro
테니스 선수들
며칠 전 요즘 Redis를 배우고 있는 친구와 함께 애플리케이션을 모델링하고 있었다. 지역 테니스 코트와 선수, 경기를 추적하는 앱이었다. Redis에서 선수를 모델링하는 방법은 꽤 자명하다. 선수는 작은 객체이므로 Hash 하나면 충분하고, 키 이름은 player:<id> 같은 형식이면 된다. 애플리케이션 데이터를 더 모델링해 Redis를 주 저장소로 사용하려다 보면, 특정 테니스 클럽에서 치러진 경기를 추적할 방법이 필요하다는 것을 바로 깨닫게 된다. player:1과 player:2가 경기를 했고 player 1이 이겼다면, 스트림에 다음과 같은 항목을 쓸 수 있다.
> XADD club:1234.matches * player-a 1 player-b 2 winner 1 "1553254144387-0"
이 간단한 연산만으로 우리는 다음을 얻는다.
- 경기의 고유 식별자: 스트림 내 ID.
- 경기를 식별하기 위해 별도의 객체를 만들 필요가 없다.
- 경기를 페이지네이션하거나 과거 특정 시점에 치러진 경기를 확인하기 위한 범위 질의를 공짜로 얻을 수 있다.
Streams가 있기 전에는 시간 값을 점수로 하는 Sorted Set을 만들어야 했다. Sorted Set의 요소는 경기의 ID가 되고, 실제 데이터는 다른 키에 Hash 값으로 존재했다. 이는 단순히 작업량이 더 많은 것을 넘어, 엄청난 메모리를 낭비하는 방식이다. 생각보다 훨씬 더 많이 낭비한다(뒤에서 살펴본다).
지금 짚고 넘어갈 점은 Redis Streams가 일종의 append only 모드 Sorted Set이라는 것이다. 시간으로 키가 지정되며, 각 요소는 작은 Hash다. 그리고 그 단순함 속에 Redis 모델링 관점에서의 혁명이 있다.
메모리 사용량
위 유스케이스는 단순히 더 견고한 패턴의 문제가 아니다. 모든 객체마다 Sorted Set과 Hash를 함께 쓰던 기존 방식과 비교했을 때 Stream 솔루션의 메모리 비용은 너무나 달라서, 이전에는 비현실적이던 일들이 이제는 충분히 가능해진다.
앞서 설명한 구성으로 100만 건의 경기를 저장했을 때의 수치는 다음과 같다.
Sorted Set + Hash memory usage = 220 MB (242 RSS) Stream memory usage = 16.8 MB (18.11 RSS)
이는 한 자릿수를 훌쩍 넘는 차이(정확히는 13배 차이)이며, 어제까지는 인메모리에 두기에는 비용이 너무 많이 들던 유스케이스가 이제는 충분히 현실적인 선택이 되었다는 것을 의미한다. 그 비결은 전적으로 Redis Streams의 표현 방식에 있다. 매크로 노드 하나에 여러 요소를 담을 수 있는데, 이 요소들은 listpack이라는 자료구조에 매우 간결하게 인코딩된다. 예를 들어 listpack은 의미상으로는 문자열이라도 정수를 바이너리 형태로 인코딩하는 일을 맡는다. 그 위에 델타 압축과 동일 필드 압축을 적용한다. 그럼에도 이런 매크로 노드들이 적은 메모리를 사용하도록 설계된 radix 트리로 연결되어 있기 때문에 ID나 시간으로 탐색할 수 있다. 이 모든 것이 합쳐져 낮은 메모리 사용량을 만든다. 하지만 흥미로운 점은, 의미론적으로 사용자는 Streams를 효율적으로 만드는 이러한 구현 세부사항을 전혀 의식하지 못한다는 것이다.
이제 간단한 계산을 해보자. 100만 개 항목을 약 18MB 메모리에 저장할 수 있다면, 1,000만 개는 180MB에, 1억 개는 1.8GB에 저장할 수 있다. 불과 18GB 메모리만으로 10억 개 항목을 가질 수 있는 셈이다.
시계열
내 생각에 주목할 중요한 점 하나는, 위에서 스트림을 테니스 경기 표현에 사용한 방식이 시계열을 위해 Redis Stream을 사용하는 것과 의미론적으로 *매우 다르다*는 것이다. 논리적으로는 여전히 일종의 이벤트를 기록하는 것이 맞지만, 근본적인 차이가 하나 있다. 한 경우에는 로깅과 항목 생성을 객체를 렌더링하기 위해 사용한다. 반면 시계열의 경우에는 외부에서 일어나는 일을 단순히 측정할 뿐이며, 이는 실제로 객체를 나타내지 않는다. 이 차이가 사소해 보일 수도 있지만 그렇지 않다. Redis Streams를 전체 순서(total order)를 가지며 ID가 부여된 작은 객체를 만드는 데 사용할 수 있다는 개념을 Redis 사용자가 형성하는 것이 중요하다.
그럼에도 시계열이라는 가장 기본적인 유스케이스조차 분명히 매우 중요한데, Streams가 등장하기 전까지 Redis는 그런 유스케이스에 대해서는 다소 무력했기 때문이다. 스트림의 메모리 특성과 유연성, 그리고 크기가 제한된 스트림을 만들 수 있다는 점(XADD 옵션을 참고하라)은 개발자의 손에 쥐어진 매우 중요한 도구다.
결론
Streams는 유연하며 수많은 유스케이스를 가지고 있지만, 이번 글은 짧게 유지하고 싶었다. 위의 예시와 메모리 사용량 분석을 통해 명확한 핵심 메시지를 전달하기 위해서다. 많은 독자에게는 이미 자명했을지도 모르지만, 지난 몇 달간 사람들과 이야기하면서 Streams와 스트리밍 유스케이스 사이에 강한 연관성이 형성되어 마치 이 자료구조가 그것에만 유용하다는 인식이 있다는 느낌을 받았다. 그렇지 않다 :-)
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기