redis.io official documentation

클라이언트 사이드 캐싱

Redis 6 New-Feature🎉

GARIMOO
garimoo
Published in
16 min readMay 5, 2020

--

https://redis.io/topics/client-side-caching

클라이언트 사이드 캐싱은 고성능 서비스를 위해 사용되는 기술이다. 데이터베이스 정보의 일부 서브셋을 어플리케이션에 직접 저장하기 위해 데이터베이스가 아닌 어플리케이션 서버의 사용 가능한 메모리를 이용한다.

일반적으로 데이터를 질의할 때, 어플리케이션 서버는 다이어그램에서와 같이 데이터베이스에 요청한다.

+-------------+                                +----------+
| | ------- GET user:1234 -------> | |
| Application | | Database |
| | <---- username = Alice ------- | |
+-------------+ +----------+

클라이언트 사이드 캐싱을 사용하면, 어플리케이션은 자주 사용되는 쿼리의 응답을 어플리케이션 메모리 내에 직접 저장하여, 나중에 데이터베이스에 다시 쿼리하지 않고 응답을 재사용할 수 있도록 한다.

+-------------+                                +----------+
| | | |
| Application | ( No chat needed ) | Database |
| | | |
+-------------+ +----------+
| Local cache |
| |
| user:1234 = |
| username |
| Alice |
+-------------+

로컬 캐싱을 위한 어플리케이션의 메모리는 그렇게 크지 않으며, 로컬 메모리에 접근하는 시간은 네트워크를 통해 데이터베이스에 접근하는 시간과 비교하면 굉장히 작다. 동일하고 작은 데이터를 자주 가져오는 패턴에서는 데이터를 가져오기 위한 시간을 크게 줄여줌과 동시에, 데이터베이스의 부하도 줄일 수 있다.

데이터셋의 아이템이 자주 변경되지 않는 경우가 생각보다 많이 있다. 예를 들어 소셜 네트워크에서 대부분의 사용자 게시물은 변하지 않으며, 수정도 드물게 일어난다. 또한 일부 사용자들이 굉장히 많은 팔로워를 가지고 있는 구조이며, 최근 게시물일 수록 더 많이 노출되기 때문에 이런 패턴은 상당히 유용하다.

일반적으로 클라이언트 사이드 캐싱의 두 가지 주요 이점은 다음과 같다.

  1. latency가 줄어든다.
  2. 데이터베이스는 더 적은 쿼리를 수신하며, 이는 더 적은 노드수로 이전과 같은 데이터셋을 받아들일 수 있음을 의미한다.

There are only two big problems in computer science…

위 패턴의 단점은 사용자에게 오래된 데이터를 제공하지 않기 위해 어플리케이션에서 보유하고 있는 정보화 무효화(invalidate)해야 한다는 것이다. 예를 들어 위의 어플리케이션에서 user:1234 라는 키를 로컬에 캐싱한 상태에서, Alice가 이름을 Flora로 업데이트 한다고 하자. 하지만 어플리케이션은 1234라는 유저의 이름을 계속 Alice라고 저장하고 있을 수 있다.

어플리케이션에 따라 이 이슈는 큰 문제가 되지 않을 수도 있기 때문에, TTL (Time To Live) 로 간단히 처리할 수도 있다. 이 시간이 지나면 이 데이터는 더이상 유효하다고 여기지 않고 바로 삭제하는 것이다 . 레디스를 이용해서 이를 구현하려면, Pub/Sub 시스템을 사용해서 이 키를 가지고 있는 클라이언트에게 무효화 메시지를 전송하면 된다. 하지만 이 키를 가지고 있지 않은 클라이언트도 메시지를 수신할 수 있으며, 따라서 대역폭을 낭비할 수 있다. 또한 데이터를 변경하는 모든 어플리케이션 쿼리는 PUBLISH 커맨드를 사용해야 하기 때문에 데이터베이스가 이 커맨드를 처리하기 위해 더 많은 CPU를 사용한다.

대부분의 크기가 큰 어플리케이션에서는 클라이언트 사이드 캐싱과 비슷한 기능을 사용하고 있으며, 왜냐하면 이는 빠른 데이터베이스, 빠른 캐시서버 다음으로 논의되어야 하는 스텝이기 때문이다. 따라서 레디스 6 에서는 간편하고 접근성, 신뢰성, 효율성을 높이면서 사용할 수 있도록 구현하였다.

