Cache

mongBrown·2026년 4월 23일

캐시는 어디에 있고, 왜 필요한가

이커머스 서비스에서 특정 상품 상세 페이지가 실시간 검색어에 올랐다. 1만 명이 동시에 같은 페이지를 요청한다. 이 상품의 정보는 방금 수정된 것도 아니고, 1분 전과 똑같다. DB는 지금 같은 쿼리를 1만 번 실행하고 있다.

이 상황에서 뭔가 이상하다는 생각이 든다면, 캐시가 왜 존재하는지를 이미 이해한 거다.

캐시가 효과적인 이유 — 데이터 지역성 원리

캐시의 원리는 단순하다. 같은 요청에 같은 결과가 보장되는 상황이라면, 더 깊은 레이어까지 가지 않고 앞단에 저장해둔 값을 그대로 돌려준다.

그런데 왜 이게 효과적일까. 저장해둔 값이 다시 요청될 확률이 높아야 히트율이 올라간다. 이 확률이 높은 이유가 데이터 지역성 원리다.

프로그램이 데이터를 접근하는 데는 자연적인 패턴이 있다. 시간적 지역성 — 방금 접근한 데이터는 곧 다시 접근할 가능성이 높다. 공간적 지역성 — 특정 주소를 접근하면 그 근처 주소도 곧 접근할 가능성이 높다. 배열을 순서대로 순회할 때 arr[0]을 읽으면 arr[1], arr[2]도 곧 읽히는 것처럼. 이 패턴 덕분에 캐시에 올려둔 데이터가 다시 요청될 확률이 높고, 히트율이 높게 유지된다.

캐시 레이어 전체 구조

요청이 처리되는 경로를 따라가면 레이어마다 캐시가 존재한다.

graph TD
    A[사용자 요청] --> B[브라우저 캐시]
    B --> C[CDN / 네트워크 캐시]
    C --> D[어플리케이션 캐시]
    D --> E[OS 페이지 캐시]
    E --> F[CPU 캐시 - L1/L2/L3]
    F --> G[RAM]
    G --> H[Disk]

    style B fill:#d4e6f1
    style C fill:#d4e6f1
    style D fill:#d5f5e3
    style E fill:#fdebd0
    style F fill:#fdebd0

앞단에서 hit이 나면 그 뒤 레이어는 거치지 않는다.

CPU 캐시

CPU는 연산할 때 데이터가 필요한데 RAM에서 직접 가져오면 속도 차이가 너무 크다. CPU는 나노초 단위로 연산하지만 RAM 접근은 수십~수백 나노초가 걸린다. 그래서 CPU 안에 L1, L2, L3 캐시를 층층이 박아놨다.

레지스터 → L1(코어 전용) → L2(코어 전용/공유) → L3(전체 코어 공유) → RAM

공간적 지역성 덕분에 CPU는 하나의 데이터만 가져오지 않고 캐시 라인(64바이트) 단위로 한 번에 퍼올린다. arr[0]을 읽을 때 arr[1]~arr[15]까지 함께 올라오는 이유다.

OS 페이지 캐시

Disk와 RAM 사이의 캐시다. 파일을 읽을 때 OS는 내용을 페이지 단위(4KB)로 RAM에 올려두고, 이후 같은 파일 요청 시 Disk까지 내려가지 않고 RAM에서 바로 반환한다.

파일 읽기 요청
  → cache hit  → RAM에서 반환
  → cache miss → Disk에서 읽어 RAM에 올린 뒤 반환

RAM이 꽉 차면 OS는 LRU(Least Recently Used, 가장 오랫동안 접근하지 않은 것부터 제거) 기반으로 active / inactive 두 리스트를 관리한다. 최근 접근된 페이지는 active로 올라가고, 한동안 접근 없으면 inactive로 내려온다. 꽉 찼을 때는 inactive 끝부터 evict한다.

어플리케이션 캐시

서버 코드에서 직접 다루는 캐시다. JVM(Java Virtual Machine) 메모리 안에 데이터를 올려두는 로컬 캐시(Caffeine 등)와 외부 서버로 운영하는 분산 캐시(Redis 등) 중에서 선택하거나 조합해서 쓴다. 어떤 전략으로 운영할지 개발자가 직접 결정해야 한다. 그만큼 관리 포인트도 생긴다. 캐시와 DB가 항상 같은 값을 가리키도록 동기화를 신경 써야 하고, 캐시 서버 자체가 장애 지점이 될 수 있다는 점도 고려해야 한다.

쓸 때는 어떻게 할 것인가 — Write 전략

