클라이언트 사이드 캐싱

클라이언트 사이드 캐싱 (Client-side caching) 레퍼런스

Redis 6이 직접 지원하는 서버-보조 서버 클라이언트 사이드 캐싱 기법입니다. 애플리케이션 서버의 로컬 메모리에 데이터베이스 정보 일부를 캐싱하고, Redis가 키 변경 시 무효화 메시지를 보내 정확성을 보장합니다. 이 문서는 심층 레퍼런스로, 일반 사용법 지침은 Client-side caching introduction을 참고하세요.

출처: 공식문서 — Client-side caching reference

클라이언트 사이드 캐싱이란

클라이언트 사이드 캐싱은 고성능 서비스를 만드는 기법입니다. 보통 DB 노드와 구별되는 컴퓨터인 애플리케이션 서버에 있는 메모리를 이용해, 데이터베이스 정보의 일부 부분집합을 애플리케이션 쪽에 직접 저장합니다.

보통 데이터가 필요하면 애플리케이션 서버가 DB에 물어봅니다:

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

클라이언트 사이드 캐싱을 쓰면 애플리케이션이 인기 쿼리의 응답을 애플리케이션 메모리에 직접 저장해, DB에 다시 접촉하지 않고 나중에 재사용합니다:

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

로컬 캐시에 쓰이는 애플리케이션 메모리는 크지 않을 수 있어도, 로컬 컴퓨터 메모리 접근 시간은 DB 같은 네트워크 서비스 접근보다 몇 자릿수 더 짧습니다. 데이터의 작은 일부만 자주 접근되는 경우가 많으므로, 이 패턴은 애플리케이션의 데이터 획득 대기 시간과 동시에 DB 측 부하를 크게 줄일 수 있습니다.

또한 항목이 매우 드물게 변하는 데이터셋이 많습니다. 예를 들어 소셜 네트워크의 대부분 사용자 게시물은 불변이거나 거의 편집되지 않습니다. 게시물 중 작은 비율만 인기가 있다는 사실(소수 사용자가 많은 팔로워를 갖거나 최근 게시물이 훨씬 잘 보이거나)을 더하면, 이런 패턴이 왜 유용한지 분명해집니다.

클라이언트 사이드 캐싱의 두 핵심 장점:

  • 데이터를 아주 작은 대기 시간으로 사용 가능.
  • DB 시스템이 받는 쿼리가 줄어 더 적은 노드로 같은 데이터셋을 서빙 가능.

컴퓨터 과학의 두 어려운 문제...

위 패턴의 문제는 애플리케이션이 보유한 정보를 무효화해 stale 데이터를 사용자에게 보여주지 않는 방법입니다. 예를 들어 애플리케이션이 user:1234의 정보를 로컬 캐시한 뒤 Alice가 자기 이름을 Flora로 바꾸면, 애플리케이션은 계속 예전 이름을 서빙할 수 있습니다.

모델링하는 애플리케이션에 따라 이는 대수롭지 않을 수 있어, 클라이언트는 캐시된 정보에 고정 최대 "유효 수명(time to live)"을 그냥 씁니다. 일정 시간이 지나면 정보는 더 이상 유효하지 않다고 봅니다. Redis를 쓸 때 더 복잡한 패턴은 Pub/Sub 시스템을 활용해 리스닝 클라이언트에 무효화 메시지를 보냅니다. 이는 동작하지만, 종종 무효화 데이터의 복사본이 없는 클라이언트까지 포함해 애플리케이션의 모든 클라이언트에 메시지를 보내야 하므로 대역폭 관점에서 까다롭고 비쌉니다. 게다가 데이터를 바꾸는 모든 애플리케이션 쿼리는 PUBLISH 명령을 사용해야 해 DB가 이 명령 처리에 더 많은 CPU를 소비합니다.

어떤 스키마를 쓰든 단순한 사실이 있습니다: 많은 초대형 애플리케이션이 어떤 형태의 클라이언트 사이드 캐싱을 구현합니다. 빠른 스토어나 빠른 캐시 서버 다음의 논리적 다음 단계이기 때문입니다. 그래서 Redis 6은 이 패턴을 구현·접근·신뢰·효율을 훨씬 쉽게 만들기 위해 클라이언트 사이드 캐싱을 직접 지원합니다.

Redis의 클라이언트 사이드 캐싱 구현

