사전 캐싱
개념

캐싱

gabury1

자주 쓰는 것의 복사본을 가까이 두고 다시 쓰는 일입니다. 그러면 원본까지 다시 다녀오지 않아도 됩니다. 대신 복사본은 원본이 바뀌는 순간 어긋납니다. 그 어긋남을 다루는 규칙이 캐싱의 절반입니다.

상세

게시판에 붙은 시간표를 휴대폰으로 찍어 둡니다. 궁금해지면 게시판까지 다시 가는 대신 사진을 펴 봅니다. 게시판의 종이가 새 시간표로 갈려도 사진 속 시간표는 바뀌지 않습니다.

캐시에 있는 것은 복사본입니다. 원본이 바뀌어도 복사본은 그 자리에 그대로 남습니다.

RFC 9111 은 HTTP 캐시를 응답 메시지의 로컬 저장소와 그 안의 메시지를 저장·조회·삭제하는 서브시스템이라고 정의합니다. 저장하는 자리만 캐시인 것은 아닙니다. 무엇을 넣고 언제 꺼내고 언제 지울지를 정하는 부분까지 캐시입니다.

꺼내 쓰려면 "이건 아까 그것과 같은 요청이다"를 판정해야 합니다. 그 판정에 쓰는 정보가 캐시 키입니다. HTTP(HyperText Transfer Protocol, 하이퍼텍스트 전송 규약) 에서 캐시 키는 최소한 요청 메서드와 대상 URI(Uniform Resource Identifier, 통합 자원 식별자) 로 구성됩니다. 오늘날 흔히 쓰이는 HTTP 캐시의 상당수는 GET 응답만 캐시하므로 URI 만 키로 씁니다. 캐시가 재료를 더 넣기도 합니다. 사용자 에이전트 캐시는 참조한 사이트의 신원까지 넣어 캐시를 이중 키로 만들기도 합니다. 자리가 바뀌면 키를 구성하는 요소도 달라집니다.

복사본을 언제까지 쓸 것인가가 따라옵니다. RFC 9111 은 나이가 신선도 수명을 아직 넘지 않은 응답을 신선하다고 부릅니다. 넘은 것은 낡았다고 부릅니다. 신선하면 오리진 서버에 연락하지 않고 그대로 씁니다. 낡았어도 검증으로 되살릴 수 있으면 여전히 쓸 수 있습니다. 자리가 모자랄 때 무엇을 버릴 것인가도 따라옵니다. 이 고르는 일을 축출이라 부릅니다. 캐시 항목은 이미 어딘가에 저장된 데이터의 복사본이므로 메모리가 다하면 대개 버려도 됩니다.

캐싱은 한 자리의 기법이 아닙니다. 같은 데이터가 여러 벌 존재하기도 하고, 계층을 이루기도 합니다. 한 계층의 캐시가 다시 다른 계층의 캐시에 얹히는 구조도 흔합니다.

배경

같은 일이 되풀이됩니다. 동적 웹사이트는 요청이 올 때마다 데이터베이스 질의부터 템플릿 렌더링, 비즈니스 로직까지 다시 계산해 페이지를 만듭니다. 파일 하나를 읽어 내보내는 것에 비하면 처리 부담이 큽니다. RFC 9111 은 캐시가 캐시 가능한 응답을 저장하는 것이 앞으로 올 동등한 요청을 받아내기 위해서라고 적습니다. 되풀이가 없으면 저장할 이유도 없습니다.

되풀이가 균등하지 않다는 점이 여지를 만듭니다. 흔히 같은 소량의 데이터가 자주 읽힙니다. 그 소량을 곁에 두면 나머지를 건드리지 않고도 요청 대부분을 받아낼 수 있습니다. 애플리케이션 서버의 로컬 메모리에 두면 데이터베이스 같은 네트워크 서비스에 다녀오는 것에 비해 접근 시간의 자릿수가 달라집니다.