The Redis implementation of client side caching

레디스 클라이언트 사이드 캐싱은 트래킹(tracking) 이라 불리며, 두 가지 모드를 가진다.

  • 기본 모드에서 서버는 클라이언트가 액세스한 키를 기억하며, 동일한 키가 수정될 때 무효 메시지를 전송한다. 이는 서버측에서 메모리 비용이 들지만, 정확히 클라이언트가 가지고 있는 키에 대해서만 무효 메시지를 보낼 수 있다.
  • 브로드캐스팅 모드에서 서버는 특정 클라이언트가 액세스한 키를 기억하려고 시도하지 않으므로 서버측에서 메모리 비용이 전혀 들지 않는다. 대신 클라이언트는 object:, user: 와 같은 프리픽스를 구독하며, 해당 프리픽스와 일치하는 키가 변경될 때마다 메시지를 받는다.

일단 기본 모드에 대해서 알아보자.

  1. 클라이언트는 원한다면 트래킹을 시작할 수 있다. 트래킹이 없어도 연결은 시작될 수 있다.
  2. 트래킹이 활성화되면 서버는 연결되는 동안 각 클라이언트가 요청한 키를 기억한다.
  3. 키가 일부 클라이언트에 의해 수정되거나, 키가 연결된 만료 시간이 있기 때문에 제거되거나, 메모리 정책으로 인해 삭제(evict)되는 경우 키를 캐시하여 트래킹이 활성화 된 모든 클라이언트에게 무효 메시지를 보내게 된다.
  4. 클라이언트가 무효 메시지를 수신하면 해당 키를 제거한다.

다음은 프로토콜이 동작하는 방식이다.

  • Client 1 -> Server: CLIENT TRACKING ON
  • Client 1 -> Server: GET foo
  • 서버는 Client 1이 foo라는 키를 캐시했다는 것을 기억한다.
  • 클라이언트 1은 foo의 값이 로컬 메모리에 저장되었다는 것을 기억한다.
  • Client 2 -> Server: SET foo SomeOtherValue
  • Server -> Client 1: INVALIDATE “foo”

보기에는 좋아보이지만, 1만개의 클라이언트가 각각의 커넥션에서 수백만개의 키를 요구한다고 생각한다면 서버는 너무 많은 정보를 저장하게 될 것이다. 이런 이유 때문에 레디스는 서버측의 메모리 사용량과 이 기능의 구현을 처리하는 데 필요한 CPU 비용을 제한하기 위해 두 가지 핵심 아이디어를 사용한다.

  • 서버는 한 개의 글로벌 테이블에 키를 캐시했을 수도 있는 클라이언트 목록을 저장한다. 이 테이블을 Invalidation Table 이라 한다. 이 테이블의 엔트리 수는 최대값을 가지며, 새로운 키가 들어오면 서버는 오래된 엔트리에 들어있는 키 값이 수정된 것 처럼 제거하고 (실제로 수정되지 않았을 때애도), 클라이언트에게 무효화 메시지를 발송한다. 이 방법은 각 로컬에 캐시되어있는 키를 삭제시킨다 하더라도, 서버의 메모리를 재사용 할 수 있는 방법이다.
  • 이 테이블 내부에는 클라이언트 구조에 대한 포인터를 저장하고, 클라이언트가 끊어질 때 GC를 수행할 필요가 없다. 대신 클라이언트 ID만 저장하면 된다. 클라이언트가 커넥션을 끊으면 캐싱 슬롯이 무효화됨에 따라 자동적으로 삭제된다.
  • 테이블에는 키 네임스페이스가 있으며, 데이터베이스 넘버로 나뉘지는 않는다. 따라서 클라이언트가 데이터베이스2의 키 foo를 캐싱하고, 다른 클라이언트가 데이터베이스3의 foo 값을 변경하면 무효화 메시지가 전송된다. 메모리 사용과 구현의 복잡도를 줄이기 위해 데이터베이스 번호는 무시된다.

Two connections mode

