Java 아키텍처·Spring 용어 사전
캐시Caffeine

로컬 캐시·분산 캐시

JVM 안에 둘 것인가 Redis에 둘 것인가. 네트워크 비용과 일관성의 맞바꿈이다.

캐시를 어디에 둘 것인가의 선택.

로컬 (인프로세스, Caffeine)분산 (Redis)
위치그 JVM 의 힙별도 서버
접근 비용메모리 참조 — 나노초네트워크 왕복 — 수백 마이크로초
일관성인스턴스마다 따로 논다모두가 같은 값을 본다
메모리인스턴스 수만큼 중복 보관한 벌
크기 한계힙 안 — GC 압박을 준다별도 서버 메모리만큼
장애앱과 생사를 같이한다캐시만 따로 죽을 수 있다
직렬화없다. 객체를 그대로 쓴다넣고 뺄 때마다 직렬화·역직렬화

속도 차이가 세 자릿수인 이유는 단순하다. 로컬은 참조를 따라가는 것이고 분산은 네트워크를 다녀오는 것이다. 거기에 직렬화 비용이 얹힌다.

로컬 캐시의 진짜 문제는 무효화다

인스턴스가 10대면 같은 데이터의 사본이 10벌이다. 하나를 고쳤을 때 나머지 9대에게 알릴 방법이 없다. TTL 이 지나기를 기다리는 동안 사용자는 어느 서버에 붙느냐에 따라 다른 값을 본다. 새로고침할 때마다 값이 바뀌는 증상이 여기서 온다.

그래서 로컬 캐시는 TTL 을 짧게 두고, 좀 낡아도 괜찮은 것에 쓴다. 코드 테이블, 설정값, 권한 정의처럼 거의 안 바뀌고 자주 읽는 것이 잘 맞는다.

굳이 맞춰야 한다면 변경 이벤트를 발행해(Redis Pub/Sub 등) 각 인스턴스가 자기 로컬 캐시를 비우게 한다. 다만 이벤트가 유실되면 조용히 어긋난 채로 남는다는 점을 알고 써야 한다. 그래서 이벤트 무효화를 쓰더라도 TTL 은 안전망으로 남겨 둔다.

힙에 있다는 것의 의미

로컬 캐시는 결국 살아 있는 객체다. 캐시가 크면 그만큼 오래 사는 객체가 늘어 구세대로 승격되고, GC 가 매번 훑어야 할 양이 늘어 정지 시간이 길어진다.

"캐시를 늘렸더니 응답이 느려졌다" 는 대개 이 경로다. 평균은 좋아졌는데 꼬리 지연(p99)이 나빠진다면 GC 를 먼저 의심한다. Redis 로 옮기면 그 메모리는 JVM 힙 밖이라 GC 와 무관해진다 — 네트워크 왕복을 사고 GC 압박을 파는 거래다.

크기를 어떻게 정하나

로컬 캐시는 반드시 최대 크기를 건다(maximumSize). 무제한으로 두면 캐시가 아니라 메모리 누수다. 크기를 넘으면 무엇을 버릴지는 축출 정책과 같은 문제이고, Caffeine 은 접근 빈도와 최근성을 함께 보는 방식으로 단순 LRU 보다 높은 적중률을 낸다.

다층으로 함께 쓴다

요청 → L1 로컬 (아주 뜨거운 소수)
        └ 미스 → L2 Redis (공유)
                  └ 미스 → DB

가장 자주 쓰는 소수를 L1 이 받아 네트워크 왕복을 없애고, 나머지는 L2 가 공유해 일관성을 지킨다. 어밸런치 때 L1 이 완충재가 되어 주는 이점도 있다.

L1 은 작게, TTL 을 짧게 두는 것이 요령이다 — 크게 잡는 순간 위의 무효화 문제와 GC 문제를 그대로 떠안는다. 캐시를 두 겹 두면 디버깅도 두 겹이 된다는 비용도 계산에 넣어야 한다.

어느 쪽을 고르나

판단 기준은 결국 "낡은 값을 얼마나 견딜 수 있나" 다.

데이터선택
코드 테이블·설정값 — 거의 안 바뀐다로컬. 짧은 TTL 로 충분하다
상품 정보 — 가끔 바뀌고 즉시 반영돼야 한다분산
세션·분산 락 — 모두가 같은 값을 봐야 한다분산. 로컬은 아예 틀린 선택이다
아주 뜨거운 소수 키로컬 + 분산 다층

"빠르니까 로컬" 이 아니라 "불일치를 견딜 수 있으니 로컬" 이 올바른 순서다.

면접 함정

  • "로컬이 빠르니 로컬을 쓰면 된다" → 인스턴스가 둘 이상인 순간 일관성 문제가 생긴다.
  • "Redis 가 있으니 로컬은 필요 없다" → 왕복을 없애는 값어치가 있고, Redis 장애 시 완충재가 된다.
  • "로컬 캐시는 공짜" → 힙을 쓰므로 GC 비용을 낸다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 캐시·Redis — 어사이드·쓰기 전략