그래서 앞선 결과를 다시 씁니다. RFC 9111 은 HTTP 캐싱의 목표를 앞선 응답 메시지를 재사용해 성능을 크게 개선하는 것이라고 적습니다. 신선한 응답은 캐시가 그것을 재사용할 때마다 지연과 네트워크 부담을 줄입니다.

다만 강제가 아닙니다. 같은 문서가 캐싱은 HTTP 에서 전적으로 선택적인 기능이라고 적습니다. 그래서 명세의 요구사항은 "캐시는 항상 저장하고 재사용하라"가 아닙니다. 재사용할 수 없는 응답을 저장하는 것과 저장된 응답을 부적절하게 재사용하는 것을 막는 쪽에 초점이 있습니다.

캐시 전략

애플리케이션이 데이터베이스 앞에 캐시를 둡니다. 원본은 데이터베이스입니다. 이때 정할 것이 둘입니다. 하나는 읽을 값이 캐시에 없을 때(캐시 미스) 누가 원본에서 가져와 채우는가입니다. 다른 하나는 새 값을 원본과 캐시에 각각 언제 넣는가입니다. 앞의 물음에 답하는 읽기 쪽 전략이 cache-aside · read-through · refresh-ahead 셋입니다. 뒤의 물음에 답하는 쓰기 쪽 전략이 write-through · write-behind · write-around 셋입니다. 두 쪽 모두에서 전략을 가르는 것은 원본을 읽고 쓰는 일을 누가 맡느냐입니다.

여섯을 나란히 놓으면 이렇습니다.

전략 원본을 맡는 쪽 원본과 오가는 때 대가
cache-aside (읽기) 애플리케이션 미스가 나면 읽어 채움 · 쓰기는 곧바로 원본에 미스 한 번에 세 번 왕복
read-through (읽기) 캐시 미스가 나면 캐시가 읽어 채움 원본에서 값을 불러오는 부품을 캐시에 설정
refresh-ahead (읽기) 캐시 수명이 거의 다한 항목이 읽히면 미리 다시 불러옴 read-through 와 같은 부품이 필요
write-through (쓰기) 정의에 따라 갈림 쓰기와 함께 곧바로 쓰기마다 두 곳을 오가 지연
write-behind (쓰기) 캐시 나중에 비동기로 전원이 나가면 최근 쓰기를 잃을 수 있음
write-around (쓰기) — 쓰기와 함께 곧바로 · 캐시에는 안 넣음 —

읽기 전략

원본에서 값을 가져와 캐시를 채우는 일을 누가 맡고 언제 하는지로 갈립니다.

cache-aside

캐시를 채우고 고치는 일을 애플리케이션이 맡습니다. 먼저 캐시를 읽어 보고, 값이 없으면(캐시 미스) 데이터베이스에서 가져옵니다. 가져온 값을 캐시에 넣고 나서 호출자에게 돌려줍니다. 쓸 때는 데이터베이스에 쓰고, 캐시의 그 항목은 지우거나 새 값으로 갈아 둡니다.

sequenceDiagram
    autonumber
    participant 앱
    participant 캐시
    participant 원본
    앱->>캐시: 읽어 본다
    캐시-->>앱: 없다
    앱->>원본: 가져온다
    앱->>캐시: 넣어 둔다

read-through

애플리케이션은 캐시에만 묻습니다. 값이 없으면 캐시가 데이터베이스에서 불러와 채웁니다. 그다음 호출자에게 돌려줍니다. 원본 읽기는 캐시가 맡습니다. 원본에서 값을 불러오는 부품을 캐시에 설정해 둡니다.

sequenceDiagram
    autonumber
    participant 앱
    participant 캐시
    participant 원본
    앱->>캐시: 읽어 본다
    캐시->>원본: 없으면 캐시가 읽는다
    원본-->>캐시: 값
    캐시-->>앱: 값

refresh-ahead