캐시와 DB를 같이 운영하면 읽기 흐름은 자연스럽다. 캐시 먼저 보고 없으면 DB에서 가져오면 된다. 문제는 쓰기다. 데이터를 수정할 때 캐시와 DB를 어떻게 처리할지에 따라 전략이 나뉜다.

write-through

실무에서 가장 많이 쓰는 방식이다. 쓸 때 캐시와 DB를 동시에 갱신하기 때문에 둘이 항상 같은 값을 가리킨다. 방금 저장한 데이터를 바로 조회하는 패턴이 많은 서비스라면 이 방식이 맞다. 쓸 때마다 두 곳을 갱신하니 쓰기 속도 자체는 느려지지만, 읽기 정합성을 보장한다는 점에서 상품 정보나 사용자 프로필 같은 데이터에 주로 쓴다.

write-back

DB 쓰기를 나중으로 미루는 방식이다. 캐시에만 먼저 반영하고, DB는 비동기로 배치 처리한다. 쓰기가 빠르고 DB 부하가 줄어드는 건 맞지만, Redis 장애가 나면 아직 DB에 반영 안 된 데이터가 그대로 사라진다. 조회수나 집계처럼 쓰기가 매우 잦고 일부 유실이 허용되는 데이터에 쓴다.

write-around

모든 쓰기가 캐시에 들어갈 필요는 없다. 한 번 쓰고 거의 읽히지 않는 데이터를 캐시에 올려봤자 공간만 차지한다. write-around는 DB에만 쓰고 캐시는 건드리지 않는 방식으로, 이후 실제로 조회가 들어올 때 그때 캐시에 올린다. 감사 로그나 이력 데이터처럼 쓰기 후 재조회가 드문 경우에 적합하다.

캐시가 한꺼번에 만료되면 — Cache Stampede

캐시 만료 순간 동시 요청이 DB로 몰리는 현상이다. 인기 상품 캐시가 만료된 순간 1000개 요청이 전부 DB를 치면 순간 부하로 장애가 날 수 있다.

Sliding TTL

캐시에 접근할 때마다 TTL(Time To Live, 캐시 만료 시간)을 연장해 인기 있는 항목이 자연스럽게 살아있게 유지하는 방식이다. 구현이 단순하고 트래픽이 있는 동안은 만료되지 않는다. 단, 데이터가 변경됐을 때 만료 시점을 예측하기 어려워지고, 트래픽이 갑자기 끊기면 결국 만료되어 stampede가 다시 발생할 수 있다.

분산 락

Redis의 SETNX(Set if Not Exists)로 "이미 DB 조회 중" 락을 걸어두고, 같은 키를 요청하는 나머지 요청은 짧게 대기하다 캐시가 채워지면 가져간다. 1000개 요청 중 실제로 DB를 치는 건 락을 획득한 1개뿐이다. 나머지는 대기 후 캐시 결과를 공유하므로 DB 부하가 1회분으로 줄어든다.

Pre-warming

TTL이 만료되기 전에 백그라운드 스케줄러가 미리 새 값을 캐시에 채워두는 방식이다. TTL이 60초라면 50초쯤에 갱신을 완료해두어 캐시가 빈 순간 자체를 없앤다. 광고 이벤트, 특정 시간대 트래픽 급증처럼 언제 몰릴지 예측 가능한 상황에 효과적이다. 예측이 어려운 상황에서는 적용하기 까다롭다.

실무에서 가장 흔한 조합은 고정 TTL + 분산 락이다. TTL로 갱신 주기를 보장하면서 만료 순간의 동시 요청은 락으로 막는다.

CDN / 네트워크 캐시

서버까지 도달하기 전에 가로채는 캐시다. Cloudflare 같은 CDN(Content Delivery Network)은 전 세계 엣지 서버에 정적 파일(JS, CSS, 이미지)을 복사해두고, 요청이 오리진 서버까지 오지 않아도 되게 만든다. 사용자와 가까운 엣지 서버가 응답하니 지연도 줄고 오리진 서버 트래픽 부담도 함께 줄어든다.

다만 캐시된 파일에 문제가 생겨 즉시 교체해야 할 때는 까다롭다. 전 세계 엣지 서버에 퍼진 캐시를 한 번에 purge하는 데 시간이 걸릴 수 있고, 설정을 잘못하면 잘못된 파일이 계속 서빙된다. CDN은 모든 사용자에게 동일한 응답을 돌려주는 구조라 사용자마다 다른 응답이 필요한 동적 콘텐츠에도 맞지 않는다.

브라우저 캐시

