Three Write Strategies, and Stampedes
한국어 원문으로 표시합니다.
한 줄 요약
읽기 전략과 쓰기 전략은 서로 다른 축이다. 그리고 인기 있는 키일수록 만료 순간이 위험하다는 역설이 스탬피드의 핵심이다.
왜 이게 필요했나
캐시 패턴을 이야기할 때 사람들이 섞어 쓰는 세 이름이 있습니다. cache-aside, write-through, write-behind 입니다. 이것들은 대립하는 선택지가 아니라 서로 다른 질문에 대한 답입니다. 앞의 것은 읽기를 누가 채우는가에 대한 답이고, 뒤의 둘은 쓰기를 어떻게 전파하는가에 대한 답입니다.
cache-aside 는 애플리케이션이 캐시를 직접 관리합니다. 읽을 때 캐시를 보고, 없으면 원본에서 읽어 캐시에 넣습니다. 쓸 때는 원본을 갱신하고 캐시를 삭제합니다. 갱신이 아니라 삭제라는 점이 중요합니다 — 두 쓰기가 엇갈리면 캐시에 옛 값이 남을 수 있지만, 삭제는 그 위험이 없습니다.
write-through 는 쓰기가 캐시와 원본을 동시에 갱신합니다. 캐시가 항상 최신이라 읽기가 늘 빠릅니다. 대신 쓰기가 느려지고, 한동안 읽히지 않을 데이터까지 캐시에 채워 공간을 낭비합니다.
write-behind 는 캐시에만 쓰고 원본 반영을 나중에 배치로 합니다. 쓰기가 아주 빠르고 여러 쓰기를 모을 수 있지만, 반영 전에 캐시가 죽으면 데이터가 사라집니다. 유실을 감당할 수 있거나 별도의 내구성 장치가 있을 때만 씁니다.
어떻게 동작하나
세 전략을 한 표로 정리하면 이렇습니다.
| 전략 | 쓰기 속도 | 읽기 최신성 | 유실 위험 | 대표 상황 |
|---|---|---|---|---|
| cache-aside | 보통 | 미스 후 최신 | 낮음 | 범용, 읽기 위주 |
| write-through | 느림 | 항상 최신 | 낮음 | 최신성이 중요 |
| write-behind | 매우 빠름 | 항상 최신(캐시 기준) | 높음 | 쓰기 폭주, 유실 허용 |
그리고 캐시에서 가장 악명 높은 함정이 스탬피드입니다. 초당 수천 번 읽히던 인기 키가 만료되는 그 찰나, 수천 개 요청이 동시에 미스를 겪고 모두 원본으로 몰려가 같은 값을 다시 계산합니다. 하나면 충분했을 조회가 수천 개로 폭증하고, 원본이 짓눌립니다. 최악의 경우 원본이 죽고 더 많은 미스가 나며 연쇄적으로 무너집니다.
역설은 이것입니다 — 인기 있는 항목일수록 위험합니다. 자주 읽히는 값일수록 만료 순간의 동시 미스가 크기 때문입니다.
완화책은 넷입니다. 첫째 잠금 또는 싱글플라이트 — 미스 때 첫 요청만 원본을 조회하고 나머지는 기다립니다. 둘째 TTL 지터 — 만료 시점을 흩뜨립니다. 셋째 stale-while-revalidate — 만료돼도 옛 값을 즉시 돌려주고 백그라운드에서 한 번만 갱신합니다. 넷째 확률적 조기 만료 — 만료가 임박할수록 높은 확률로 미리 갱신합니다.
실무에서는 조합합니다. 지터로 만료를 흩뜨리고, stale-while-revalidate 로 대기를 없애고, 잠금으로 갱신을 하나로 모읍니다.
현장에서 만나는 모습
잠금을 쓸 때 반드시 함께 챙길 것이 둘입니다. 잠금에 TTL 을 걸어 소유자가 죽어도 풀리게 하는 것, 그리고 소유권 토큰을 두어 남의 잠금을 실수로 풀지 않게 하는 것입니다. 후자를 빠뜨리면 A 의 잠금이 TTL 로 만료된 뒤 B 가 잡았는데, 뒤늦게 깨어난 A 가 B 의 잠금을 해제하는 일이 생깁니다.
무효화를 어떻게 설계하나
캐시에서 가장 어려운 문제는 채우는 것이 아니라 언제 버릴지 정하는 것이다. 방법은 셋이고, 각각 감당하는 복잡도가 다르다.
시간으로 버린다(TTL). 가장 단순하고 대부분의 경우에 충분하다. "얼마나 낡은 값까지 보여 줘도 되는가" 를 정하면 그것이 곧 TTL 이다. 다만 이 질문은 기술이 아니라 업무의 결정이므로, 개발자가 혼자 정하고 넘어가면 나중에 "왜 방금 바꾼 게 안 보이죠" 가 된다.
바뀔 때 지운다. 원본을 고치는 코드가 관련된 캐시 키를 함께 지운다. 최신성이 좋아지지만, 그 키를 전부 알아야 한다는 것이 문제다. 상품 하나가 바뀌면 상품 상세, 목록, 검색 결과, 추천 목록이 전부 낡는데, 코드 여기저기에 흩어진 이 관계를 빠뜨리지 않고 유지하기는 어렵다.
버전으로 통째로 무효화한다. 키에 버전 번호를 넣고, 무언가 바뀌면 그 번호를 올린다. 옛 키는 아무도 찾지 않게 되어 TTL 로 자연히 사라진다. 개별 키를 추적하지 않아도 되어 훨씬 튼튼하지만, 한 번에 너무 많은 것이 무효가 되므로 그 직후에 미스가 몰린다. 앞에서 본 스탬피드가 정확히 이때 일어난다.
실무에서는 대개 TTL 을 바닥에 깔고, 정말 즉시 반영되어야 하는 것만 골라 삭제를 붙인다. 전부에 삭제를 붙이려 들면 유지가 안 되고, 전부 TTL 로만 두면 중요한 변경이 늦게 보인다. 어느 데이터가 어느 쪽인지는 업무 담당자와 함께 정할 일이고, 그 결정을 표로 적어 두면 나중에 캐시 관련 사고의 절반이 사라진다.
마지막으로 캐시가 없어도 서비스가 돌아가야 한다. 캐시를 통째로 비웠을 때 원본이 그 부하를 견디지 못한다면, 그것은 캐시가 아니라 원본의 일부가 된 것이다. 이 상태에서는 캐시 장애가 곧 서비스 장애가 되므로, 적어도 원본이 얼마나 버티는지는 알고 있어야 한다.
다음 실습에서 할 것
느린 원본을 상대로 cache-aside 를 구현해 적중률을 재고 버전 키로 전체 무효화를 해 봅니다. 그다음 별도 실습에서 스탬피드를 실제로 일으키고 네 가지 방어를 하나씩 붙여 원본 호출 수를 숫자로 비교합니다.