캐시 항목에는 신선하게 쓸 수 있는 기간인 신선도 수명이 붙습니다. 이 수명이 다하는 것이 만료입니다. 항목마다 붙여 두는 수명 값을 TTL(Time To Live, 생존 시간)이라 부릅니다.

만료가 오기 전에 값을 새로 고칩니다. 애플리케이션이 만료 시각에 충분히 가까운 항목을 읽으면 캐시가 그 값을 데이터베이스에서 비동기로 다시 불러 둡니다. 계기는 접근입니다. 새로 고치는 것은 최근에 접근된 항목입니다.

만료가 지난 뒤에 접근된 항목에는 이 비동기 재적재가 걸리지 않습니다. 그때는 캐시가 데이터베이스에서 동기로 읽어 값을 채우고, 그 요청은 값이 올 때까지 기다립니다. read-through 가 캐시에 없는 값을 채울 때와 같은 읽기이고, 값을 불러오는 부품도 read-through 가 캐시에 설정하는 것과 같습니다. 그래서 원본 읽기는 캐시가 맡습니다.

sequenceDiagram
    autonumber
    participant 앱
    participant 캐시
    participant 원본
    앱->>캐시: 읽어 본다
    캐시-->>앱: 지금 값
    캐시->>원본: 응답 뒤에 다시 읽는다
    원본-->>캐시: 새 값

쓰기 전략

새 값을 원본에 언제 쓰고 캐시에는 넣는지로 갈립니다.

write-through

새 값을 쓰면 데이터베이스와 캐시가 한 번의 쓰기에서 함께 바뀝니다. 두 쪽에 다 닿아야 쓰기가 끝납니다. 그래서 캐시의 값이 낡지 않습니다.

sequenceDiagram
    autonumber
    participant 앱
    participant 캐시
    participant 원본
    앱->>캐시: 쓴다
    앱->>원본: 같은 쓰기
    캐시-->>앱: 닿음
    원본-->>앱: 닿음
    Note over 앱,원본: 양쪽에 닿기 전에는 끝이 아니다<br/>원본 쓰기를 캐시가 보내는 정의도 있다

원본 쓰기를 누가 보내느냐는 정의에 따라 갈립니다. 캐시에 쓰는 부품을 설정해 캐시가 보내기도 하고, 애플리케이션이 두 곳에 각각 쓰기도 합니다. 쓰기 한 번에 캐시와 원본을 두 번 오가는 셈이라 그만큼 지연이 붙습니다.

write-behind

애플리케이션이 새 값을 쓰면 캐시에만 들어갑니다. 데이터베이스에는 캐시가 나중에 비동기로 씁니다. 원본 쓰기는 캐시가 맡습니다. 호출자는 데이터베이스까지 기다리지 않습니다. write-back 과 write-behind 는 같은 전략을 가리키는 두 이름입니다.

sequenceDiagram
    autonumber
    participant 앱
    participant 캐시
    participant 원본
    앱->>캐시: 쓴다
    캐시-->>앱: 끝
    캐시->>원본: 나중에 비동기로

블록 계층 캐시에서는 이것이 기본인 경우가 있습니다. 캐시된 블록에 쓰면 그 쓰기는 캐시로만 가고 메타데이터에 dirty 표시가 붙습니다. 원본에 아직 반영되지 않았다는 표시입니다.

대가는 전원이 나갈 때 드러납니다. 전원이 나가면 최근 쓰기 일부를 잃을 수 있습니다.

write-around

새 값을 원본에만 씁니다. 캐시에는 넣지 않습니다. 그 값은 나중에 읽기에서 미스가 나면 그때 캐시에 올라옵니다.

sequenceDiagram
    autonumber
    participant 앱
    participant 캐시
    participant 원본
    앱->>원본: 쓴다
    원본-->>앱: 끝
    Note over 앱,캐시: 캐시에는 넣지 않는다

채우는 쪽은 함께 쓰는 읽기 전략에 달려 있습니다. 이 정의로는 어느 쪽인지 알 수 없습니다.

