1. 라이브러리
  2. HTTP와 웹
  3. 웹 아키텍처

업데이트됨 1개월 전

가장 빠른 요청은 아예 보내지 않는 요청이다.

사용자가 페이지를 로드할 때마다 수십 개의 리소스가 조합되어야 한다: HTML, CSS, JavaScript, 이미지, 폰트, API 데이터. 캐싱 없이는 각 리소스마다 서버에 왕복 요청이 필요하다. 50개의 리소스는 50번의 요청을 의미하고, 각각 시간과 컴퓨팅 자원을 소모한다.

캐싱은 이 구조를 뒤집는다. 데이터의 복사본을 필요한 곳 가까이 저장해두면—브라우저에, 엣지 서버에, 메모리에—대부분의 요청은 네트워크를 타지 않는다. 3초 걸리던 페이지가 300밀리초에 로드된다. 백만 번의 요청을 처리하던 서버가 이제는 십만 번만 처리하면 된다.

하지만 캐싱은 악마와의 거래다. 원본이 아닌 복사본을 제공하는 것이다. 원본이 바뀌면, 복사본은 거짓말을 한다. 오래된 데이터를 언제 갱신할지—캐시 무효화—는 컴퓨팅 분야의 진짜 어려운 문제 중 하나다.

캐시가 존재하는 곳

캐싱은 여러 계층에서 일어나며, 각 계층마다 트레이드오프가 다르다.

브라우저 캐시는 리소스를 사용자 기기에 저장한다. 이미지, CSS, JavaScript 파일은 처음 다운로드된 후 로컬에 저장된다. 이후 방문 시에는 즉시 로드된다—네트워크 지연이 전혀 없다. 단점: 재방문자에게만 효과가 있고, 브라우저가 무엇을 얼마나 오래 보관할지에 대한 제어권이 제한적이다.

CDN 캐시는 전 세계에 분산된 엣지 서버에 콘텐츠를 저장한다. 도쿄의 사용자는 버지니아에 있는 오리진 서버가 아닌 도쿄 서버에서 리소스를 받는다. 신규 방문자, 재방문자 모두에게 도움이 되며, 트래픽 급증을 인프라에 도달하기 전에 흡수한다.

리버스 프록시 캐시는 애플리케이션 서버 앞에 위치해 전체 응답을 캐싱한다. 동일한 API 엔드포인트에 반복 요청이 들어오면, 프록시는 백엔드를 건드리지 않고 캐시된 결과를 제공한다. Varnish, Nginx, 클라우드 로드 밸런서가 모두 이 역할을 한다.

애플리케이션 캐시는 Redis나 Memcached 같은 메모리 시스템에 데이터를 저장한다. 데이터베이스 쿼리 결과, 계산된 값, 비용이 많이 드는 API 응답—생성하는 데 오래 걸리지만 자주 필요한 모든 것. 이 계층은 완전히 당신의 제어 하에 있다.

데이터베이스 캐시는 데이터베이스 자체에 내장되어 있으며, 자주 사용되는 데이터와 쿼리 결과를 메모리에 유지한다. 직접 제어하지는 않지만, 이를 이해하면 반복 쿼리가 첫 실행보다 빠른 이유를 알 수 있다.

HTTP 캐시 헤더

HTTP에는 캐시 제어를 위한 내장 메커니즘이 있다. 응답의 헤더가 브라우저와 중간 서버에게 콘텐츠를 어떻게 다룰지 정확히 알려준다.

Cache-Control이 핵심 지시자다. max-age=3600은 "이것을 한 시간 동안 캐시하라"는 의미다. no-cache는 "저장은 할 수 있지만, 사용하기 전에 나에게 확인하라"는 의미다. no-store는 "이것을 전혀 캐시하지 말라"는 의미로—민감한 데이터에 필수적이다. public은 공유 캐시(CDN)가 응답을 저장하도록 허용하고, private는 브라우저에만 캐싱을 제한한다.

지시자는 조합할 수 있다: Cache-Control: public, max-age=86400, immutable은 이 리소스가 절대 변경되지 않으며 재검증 없이 24시간 동안 저장할 수 있다고 캐시에 알린다.

