
웹 서버와 DB 사이에 캐시 서버를 따로 두는 구성입니다. DB보다 훨씬 빠르고, DB 부하가 줄고, 캐시 계층만 따로 늘릴 수 있습니다.

캐시에 있으면 바로 돌려주고, 없으면 DB에서 읽어 캐시에 저장한 뒤 돌려줍니다. 아래와 같이 사용할 수 있습니다.
# memcached 클라이언트
SECONDS = 1
cache.set('myKey', 'hi there', 3600 * SECONDS)
cache.get('myKey')
해당 책에서는 이 흐름을 읽기 주도형(read-through)이라고 부릅니다. 흔히 cache-aside 라 부르는 것과 같은 것입니다.
캐시는 휘발성 메모리라서 재시작하면 사라집니다. 갱신은 드물고 조회는 잦은 데이터에 맞고, 영속해야 하는 데이터는 DB에 둡니다. TTL이 짧으면 DB를 자주 읽고 길면 원본과 차이가 납니다. DB 갱신과 캐시 갱신이 한 트랜잭션이 아니면 일관성이 깨질 수 있습니다.
SPOF(단일 장애 지점)는 한 곳이 고장 났을 때 시스템 전체가 멈출 수 있는 지점입니다. 대체할 것이 없어서 발생합니다. 피하는 방법은 같은 역할을 하는 것을 중복으로 하나 더 두는 것입니다. 캐시 서버를 한 대만 두면 그 서버가 SPOF가 될 수 있습니다. 그래서 캐시 서버를 여러 지역에 걸쳐 분산하는 것이 좋다고 합니다.
여러 대로 나누면 키를 어느 서버에 둘지가 문제인데, memcached는 서버끼리 서로 모르고 클라이언트가 키를 해싱해서 서버를 고른다고 공식 문서에 나옵니다(memcached docs).
캐시 메모리가 작으면 데이터가 너무 자주 밀려나 성능이 떨어지고, 이를 막으려고 메모리를 넉넉하게 잡으라고(overprovision) 합니다. 가득 찼을 때 무엇을 내보낼지가 eviction 정책이고, 책은 LRU를 가장 많이 쓰고 LFU와 FIFO도 있다고 설명합니다.
세 정책은 내보낼 키를 고르는 기준이 다릅니다.
용량이 3인 캐시에 B, A, C 순서로 들어와 있습니다. 그 뒤에 A를 3번, B를 2번, C를 1번 읽었고 읽은 순서는 A, A, A, B, B, C입니다. 여기에 새 키 D가 들어오면 정책마다 내보내는 키가 달라집니다.
| 정책 | 내보내는 키 | 이유 |
|---|---|---|
| FIFO | B | 가장 먼저 들어왔습니다 |
| LRU | A | 마지막으로 읽은 시점이 가장 오래됐습니다 |
| LFU | C | 읽은 횟수가 가장 적습니다 |
A는 가장 많이 읽힌 키인데도 LRU 기준에서는 마지막으로 읽은 지 오래돼서 나가고, C는 방금 읽혔는데도 LFU 기준에서는 횟수가 적어서 나갑니다. FIFO는 둘 다 보지 않고 들어온 순서만 봅니다.
Redis에서는 아래와 같이 설정해주면 됩니다.
# redis.conf
maxmemory 100mb
maxmemory-policy allkeys-lru
Redis 는 일부 키가 나머지보다 훨씬 자주 접근되는 경우가 흔하다며, 다른 이유가 없으면 allkeys-lru를 좋은 기본값으로 듭니다. volatile-* 정책은 TTL이 걸린 키만 지우기 때문에 TTL이 있는 키가 하나도 없으면 noeviction처럼 동작합니다.
히트율은 아래 식으로 계산할 수 있습니다.
keyspace_hits / (keyspace_hits + keyspace_misses) * 100
히트율이 예상보다 낮은데 evicted_keys가 많으면 엉뚱한 키가 너무 자주 지워지고 있다는 신호라서 정책을 다시 봐야 한다고 합니다.
메인 화면의 인기 상품 정보처럼 한 키를 아주 많은 요청이 읽는다고 해 보겠습니다. TTL이 끝나서 이 키가 캐시에서 사라지는 순간, 그 직후에 동시에 들어온 요청은 한꺼번에 전부 miss가 되어 DB 를 동시에 많은 요청이 될 수 있습니다. DB가 이걸 못 버티면 캐시를 둔 의미가 없어집니다. 이런 현상을 cache stampede, 또는 thundering herd라고 부릅니다.
줄이는 방법은 miss가 나도 DB에는 한 요청만 보내는 것입니다. miss가 난 요청 중 하나에게만 값을 채울 권한(락이나 토큰)을 주고, 나머지는 잠깐 기다렸다가 다시 캐시를 읽게 합니다. 값을 채우는 데 걸리는 시간이 보통 짧아서, 기다렸던 요청이 다시 읽을 때는 캐시에 값이 들어 있는 경우가 많을 것 같습니다.
요청 A가 DB에서 값을 읽었는데 아직 캐시에 넣기 전에, 다른 곳에서 그 값을 바꾸고 캐시를 지우는 문제 역시 락이나 토큰으로 해결할 수 있습니다. A가 읽어 둔 옛 값이 뒤늦게 캐시에 들어가고, 그 옛 값이 TTL이 끝날 때까지 남습니다. 채울 권한을 토큰으로 주고 그 키가 지워질 때 토큰도 무효로 만들어 두면, A의 늦은 값은 거절할 수 있습니다.
또 다른 방식으로는, TTL에 무작위 값을 조금 더해서 인기 키들이 같은 순간에 만료되지 않게 하는 방법이 있습니다.
캐시 서버가 죽으면 같은 문제가 더 크게 나타납니다. 그 서버에 있던 키가 한꺼번에 miss가 되기 때문입니다. 이것을 대비해서 작은 예비 캐시 서버를 따로 두고, 원래 서버가 응답하지 않으면 그쪽으로 요청을 보내는 방법이 있습니다. 예비 서버의 항목은 짧게 만료시켜서 옛 값이 오래 남지 않게 합니다.
CDN은 정적 콘텐츠를 지리적으로 분산된 서버에서 내려주는 네트워크입니다. 이미지, 비디오, CSS, JavaScript 파일이 대상입니다. 책에서는 HTML을 경로, 쿼리 문자열, 쿠키, 헤더 기준으로 캐시하는 동적 콘텐츠 캐싱은 범위 밖이라고 구분합니다.

