Redis 6의 클라이언트 사이드 캐싱
원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기
[참고: 이 글은 Redis 6 최종 구현에서의 클라이언트 사이드 구현을 더 이상 설명하지 않습니다. 해당 구현은 크게 변경되었으니 https://redis.io/topics/client-side-caching을 참조하세요.]
뉴욕에서 열린 Redis Day가 끝나고, 나는 호텔에서 새벽 5시 30분에 일어났다. 아직 이탈리아 시차에 맞춰진 몸으로 곧장 맨해튼 거리를 걷기 시작했다. 풍경에 완전히 매료된 채, 수백만 명 중 그저 하나의 숫자가 되는 그 멋진 기분에 흠뻑 빠져 있었다. 그런데도 머릿속으로는 Redis 6 릴리스를 생각하고 있었다. 아마도 가장 중요한 기능이었을 Redis 프로토콜의 새 버전(RESP3)이 도입 곡선이 매우 더딜 것이라는 느낌과 함께였다. 그리고 그건 당연한 이유 때문이었다. 현명한 사람들은 아주 좋은 이유가 없으면 도구를 바꾸지 않는다. 애초에 내가 왜 그토록 필사적으로 프로토콜을 개선하고 싶었을까? 크게 두 가지 이유 때문이었다. 클라이언트에게 더 의미 있는(semantical) 응답을 제공하기 위해서, 그리고 기존 프로토콜로는 구현하기 어려웠던 새로운 기능들을 열어주기 위해서였다. 그중에서도 특히 가장 중요했던 기능 하나가 있었다. 바로 클라이언트 사이드 캐싱이었다.
시간을 약 1년 전으로 되돌려 보자. 나는 2018년 샌프란시스코에서 열린 Redis Conf에 참석했는데, 클라이언트 사이드 캐싱이야말로 Redis의 미래에서 가장 중요한 것이라는 확신을 가지고 있었다. 빠른 저장소와 빠른 캐시가 필요하다면, 정보의 일부를 클라이언트 내부에 저장해야 한다. 이는 적은 지연으로 대규모 데이터를 제공한다는 아이디어의 자연스러운 확장이다. 사실 거의 모든 초대형 기업은 이미 그렇게 하고 있다. 결국 부하를 견뎌낼 수 있는 유일한 방법이기 때문이다. 하지만 Redis에는 그런 과정을 클라이언트가 수행하도록 도울 방법이 없었다. 운 좋게도 Ben Malec이 같은 Redis Conf에서 정확히 클라이언트 사이드 캐싱에 대한 발표를 했다[1]. Redis가 제공하는 도구와 여러 기발한 아이디어만을 활용해서였다.
[1] https://www.youtube.com/watch?v=kliQLwSikO4
Ben이 택한 접근법은 내 상상력을 크게 자극했다. 그의 설계를 동작하게 만든 핵심 아이디어는 두 가지였다. 첫째는 Redis Cluster의 “해시 슬롯” 아이디어를 이용해 키를 1만 6천여 개 그룹으로 나누는 것이었다. 그렇게 하면 클라이언트가 각 키의 유효성을 일일이 추적할 필요가 없고, 키 그룹당 하나의 메타데이터 항목만으로 관리할 수 있다. Ben은 키가 변경될 때 알림을 보내기 위해 Pub/Sub을 사용했기 때문에 애플리케이션 전반의 도움이 필요했지만, 전체 구조는 매우 견고했다. 키를 수정했는가? 그러면 그 키를 무효화하는 메시지도 함께 발행(publish)한다. 클라이언트 측에서는 키를 캐싱하고 있는가? 각 키를 캐싱한 시점의 타임스탬프를 기억하고, 무효화 메시지를 받을 때는 각 슬롯의 무효화 시점을 기억한다. 캐싱된 특정 키를 사용할 때는 지연(lazy) 방식으로 제거를 수행한다. 캐싱한 키의 타임스탬프가 그 키가 속한 슬롯에 대해 받은 무효화 타임스탬프보다 오래되었는지 확인하는 것이다. 그렇다면 그 키는 오래된(stale) 데이터이므로 서버에 다시 요청해야 한다.
발표를 본 뒤, 나는 이것이 서버 내부에서 활용하기에 훌륭한 아이디어라는 것을 깨달았다. Redis가 클라이언트를 대신해 작업의 일부를 처리하게 함으로써 클라이언트 사이드 캐싱을 더 간단하고 효과적으로 만들 수 있겠다는 생각이었다. 그래서 집으로 돌아와 내 설계를 설명하는 문서를 작성했다[2].
[2] https://groups.google.com/d/msg/redis-db/xfcnYkbutDw/kTwCozpBBwAJ
하지만 내 설계를 실제로 동작하게 하려면 Redis 프로토콜을 더 나은 것으로 교체하는 데 집중해야 했다. 그래서 나는 RESP3의 스펙을 쓰고 이어서 코드를 작성하기 시작했고, ACL 같은 다른 Redis 6 관련 작업들도 함께 진행했다. 그리고 클라이언트 사이드 캐싱은 시간 부족으로 어쩌다 보니 내가 한쪽에 미뤄둔 수많은 Redis 아이디어들이 가득한 커다란 방에 합류하게 되었다.
그럼에도 나는 뉴욕 거리 한복판에서 이 아이디어를 곱씹고 있었다. 이후 컨퍼런스에서 알게 된 친구들과 점심과 커피 브레이크를 가졌다. 호텔 방으로 돌아오니 저녁 전체와 비행 전까지 다음 날 대부분이 남아 있었다. 그래서 나는 1년 전 그룹에 제안했던 내용을 거의 그대로 따라 Redis 6용 클라이언트 사이드 캐싱 구현을 작성하기 시작했다. 여전히 훌륭해 보였다.
Redis의 서버 지원(server-assisted) 클라이언트 사이드 캐싱, 최종적으로는 “트래킹(tracking)”이라 불리게 된 이 기능은(물론 이름은 바뀔 수도 있다) 몇 가지 핵심 아이디어로 이루어진 아주 단순한 기능이다.
키 공간은 “캐싱 슬롯(caching slots)”으로 나뉜다. 하지만 Ben이 사용한 해시 슬롯보다 훨씬 많다. 우리는 CRC64 출력 중 24비트를 사용하므로, 서로 다른 슬롯이 약 1,600만 개가 넘는다. 왜 이렇게나 많이 만들었을까? 서버에 1억 개의 키가 있더라도, 무효화 메시지 하나가 클라이언트 사이드 캐시의 몇 개 안 되는 키에만 영향을 주도록 하고 싶기 때문이다. Redis 내부에서 무효화 테이블을 유지하는 데 드는 메모리 오버헤드는 130메가바이트다. 1,600만 개 항목에 대한 8바이트 포인터 배열이다. 내 생각에는 괜찮은 수준이다. 이 기능을 쓰려는 사람이라면 클라이언트에 있는 메모리를 최대한 활용할 것이므로, 서버 측에서 130MB를 쓰는 것은 충분히 감수할 만하다. 그 대신 훨씬 더 세밀한(fine-grained) 무효화를 얻을 수 있다.
클라이언트는 간단한 명령어로 이 기능을 선택적(opt-in) 방식으로 활성화한다.
CLIENT TRACKING on서버는 예전 그대로 +OK로 응답하고, 그 순간부터 커맨드 테이블에서 “읽기 전용(read only)”으로 표시된 모든 명령은 단순히 호출자에게 키를 반환하는 데 그치지 않는다. 부수 효과(side effect)로, 지금까지 클라이언트가 요청한 모든 키의 캐싱 슬롯을 기억한다(단, 읽기 전용 명령을 사용한 키에 한해서다. 이것이 서버와 클라이언트 사이의 약속이다). 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가 모두 해당 슬롯에 캐싱된 키를 가지고 있을 수 있다는 것을 알게 된다. 우리는 두 클라이언트 모두에게 무효화 메시지를 보낼 것이고, 클라이언트는 이를 원하는 방식으로 처리하면 된다. 슬롯이 무효화된 마지막 시점을 타임스탬프로 기억해 두고, 나중에 지연(lazy) 방식으로 캐싱된 객체의 타임스탬프(혹은 더 안전하다고 생각한다면 점진적인 “에포크(epoch)”)와 비교해 제거하는 방법이 있다. 아니면 클라이언트가 해당 슬롯에 대해 캐싱한 것이 무엇인지 테이블로 관리해 두고 직접 객체를 회수하는 방법도 있다. 이 방식에서 24비트 해시 함수를 쓰는 것은 문제가 되지 않는다. 수천만 개의 키를 캐싱하더라도 목록이 그리 길어지지 않기 때문이다. 무효화 메시지를 보낸 뒤에는 무효화 테이블에서 해당 항목을 제거할 수 있다. 그러면 해당 슬롯에 대해 다시 키를 읽을 때까지는 그 클라이언트들에게 더 이상 무효화 메시지를 보내지 않게 된다.
클라이언트가 반드시 해시 함수의 24비트를 모두 사용할 필요는 없다는 점에 유의하자. 예를 들어 20비트만 사용하고, Redis가 보내는 무효화 메시지의 슬롯도 그에 맞춰 시프트(shift)해서 사용할 수도 있다. 그렇게 해야 할 좋은 이유가 많을지는 모르겠지만, 메모리가 제한적인 시스템에서는 고려해 볼 만한 아이디어다.
지금까지 설명을 주의 깊게 따라왔다면, 같은 연결이 일반 클라이언트 응답과 무효화 메시지를 모두 받는다는 점을 눈치챘을 것이다. 이는 RESP3에서는 무효화가 “푸시(push)” 메시지 타입으로 전송되기 때문에 가능하다. 하지만 클라이언트가 이벤트 기반이 아닌 블로킹(blocking) 클라이언트라면 이 방식은 복잡해지기 시작한다. 애플리케이션이 수시로 새로운 데이터를 읽어올 방법이 필요해지는데, 이는 복잡하고 깨지기 쉬워 보인다. 그런 경우라면 아마도 별도의 애플리케이션 스레드와 다른 클라이언트 연결을 사용해 무효화 메시지를 받는 것이 훨씬 낫다. 따라서 다음과 같이 할 수 있다.
CLIENT TRACKING on REDIRECT 1234기본적으로 현재 연결에서 가져온 모든 키에 대해 무효화 메시지를 클라이언트 1234로 보내달라고 요청하는 셈이다. 예를 들어 커넥션 풀을 사용하는 경우 여러 클라이언트가 무효화 메시지를 하나의 클라이언트로 리다이렉트하도록 요청할 수도 있다. 필요한 것은 무효화 메시지를 수신할 이 특별한 연결을 만들고, CLIENT ID를 호출해 이 클라이언트 연결의 ID가 무엇인지 알아낸 뒤, 트래킹을 활성화하는 것뿐이다.
한 가지 남은 문제가 있다. 무효화 링크의 서버 연결이 끊기면 어떻게 될까? 무효화 메시지를 더 이상 받지 못하게 되므로 문제가 생길 수 있다. 일반적으로 애플리케이션은 링크가 끊어졌다는 것을 감지하고 다시 연결한 뒤 현재 캐시를 비울 것이다(혹은 모든 슬롯의 타임스탬프를 몇 초 뒤로 설정해 캐시를 채울 시간을 벌면서 몇 초 정도 오래된 데이터를 제공하는 등 더 부드러운 방식을 택할 수도 있다). 하지만 무효화 스레드가 가끔 연결을 핑(ping)해서 살아 있는지 확인하는 것이 더 나은 아이디어일 수도 있다. 그럼에도 오래된 데이터의 위험을 줄이기 위해, Redis는 무효화 메시지를 다른 클라이언트로 리다이렉트한 클라이언트들에게 해당 클라이언트의 연결이 끊어졌다는 사실을 특별한 푸시 메시지로 알리기 시작할 것이다. 다음 쿼리를 수행할 때 클라이언트는 그 상황을 알게 된다.
내가 설명한 내용은 방금 Redis 불안정 버전(unstable)에 머지되었다. 아마 최종안은 아닐 것이다. 하지만 첫 번째 Redis 6 릴리스 후보(RC)까지는 아직 몇 달이 남아 있어 모든 것을 바꿀 시간은 충분하다. 피드백을 보내달라. 나는 또한 RESP2에서도 이 기능을 활성화할 방법을 모색하고 있다. 그 경우에는 리다이렉션이 활성화된 경우에만 동작할 것이며, 메시지를 수신하는 클라이언트는 아마도 Pub/Sub 모드로 들어가 우리가 일종의 Pub/Sub 메시지 형태로 보낼 수 있도록 해야 할 것이다. 이렇게 하면 기존 클라이언트를 그대로 재사용할 수 있다.
이 정도면 여러분의 식욕을 자극하기에 충분했기를 바란다. 우리가 이를 Redis 내부에서 아주 잘 구현하고, 클라이언트 저자들이 지원 방법을 알 수 있도록 잘 문서화한다면, 데이터는 그 어느 때보다 애플리케이션에 더 가까이 다가갈 수 있을 것이다. 지금까지 클라이언트 사이드 캐싱 구현을 시도조차 꺼렸던 소규모 팀이 운영하는 애플리케이션에서도 말이다. 이미 이를 구현해 사용 중인 대규모 팀과 초대형 애플리케이션의 경우에는 오버헤드와 구현 복잡성을 함께 줄일 수 있을 것이다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기