캐시에 그 항목의 옛 값이 남아 있을 때 어떻게 되는지는 이 정의로는 알 수 없습니다.

읽기 쪽과 쓰기 쪽의 조합

두 묶음은 원본을 맡는 쪽을 따라 짝을 짓습니다. 캐시를 원본인 것처럼 쓰는 구성을 cache-as-sor 라 부릅니다. sor 는 원본을 뜻하는 system of record 의 줄임입니다. 이 구성은 원본을 읽고 쓰는 일을 캐시에 맡깁니다. 애플리케이션 코드는 적어도 직접 그 일을 하지는 않습니다. 읽기 쪽 read-through 와 쓰기 쪽 write-through 또는 write-behind 를 묶어 이 구성을 만듭니다. 그 반대편이 애플리케이션이 원본을 맡는 cache-aside 입니다. 캐시와 원본 사이에는 연결이 없습니다. refresh-ahead 도 캐시 쪽에 섭니다. 캐시에 설정한 부품으로 원본에서 값을 다시 불러오므로 원본 읽기를 캐시가 맡아야 성립합니다. write-through 는 정의에 따라 캐시 쪽에도 애플리케이션 쪽에도 섭니다.

예시

nginx 의 리버스 프록시 캐시

proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;

200 과 302 응답은 10분, 404 응답은 1분 캐시합니다. 시간만 적으면 200·301·302 응답만 캐시됩니다. proxy_cache_lock on; 을 켜면 새 캐시 항목 하나를 채우는 요청이 한 번에 하나만 프록시된 서버로 나갑니다. 나머지는 캐시에 응답이 나타나거나 그 항목의 락이 풀릴 때까지 기다립니다. 마지막으로 넘긴 요청이 proxy_cache_lock_age(기본 5초) 안에 끝나지 않으면 요청이 하나 더 나갈 수 있습니다.

Redis 의 메모리 상한과 축출 정책

maxmemory 가 캐시 데이터에 쓸 메모리 상한을 정합니다. 0 으로 두면 상한이 없습니다. 상한에 닿으면 maxmemory-policy 가 무엇을 버릴지 정합니다. allkeys-lru 는 가장 오래 안 쓰인 키를 버립니다. volatile-ttl 은 만료가 붙은 키 중 남은 TTL 이 가장 짧은 것을 버립니다. volatile- 로 시작하는 정책들은 만료가 붙은 키가 하나도 없으면 noeviction 처럼 동작합니다.

PostgreSQL 의 공유 버퍼

shared_buffers 의 기본값은 대개 128MB 입니다. 전용 데이터베이스 서버에 RAM(Random Access Memory, 임의 접근 기억장치) 이 1GB 이상이면 25% 가 시작값으로 합리적입니다. RAM 의 40% 를 넘겨 잡는 것이 더 적은 양보다 나을 것 같지는 않습니다. PostgreSQL 이 운영체제 캐시에도 기대기 때문입니다.

DNS 의 TTL

RFC 1035 의 리소스 레코드는 32비트 정수 TTL 을 함께 나릅니다. 캐시 수명을 데이터 자신이 들고 다니는 형태입니다. 값이 0 이면 진행 중인 트랜잭션에만 쓰고 캐시하면 안 됩니다. SOA 레코드는 캐싱을 막기 위해 늘 TTL 0 으로 배포됩니다.

파이썬의 메모이제이션

@functools.lru_cache(maxsize=128) 는 함수를 메모이제이션하는 호출 가능 객체로 감쌉니다. 최근 호출 128건까지 저장합니다. @functools.cache 는 상한이 없는 판이고, 문서는 이것을 "memoize" 라 부르기도 한다고 적습니다. 캐시하면 안 되는 것도 문서가 못 박습니다. 부작용이 있는 함수, 호출마다 새 가변 객체를 만들어야 하는 함수, time() 이나 random() 같은 순수하지 않은 함수입니다.

Gradle 빌드 캐시