Redis의 클라이언트 사이드 캐싱 지원은 Tracking이라 불리며 두 가지 모드가 있습니다:

  • 기본 모드(default): 서버가 특정 클라이언트가 접근한 키를 기억하고, 같은 키가 수정되면 무효화 메시지를 보냅니다. 서버 쪽에 메모리를 쓰지만, 클라이언트가 메모리에 보유할 수 있는 키 집합에 대해서만 무효화 메시지를 보냅니다.
  • 브로드캐스팅 모드(broadcasting): 서버가 특정 클라이언트가 접근한 키를 기억하지 않으므로 서버 쪽 메모리를 전혀 쓰지 않습니다. 대신 클라이언트가 object:user: 같은 키 접두어를 구독하고, 구독된 접두어와 일치하는 키가 터치될 때마다 알림 메시지를 받습니다.

기본 모드부터 살펴보겠습니다(브로드캐스팅은 나중에 상세히 설명).

  • 클라이언트는 원하면 Tracking을 켤 수 있습니다. 연결은 Tracking을 켜지 않은 채 시작합니다.
  • Tracking이 켜지면 서버는 연결 수명 동안 각 클라이언트가 요청한 키(그 키에 대한 읽기 명령을 보낸다는 뜻)를 기억합니다.
  • 어떤 클라이언트가 키를 수정하거나, 연관된 만료 시간으로 퇴출되거나, maxmemory 정책으로 퇴출되면, 해당 키를 캐시했을 수 있는 Tracking이 켜진 모든 클라이언트가 *무효화 메시지(invalidation message)*를 받습니다.
  • 클라이언트는 무효화 메시지를 받으면 stale 데이터 서빙을 피하기 위해 해당 키를 제거해야 합니다.

프로토콜 예시:

  • Client 1 -> Server: CLIENT TRACKING ON
  • Client 1 -> Server: GET foo
  • (서버는 Client 1이 키 "foo"를 캐시했을 수 있다고 기억)
  • (Client 1은 "foo" 값을 로컬 메모리에 기억 가능)
  • Client 2 -> Server: SET foo SomeOtherValue
  • Server -> Client 1: INVALIDATE "foo"

겉보기엔 좋아 보이지만, 오래 사는 연결에서 1만 개 연결이 수백만 키를 요청한다고 상상하면 서버는 너무 많은 정보를 저장하게 됩니다. Redis는 서버 쪽 메모리 사용과 기능을 구현하는 데이터 구조 처리의 CPU 비용을 제한하기 위해 두 가지 핵심 아이디어를 씁니다:

  • 서버는 특정 키를 캐시했을 수 있는 클라이언트 목록을 단일 전역 테이블로 기억합니다. 이 테이블을 무효화 테이블(Invalidation Table) 이라 합니다. 최대 항목 수가 있으며, 새 키가 삽입되면 서버는 그 키가 수정된 척(실제로 아니어도) 하고 클라이언트에 무효화 메시지를 보내 이전 항목을 퇴출할 수 있습니다. 이렇게 하면 해당 키의 로컬 복사본을 갖는 클라이언트가 퇴출해도 좋지만, 이 키에 쓰인 메모리를 회수할 수 있습니다.
  • 무효화 테이블 안에는 클라이언트 구조 포인터를 저장할 필요가 없습니다(그러면 클라이언트 연결 종료 시 가비지 컬렉션 절차가 강제됨). 대신 클라이언트 ID만 저장합니다(각 Redis 클라이언트는 고유 숫자 ID를 가짐). 클라이언트가 연결을 끊으면, 캐싱 슬롯이 무효화될 때 정보가 점진적으로 가비지 컬렉션됩니다.
  • 단일 키 네임스페이스가 있으며 DB 번호로 나뉘지 않습니다. 클라이언트가 DB 2의 키 foo를 캐싱하고, 다른 클라이언트가 DB 3의 키 foo 값을 바꿔도 무효화 메시지는 여전히 전송됩니다. 이렇게 DB 번호를 무시할 수 있어 메모리 사용과 구현 복잡도를 모두 줄입니다.

이중 연결 모드 (Two connections mode)