가장 앞단이다. 서버 응답 헤더에 Cache-Control: max-age=3600을 보내면 브라우저는 그 시간 동안 같은 URL 요청을 네트워크로 내보내지 않고 로컬 저장 응답을 그대로 쓴다. 네트워크를 거치지 않으니 가장 빠르다. 대신 한번 캐시되면 TTL이 만료되기 전까지 서버에서 강제로 무효화할 방법이 없다. 파일이 바뀌어도 브라우저는 만료 전까지 기존 캐시를 계속 쓴다.

파일이 바뀌었다는 걸 브라우저에게 알리는 방법 — Cache Busting

파일을 수정했는데 브라우저가 기존 캐시를 계속 쓰는 문제가 생긴다. URL이 달라지면 브라우저는 캐시에 없는 새 자원으로 인식한다는 성질을 이용한다.

React 같은 SPA(Single Page Application) 프레임워크는 빌드할 때 파일 내용 기반 해시를 파일명에 붙인다. main.a3f2bc1d.js처럼. 파일 내용이 바뀌면 해시가 달라지고 브라우저는 새 파일로 인식해 다시 받아온다. 쿼리스트링 방식(style.css?v=2)도 가능하지만 일부 CDN이 캐시 대상에서 제외하는 경우가 있어 파일명 해시가 더 안전하다.

주의점

분산 캐시(Redis) 사용 시

Eviction Policy 설정

Redis 메모리가 꽉 찼을 때 어떤 키를 버릴지 직접 설정해야 한다.

먼저 eviction 대상 범위를 결정한다. volatile-*는 TTL이 설정된 키만 대상으로 삼아 TTL 없는 영구 키를 보호한다. allkeys-*는 TTL 유무 관계없이 전체 키를 대상으로 한다.

범위 안에서 어떤 기준으로 버릴지는 접근 패턴에 따라 다르다. 최근에 접근한 데이터를 보존하고 싶으면 lru를, 오랫동안 자주 조회된 데이터를 보존하고 싶으면 lfu(Least Frequently Used, 접근 횟수가 가장 낮은 것부터 제거)를 쓴다. lru는 시간적 지역성을 기반으로 한다. 최근에 쓴 데이터는 또 쓸 가능성이 높으므로 오래 방치된 키부터 버리는 방식이다. lfu는 총 조회 횟수 기준이라, 오래전에 많이 쓰였지만 지금은 조회가 없는 데이터가 남아있는 문제가 생길 수 있다. 만료 시간이 가까운 키부터 버리는 ttl, 아무 기준 없이 버리는 random도 있다. noeviction은 eviction을 하지 않고 메모리가 꽉 차면 쓰기 요청 자체를 오류로 반환한다.

TTL 없는 영구 키가 섞여 있다면 volatile-lru로 TTL 있는 키만 eviction 대상으로 삼는다. 캐시 전용으로 Redis를 운영할 때는 allkeys-lru가 기본 선택이다. 어차피 전부 캐시 데이터이므로 TTL 구분 없이 오래 안 쓴 것부터 버리는 게 단순하고 효과적이다.

write-back 사용 시 유실 위험

캐시에 먼저 쓰고 DB는 나중에 반영하는 구조에서 Redis 장애가 나면 미반영 데이터가 사라진다. 유실을 허용할 수 없는 데이터에는 쓰지 않는다.

로컬 캐시 사용 시

서버 간 불일치

서버가 여러 대라면 각 서버가 각자의 로컬 캐시를 들고 있다. 한 서버에서 캐시를 갱신해도 다른 서버는 여전히 옛날 값을 반환할 수 있다. 모든 서버가 동일한 값을 가져도 문제없는 데이터에만 쓴다.

적합한 데이터 기준

변경이 거의 없는 공통 데이터(코드 테이블, 설정값, 국가/통화 코드 등)가 여기 해당한다. 사용자별로 달라지거나 실시간 정확도가 중요한 데이터는 Redis를 쓴다. 둘을 조합하는 2-tier 패턴(로컬 캐시 L1 → Redis L2 → DB)도 자주 쓰인다.

어떤 레이어에 무엇을 캐시할 것인가

레이어와 전략을 고르기 전에 먼저 물어봐야 할 게 있다. 이 데이터가 얼마나 자주 바뀌는가, 그리고 잘못된 값이 반환됐을 때 얼마나 버틸 수 있는가. 이 두 질문에 답하면 레이어와 전략은 따라온다.

캐시는 빠르지만, 잘못 쓰면 빠른 게 아니라 틀린 값을 빠르게 돌려주는 시스템이 된다.

profile
화이팅!

0개의 댓글