레디스 6에서 지원하는 레디스 프로토콜 RESP3을 사용하면 동일한 커넥션으로 데이터 쿼리를 실행하면서 동시에 무효화 메시지를 수신할 수 있다. 하지만 많은 클라이언트에서는 두 개의 분리된 커넥션(데이터용 커넥션, 무효화 커넥션)을 사용해서 클라이언트 측 캐싱을 구현하고 싶어할 수도 있다. 이러한 이유로 클라이언트는 추적을 활성화 할 때 서로 다른 커넥션의 클라이언트ID를 지정해서 무효화 메시지를 다른 커넥션으로 리디렉션하도록 지정할 수 있다. 많은 데이터 커넥션은 무효화 메시지를 동일한 커넥션으로 리디렉션할 수 있으며, 이는 커넥션 pooling을 구현하는 클라이언트에게 유용하다. 두 커넥션 모델은 RESP2(동일한 커넥션에서 서로 다른 종류의 정보를 멀티플렉싱 할 수 있는 기능이 없음)에도 지원되는 유일한 모델이다.

예시를 보여주기 위해, 이전의 프로토콜인 RESP2에서 다른 커넥션으로 트래킹을 리디렉션하는 방법, 키를 물어보는 방법, 키가 만료되었을 때 무효화 메시지를 받는 방법 등에 대해서 실제로 알아보자.

시작하기 위해, 클라이언트는 RESP2 모드에서 무효화 메시지를 얻는데 사용되는 특수 채널에 Pub/Sub을 통해 무효화에 사용할 첫 번째 커넥션을 열고(Connection 1), 커넥션 ID를 요청해서 구독한다. (RESP2는 최신 버전이 아니며, Redis 6은 HELLO 커맨드를 사용해서 시작한다.)

(Connection 1 -- used for invalidations)
CLIENT ID
:4
SUBSCRIBE __redis__:invalidate
*3
$9
subscribe
$20
__redis__:invalidate
:1

이제 데이터 커넥션에서 추적 기능을 사용할 수 있다.

(Connection 2 -- data connection)
CLIENT TRACKING on REDIRECT 4
+OK
GET foo
$3
bar

클라이언트는 로컬 메모리에 “foo” => “bar”를 캐시하도록 결정할 수 있다. 이제 다른 클라이언트가 “foo” 키의 값을 수정한다.

(Some other unrelated connection)
SET foo bar
+OK

무효화 정보를 받기 위한 커넥션에서 지정된 키가 무효화되었다는 메시지를 수신한다.

(Connection 1 -- used for invalidations)
*3
$7
message
$20
__redis__:invalidate
*1
$3
foo

클라이언트는 캐싱 슬롯에 캐시된 키가 있는지 확인하고, 유효하지 않은 모든 데이터를 삭제한다.

Pub/Sub 메시지의 세 번째 요소는 단일 키가 아니라 키 배열임에 유의해야 한다. 항상 배열 형태로 보내기 때문에 무효화할 키가 여러개 있다면 하나의 메시지로 수신 가능하다.

한가지 중요하게 알아두어야 할 것은, Pub/Sub을 사용하는 것은 전적으로 오래된 클라이언트에 대해서도 구현될 수 있도록 하기 위한 방법이지만, 실제로 그 메시지는 그 Pub/Sub 채널에 가입된 모든 클라이언트가 수신하는 것은 아니라는 것이다. CLIENT 커맨드의 REDIRECT 인수에 지정된 커넥션만이 실제로 Pub/Sub 메시지를 수신하기 때문에 기능의 확장성을 향상시킨다.

RESP3을 사용하면, 무효화 메시지는 동일한 커넥션에서 푸시 메시지로 전송된다. (혹은 redirection을 지정하면 두 번째 커넥션으로 전달된다.)

What tracking tracks

위에서 보았듯이, 기본적으로 클라이언트는 서버에게 어떤 키를 캐싱하고 있는지 알릴 필요가 없다. 읽기 전용 커맨드에 의해 컨텍스트에서 언급되는 모든 키는 서버에 의해 추적되며, 이는 캐시될 수 있기 때문이다.

이것은 클라이언트가 캐싱하는 것을 서버에 알릴 필요가 없다는 명백한 이점이 있다. 그리고 많은 클라이언트는 이 방식을 원할 것이다. 왜냐하면 좋은 솔루션은 아직 캐싱되지 않은 모든 것을 캐싱하는 것일 수 있기 때문이다. 즉, 최종 단계별 접근 방식을 사용하여 우리는 고정된 개체의 수를 캐싱하고 싶을 수도 있고, 우리가 검색하는 모든 새로운 데이터를 캐싱할 수도 있고, 가장 오래된 캐싱된 아이템을 삭제할 수도 있다. 가장 좋은 방식은 가장 적게 사용되는 개체를 삭제하는 것이다.

