원본 호출이 비싸서 캐시를 둔다. 그런데 캐시를 둔다는 결정 하나로는 끝나지 않는다. 그 결정 뒤에 곧바로 두 가지 질문이 따라온다 — 캐시 miss 때 누가 캐시를 채우는가, 그리고 쓰기는 어디로 가는가.
캐시 디자인 패턴 정전 5종 (Cache-aside, Read-through, Write-through, Write-back, Refresh-ahead) 은 결국 이 두 축의 조합이다. 읽기 채움 책임이 application 에 있느냐 캐시 자체에 있느냐, 쓰기가 캐시를 통과하느냐 우회하느냐 비동기로 흘리느냐. 두 축을 분리해서 결정하면 패턴 선택이 따라온다.
Cache-aside
가장 기본 패턴이고 가장 흔하게 보이는 결이다. application 이 직접 캐시를 조회하고, miss 면 원본을 조회한 뒤 캐시에 채워 넣는다. 쓰기는 원본에 직접 쓰고 캐시는 application 이 무효화하거나 갱신한다.
flowchart LR
APP["App"] -->|"1. 조회"| C["캐시"]
C -->|"2. miss"| APP
APP -->|"3. 조회"| DB[("원본")]
DB -->|"4. 데이터"| APP
APP -->|"5. 채움"| C
읽기 채움 책임을 application 이 진다. 구현은 단순하지만, 일관성 책임이 application 코드 곳곳에 분산된다. 캐시와 원본이 어긋나는 stale 윈도우를 application 이 직접 관리해야 하고, 쓰기 시점에 캐시를 무효화할지 갱신할지도 application 결정이다.
Read-through
읽기 채움을 캐시가 책임진다. application 은 캐시만 호출한다. miss 가 발생하면 캐시가 원본을 조회해서 자기 자신을 채우고 application 에 돌려준다.
flowchart LR
APP["App"] -->|"1. 조회"| C["캐시"]
C -->|"2. miss → 조회"| DB[("원본")]
DB -->|"3. 데이터"| C
C -->|"4. 데이터"| APP
Cache-aside 와의 차이는 채움 책임이 어디에 있는가 한 가지다. application 코드는 단순해지지만, 캐시가 원본을 알아야 한다. 보통 캐시에 원본 조회 함수를 등록해서 캐시가 자체적으로 원본을 부르게 한다. 캐시 계층과 데이터 계층의 결합이 Cache-aside 보다 커진다.
Write-through
쓰기 시 캐시와 원본을 동기로 함께 갱신한다. application 이 쓰기를 캐시로 보내면 캐시가 원본까지 갱신한 뒤 응답한다.
flowchart LR
APP["App"] -->|"1. 쓰기"| C["캐시"]
C -->|"2. 쓰기"| DB[("원본")]
DB -->|"3. ack"| C
C -->|"4. ack"| APP
캐시와 원본이 항상 일치한다. 강한 일관성이 자연스럽게 따라온다. 대신 쓰기 응답에 캐시 + 원본 두 곳의 write latency 가 모두 포함된다. Read-through 와 결합되면 read/write 모두 캐시를 거치는 구조가 된다. 자주 읽지 않는 항목까지 캐시에 들어가서 적중률이 떨어지는 패턴 (cold-cache pollution) 도 같이 따라온다.
Write-back
쓰기를 캐시에만 동기로 반영하고, 원본은 비동기로 따라간다. write-behind 라는 이름으로도 불린다.
flowchart LR
APP["App"] -->|"1. 쓰기"| C["캐시"]
C -->|"2. ack"| APP
C -.->|"3. 비동기 flush"| DB[("원본")]
쓰기 응답이 빨라진다. write-heavy 워크로드에서 원본 부담을 큰 폭으로 낮춘다. 다만 캐시와 원본의 일관성은 eventual 이 된다. 캐시가 죽으면 아직 flush 되지 않은 쓰기는 손실된다. 그래서 보통 캐시 쪽에 영속 큐, 배치 flush, replication 같은 내구성 보강을 함께 둔다. 쓰기 손실을 어느 정도 받아들일 수 있는 도메인에서 선택지가 된다.
Refresh-ahead
만료 시점이 임박한 항목을 캐시가 선제적으로 다시 가져온다. read miss 자체를 줄이는 predictive 한 접근이다.
flowchart LR
APP["App"] -->|"조회"| C["캐시"]
C -->|"hit"| APP
C -.->|"비동기 (만료 임박)"| DB[("원본")]
DB -.->|"갱신"| C
자주 읽힐 항목을 만료 전에 갱신해두면 application 입장에서는 거의 항상 hit 이 된다. 만료 직후의 burst latency 를 회피한다. 대신 “자주 읽힐 항목” 을 예측해야 한다. TTL(Time To Live) 임박 임계로 단순히 trigger 하거나, 접근 빈도 기반 heuristic 으로 trigger 한다. 예측이 빗나가면 불필요한 원본 호출이 누적된다.
비교 매트릭스
| 패턴 | 읽기 채움 | 쓰기 경로 | 일관성 | 적합 워크로드 |
|---|---|---|---|---|
| Cache-aside | application | 원본 직접 (캐시 무효화/갱신은 app 책임) | 약 (app 관리) | 읽기 多 / 쓰기 패턴 다양 |
| Read-through | 캐시 | (별도 패턴과 조합) | 약~중 | 읽기 多 / app 코드 단순화 우선 |
| Write-through | (별도 패턴과 조합) | 캐시 + 원본 동기 | 강 | 일관성 우선 / 쓰기 빈도 낮음 |
| Write-back | (별도 패턴과 조합) | 캐시 동기 + 원본 비동기 | eventual | 쓰기 多 / 손실 허용 가능 |
| Refresh-ahead | 캐시 선제 | (별도 패턴과 조합) | 약~중 | 읽기 多 / 접근 패턴 예측 가능 |
읽기 패턴 (Cache-aside, Read-through, Refresh-ahead) 과 쓰기 패턴 (Write-through, Write-back) 은 직교라 조합이 가능하다. Read-through + Write-through 가 가장 흔한 짝이고, Refresh-ahead 는 어떤 쓰기 패턴과도 함께 둘 수 있다.
도입에서 던졌던 두 질문 — 읽기 채움 책임은 어디에 있는가, 쓰기는 어디로 가는가 — 이 결정 두 개가 패턴 선택보다 먼저 와야 자연스럽다. “Cache-aside 인가 Read-through 인가” 라는 질문은 그 다음에 의미를 가진다.