ETag는 리소스의 특정 버전에 대한 지문이다. 콘텐츠가 변경되면 ETag도 변경된다. 이후 요청 시 브라우저는 If-None-Match 헤더에 이전 ETag를 담아 보낸다. 여전히 일치하면 서버는 304 Not Modified로 응답한다—전체 페이로드 대신 아주 작은 응답만 전송된다.

Last-Modified는 비슷하지만 타임스탬프를 사용한다. 브라우저가 If-Modified-Since를 보내면, 변경이 없으면 서버는 304로 응답한다.

이 헤더들은 조건부 요청을 가능하게 한다: "내가 마지막으로 요청한 이후 변경되었나요?" 대부분의 경우 답은 아니오이고, 200킬로바이트 다운로드 대신 200바이트짜리 304 응답이 전송된다.

진짜 어려운 문제: 무효화

Phil Karlton이 말했다: "컴퓨터 과학에서 진짜 어려운 것은 두 가지뿐이다: 캐시 무효화와 이름 짓기."

농담이 아니다. 캐시 무효화가 진정으로 어려운 것은 근본적인 딜레마 때문이다: 지금 보고 있지 않은 것이 변경되었다는 걸 어떻게 알 수 있는가?

시간 기반 만료가 가장 단순한 답이다: 고정된 기간 동안 유효하다고 가정한다. max-age=300은 "5분간 이것을 신뢰하고, 그 후에 확인하라"는 의미다. 단순하고 예측 가능하지만 조잡하다. 콘텐츠는 30초 후에 변경될 수도 있고 몇 주간 동일하게 유지될 수도 있는데—시간 기반 만료는 그것을 알지도 신경 쓰지도 않는다.

짧은 기간은 콘텐츠를 신선하게 유지하지만 캐시 효율을 낮춘다. 긴 기간은 캐시 적중률을 높이지만 낡은 데이터를 더 오래 제공한다. 보편적으로 옳은 답은 없다.

버전이 지정된 URL은 이 문제를 완전히 회피한다. style.css 대신 style.a3f8c2.css를 제공한다—여기서 해시는 파일 내용에서 계산된다. CSS를 변경하면 해시가 변경되고 URL이 변경되며, 브라우저는 그 URL을 본 적이 없으므로 새 버전을 가져온다. 이전 버전은 무효화된 것이 아니라 단순히 더 이상 참조되지 않는 것이다.

이것은 정적 자산에 완벽하게 작동한다. 빌드 도구는 콘텐츠 해시를 자동으로 생성한다. URL 자체가 신선함을 보장하기 때문에 max-age=31536000(1년)을 설정할 수 있다.

수동 퍼지는 캐시에서 콘텐츠를 명시적으로 제거한다. 블로그 포스트를 업데이트할 때, 해당 URL에 대해 CDN의 퍼지 API를 호출한다. 정밀한 제어가 가능하지만, CMS와 캐싱 레이어 간의 통합이 필요하다.

캐시 태그를 사용하면 관련 콘텐츠를 함께 퍼지할 수 있다. 모든 상품 관련 응답에 product:123 태그를 붙인다. 해당 상품이 변경되면, 그 태그가 붙은 모든 것을 퍼지한다. 개별 URL을 추적하는 것보다 유지보수가 쉽고, 대부분의 CDN에서 지원된다.

Stale-while-revalidate는 백그라운드에서 업데이트를 확인하는 동안 즉시 캐시된 콘텐츠를 제공한다. 사용자는 빠른 응답을 받고, 업데이트는 다음 요청에 반영된다. 신선함과 속도 사이의 영리한 타협점이다.

콘텐츠 유형별 전략

콘텐츠마다 다른 접근법이 필요하다.

정적 자산—CSS, JavaScript, 이미지, 폰트—은 개발자가 배포할 때만 변경된다. 버전이 지정된 URL을 사용하고 1년간 캐시하라. 첫 번째 이후의 모든 요청은 캐시 적중이다.