어쨌든 서버에 쓰기 트래픽이 있는 경우 캐싱 슬롯은 시간이 경과하는 동안 무효화된다는 점에 유의해야 한다. 일반적으로 서버가 우리가 얻은 것도 캐시한다고 가정할 때, 장단점이 존재한다.

  1. 클라이언트가 새로운 데이터를 캐싱하려는 정책을 사용할 때 더욱 효율적이다.
  2. 서버는 클라이언트 키에 대한 더 많은 데이터를 저장해야 한다.
  3. 클라이언트는 캐시하지 않은 객체에 대한 쓸데없는 무효 메시지를 수신할 수도 있다.

따라서 다음 파트에서 이에 대한 대안을 소개한다.

Opt-in caching

클라이언트는 선택한 키만 캐시하길 원할 수 있으며, 서버에게 어떤 키를 캐시하고싶은지, 아닌지 명시할 수 있다. 새 개체를 캐싱할 때 더 많은 대역폭을 사용할 수 있지만, 서버가 기억해야 하는 데이터의 양과 클라이언트가 수신하는 무효화 메시지의 양을 줄일 수 있는 방법이다.

이를 위해서 OPTIN 옵션을 사용한다.

CLIENT TRACKING on REDIRECT 1234 OPTIN

이 모드에서는 기본적으로 읽기 쿼리에서 언급된 키는 캐시되지 않도록 하며, 대신 클라이언트가 무언가를 캐시하기 위해서 실제 명령 바로 앞에 특수 커맨들를 전송해야 한다.

CACHING
+OK
GET foo
"bar"

프로토콜의 효율성을 높이기 위해, 캐싱 커맨드는 NOREPLY 옵션과 함께 전송될 수 있다. 이 경우 OK를 받지 않는다.

CACHING NOREPLY
GET foo
"bar"

CACHING 커맨드는 그 직후에 실행된 커맨드에 영향을 끼치지만, 다음 커맨드가 MULTI 인 경우 트랜잭션의 모든 커맨드가 추적된다. 루아 스크립트의 경우도 동일하다.

Broadcasting mode

지금까지 레디스의 첫 번째 클라이언트 캐싱 모델을 설명했다. 브로드캐스팅 모드는 트레이드오프의 다른 관점을 보고 있으며, 서버측의 메모리를 전혀 소비하지 않고 클라이언트에 무효 메시지를 더 많이 보내는 방식이다. 이 모드에서는 다음과 같은 주요 동작이 있다.

클라이언트는 BCAST 옵션을 사용해서 클라이언트측 캐싱을 활성화하고, 프리픽스 옵션을 사용해서 하나 이상의 프리픽스를 지정한다. 예를 들어 CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user: 처럼 사용할 수 있다. 프리픽스를 지정하지 않으면 빈 문자열로 가정하기 때문에 클라이언트는 수정되는 모든 키에 대해 무효 메시지를 수신하게 된다. 대신 하나 이상의 프리픽스를 사용한다면 지정된 프리픽스 중 하나만 매칭되어도 무효화 메시지를 수신할 수 있다.

서버는 invalidation 테이블에 아무것도 저장하지 않는다. 대신 Prifixes Table에 프리픽스를 사용하는 클라이언트 목록을 저장한다. 매번 프리픽스에 연결된 키가 변경될 때마다, 그 키를 포함하는 클라이언트는 무효화 메시지를 수신한다.

서버는 등록된 프리픽스에 비례하는 CPU를 소비한다. 프리픽스를 몇 개만 가지고 있을 때에는 별 차이를 보이지 않지만, 프리픽스의 수가 커지면 CPU를 상당히 많이 사용하게 될 수 있다. 이 모드에서 서버는 주어진 프리픽스에 가입한 모든 클라이언트에 대해 단일 회신을 보내지 않고 모든 클라이언트에게 동일한 회신을 함으로 CPU 사용량을 낮출 수 있다.

The NOLOOP option

기본적으로 클라이언트 측 트래킹은 키를 수정한 클라이언트에도 무효 메시지를 보낸다. 때때로 클라이언트는 이것을 원하는데, 왜냐하면 자동으로 쓰기 캐싱을 포함하지 않는 매우 기본적인 논리를 구현하기 때문이다. 그러나 많은 고급 클라이언트는 로컬 메모리 테이블에서 수행중인 쓰기 작업까지 캐싱하기를 원할 수 있다. 이러한 경우, 방금 캐시한 값을 클라이언트가 제거하도록 강제시킬 수 있기 때문에 쓰기 후 바로 무효화 메시지를 수신하는 것이 문제가 될 수 있다.

