캐시는 어디에 두느냐에 따라 속도, 용량, 관리 비용이 완전히 달라진다. 브라우저에 둘 수도 있고, 서버 메모리에 둘 수도 있고, Redis 같은 별도 서버에 둘 수도 있다. 이 선택을 잘못하면 캐시를 넣고도 효과를 못 보거나, 오히려 정합성 문제가 생긴다.

저장소 계층

CPU가 데이터를 처리하려면 메모리에서 가져와야 한다. 그런데 메모리는 CPU에 비해 느리다. 이 속도 차이를 메우기 위해 CPU와 메모리 사이에 여러 단계의 캐시가 존재한다. 위로 갈수록 빠르고 비싸고 작고, 아래로 갈수록 느리고 싸고 크다.

block-beta columns 3 L1["L1 캐시
~1ns / 64KB"]:3 L2["L2 캐시
~4ns / 256KB"]:3 L3["L3 캐시
~10ns / 수 MB"]:3 RAM["메모리 (RAM)
~100ns / 수 GB"]:3 DISK["디스크 (SSD)
~μs / 수 TB"]:3 style L1 fill:#FFE6E6,stroke:#F44336,stroke-width:2px style L2 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style L3 fill:#FFF8E1,stroke:#FFC107,stroke-width:2px style RAM fill:#E8F4F8,stroke:#2196F3,stroke-width:2px style DISK fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px

CPU가 데이터를 읽으면 L1부터 찾고, 없으면 L2, L3, RAM 순으로 내려간다. 이전 편에서 다룬 히트와 미스가 매 계층에서 반복되는 구조다.

이 하드웨어 계층은 개발자가 직접 제어하지 않는다. 백엔드 개발자가 다루는 캐시는 소프트웨어 레벨의 캐시다. 하지만 계층 구조의 원리는 동일하다 — 가까울수록 빠르고, 멀수록 느리다.

소프트웨어 캐시 유형

웹 요청이 사용자의 브라우저에서 출발해 DB까지 도달하는 경로에는 여러 캐시 계층이 존재한다.

flowchart LR Browser["브라우저 캐시"] --> CDN["CDN"] CDN --> LB["로드 밸런서"] LB --> Local["로컬 캐시
(Caffeine)"] Local --> Dist["분산 캐시
(Redis)"] Dist --> DB[("DB")] style Browser fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px style CDN fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px style Local fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style Dist fill:#FFF3E0,stroke:#FF9800,stroke-width:2px

각 계층이 담당하는 역할이 다르다.

브라우저 캐시

클라이언트(브라우저)에 저장되는 캐시다. HTTP 응답 헤더(Cache-Control, ETag)로 제어한다. 정적 리소스(이미지, CSS, JS)를 네트워크 요청 없이 즉시 반환한다.

서버까지 요청이 가지 않으므로 서버 부하를 완전히 제거한다. 하지만 서버 측에서 캐시를 강제로 무효화할 수 없다는 한계가 있다.

CDN 캐시

전 세계에 분산된 엣지 서버에 콘텐츠를 캐시한다. 사용자와 지리적으로 가까운 서버에서 응답하므로 네트워크 지연이 줄어든다. CloudFront, Cloudflare 등이 대표적이다.

정적 콘텐츠뿐 아니라 API 응답도 캐시할 수 있다. 뉴스 피드처럼 전 사용자에게 동일한 응답을 내려주는 경우에 효과적이다.

로컬 캐시

애플리케이션 프로세스 내부 메모리(JVM 힙)에 저장한다. 네트워크 호출이 없으므로 가장 빠르다.

  • ConcurrentHashMap : JDK 내장. TTL 기능이 없어서 실무에서 거의 안 쓴다.
  • Caffeine : 고성능 로컬 캐시 라이브러리. TTL, 최대 크기, 통계 지원. 로컬 캐시의 사실상 표준이다.
  • EhCache : 오래된 캐시 라이브러리. 디스크 캐싱까지 지원하지만 Caffeine에 비해 무겁다.

분산 캐시

애플리케이션 외부의 별도 서버에 캐시를 저장한다. 여러 인스턴스가 하나의 캐시를 공유한다.

  • Redis : 인메모리 데이터 스토어. 캐시 외에도 세션, 메시지 큐 등 다용도. 분산 캐시의 사실상 표준이다.
  • Memcached : 단순 키-값 캐시 특화. Redis보다 가볍지만 기능이 제한적이다.

로컬 캐시 vs 분산 캐시

백엔드 개발에서 가장 빈번하게 고민하는 선택이다.

flowchart TB subgraph Local["로컬 캐시"] direction TB S1["서버 A"] --- C1["캐시 A"] S2["서버 B"] --- C2["캐시 B"] S1 & S2 -->|"각각 조회"| DB1[("DB")] end Local ~~~ Dist subgraph Dist["분산 캐시"] direction TB S3["서버 A"] & S4["서버 B"] -->|"공유"| RC["Redis"] S3 & S4 -->|"Miss일 때"| DB2[("DB")] end style Local fill:#f0f8f0,stroke:#4CAF50 style Dist fill:#fff8f0,stroke:#FF9800 style C1 fill:#E8F8E8,stroke:#4CAF50 style C2 fill:#E8F8E8,stroke:#4CAF50 style RC fill:#FFF3E0,stroke:#FF9800
구분로컬 캐시분산 캐시
속도매우 빠름 (네트워크 없음)빠름 (네트워크 왕복 ~1ms)
일관성서버별 캐시 불일치 가능모든 서버가 동일 캐시 공유
용량JVM 힙에 제한별도 서버 메모리 활용
장애 영향서버 재시작 시 캐시 소멸Redis 장애 시 전체 영향
복잡성낮음높음 (직렬화, 네트워크 등)
로컬 캐시의 일관성 함정

서버가 여러 대일 때 각 서버가 독립된 캐시를 가진다. A 서버에서 데이터를 수정해도 B 서버의 캐시에는 여전히 옛날 데이터가 남아 있다. 단일 서버 환경이 아니라면 반드시 이 문제를 인식해야 한다.

선택 기준

  • 단일 서버, 빠른 응답이 최우선 → 로컬 캐시
  • 다중 서버, 데이터 일관성이 중요 → 분산 캐시
  • 둘 다 필요 → 로컬 + 분산 조합 (L1/L2 패턴)

L1/L2 캐시 패턴

로컬 캐시와 분산 캐시를 함께 사용하는 2-tier 구조다. 로컬 캐시가 1차 방어선, 분산 캐시가 2차 방어선 역할을 한다.

sequenceDiagram autonumber participant App as 애플리케이션 participant L1 as L1 (Caffeine) participant L2 as L2 (Redis) participant DB as DB App->>L1: 조회 alt L1 Hit L1-->>App: 즉시 반환 (~0.1ms) else L1 Miss App->>L2: 조회 alt L2 Hit L2-->>App: 반환 (~1ms) App->>L1: L1에 저장 else L2 Miss App->>DB: DB 조회 (~50ms) DB-->>App: 결과 App->>L2: L2에 저장 App->>L1: L1에 저장 end end
  • L1 Hit → 가장 빠르다. 네트워크 호출 없음.
  • L1 Miss, L2 Hit → Redis에서 가져온다. DB보다 훨씬 빠르다.
  • 둘 다 Miss → DB에서 가져와서 L1과 L2 모두에 저장한다.

이 패턴은 읽기 빈도가 매우 높고, Redis 네트워크 왕복 비용도 아끼고 싶은 경우에 효과적이다. 다만 캐시 무효화가 복잡해진다. 데이터가 변경되면 L1과 L2를 모두 무효화해야 하는데, L1은 서버별로 존재하므로 모든 서버의 L1을 동기화하는 추가 메커니즘(Pub/Sub 등)이 필요하다.

L1/L2 패턴이 과잉인 경우

대부분의 프로젝트에서는 로컬 캐시 또는 분산 캐시 하나만으로도 충분하다. 2-tier 구조는 초당 수만 건 이상의 읽기 요청이 있고, 밀리초 단위의 응답 시간이 중요한 경우에만 고려하라. 복잡성 비용이 크다.

웹 요청의 캐시 계층 전체 그림

사용자의 요청이 어떤 캐시 계층을 거쳐 DB까지 도달하는지 전체 흐름을 정리해보자.

sequenceDiagram autonumber participant User as 사용자 participant Browser as 브라우저 participant CDN as CDN participant App as 서버 participant Local as 로컬 캐시 participant Redis as Redis participant DB as DB User->>Browser: 페이지 요청 alt 브라우저 캐시 Hit Browser-->>User: 캐시된 리소스 반환 else 브라우저 캐시 Miss Browser->>CDN: 네트워크 요청 alt CDN Hit CDN-->>Browser: 엣지 서버에서 반환 else CDN Miss CDN->>App: 원본 서버로 전달 App->>Local: 로컬 캐시 조회 alt 로컬 캐시 Hit Local-->>App: 반환 else 로컬 캐시 Miss App->>Redis: Redis 조회 alt Redis Hit Redis-->>App: 반환 else Redis Miss App->>DB: DB 조회 DB-->>App: 결과 end end App-->>CDN: 응답 CDN-->>Browser: 응답 end end

각 계층에서 히트가 발생하면 그 아래 계층은 호출되지 않는다. 위쪽 계층에서 히트가 날수록 아래쪽 인프라의 부하가 줄어든다.

자주 하는 실수

다중 서버 환경에서 로컬 캐시만 사용하기

서버 A에서 수정한 데이터가 서버 B의 로컬 캐시에는 반영되지 않는다. 사용자가 새로고침할 때마다 다른 데이터가 보이는 현상이 발생할 수 있다. 다중 서버 환경에서는 분산 캐시를 사용하거나, TTL을 매우 짧게 설정해서 불일치 시간을 최소화해야 한다.

[!DANGER] 처음부터 L1/L2 패턴으로 시작하기

최적화는 문제가 발생한 이후에 해야 한다. 로컬 캐시 하나로 시작하고, 다중 서버 환경이 되면 Redis를 추가하고, Redis 네트워크 비용이 병목이 되면 L1/L2를 고려하라. 처음부터 복잡한 구조를 만들면 유지보수만 어려워진다.

[!DANGER] 캐시 계층별 TTL을 같게 설정하기

L1/L2 패턴에서 두 캐시의 TTL이 같으면, L1이 만료되는 시점에 L2도 이미 만료되어 있어서 항상 DB까지 가게 된다. L1의 TTL을 L2보다 짧게 설정해야 L1 만료 시 L2에서 히트가 나는 계층 구조의 이점을 살릴 수 있다.