HTML 페이지는 더 까다롭다. 순수 정적 사이트는 적극적으로 캐시할 수 있다. 동적 사이트는 짧은 기간이나 ETag와 함께 no-cache가 필요하며, 각 로드 시 신선함을 확인하면서도 304 응답의 혜택을 누린다.

API 응답은 편차가 크다. 참조 데이터(국가 코드, 상품 카테고리)는 몇 시간 동안 캐시할 수 있다. 사용자별 데이터는 공유 캐시에 있어서는 안 되지만 브라우저에는 캐시될 수 있다. 실시간 데이터—주가, 라이브 점수—는 전혀 캐시할 수 없다.

개인화된 콘텐츠가 가장 어려운 경우다. 각 사용자가 다른 데이터를 보므로 공유 캐시는 도움이 되지 않는다. 해결책: 브라우저에만 캐시하거나(private), 정적 셸과 동적 콘텐츠를 분리하거나, 캐시된 조각에서 개인화된 페이지를 조합한다.

캐싱 패턴

Cache-aside(지연 로딩)는 가장 일반적인 패턴이다. 먼저 캐시를 확인한다. 적중하면 캐시된 데이터를 반환한다. 미스면 데이터베이스에서 가져와 캐시에 저장하고 반환한다. 단순하지만 첫 번째 요청은 항상 느리다.

Write-through는 캐시와 데이터베이스에 동시에 쓴다. 사용자 프로필을 업데이트하면? 두 저장소 모두 같은 작업에서 새 데이터를 받는다. 캐시가 동기화 상태를 유지하지만, 결코 읽히지 않을 수도 있는 데이터까지 캐싱하게 된다.

Write-behind는 캐시에 즉시 쓰고 데이터베이스에는 비동기로 쓴다. 빠른 쓰기, 배치 데이터베이스 작업이 가능하지만, 위험하다—데이터베이스에 기록되기 전에 캐시가 실패하면 데이터가 손실된다.

Refresh-ahead는 만료 전에 인기 있는 항목을 선제적으로 갱신하여 인기 데이터가 절대 캐시 미스를 경험하지 않도록 한다. 예측 가능한 접근 패턴에서는 잘 작동하지만, 인기 없는 데이터에는 자원을 낭비한다.

분산 캐싱

애플리케이션이 여러 서버에서 실행될 때, 로컬 캐싱은 문제가 생긴다. 각 서버는 자체 캐시를 유지한다. 사용자 요청은 다른 서버에 도달한다. 캐시 내용이 달라진다. 불일치가 발생한다.

RedisMemcached는 모든 애플리케이션 서버에서 접근 가능한 공유 캐시로 이 문제를 해결한다. 모든 서버가 동일한 캐시 데이터를 본다. 한 서버에서의 캐시 채움이 다른 모든 서버에 혜택을 주기 때문에 적중률이 향상된다.

Redis는 영속성, 풍부한 데이터 구조(리스트, 셋, 정렬된 셋), pub/sub, 원자적 연산을 제공한다. Memcached는 더 단순하다—순수 키-값 저장소—약간 낮은 지연 시간을 갖는다.

트레이드오프: 네트워크 왕복. 로컬 메모리 조회는 나노초가 걸리고, Redis 조회는 밀리초가 걸린다. 데이터베이스 쿼리보다는 훨씬 빠르지만, 공짜는 아니다.

캐시 서버 장애는 우아하게 처리되어야 한다. 애플리케이션은 데이터베이스 쿼리로 성능을 저하시켜야 하지, 멈춰버려서는 안 된다. 캐시를 필수 요소가 아닌 최적화 수단으로 취급하라.

제거 정책

캐시는 메모리가 한정되어 있다. 가득 차면 무언가는 제거되어야 한다.

**LRU(Least Recently Used)**는 가장 오랫동안 접근되지 않은 것을 제거한다. 최근 접근이 미래 접근을 예측할 때 잘 작동한다—대부분의 워크로드에서 해당되는 이야기다.

**LFU(Least Frequently Used)**는 접근 횟수가 가장 낮은 항목을 제거한다. 최근의 일회성 접근보다 꾸준히 인기 있는 데이터를 선호한다.