Redis 6이 지원하는 새 프로토콜 RESP3을 쓰면 데이터 쿼리 실행과 무효화 메시지 수신을 같은 연결에서 할 수 있습니다. 하지만 많은 클라이언트 구현은 데이터용 연결과 무효화 메시지용 연결, 두 개의 분리된 연결로 클라이언트 사이드 캐싱을 구현하는 것을 선호할 수 있습니다. 그래서 클라이언트가 Tracking을 켤 때 "클라이언트 ID"를 지정해 무효화 메시지를 다른 연결로 리다이렉트할 수 있습니다. 여러 데이터 연결이 같은 연결로 무효화 메시지를 리다이렉트할 수 있어 커넥션 풀링을 구현하는 클라이언트에 유용합니다. 이 이중 연결 모델은 RESP2(같은 연결에서 서로 다른 종류의 정보를 멀티플렉싱할 수 없음)에서도 지원되는 유일한 모델입니다.

다음은 RESP2 모드에서 완전한 세션 예시입니다 — Tracking을 다른 연결로 리다이렉트하며 켜고, 키를 요청하고, 키가 수정되면 무효화 메시지를 받는 단계입니다.

시작은 클라이언트가 무효화용 첫 연결을 열고, 연결 ID를 요청하고, RESP2 모드에서 무효화 메시지를 받는 데 쓰는 특수 채널에 Pub/Sub으로 구독합니다(RESP2는 일반 Redis 프로토콜이며 Redis 6에서 HELLO 명령으로 선택적으로 쓸 수 있는 더 진보된 프로토콜이 아님을 기억하세요):

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

이제 데이터 연결에서 Tracking을 켭니다:

