서버 보조형 클라이언트 캐시 무효화 설계
이 글에서 먼저 가져갈 세 가지
로컬 캐시의 속도보다 무효화 경로의 안전성을 먼저 설계하는 운영 기준입니다.
-
01
캐시 적중률보다 연결 생명주기를 먼저 본다.
무효화 연결을 잃는 순간 전체 캐시를 비워야 오래된 값의 재사용을 막을 수 있다. 본문 3절
-
02
정밀 추적과 방송의 비용을 구분한다.
기본 Tracking은 서버 메모리를, BCAST는 넓은 무효화 트래픽을 지불한다. 본문 2절
-
03
평균 속도가 아닌 장애 복구까지 측정한다.
p99·무효화 지연·전체 플러시·재가열 QPS를 함께 봐야 실제 도입 가치를 판단할 수 있다. 본문 3·5절
1. 왜 캐시 서버가 빨라도 네트워크 왕복은 사라지지 않을까?
인메모리 데이터 저장소를 도입해도 애플리케이션과 서버 사이에는 직렬화, 소켓 대기, 네트워크 왕복, 서버 명령 실행이 남는다. 자주 읽고 드물게 바뀌는 기능 플래그, 권한 스냅샷, 상품 메타데이터를 매 요청마다 원격 조회하면 데이터 자체보다 왕복 경로가 더 비싸질 수 있다. 애플리케이션 프로세스 안의 로컬 캐시는 이 경로를 없애지만, 원본이 바뀌었을 때 모든 복제본을 어떻게 버릴 것인가라는 더 어려운 문제를 만든다.
단순 TTL은 변경 시점과 만료 시점 사이에 오래된 값을 허용한다. 짧은 TTL은 그 창을 줄이는 대신 만료가 겹칠 때 원격 서버로 요청을 되돌려 보낸다. 애플리케이션이 직접 Pub/Sub 이벤트를 발행하는 방식은 쓰기 경로마다 발행 누락을 막아야 하고, 실제로 해당 키를 가진 프로세스만 골라 통지하기도 어렵다. 서버 보조형 클라이언트 캐싱은 원본 서버가 읽은 키를 추적하거나 prefix 변경을 방송해 이 간극을 줄인다.
- [ ] 캐시 무효화 연결이 끊겼는데도 프로세스가 기존 값을 계속 제공하는가?
- [ ] 읽기보다 쓰기가 잦은 키까지 같은 정책으로 로컬 캐시에 넣는가?
- [ ] 적중률만 보고 무효화 지연과 전체 플러시 횟수는 측정하지 않는가?
- [ ] 배포 직후 모든 인스턴스가 같은 키를 동시에 다시 읽는가?
그렇다면 서버는 모든 클라이언트의 로컬 메모리를 알지 못하면서 어떻게 필요한 변경만 전달할까요? 핵심은 값을 복제하는 데이터 경로와 복제본을 버리는 제어 경로를 분리하는 데 있다.
2. ‘정밀 추적과 전체 방송 중 무엇이 먼저 무너질까?’ 두 무효화 모드의 비용 구조
Redis의 CLIENT TRACKING 기본 모드에서 서버는 연결이 읽은 키를 기억한다. 다른 연결이 해당 키를 수정하거나, 만료 또는 메모리 정책으로 키가 제거되면 서버는 그 키를 읽었던 클라이언트에 무효화 메시지를 보낸다. 클라이언트는 메시지를 받은 즉시 로컬 항목을 제거하고 다음 읽기에서 원본을 다시 조회한다. 원격 서버는 값을 클라이언트에 밀어 넣지 않는다. “이 복제본은 더 이상 유효하지 않다”는 사실만 전달한다.
text
첫 읽기 경로
Request -> Local Cache MISS -> Redis GET -> Local Cache STORE
|
+-> server tracks (key -> client IDs)
변경 경로
Writer -> Redis SET -> Invalidation PUSH -> Local Cache EVICT
다음 읽기
Request -> Local Cache MISS -> Redis GET -> refreshed value
기본 모드는 읽은 키와 연결의 관계를 보관하므로 서버 메모리를 사용하지만, 관련된 클라이언트에만 메시지를 보낼 수 있다. 반면 BCAST 모드는 개별 읽기 관계를 저장하지 않고 지정한 PREFIX에 속하는 변경을 구독 클라이언트에 방송한다. 서버 추적 메모리는 줄지만, 클라이언트가 실제로 갖고 있지 않은 키의 무효화까지 받을 수 있다. prefix 없이 방송하면 모든 키 변경이 대상이 되므로 쓰기량과 인스턴스 수의 곱이 제어 경로 부하로 돌아온다.
OPTIN은 CLIENT CACHING YES 직후의 읽기만 추적해 선별 캐싱을 강제한다. 자주 읽고 드물게 바뀌는 소수 키가 명확하다면 가장 방어적인 출발점이다. OPTOUT은 기본적으로 읽기 키를 추적하되 특정 명령만 제외한다. NOLOOP은 같은 연결이 수정한 키에 무효화 메시지를 되돌려 보내지 않지만, 쓰기 뒤 로컬 값을 안전하게 갱신하는 책임까지 없애 주지는 않는다.
여기서 시니어 엔지니어가 가장 자주 놓치는 지점이 있다. 모드 선택은 “서버 메모리 대 네트워크”의 단순 비교가 아니다. 키 변경 빈도, 프로세스 수, prefix의 넓이, 연결 풀의 구조가 함께 fan-out을 결정한다.
3. ‘연결 하나가 끊겼는데 왜 전체 캐시를 비워야 할까?’ 일관성 창을 닫는 구현
무효화 메시지는 제어 경로다. 이 연결이 끊긴 동안 변경된 키를 클라이언트가 알아낼 방법이 없다면, 재연결 성공만으로 기존 복제본의 신뢰가 복구되지 않는다. Redis 공식 클라이언트 캐싱 지침은 연결 풀의 연결 하나를 포함해 연결이 끊기면 클라이언트 측 캐시의 모든 키를 비우도록 설명한다. 이는 과잉 반응이 아니라 변경 통지를 놓쳤을 가능성을 제거하는 안전 전환이다.
RESP3는 일반 응답과 push 무효화를 같은 연결에서 전달할 수 있다. 별도의 연결로 무효화를 리디렉션하는 구성에서는 데이터 응답과 무효화의 순서를 애플리케이션이 더 신중히 다뤄야 한다. 연결 상태 머신은 최소한 healthy, suspect, warming 세 상태를 가져야 한다. heartbeat 실패나 재연결을 감지하면 suspect로 전환해 전체 캐시를 비우고, 원격 조회로 안전하게 폴백한다. 재연결 뒤에는 인기 키를 한꺼번에 채우지 말고 요청 흐름에 따라 점진적으로 재가열한다.
typescript
type CacheState = 'healthy' | 'suspect' | 'warming';
class TrackedLocalCache<V> {
private state: CacheState = 'warming';
private values = new Map<string, V>();
onInvalidation(keys: string[] | null) {
if (keys === null) this.values.clear();
else keys.forEach((key) => this.values.delete(key));
}
onTrackingConnectionLost() {
this.state = 'suspect';
this.values.clear(); // 놓친 변경을 특정할 수 없으므로 fail closed
}
onTrackingConnectionReady() {
this.state = 'warming'; // 트래픽 기반으로 점진적 재가열
}
}
▲ 데이터 조회선보다 중요한 것은 끊김을 감지하고 안전하게 폴백하는 무효화 제어선이다.
벤치마크는 임의의 “몇 배 빨라졌다”를 먼저 적는 방식이 아니라 동일한 workload에서 세 경로를 비교해야 한다. 원격 조회만 사용하는 기준선, TTL 로컬 캐시, 서버 보조형 무효화 캐시를 같은 키 분포와 읽기·쓰기 비율로 실행한다. 아래 숫자는 제품 성능을 주장하는 값이 아니라 도입 실험의 통과 기준 예시다.
| 측정 지표 | 기준선: 원격 조회 | Tracking 실험 통과 기준 | 함께 볼 위험 신호 |
|---|---|---|---|
| 원격 읽기 명령 수 | 요청당 1회 | 핫키 구간 70% 이상 감소 | 미스 집중으로 순간 QPS가 기준선 초과 |
| 애플리케이션 p99 | 실측 기준값 | 기준선 대비 20% 이상 단축 | 평균만 개선되고 p99는 악화 |
| 무효화 처리 지연 | 해당 없음 | 서비스의 허용 stale window 이내 | 이벤트 루프 정체와 큐 누적 |
| 연결 손실 후 잔존 항목 | 해당 없음 | 0개 | 재연결 뒤 기존 항목 재사용 |
테스트에는 균등 분포만 쓰지 않는다. Zipf형 핫키, 쓰기 폭주, 프로세스 동시 재시작, 무효화 연결 강제 단절을 포함해야 한다. 특히 전체 플러시 직후의 재가열 QPS와 원격 서버 CPU를 측정해야 한다. 정상 상태의 높은 적중률은 장애 복구 시 캐시 스탬피드를 숨길 수 있기 때문이다.
연결 풀에서는 ‘응답 도착’과 ‘무효화 도착’의 순서를 어떻게 보장할까?
단일 RESP3 연결에서 데이터 응답과 push 메시지를 함께 처리하면 한 소켓 위의 순서를 이용할 수 있다. 하지만 데이터 연결 여러 개가 별도 무효화 연결 하나로 메시지를 모으는 구조에서는 경쟁 조건을 별도로 검증해야 한다. 한 연결이 값을 읽는 동안 다른 연결의 쓰기가 완료되고 무효화가 먼저 처리되면, 늦게 도착한 읽기 응답을 로컬 캐시에 다시 넣어 오래된 값을 되살릴 수 있다. 라이브러리가 이 순서를 안전하게 처리하는지 확인하지 않고 명령 지원 여부만 보고 도입하면 안 된다.
가장 보수적인 구현은 각 캐시 적재 시점에 세대 번호를 확인하는 것이다. 무효화 수신기는 전역 또는 키별 세대를 증가시키고, 읽기 시작 시점과 응답 적재 시점의 세대가 다르면 응답은 호출자에게 반환하더라도 로컬 캐시에 저장하지 않는다. 키별 세대는 정밀하지만 메모리와 정리 비용을 만든다. 전역 세대는 단순하지만 관련 없는 변경에도 적재를 포기해 적중률을 낮춘다. 따라서 먼저 사용하는 클라이언트가 이미 제공하는 일관성 보장을 확인하고, 직접 구현은 장애 테스트로 순서 경쟁을 재현할 수 있을 때만 선택한다.
다중 키 명령은 캐시 키 설계를 더 복잡하게 만든다. MGET a b와 MGET b a는 반환 순서가 다르므로 같은 항목으로 취급할 수 없다. 명령 이름, 인자 순서, 직렬화 방식이 캐시 식별자에 포함되어야 하며, 키 하나가 무효화될 때 그 키가 포함된 다중 키 결과도 제거되어야 한다. 이 역색인을 애플리케이션이 직접 관리하면 로컬 메모리와 삭제 비용이 급격히 늘 수 있다. 첫 도입에서는 단일 키의 결정적 읽기 명령으로 범위를 제한하는 편이 안전하다.
또한 프로세스 수가 늘어나면 로컬 캐시 총량도 선형으로 증가한다. 인스턴스당 200메가바이트가 작아 보여도 수백 개 인스턴스에서는 원격 캐시보다 더 큰 복제 메모리를 소비할 수 있다. 컨테이너 메모리 제한에 가까워지면 언어 런타임의 가비지 컬렉션 중단이 p99를 악화시키고, 재시작 증가가 다시 전체 플러시와 재가열을 부른다. localcachebytes는 단순 용량 지표가 아니라 재시작률, 가비지 컬렉션 시간, 원격 폴백 QPS와 함께 읽어야 한다.
이 때문에 카나리의 롤백 조건은 명확해야 한다. 무효화 지연이 stale 허용 시간을 넘거나, 연결 손실 뒤 잔존 항목이 하나라도 발견되거나, 재가열 QPS가 원격 서버 예산을 초과하면 적중률과 관계없이 기능을 끈다. 런타임 스위치는 로컬 조회만 우회하는 것이 아니라 Tracking 등록을 해제하고 메모리를 비우는 절차까지 포함해야 한다. 그래야 롤백 뒤에도 보이지 않는 제어 트래픽과 오래된 복제본이 남지 않는다.
4. ‘이럴 땐 절대 켜지 마세요’ 무효화 폭주와 로컬 메모리의 안티패턴
초당 계속 증가하는 카운터, 세션의 즉시 폐기 여부, 결제·재고처럼 오래된 읽기의 비용이 큰 값은 기본 후보가 아니다. 변경 빈도가 읽기 빈도와 비슷하면 로컬 적중 전에 무효화가 도착해 CPU와 네트워크만 추가로 쓴다. 서버 측 Tracking 테이블의 항목이 밀려나면 서버는 실제 변경이 없어도 해당 키를 무효화해 공간을 회수할 수 있으므로, 테이블 압박은 정합성 오류보다 적중률 급락과 재조회 증가로 나타난다.
BCAST PREFIX user:처럼 넓은 prefix도 위험하다. 한 테넌트의 대량 업데이트가 다른 프로세스의 무관한 로컬 항목까지 연속으로 제거할 수 있다. prefix는 도메인 이름이 아니라 변경률과 구독 범위를 기준으로 나눈다. product:catalog:과 product:inventory:가 같은 화면에서 쓰이더라도 전자는 캐싱 후보, 후자는 제외 후보가 될 수 있다.
💡 실무 원칙: 적중률 SLO보다 먼저 stale read 허용 범위와 fail-closed 조건을 문서화하고, 캐시 비활성화 런타임 스위치를 둔다.
관측 지표도 로컬 hit/miss 두 개로 끝내면 안 된다. invalidationreceivedtotal, invalidationqueuelag, trackingdisconnecttotal, fullflushtotal, remotefallbacktotal, localcachebytes를 같은 대시보드에 놓는다. 서버에서는 Tracking 테이블 압박, 연결 수, 명령 처리량을 함께 본다. 적중률이 높아졌는데 무효화 큐 지연도 증가했다면, 시스템은 더 빠른 것이 아니라 오래된 값을 더 오래 제공하는 중일 수 있다.
5. ‘2주 안에 안전하게 검증하려면?’ 키 선별부터 장애 주입까지의 로드맵
| 단계 | 핵심 실행 과제 | 산출물 및 검증 지표 |
|---|---|---|
| Phase 1 | 읽기 빈도, 변경 빈도, 값 크기, stale 허용 시간을 기준으로 후보 키를 1~2개 고르고 `OPTIN`으로 제한한다. | 키별 원격 조회량과 변경률 기준선, 캐시 제외 목록 |
| Phase 2 | 일부 인스턴스에 카나리 적용하고 p99·원격 명령 감소·무효화 지연·로컬 메모리를 동시에 측정한다. | 기준선 대비 효과와 최악 구간의 회귀 여부 |
| Phase 3 | 연결 단절, 서버 재시작, 전체 무효화, 동시 재배포를 주입하고 전체 플러시와 지터 재가열을 검증한다. | 잔존 항목 0개, 원격 서버의 재가열 QPS 예산 준수 |
서버 보조형 클라이언트 캐싱은 캐시 서버를 없애는 기술이 아니다. 원격 서버를 정본으로 유지하면서 반복 읽기의 일부를 애플리케이션 메모리로 옮기고, 그 복제본을 폐기할 제어 경로를 서버가 보조하는 기술이다. 성공 여부는 정상 상태에서 몇 마이크로초를 줄였는지가 아니라, 통지를 잃은 순간 얼마나 빨리 로컬 상태를 불신할 수 있는지로 판별해야 한다.
가장 빠른 캐시는 네트워크를 건너지 않는 캐시다. 그러나 프로덕션에서 가장 안전한 캐시는 무효화 경로를 잃었을 때 스스로를 즉시 버릴 줄 아는 캐시다.
공식 참고 자료
- [Redis Client-side caching introduction](https://redis.io/docs/latest/develop/clients/client-side-caching/)
- [Redis Client-side caching reference](https://redis.io/docs/latest/develop/reference/client-side-caching/)
- [Redis CLIENT TRACKING command](https://redis.io/docs/latest/commands/client-tracking/)
댓글 0