Client side caching in Redis 6

Salvatore Sanfilippo

Redis 6의 클라이언트 사이드 캐싱

[참고: 이 글은 더 이상 Redis 6 최종 구현에 포함된 클라이언트 사이드 캐싱 구현을 설명하지 않습니다. 구현 방식이 크게 변경되었으니 https://redis.io/topics/client-side-caching을 참조하시기 바랍니다.]

뉴욕에서 열린 Redis Day가 끝났습니다. 호텔에서 새벽 5시 30분에 일어났습니다. 아직 이탈리아 시간대에 맞춰진 몸 상태였고, 곧바로 맨해튼 거리로 산책을 나섰습니다. 풍경에 완전히 매료되었고, 수백만 명 중 그저 하나의 숫자가 된다는 멋진 느낌에 빠져 있었습니다. 그러면서도 Redis 6 릴리스에 대해 생각하고 있었습니다. 아마도 가장 중요한 기능이라 할 수 있는 Redis 프로토콜의 새 버전(RESP3)의 도입 속도가 매우 느릴 것 같다는 느낌이 들었습니다. 그리고 그럴 만한 이유가 있었습니다. 현명한 사람들은 아주 타당한 이유가 없으면 도구를 바꾸지 않기 때문입니다. 애초에 왜 프로토콜을 그렇게 개선하고 싶었을까요? 크게 두 가지 이유 때문이었습니다. 클라이언트에 더 의미가 풍부한 응답을 제공하기 위해서였고, 기존 프로토콜로는 구현하기 어려웠던 새로운 기능들을 열어주기 위해서였습니다. 그중에서도 특히 가장 중요하다고 생각한 기능이 하나 있었습니다. 바로 클라이언트 사이드 캐싱이었습니다.

시간을 약 1년 전으로 돌려보겠습니다. 저는 샌프란시스코에서 열린 Redis Conf 2018에 참가했는데, 클라이언트 사이드 캐싱이 Redis의 미래에서 가장 중요한 것이라는 확신을 가지고 갔습니다. 빠르고 빠른 저장소와 캐시가 필요하다면, 정보의 일부를 클라이언트 안에 저장해야 합니다. 이는 지연을 짧게 유지하면서 대규모로 데이터를 제공한다는 아이디어의 자연스러운 확장입니다. 실제로 거의 모든 초대형 기업은 이미 그렇게 하고 있습니다. 결국 부하를 감당할 수 있는 유일한 방법이기 때문입니다. 하지만 Redis에는 클라이언트가 그런 과정을 수행하도록 돕는 방법이 없었습니다. 운 좋게도 Ben Malec이 같은 Redis Conf에서 마침 클라이언트 사이드 캐싱에 대해 발표를 했습니다[1]. Redis가 제공하는 도구와 몇 가지 매우 기발한 아이디어만을 활용한 발표였습니다.

[1] https://www.youtube.com/watch?v=kliQLwSikO4

Ben이 택한 접근 방식은 제 상상력을 크게 자극했습니다. 그의 설계를 동작하게 만든 핵심 아이디어는 두 가지였습니다. 첫 번째는 Redis Cluster의 “해시 슬롯(hash slots)” 개념을 이용해 키를 1만 6천여 개 그룹으로 나누는 것이었습니다. 이렇게 하면 클라이언트는 각 키의 유효성을 일일이 추적할 필요 없이, 키 그룹 하나당 하나의 메타데이터 항목으로 관리할 수 있습니다. Ben은 키가 변경되었을 때 알림을 보내기 위해 Pub/Sub을 활용했습니다. 그래서 애플리케이션 전반의 도움이 필요하긴 했지만, 전체 구조는 매우 견고했습니다. 키를 수정했나요? 그럼 그 키를 무효화하는 메시지도 함께 발행합니다. 클라이언트 쪽에서는 키를 캐싱하고 있나요? 각 키를 캐싱한 시점의 타임스탬프를 기억하고, 무효화 메시지를 받으면 각 슬롯이 무효화된 시점도 기억합니다. 캐시된 키를 사용할 때는 타임스탬프를 비교해 지연된 방식으로 제거(lazy eviction)합니다. 캐시한 키의 타임스탬프가 해당 키가 속한 슬롯에 대해 받은 무효화 타임스탬프보다 오래되었다면, 그 키는 오래된 데이터이므로 서버에 다시 요청해야 합니다.

발표를 본 뒤, 이것이 서버 내부에서 활용하면 훌륭한 아이디어가 되겠다는 생각이 들었습니다. Redis가 클라이언트를 대신해 작업의 일부를 처리하게 하면 클라이언트 사이드 캐싱을 더 단순하고 효과적으로 만들 수 있을 것 같았습니다. 그래서 집으로 돌아와 제 설계를 설명하는 문서를 작성했습니다[2].

[2] https://groups.google.com/d/msg/redis-db/xfcnYkbutDw/kTwCozpBBwAJ

하지만 제 설계를 동작하게 하려면 Redis 프로토콜을 더 나은 것으로 교체하는 데 집중해야 했습니다. 그래서 RESP3의 명세와 이후 코드를 작성하기 시작했고, ACL 등 다른 Redis 6 관련 작업들도 진행했습니다. 그러는 동안 클라이언트 사이드 캐싱은 시간이 부족해 어쩔 수 없이 미뤄둔 수많은 Redis 아이디어들이 모여 있는 방에 들어가게 되었습니다.

그런데도 저는 뉴욕 거리에서 이 아이디어를 곱씹고 있었습니다. 이후 콘퍼런스에서 만난 친구들과 점심과 커피 브레이크를 가졌습니다. 호텔 방으로 돌아오니 저녁 시간 전체와 비행 전까지 다음 날 대부분이 남아 있었습니다. 그래서 1년 전 그룹에 제안했던 내용을 거의 그대로 따라 Redis 6용 클라이언트 사이드 캐싱 구현을 작성하기 시작했습니다. 여전히 훌륭해 보였습니다.

Redis의 서버 보조 클라이언트 사이드 캐싱, 최종적으로는 “트래킹(tracking)”이라 부르게 된 이 기능(하지만 이름은 바꿀 수도 있습니다)은 몇 가지 핵심 아이디어로 이루어진 매우 단순한 기능입니다.

키 공간은 “캐싱 슬롯(caching slots)”으로 나뉩니다. 다만 Ben이 사용한 해시 슬롯보다 훨씬 더 많습니다. CRC64 출력 중 24비트를 사용하므로 약 1,600만 개가 넘는 서로 다른 슬롯이 생깁니다. 왜 이렇게 많이 둘까요? 서버에 1억 개의 키가 있더라도, 무효화 메시지 하나가 클라이언트 사이드 캐시의 몇 개 키에만 영향을 미치도록 하고 싶기 때문입니다. Redis 내부에서 무효화 테이블이 차지하는 메모리 오버헤드는 130메가바이트입니다. 1,600만 개 항목에 대한 8바이트 포인터 배열입니다. 저는 이 정도면 괜찮다고 생각합니다. 이 기능을 쓰려는 분이라면 클라이언트에서 보유한 메모리를 최대한 활용하게 될 테니, 서버 쪽에서 130MB를 쓰는 것은 충분히 감수할 만합니다. 그 대신 훨씬 더 세밀한 무효화를 얻을 수 있습니다.

클라이언트는 간단한 명령 하나로 이 기능을 선택적으로 활성화합니다.

CLIENT TRACKING on

서버는 예전 그대로 +OK로 응답하고, 그 순간부터 커맨드 테이블에서 “읽기 전용(read only)”으로 표시된 모든 명령은 호출자에게 키를 반환하는 데 그치지 않고, 부가 효과로 해당 클라이언트가 지금까지 요청한 모든 키의 캐싱 슬롯을 기억합니다(단, 읽기 전용 명령으로 요청한 키에 한해서입니다. 이것이 서버와 클라이언트 사이의 약속입니다). Redis가 이 정보를 저장하는 방식은 단순합니다. 각 Redis 클라이언트는 고유한 ID를 가지고 있으므로, 클라이언트 ID 123이 슬롯 1, 2, 5에 해싱되는 키들에 대해 MGET을 수행하면 무효화 테이블(Invalidation Table)에는 다음과 같은 항목이 생깁니다.

1 -> [123]
2 -> [123]
5 -> [123]

이후 클라이언트 ID 444도 슬롯 5에 속한 키들을 요청하면, 테이블은 다음과 같이 됩니다.

5 -> [123, 444]

이제 다른 클라이언트가 슬롯 5에 속한 키를 변경한다고 해보겠습니다. 그러면 Redis는 무효화 테이블을 확인해 클라이언트 123과 444 모두가 해당 슬롯에 대해 키를 캐시하고 있을 가능성이 있다는 것을 알게 됩니다. 두 클라이언트 모두에게 무효화 메시지를 보냅니다. 그러면 클라이언트는 원하는 방식으로 처리할 수 있습니다. 슬롯이 마지막으로 무효화된 시점을 타임스탬프로 기억해 두었다가, 나중에 캐시된 객체의 타임스탬프(더 선호한다면 더 안전한 증분 “epoch”를 써도 됩니다)를 지연된 방식으로 비교해 제거할 수도 있습니다. 혹은 해당 슬롯에 대해 캐시한 내용을 테이블로 관리하면서 객체를 즉시 회수하는 방식을 택할 수도 있습니다. 24비트 해시 함수를 쓰는 이 접근 방식은 문제가 되지 않습니다. 수천만 개의 키를 캐시하더라도 리스트가 매우 길어지는 일은 없기 때문입니다. 무효화 메시지를 보낸 뒤에는 무효화 테이블에서 해당 항목을 제거할 수 있습니다. 이렇게 하면 클라이언트가 해당 슬롯의 키를 다시 읽기 전까지는 더 이상 무효화 메시지를 보내지 않습니다.

클라이언트가 반드시 해시 함수의 24비트를 모두 사용해야 하는 것은 아닙니다. 예를 들어 20비트만 사용하고, Redis가 보내는 무효화 메시지의 슬롯 값을 시프트해서 사용할 수도 있습니다. 그렇게 해야 할 타당한 이유가 많을지는 잘 모르겠지만, 메모리가 제한된 시스템에서는 고려해 볼 만한 방법일 수 있습니다.

지금까지 설명을 잘 따라오셨다면, 같은 연결로 일반 클라이언트 응답과 무효화 메시지를 모두 받는다는 점이 궁금하셨을 겁니다. 이는 RESP3에서는 가능합니다. 무효화가 “push” 메시지 타입으로 전송되기 때문입니다. 하지만 클라이언트가 이벤트 기반이 아닌 블로킹 방식이라면 이야기가 복잡해집니다. 애플리케이션이 수시로 새로운 데이터를 읽어올 방법이 필요하고, 이는 복잡하고 취약해 보입니다. 그런 경우에는 별도의 애플리케이션 스레드와 다른 클라이언트 연결을 사용해 무효화 메시지를 받는 편이 훨씬 나을 수 있습니다. 그래서 다음과 같은 방식을 허용합니다.

CLIENT TRACKING on REDIRECT 1234

기본적으로 현재 연결로 가져온 모든 키에 대해 무효화 메시지를 클라이언트 1234로 보내달라고 요청하는 셈입니다. 예를 들어 커넥션 풀을 사용하는 경우 여러 클라이언트가 무효화 메시지를 하나의 클라이언트로 리다이렉트하도록 요청할 수도 있습니다. 필요한 작업은 무효화 메시지를 받을 특별한 연결을 하나 만들고, CLIENT ID를 호출해 해당 클라이언트 연결의 ID를 확인한 뒤 트래킹을 활성화하는 것뿐입니다.

한 가지 남은 문제가 있습니다. 무효화 링크의 서버 연결이 끊기면 어떻게 될까요? 무효화 메시지를 더 이상 받을 수 없으므로 문제가 생길 수 있습니다. 일반적으로 애플리케이션은 링크가 끊어졌음을 감지하고 다시 연결하면서 현재 캐시를 비우게 됩니다(또는 모든 슬롯의 타임스탬프를 몇 초 뒤로 설정해, 몇 초 정도 오래된 데이터를 제공하면서 캐시를 다시 채울 시간을 버는 식의 더 부드러운 대응을 할 수도 있습니다). 그래도 무효화 스레드가 가끔 연결에 ping을 보내 살아 있는지 확인하는 편이 더 나을 수 있습니다. 하지만 오래된 데이터(stale data) 위험을 줄이기 위해, Redis는 무효화 메시지를 다른 클라이언트로 리다이렉트한 클라이언트들에게 해당 클라이언트의 연결이 끊겼다는 사실을 특수한 push 메시지로 알려주기 시작합니다. 그러면 클라이언트는 다음 쿼리를 수행할 때 그 상황을 알게 됩니다.

제가 설명한 내용은 방금 Redis unstable 브랜치에 머지되었습니다. 아마 최종안은 아닐 겁니다. 첫 번째 Redis 6 릴리스 후보까지는 아직 몇 달이 남아 있어 모든 것을 바꿀 시간이 충분합니다. 피드백을 보내주시기 바랍니다. RESP2에서도 이 기능을 활성화할 방법을 고민하고 있습니다. 이 경우 리다이렉션이 활성화된 경우에만 동작하며, 메시지를 수신하는 클라이언트는 아마 Pub/Sub 모드로 전환되어 일종의 Pub/Sub 메시지를 받을 수 있어야 할 것입니다. 이렇게 하면 기존 클라이언트를 그대로 재활용할 수 있습니다.

이 정도면 여러분의 흥미를 자극하기에 충분했기를 바랍니다. Redis 내부에서 이 기능을 아주 잘 구현하고, 클라이언트 작성자들이 지원 방법을 알 수 있도록 문서화를 잘해 둔다면, 데이터는 그 어느 때보다 애플리케이션에 훨씬 더 가까이 다가갈 수 있을 것입니다. 지금까지 클라이언트 사이드 캐싱 구현을 시도조차 꺼렸던 소규모 팀의 애플리케이션에서도 말입니다. 이미 이를 구현해 사용 중인 대규모 팀과 초대형 애플리케이션의 경우, 구현의 복잡도와 함께 오버헤드를 줄일 수 있을 것입니다.

원문은 Salvatore Sanfilippo님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.