(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 메시지의 세 번째 요소는 단일 키가 아니라 요소 하나짜리 Redis 배열입니다. 배열을 보내므로 무효화할 키 그룹이 있으면 한 메시지로 처리할 수 있습니다. flush(FLUSHALL 또는 FLUSHDB)의 경우 null 메시지가 전송됩니다.

RESP2와 함께 무효화 메시지를 읽기 위한 Pub/Sub 연결을 쓰는 클라이언트 사이드 캐싱에서 아주 중요한 점: Pub/Sub 사용은 전적으로 기존 클라이언트 구현을 재사용하기 위한 트릭입니다. 실제로 메시지가 채널로 보내져 구독한 모든 클라이언트가 받는 것은 아니고, CLIENT 명령의 REDIRECT 인자에 지정한 연결만 실제로 Pub/Sub 메시지를 받습니다. 그래서 이 기능이 훨씬 더 확장 가능합니다.

RESP3을 쓰면 무효화 메시지는 (같은 연결이거나 리다이렉트 시 보조 연결에서) push 메시지로 전송됩니다(RESP3 스펙 참고).

Tracking이 무엇을 추적하는가

기본적으로 클라이언트는 서버에 자신이 캐싱하는 키를 알릴 필요가 없습니다. 읽기 전용 명령의 맥락에서 언급된 모든 키는 캐시될 수 있으므로 서버가 추적합니다.

이는 클라이언트가 서버에 무엇을 캐싱하는지 알릴 필요가 없다는 명백한 장점이 있습니다. 게다가 많은 클라이언트 구현에서 이는 원하는 바입니다. 아직 캐시되지 않은 모든 것을 선입선출(FIFO)로 캐싱하는 좋은 해법이 될 수 있기 때문입니다 — 일정 수의 객체를 캐싱하고, 새 데이터를 조회할 때마다 캐싱한 뒤 가장 오래된 객체를 버리는 방식입니다. 더 진보된 구현은 가장 덜 사용된 객체를 버리거나 비슷한 방식을 쓸 수 있습니다.

서버에 쓰기 트래픽이 있으면 캐싱 슬롯이 시간에 걸쳐 무효화된다는 점에 유의하세요. 일반적으로 서버가 "가져온 것은 캐시하기도 한다"고 가정할 때 절충이 있습니다:

  • 클라이언트가 새 객체를 환영하는 정책으로 많은 것을 캐싱할 때 더 효율적
  • 서버는 클라이언트 키에 대해 더 많은 데이터를 유지해야 함
  • 클라이언트는 캐시하지 않은 객체에 대한 쓸모없는 무효화 메시지를 받게 됨

그래서 다음 절에서 설명하는 대안이 있습니다.

Opt-in과 Opt-out 캐싱

Opt-in

클라이언트 구현이 선택된 키만 캐싱하고, 캐싱할 것과 하지 않을 것을 서버에 명시적으로 알리고 싶을 수 있습니다. 이는 새 객체 캐싱 시 더 많은 대역폭을 요구하지만, 동시에 서버가 기억해야 하는 데이터 양과 클라이언트가 받는 무효화 메시지 양을 줄입니다.

이를 위해 OPTIN 옵션으로 Tracking을 켜야 합니다:

CLIENT TRACKING ON REDIRECT 1234 OPTIN

이 모드에서 기본적으로 읽기 쿼리에 언급된 키는 캐시되지 않기로 약속됩니다. 클라이언트가 무언가를 캐싱하려면 실제 데이터 조회 명령 직전에 특수 명령을 보내야 합니다:

CLIENT CACHING YES
+OK
GET foo
"bar"

CACHING 명령은 그 직후 실행되는 명령에만 영향을 줍니다. 하지만 다음 명령이 MULTI라면 트랜잭션 안의 모든 명령이 추적됩니다. 마찬가지로 Lua 스크립트의 경우 스크립트가 실행한 모든 명령이 추적됩니다.

Opt-out

Opt-out 캐싱은 클라이언트가 각 키에 명시적으로 opt-in하지 않고 키를 로컬에서 자동으로 캐싱하게 합니다. 이 접근법은 달리 지정하지 않는 한 모든 키가 기본적으로 캐싱되도록 보장합니다. Opt-out 캐싱은 개별 키에 대한 캐싱 활성화 명령 필요성을 줄여 클라이언트 사이드 캐싱 구현을 단순화합니다.

OPTOUT 옵션으로 Tracking을 켜야 합니다:

CLIENT TRACKING ON OPTOUT

특정 키가 추적·캐싱되는 것을 제외하려면 CLIENT UNTRACKING 명령을 씁니다:

CLIENT UNTRACKING key

브로드캐스팅 모드 (Broadcasting mode)

지금까지 Redis가 구현하는 첫 클라이언트 사이드 캐싱 모델을 설명했습니다. 브로드캐스팅이라는 또 다른 모델이 있는데, 다른 절충의 관점에서 문제를 보고 서버 쪽 메모리를 전혀 쓰지 않지만 클라이언트에는 더 많은 무효화 메시지를 보냅니다. 주요 동작:

  • 클라이언트는 BCAST 옵션으로 클라이언트 사이드 캐싱을 켜고, PREFIX 옵션으로 하나 이상의 접두어를 지정합니다. 예: CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user:. 접두어를 전혀 지정하지 않으면 접두어가 빈 문자열로 가정되어, 클라이언트는 수정되는 모든 키에 대한 무효화 메시지를 받습니다. 하나 이상의 접두어를 쓰면 지정된 접두어 중 하나와 일치하는 키만 무효화 메시지로 전송됩니다.
  • 서버는 무효화 테이블에 아무것도 저장하지 않습니다. 대신 각 접두어가 클라이언트 목록과 연관된 다른 접두어 테이블(Prefixes Table) 을 씁니다.
  • 두 접두어는 키스페이스의 겹치는 부분을 추적할 수 없습니다. 예를 들어 접두어 "foo"와 "foob"는 키 "foobar"에 대해 둘 다 무효화를 촉발하므로 허용되지 않습니다. 하지만 접두어 "foo"만 쓰는 것은 충분합니다.
  • 접두어 중 하나와 일치하는 키가 수정될 때마다 해당 접두어를 구독한 모든 클라이언트가 무효화 메시지를 받습니다.
  • 서버는 등록된 접두어 수에 비례하는 CPU를 소비합니다. 접두어가 소수면 차이를 느끼기 어렵습니다. 접두어 수가 많으면 CPU 비용이 꽤 커질 수 있습니다.
  • 이 모드에서 서버는 특정 접두어를 구독한 모든 클라이언트에 단일 응답을 만들고 모두에게 같은 응답을 보내는 최적화를 수행할 수 있습니다. 이는 CPU 사용을 낮추는 데 도움이 됩니다.

NOLOOP 옵션

기본적으로 클라이언트 사이드 Tracking은 키를 수정한 클라이언트에도 무효화 메시지를 보냅니다. 때로 클라이언트가 원하기도 합니다 — 쓰기를 로컬에 자동 캐싱하지 않는 아주 기본적인 로직을 구현하기 때문입니다. 하지만 더 진보된 클라이언트는 자신이 수행하는 쓰기조차 로컬 인-메모리 테이블에 캐싱하고 싶을 수 있습니다. 그럴 경우 쓰기 직후 무효화 메시지를 받는 것은 문제입니다 — 방금 캐시한 값을 강제로 퇴출시키기 때문입니다.

이때 NOLOOP 옵션을 쓸 수 있습니다. 일반·브로드캐스팅 모드 모두에서 작동합니다. 이 옵션을 쓰면 클라이언트가 자신이 수정한 키에 대한 무효화 메시지를 받고 싶지 않다고 서버에 알릴 수 있습니다.

기본 모드의 Tracking에서 키가 수정되면 서버는 무효화 테이블에서 해당 키를 제거합니다. 키를 수정한 연결이 NOLOOP을 쓰면 Redis는 해당 연결로의 무효화 메시지를 억제하지만, 쓰기 후 그 연결에 대해 키는 더 이상 추적되지 않습니다. 같은 키의 미래 무효화를 받으려면 연결이 키를 다시 읽어 Redis가 다시 추적하게 해야 합니다.

레이스 컨디션 피하기

무효화 메시지를 다른 연결로 리다이렉트하며 클라이언트 사이드 캐싱을 구현할 때 가능한 레이스 컨디션이 있음을 알아야 합니다. 다음 상호작용 예를 보겠습니다(데이터 연결 "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 응답이 클라이언트에 도달하는 게 느려서, 실제로는 더 이상 유효하지 않은 데이터가 도착하기 전에 무효화 메시지를 받았습니다. 그래서 foo 키의 stale 버전을 계속 서빙하게 됩니다. 이 문제를 피하려면 명령을 보낼 때 자리표시자로 캐시를 채우는 것이 좋습니다:

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.

데이터와 무효화 메시지에 단일 연결을 쓰면 이런 레이스 컨디션은 불가능합니다. 그 경우 메시지 순서가 항상 알려져 있기 때문입니다.

서버와 연결을 잃었을 때

마찬가지로 무효화 메시지를 받는 데 쓰는 소켓과 연결을 잃으면 stale 데이터로 끝날 수 있습니다. 이를 피하려면 다음을 수행해야 합니다:

  • 연결이 끊기면 로컬 캐시를 flush 하도록 보장
  • RESP2+Pub/Sub이든 RESP3이든 무효화 채널을 주기적으로 ping(연결이 Pub/Sub 모드여도 PING 명령을 보낼 수 있습니다!). 연결이 끊긴 것처럼 보이고 ping 백을 받지 못하면, 최대 시간 후 연결을 닫고 캐시를 flush
  • 어떤 것을 캐싱할지

무엇을 캐싱할까

클라이언트는 특정 캐시된 키가 요청에서 실제로 서빙된 횟수에 대한 내부 통계를 실행해 미래에 무엇을 캐싱할지 이해할 수 있습니다. 일반적으로:

  • 연속적으로 변하는 많은 키를 캐싱하고 싶지 않음
  • 매우 드물게 요청되는 많은 키를 캐싱하고 싶지 않음
  • 자주 요청되고 합리적인 속도로 변하는 키를 캐싱해야 함. 합리적인 속도로 변하지 않는 키의 예로, 계속 INCR되는 전역 카운터가 있습니다

하지만 더 단순한 클라이언트는 어떤 임의 샘플링으로 데이터를 퇴출하고, 특정 캐시된 값이 마지막으로 서빙된 시각만 기억해 최근 서빙되지 않은 키를 퇴출하려 할 수 있습니다.

클라이언트 라이브러리 구현을 위한 기타 힌트

  • TTL 처리: TTL이 있는 키 캐싱을 지원하려면 키 TTL도 요청하고 로컬 캐시에 TTL을 설정하세요.
  • 모든 키에 최대 TTL을 두는 것(TTL이 없어도)은 좋습니다. 버그나 연결 문제로 클라이언트가 로컬 복사본에 오래된 데이터를 갖는 것에서 보호합니다.
  • 클라이언트가 사용하는 메모리 양을 제한하는 것은 절대적으로 필요합니다. 새 키가 추가될 때 오래된 키를 퇴출하는 방법이 있어야 합니다.

Redis가 사용하는 메모리 양 제한

Redis가 기억하는 키의 최대 수에 적절한 값을 구성하거나, Redis 쪽에 아무 메모리도 쓰지 않는 BCAST 모드를 사용하세요. BCAST를 쓰지 않을 때 Redis가 소비하는 메모리는 추적되는 키 수와 그러한 키를 요청하는 클라이언트 수 둘 다에 비례합니다.

더 알아보기 (Learn more)