**TTL(Time To Live)**은 접근 패턴에 관계없이 고정된 기간 후에 데이터를 만료시킨다. 종종 LRU와 결합된다—만료된 항목이 먼저 제거되고, 그 다음 최근에 가장 덜 사용된 것이 제거된다.

LRU가 기본 선택인 이유가 있다. 단순하고, 효과적이며, 대부분의 애플리케이션이 실제로 데이터에 접근하는 방식과 일치한다.

동시 요청 폭주

캐시 미스는 데이터베이스 쿼리를 발생시킨다. 천 개의 요청이 동시에 동일한 캐시 미스를 만나면 어떻게 될까?

천 개의 데이터베이스 쿼리가 한꺼번에 쏟아진다. 동일한 데이터를 요청하는.

이것이 동시 요청 폭주(thundering herd)다: 동시에 캐시 미스가 발생하는 순간이 데이터베이스로의 쇄도를 일으킨다. 인기 있는 캐시 항목이 만료되거나 캐시가 빈 상태로 재시작될 때 발생한다.

해결책: **요청 병합(Request coalescing)**은 동일한 키에 대한 동시 요청이 단일 데이터베이스 쿼리를 기다리게 한다. **캐시 잠금(Cache locks)**은 첫 번째 요청이 잠금을 획득하는 동안 다른 요청이 기다리게 한다. Stale-while-revalidate는 하나의 요청이 갱신하는 동안 계속 이전 값을 제공한다.

꼭 봐야 할 지표들

캐시 적중률: 요청의 몇 퍼센트가 캐시에서 제공되는가? 80%는 백엔드가 트래픽의 20%만 처리한다는 의미다. 각 캐시 계층별로 추적하라.

미스 지연 시간: 캐시 미스는 얼마나 걸리는가? 높은 미스 지연 시간은 느린 데이터베이스 쿼리나 외부 API를 드러낸다—캐싱의 최우선 후보들이다.

메모리 사용률: 지속적으로 용량 한계에 도달한다면 더 많은 메모리가 필요하거나 더 나은 제거 정책이 필요하다.

데이터 신선도: 사용자가 오래된 데이터를 보는 빈도는 얼마나 되는가? 이것은 적중률과 긴장 관계에 있다—더 긴 캐시 기간은 적중률을 높이지만 신선도를 낮춘다.

흔한 실수

과도한 캐싱: 모든 것을 캐싱하면 거의 접근되지 않는 데이터에 메모리를 낭비하고 너무 오래 낡은 콘텐츠를 제공하게 된다. 자주 접근되고 생성하는 데 비용이 많이 드는 것을 캐시하라.

미흡한 캐싱: 명백한 기회를 놓치는 것. 동일한 데이터베이스 쿼리가 분당 천 번 실행된다면, 캐시되어야 한다.

캐시 키 충돌: 다른 데이터가 동일한 캐시 키를 공유하는 것. 키 생성 시 관련된 모든 매개변수—사용자 ID, 로케일, 기능 플래그—를 포함하라.

개발 환경에서 캐시 무시: 사용자가 여전히 이전 버전을 캐시하고 있어 변경사항이 적용되지 않는 배포. 자산에 버전이 지정된 URL을 사용하고, 개발 환경에서는 캐시 시간을 짧게 설정하라.

캐시를 진실의 원천으로 취급하기: 캐시는 사라질 수 있다—제거되거나, 만료되거나, 서버가 재시작되거나. 애플리케이션은 (느리더라도) 캐시 없이도 작동해야 한다.

캐싱은 개념적으로 놀랍도록 단순하다—필요한 곳 가까이에 복사본을 저장한다—하지만 실제로는 진정으로 복잡하다. 성능 향상은 실질적이고 상당하다. 무효화가 잘못될 때의 버그도 마찬가지다.

핵심 기술은 캐싱하는 방법을 아는 것이 아니다. 복사본이 더 이상 충분하지 않은 때를 아는 것이다.

이 페이지가 도움이 되었나요?

😔
🤨
😃