기본으로 꺼져 있습니다. --build-cache 를 붙이거나 gradle.properties 에 org.gradle.caching=true 를 적으면 켜집니다. 태스크의 입력이 안 바뀌었다고 판정되면 이전 빌드의 출력을 꺼내 씁니다. 공유 빌드 캐시를 쓰면 개발자 기계와 빌드 에이전트 사이에서도 출력이 재사용됩니다.

동시 재계산을 막는 손잡이

같은 항목이 만료된 순간 여럿이 한꺼번에 다시 만들려 드는 일이 있습니다. 이름은 자리마다 다릅니다. Rails 는 dog pile effect 라 부르고 :race_condition_ttl 을 둡니다. 만료된 값을 새 값이 만들어지는 동안 지정한 초만큼 더 쓰게 합니다. Symfony 는 cache stampede 라 부르고 락과 확률적 조기 만료를 내장합니다. 조기 만료는 다른 사용자에게는 캐시된 값을 계속 주면서 한 사용자에게만 캐시 미스를 무작위로 흉내내는 것입니다. memcached 위키는 stampeding herd, groupcache 는 thundering herd 라 부릅니다. Cloudflare 는 엣지에서 같은 자산에 대한 동시 미스를 request collapsing 으로 묶습니다. 첫 요청만 오리진으로 나가고 나머지는 기다리다 같은 응답을 받습니다.

CDN 의 퍼지

Cloudflare 는 URL 단위 단일 파일 퍼지를 권장 방법으로 둡니다. 태그·호스트명·프리픽스·전체 퍼지도 있습니다. 요청 한도는 계정 등급마다 다릅니다. 무료는 분당 5회, 엔터프라이즈는 초당 50회입니다.

관련 항목

커널·하드웨어 계층에 있는 캐시

TLB · CPU 캐시 · 캐시 라인 · 페이지 캐시 · dentry 캐시

애플리케이션·웹 계층에 있는 캐시

브라우저 캐시 · CDN · 리버스 프록시 캐시 · 클라이언트 사이드 캐싱 · 빌드 캐시 · DNS 캐시 · 메모이제이션 · 애플리케이션 서버 · HTTP 캐시

캐싱이 기대는 프로토콜 기본 개념

RFC 9111 · HTTP · GET · URI · URL · 요청 메서드 · DNS · RFC 1035 · 리소스 레코드 · SOA 레코드

캐싱이 없으면 매번 거치는 처리 단계

데이터베이스 · 템플릿 렌더링 · 비즈니스 로직

다루는 규칙

캐시 키 · 신선도 · 나이 · 검증 · 조건부 요청 · 무효화 · 퍼지 · 축출 · LRU · LFU · TTL · Cache-Control · no-store · Vary · 이중 키 · 확률적 조기 만료 · 락 · 지연 · request collapsing · stale-while-revalidate · stale-if-error

쓰기를 미루는 메커니즘

dirty 페이지 · 메타데이터 · 쓰기 선행 로그 · 체크포인트

캐시를 우회·제어하는 커널 도구

O_DIRECT · posix_fadvise · drop_caches · 투명 거대 페이지

터지는 것과 그 이름

캐시 미스 · 낡은 데이터 · 캐시 스탬피드 · 썬더링 허드 · 도그 파일 · 거짓 공유 · 캐시 일관성

캐싱이 원본과의 읽기·쓰기를 구현하는 전략

cache-aside · read-through · refresh-ahead · write-through · write-behind · write-around

관여하는 역할·참여자

오리진 서버 · surrogate · 사용자 에이전트

캐싱을 실제로 구현·채택한 제품

bcache · dm-cache · x86 · 페이지 속성 테이블 · MTRR · AWS · Azure · Ehcache · PostgreSQL · Redis · Gradle · 파이썬 · nginx · 리눅스 · 블록 계층 · memcached · groupcache · Rails · Symfony · Cloudflare

다른 이름: caching · 캐시 · 캐시 전략