이 경우 NOLOOP 옵션이 도움이 되며, 이는 기본 모드와 브로드캐스팅 모드 둘 다에서 도움이 된다. 이 옵션에서, 클라리언트는 스스로 수정된 키에 대해 무효 메시지를 수신하지 않으려고 서버에 알릴 수 있다.

Avoiding race conditions

무효화 메시지를 다른 커넥션으로 리다이렉션하는 클라이언트 캐싱을 구현할 때 레디스 컨디션이 있다는 것을 알아야 한다. 아래 예제에서 데이터 연결이 ‘D’, 무효화 연결이 ‘I’ 이다.

[D] client -> server: GET foo
[I] server -> client: Invalidate foo (somebody else touched it)
[D] server -> client: "bar" (the reply of "GET foo")

GET에 대한 회신이 느리게 전달되었기 때문에, 더이상 유효하지 않은 이전의 값을 전달받았다. 이렇게 된다면 올바르지 않은 값을 클라이언트에게 전달할 수 있는 위험이 존재한다. 이 문제를 방지하기 위해 커맨드를 보낼 때 캐시를 덧붙이는 것이 좋은 아이디어일 수 있다.

Client cache: set the local copy of "foo" to "caching-in-progress"
[D] client-> server: GET foo.
[I] server -> client: Invalidate foo (somebody else touched it)
Client cache: delete "foo" from the local cache.
[D] server -> client: "bar" (the reply of "GET foo")
Client cache: don't set "bar" since the entry for "foo" is missing.

싱글 커넥션에서 데이터와 무효화 메시지를 같이 받을 경우에, 메시지 순서가 정렬되어 있기 때문에 레이스 컨디션은 발생하지 않는다.

What to do when losing connection with the server

마찬가지로, 무효화 메시지를 얻기 위해 사용하는 소켓과의 연결이 끊기면 오래된 데이터로 남을 수도 있다. 이 문제를 피하기 위해서는 다음과 같은 일을 수행해야 한다.

  1. 커넥션이 끊기면 캐시 데이터를 삭제해야 한다.
  2. Pub/Sub을 이용한 RESP2를 사용하거나 RESP3을 사용하는 두 경우 모두 무효화 채널을 주기적으로 ping해야 한다. 연결이 끊어져 ping의 응답을 받을 수 없는 경우 최대 시간이 지난 후 연결을 닫고 캐시 데이터를 삭제해야 한다.

What to cache

클라이언트는 캐시된 키가 실제로 요청된 횟수에 대한 내부 통계 정보를 원할 수 있다. 일반적으로 다음과 같은 기능을 원할 것이다.

  • 지속적으로 변하는 많은 키를 캐시하고 싶지 않다.
  • 드물게 요청되는 많은 키를 캐시하고 싶지 않다.
  • 자주 요청되는 키를 캐시하여 합리적인 속도로 변경하고 싶다. (여기서 합리적이지 않은 속도란 자주 INCR되는 카운터 등을 의미)

하지만 클라이언트는 최근 제공되지 않은 키를 제거하기 위해 주어진 캐시 값이 마지막으로 제공되었을 떄를 기억하는 것만으로 무작위 샘플링을 사용해서 데이터를 제거한다.

Other hints about client libraries implementation

로컬에 저장할 키가 TTL 값을 갖도록 할 수 있다. TTL이 없더라도 이 값을 넣는 것은 좋은 방법이다. 클라이언트가 로컬 캐시에 오래된 데이터를 보유하게 만드는 버그 또는 연결 문제를 해결할 수 있다. 클라이언트가 사용하는 메모리 양을 제한하는 것은 절대적으로 필요한 방법이고, 새 키가 추가될 때 오래된 키를 제거할 방법이 필요하다.

Limiting the amount of memory used by Redis

레디스에서 기억하는 최대 키 수에 적합한 값을 구성하거나, 레디스에서 메모리를 전혀 소비하지 않는 BCAST 모드를 사용하면 된다. BCAST를 사용하지 않을 때 레디스가 소비되는 메모리는 트래킹되는 키의 수와 해당 키를 요청한 클라이언트 수에 모두 비례한다.

--

--