이미지 출처: 가상 면접 사례로 배우는 대규모 설계 기초 (14p)
CloudFront 문서에는 용어가 세 개 나옵니다.
| 용어 | 뜻 |
|---|---|
| 오리진(origin) | 원본 파일이 있는 곳. 웹 서버일 수도 있고 S3 같은 저장소일 수도 있습니다 |
| POP(엣지 로케이션) | 사용자와 가까운 곳에 놓인 CDN 서버. 요청을 가장 먼저 받고 파일을 캐시해 둡니다 |
| regional edge cache | POP과 오리진 사이의 중간 캐시. POP보다 용량이 커서, 인기가 식어 POP에서 밀려난 파일도 더 오래 남습니다 |
사용자 -> POP -> regional edge cache -> 오리진
사용자가 요청하면 DNS가 보통 가장 가까운 POP으로 안내합니다. POP에 파일이 있으면 바로 주고, 없으면 regional edge cache를 확인하고, 거기에도 없으면 오리진까지 갑니다. 같은 지역의 POP들이 regional edge cache를 함께 쓰기 때문에 오리진으로 가는 요청이 줄어듭니다. 오리진에서 가져올 때는 파일이 다 올 때까지 기다리지 않고 첫 바이트가 도착하는 대로 사용자에게 보내기 시작하고, 같은 파일을 캐시에도 저장합니다(How CloudFront delivers content).
책 그림에는 "응답 헤더에 TTL이 들어 있다"고만 나옵니다. 실제로는 오리진이 보내는 Cache-Control입니다.
Cache-Control: max-age=3600
CloudFront는 cache policy를 따로 쓰지 않으면 기본 TTL이 24시간이고, 오리진 헤더와 CloudFront의 Minimum, Default, Maximum TTL 설정이 함께 작용해 실제 기간이 정해집니다. max-age와 Expires를 둘 다 보내면 max-age만 씁니다. s-maxage를 같이 보내면 엣지는 s-maxage, 브라우저는 max-age를 따릅니다(CloudFront 만료 문서).
Cache-Control에는 기간을 정하는 지시어 말고 캐시의 동작을 정하는 지시어도 있습니다. no-cache는 이름과 달리 저장을 허용하고, 재사용하기 전에 재검증을 거치게 만듭니다. 저장 자체를 막는 것은 no-store입니다(MDN).
TTL이 남은 파일을 바꾸고 싶을 때 두 가지 방법이 있습니다. CDN API로 무효화하거나, image.png?v=2처럼 URL에 버전을 붙이는 방법입니다.
AWS 문서는 파일을 자주 갱신한다면 버전 파일명을 주로 쓰라고 권합니다. 무효화는 CloudFront 엣지만 비워서, 사용자의 로컬 캐시나 회사 프록시에 남은 옛 버전은 그대로 보일 수 있습니다. 버전 파일명은 이 영향을 받지 않고, 무효화 비용이 없고, 이전 버전으로 되돌리기도 쉽습니다(Invalidate files).
MDN은 해시나 버전이 붙은 정적 파일에 Cache-Control: public, max-age=31536000, immutable을 권하고, HTML처럼 URL을 바꿀 수 없는 메인 리소스에는 no-cache로 매번 재검증하게 하라고 합니다. 갱신된 HTML이 새 파일명의 JS와 CSS를 가리키는 식으로 맞물립니다.
CDN은 보통 외부 사업자가 운영하고 드나드는 전송량만큼 요금을 냅니다. 책은 자주 쓰이지 않는 콘텐츠는 CDN에서 빼는 것도 고려하라고 합니다.
CDN 자체가 응답하지 않을 때는 클라이언트가 이를 감지해 오리진에서 직접 가져오도록 구성해야 할 수도 있다고 합니다. CloudFront의 stale-while-revalidate, stale-if-error는 이 경우와는 다릅니다. 오리진이 5xx를 낼 때 만료된 객체를 계속 내려주는 장치라서, CDN이 죽은 상황은 막아주지 않습니다.
Cache-Control: max-age=3600, stale-while-revalidate=600, stale-if-error=86400
Cache-Control 헤더로 전달됩니다.캐시와 CDN을 붙이면 구조가 이렇게 바뀝니다.

정적 콘텐츠(JS, CSS, 이미지)는 웹 서버를 거치지 않고 CDN이 내려줍니다. 웹 서버는 읽기 요청을 먼저 캐시에서 찾아서 DB 부하를 줄입니다.