순수 자료구조로서의 Redis Streams
Redis 5에서 ‘Streams’라는 이름으로 소개된 새로운 Redis 자료구조는 커뮤니티에서 꽤 큰 관심을 불러일으켰습니다. 조만간 프로덕션 유스케이스를 가진 사용자들과 이야기를 나누며 커뮤니티 설문조사를 진행하고, 그 결과를 블로그에 정리하고 싶습니다. 오늘은 다른 이야기를 해보려 합니다. 점점 많은 사용자들이 Streams를 Kafka(TM)와 유사한 유스케이스를 해결하는 용도로만 생각하고 있다는 느낌이 들기 시작했습니다. 사실 이 자료구조는 프로듀서와 컨슈머가 있는 메시징 환경에서도 동작하도록 설계된 것이 맞습니다. 하지만 Redis Streams가 그 용도로만 유용하다고 생각하는 것은 엄청나게 축소된 시각입니다. 스트리밍은 시스템을 설계할 때 큰 성공을 거둘 수 있는 훌륭한 패턴이자 ‘멘탈 모델’이지만, 대부분의 Redis 자료구조가 그렇듯 Redis Streams 역시 훨씬 더 범용적이며 전혀 관련 없는 수십 가지 문제를 모델링하는 데 사용할 수 있습니다. 그래서 이번 포스트에서는 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 파일의 한계를 마법처럼 없앨 수 있습니다.
- 여기서 범위 조회(range query)를 하는 것은 어렵고(비효율적입니다).
- 중복 정보가 너무 많습니다. 시간 값은 모든 항목에서 거의 동일하고 필드도 반복됩니다. 그렇다고 이를 제거하면 형식이 덜 유연해져 다른 필드 집합으로 바꾸고 싶을 때 불편해집니다.
- 항목 오프셋은 파일에서의 바이트 오프셋에 불과합니다. 파일 구조를 바꾸면 오프셋이 틀어지기 때문에, 사실 진정한 기본 ID(primary ID) 개념이 없습니다. 항목들은 어떤 방식으로도 고유하게 식별되지 않는 셈입니다.
- 항목을 삭제할 수 없고 더 이상 유효하지 않다고 표시만 할 수 있으며, 로그를 다시 쓰지 않고서는 가비지 컬렉션을 할 수 없습니다. 로그를 다시 쓰는 일은 여러 가지 이유로 대개 골치 아프며, 피할 수 있다면 피하는 것이 좋습니다.
그럼에도 불구하고 이런 CSV 항목들의 로그는 어떤 면에서는 훌륭합니다. 고정된 구조가 없고 필드가 바뀔 수 있으며, 만들기도 아주 쉽고 결국 꽤 간결하기도 합니다. Redis Streams의 아이디어는 이러한 장점은 유지하면서 한계를 극복하는 것이었습니다. 그 결과 탄생한 것은 Redis Sorted Set과 매우 유사한 하이브리드 자료구조입니다. 근본적인 자료구조처럼 *느껴지지만*, 그런 효과를 내기 위해 내부적으로는 여러 표현 방식을 함께 사용합니다.
Streams 101 (Redis Streams 기초를 이미 알고 있다면 건너뛰어도 됩니다)
Redis Streams는 델타 압축된 매크로 노드들이 기수 트리(radix tree)로 연결된 형태로 표현됩니다. 그 덕분에 임의의 항목을 매우 빠르게 찾고, 필요하면 범위를 조회하며, 오래된 항목을 삭제해 크기가 제한된 스트림(capped stream)을 만드는 등의 작업을 할 수 있습니다. 그럼에도 프로그래머에게 제공하는 인터페이스는 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에서 선수를 모델링하는 방법은 꽤 명확합니다. 선수는 작은 객체이므로 player:<id>와 같은 키 이름을 가진 Hash 하나면 충분합니다. 애플리케이션 데이터를 더 모델링해 Redis를 주 저장소로 사용하려고 하면, 곧 특정 테니스 클럽에서 진행된 경기를 추적할 방법이 필요하다는 것을 알게 됩니다. player:1과 player:2가 경기를 했고 player 1이 이겼다면, 스트림에 다음과 같은 항목을 쓸 수 있습니다.
> XADD club:1234.matches * player-a 1 player-b 2 winner 1 "1553254144387-0"
이 간단한 연산만으로 우리는 다음을 얻습니다.
- 경기의 고유 식별자, 즉 스트림 안의 ID입니다.
- 경기를 식별하기 위해 별도의 객체를 만들 필요가 없습니다.
- 경기를 페이지네이션하거나 과거 특정 시점에 진행된 경기를 확인하기 위한 범위 조회를 공짜로 얻을 수 있습니다.
Streams가 등장하기 전에는 시간을 점수(score)로 하는 sorted set을 만들어야 했습니다. sorted set의 요소는 다른 키에 Hash 값으로 존재하는 경기의 ID가 됩니다. 이는 단순히 더 많은 작업일 뿐만 아니라, 엄청난 메모리 낭비이기도 합니다. 여러분이 짐작하는 것보다 훨씬 더 많이 낭비됩니다(뒤에서 설명합니다).
지금 보여주고 싶은 핵심은 Redis Streams가 일종의 append-only 모드의 Sorted Set이며, 시간을 키로 하고 각 요소가 작은 Hash인 자료구조라는 점입니다. 그리고 그 단순함 속에서 Redis 모델링의 관점에서 혁명이 일어납니다.
메모리 사용량
위 유스케이스는 단지 더 견고한 패턴이라는 차원에 그치지 않습니다. Stream 방식의 메모리 비용은 모든 객체마다 Sorted Set + Hash를 두는 예전 방식과 비교했을 때 너무나도 달라서, 과거에는 현실성이 없던 일들이 이제는 충분히 가능해졌습니다.
앞서 설명한 구성으로 100만 건의 경기를 저장했을 때의 수치는 다음과 같습니다.
Sorted Set + Hash memory usage = 220 MB (242 RSS) Stream memory usage = 16.8 MB (18.11 RSS)
이는 한 자릿수 이상의 차이(정확히는 13배 차이)이며, 어제까지는 인메모리에 두기에는 비용이 너무 많이 들던 유스케이스들이 이제는 충분히 viable해졌다는 것을 의미합니다. 마법은 모두 Redis Streams의 표현 방식에 있습니다. 매크로 노드는 listpack이라는 자료구조로 매우 간결하게 인코딩된 여러 요소를 담을 수 있습니다. listpack은 예를 들어 의미상으로는 문자열인 정수조차도 이진 형태로 인코딩하도록 처리합니다. 그 위에 델타 압축과 동일 필드 압축(same-fields compression)을 적용합니다. 그럼에도 매크로 노드들이 기수 트리로 연결되어 있기 때문에 ID나 시간으로 탐색하는 것이 가능합니다. 기수 트리 역시 메모리를 적게 사용하도록 설계되었습니다. 이 모든 것이 합쳐져 낮은 메모리 사용량을 만들어내지만, 흥미로운 점은 의미론적으로 사용자는 Streams를 효율적으로 만드는 구현 세부사항을 전혀意識하지 못한다는 것입니다.
이제 간단한 계산을 해보겠습니다. 100만 개 항목을 약 18MB 메모리에 저장할 수 있다면, 1천만 개는 180MB에, 1억 개는 1.8GB에 저장할 수 있습니다. 단 18GB 메모리만으로 10억 개 항목을 가질 수 있는 셈입니다.
시계열
한 가지 주목할 중요한 점은, 제 생각에, 위에서 Stream을 이용해 테니스 경기를 표현한 방식이 Redis Stream을 시계열(time series) 용도로 사용하는 경우와 의미론적으로 *매우 다르다*는 것입니다. 물론 논리적으로는 여전히 어떤 종류의 이벤트를 로깅하는 것이지만, 근본적인 차이 하나는 한쪽에서는 로깅과 항목 생성을 객체를 표현하기 위해 사용한다는 점이고, 다른 쪽인 시계열의 경우에는 외부에서 일어나는 무언가를 단순히 측정하는 것으로 실제로는 객체를 나타내지 않는다는 점입니다. 이 차이가 사소하다고 생각할 수도 있지만, 그렇지 않습니다. Redis 사용자가 Redis Streams를 전체 순서가 정해진 작은 객체를 만들고 그러한 객체에 ID를 부여하는 용도로도 사용할 수 있다는 개념을 잡는 것이 중요합니다.
하지만 가장 기본적인 시계열 유스케이스조차도 당연히 여기서 매우 중요한 의미를 가집니다. Streams가 생기기 전에는 Redis가 이러한 유스케이스에 대해서는 다소 속수무책이었기 때문입니다. 스트림의 메모리 특성과 유연성, 그리고 크기가 제한된 스트림을 가질 수 있는 능력(XADD 옵션을 참고하세요)은 개발자의 손에 쥐어진 매우 중요한 도구입니다.
결론
Streams는 유연하며 수많은 유스케이스를 가지고 있지만, 이번 포스트에서는 짧게 끝내어 위의 예시와 메모리 사용량 분석에 담긴 핵심 메시지가 분명하게 전달되도록 하고 싶었습니다. 어쩌면 이미 많은 독자에게는 당연한 이야기였을 수도 있지만, 지난 몇 달간 사람들과 이야기하면서 Streams와 스트리밍 유스케이스 사이에 강한 연관성이 있다는 느낌을 받았습니다. 마치 이 자료구조가 그 용도로만 좋은 것처럼 말이죠. 그렇지 않습니다 :-)
글을 무작위로 읽기