Javascript

캐시에 관한 최소한의 모든 것(1)

보오 2022. 2. 27. 23:45

** 노션에서 더 편하게 보기

https://glistening-seashore-ed4.notion.site/f4a5838d303248918c146b40c9ac4b8a

 

캐시에 관한 최소한의 모든 것

캐시란?

glistening-seashore-ed4.notion.site

 

 

캐시란?

요청에 대한 응답의 복사본을 어딘가에 저장해놓고, 같은 요청이 들어오면 (서버에) 재요청하지 않고 저장한 복사본 데이터를 보내주는 것.

어떤 요청인가?

유저의 GET요청. 보통 POST요청은 캐시하지 않는다. 일반적으로 200, 301, 404 응답을 캐싱한다.

어디에 저장하나?

프록시 서버에 생성된 캐시 메모리 또는 클라이언트의 개인적인 공간(브라우저 등)에 저장한다. 이는 추후 설명하게 될 공유캐시(Public)/사설캐시(Private)가 된다.

왜 이런 행위(캐시)가 필요하나?

원본 데이터가 있는 곳은 원본 서버(Origin Server)라고 한다. 웹에서 클라이언트는 브라우저에서 요청을 시작한다. 브라우저를 통한 요청은 서버로 향하는데, 이때 오리진 서버로 가는 행위는 시간과 자원이 오래 걸린다. 그래서 그 사이에 캐시메모리라는 공간을 두고, 만약 클라이언트가 동일한 요청을 했고 그 요청에 대한 응답이 동일하다면, 굳이 서버까지 가지 않고 중간에 캐시메모리에서 데이터를 찾아서 던져주기로 한다. 클라이언트는 똑같은 상태의 데이터를 이전보다 더 빨리 받게 된다. 서버는 자원을 아끼고, 클라이언트는 응답을 더 빨리 받고 클라이언트, 서버 둘다 윈윈이다.

그러나 캐시메모리에는 명확한 제한이 있는데, 바로 크기다. 일시적으로 복사본을 저장하는 공간인 만큼 서버의 아주 작은 공간만을 할당 받는다.

그래서 캐시메모리가 다 차면 안에 있는 데이터를 삭제하여 공간을 만들고, 새로운 요청에 대한 응답을 다시 저장하는데 이때 어떤 데이터를 삭제할 것인지가 중요해지는데, 이를 결정하는 로직이 캐시 알고리즘이 있다.

캐시 알고리즘

캐시 교체 정책이라고도 하며, LRU, LFU, FIFO 등 여러 알고리즘이 있지만 LRU가 가장 많이 쓰인다.

LRU(Least Recently Used)

캐시메모리에서 사용된지 가장 오래된 페이지(— 캐시에서 각 데이터를 일컫는 말)를 지우는 방식

“오랫동안 쓰지 않은 데이터는 앞으로도 안쓸 것이다”라는 가정에서 나온 알고리즘이으로, 구현이 쉽고 가장 보편적인 방법이다.

📌 LRU 알고리즘

  1. 요청이 들어온다.
  2. 캐시에 없는 데이터면 원본 서버에서 데이터를 가져온 후 응답해주면서 캐시에 저장한다.
  3. 캐시에 있는 데이터면 캐시에서 빼서 준다.
  4. 캐시에 없는 데이터를 요청하는데 캐시가 다 찼을 경우, 가장 마지막에 쓰인 데이터를 지우고 방금 요청한 캐시를 가장 뒤(tail)에 둔다.
  5. 캐시에 있는 데이터 요청 시 해당 데이터를 가장 뒤(tail)로 옮기고 응답한다.

 

중요한 것은 가장 최근 응답한 데이터는 항상 맨 뒤로 순서를 옮긴다는 것이다.

배열로 생각하면 head에 가까울 수록 오래된 데이터, tail에 가까울 수록 최신 데이터라는 것이다. (array.push → 최근 페이지)

사용 알고리즘은 Doubly Linked List, HashMap이며, 시간복잡도는 O(1)이다.

간단하고 빠른 알고리즘이지만, 캐시데이터가 언제 사용되었는지를 알기 위해 timestamp을 찍는데, 이 때 오버헤드가 발생할 수 있다는 단점이 있다.

자바스크립트로 LRU 구현하기

아주 유명한 알고리즘인 만큼 코테에도 자주 출제되는데, 자바스크립트로 간단하게 구현할 수 있다.

(tail에 있는 데이터가 가장 최신이라고 가정)

리퀘스트가 들어온다. 이 때,


1. 기존 캐시데이터에 있는지 확인 후


2. 캐시에 없으면(MISS)

    2-1. 캐시에 넣어준다.
    2-2. 캐시가 가득차있다면, 가장 오래된 데이터를 제거하고 넣어준다. (배열의 첫번째 데이터를 제거)


3. 캐시에 있으면(HIT)
    3-2. 해당 데이터를 캐시의 가장 뒤로 보낸다. (배열의 마지막으로 보냄)

    3-1. 캐시에서 데이터를 꺼낸 뒤 응답한다.
// 캐시에는 사이즈가 있다.
// 사이즈가 5인 캐시메모리(최초에는 비워져 있다고 가정)
const cache_size = 5;
const cache_memory = Array.from({ length: cache_size }, () => null);

// 클라이언트가 요청하는 데이터
const request = [3, 4, 5, 3, 1, 1, 3, 6, 1, 2, 3, 5, 7, 3, 1];

req.forEach((el) => {
  const idx = cache.indexOf(el);
  // MISS
  if (idx < 0) {
    if (cache.length < size) {
      cache.push(el);
    } else {
      cache.shift();
      cache.push(el);
    }
  } else {
    // HIT
    cache.splice(idx, 1);
    cache.push(el);
  }
});

console.log(cache);

// [2,5,7,3,1]

여기에서 드는 의문점이 있다. 왜 배열의 가장 첫번째가 아닌 마지막 데이터가 가장 최신 데이터라고 하는걸까?

궁금증을 풀기 위해 head가 가장 신선하다고 여기는 로직(array.unshift)도 테스트해보았다.

위 로직에서 마지막이 아니라 첫번째로 추가하는 것으로 바꾸면 이렇게 된다.

req.forEach((el) => {
  const idx = cache.indexOf(el);
  // MISS
  if (idx < 0) {
    if (cache.length < size) {
      cache.unshift(el);
    } else {
      cache.pop();
      cache.unshift(el);
    }
  } else {
    // HIT
    cache.splice(idx, 1);
    cache.unshift(el);
  }
});
console.log(cache);

// [1,3,7,5,2]

console.time, console.timeEnd로 시간을 계산한 결과

tail에 최신데이터를 넣은 함수는 0.084ms, head에 최신 데이터를 넣은 함수는 0.117ms가 걸렸는데, 아마 속도때문에 tail에 최신 데이터를 넣기로 한 것 같다.

HTTP 요청의 캐시 설정들: Cache-Control

MDN(https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Cache-Control)에 가면 이미 자세히 나와있지만, 공부할 겸 한번 더 정리해보았다.

** (!) 라고 표시한 것은 비표준임(호환성 확인하기)

max-age

캐시의 수명. 지정한 시간(초) 동안 유효하다. ** 리소스가 유효하다고 판단하는 최대 시간으로, 이 안에 리소스가 바뀌었다면 update된다. Expires와 차이: 거의 없는데 둘 다 있으면 max-age가 이긴다. (max-age > expires)

ex) max-age=3153600 // 1년동안 캐싱

s-max-age

공유 캐시(프록시 캐시)에만 적용되는 max-age. 사설캐시는 적용되지 않는다.

max-stale

만약 캐시데이터에 max-age, 유효기간이 지난 캐시가 있을 때 이 캐시를 사용할지 여부. 있으면 지정된 시간동안 이 캐시를 사용한다는 얘기 ex) max-stale=3600 // 캐시가 만료되고 나서도 1시간 동안은 이 캐시를 사용

stale-while-revalidate(!)

max-stale과 비슷하지만 비동기 요청 동안 사용된다. 요청하는 동안 유효기간이 지난 캐시를 얼마만큼(얼마나 오랫동안) 받아들일지 여부로, 요청에 최대한 빠르고 유연하게 대응하기 위한 것이다. SWR, ReactQuery에서 이 설정이 사용된다. ex) max-age=600, stale-while-revalidate=30 // 600초가 지나서 캐시가 만료된 상태이지만, 새로운 데이터를 원본서버에서 찾고 응답이 올 때까지 그 사이 30초 동안은 일단 만료된 캐시를 보냄.

must-revalidate

캐시 만료 후 다시 조회할 경우 반드시 오리진 서버에 요청한다는 것으로, 만료된 캐시를 절대로 재사용하지 않는 다는 것이다. (그 데이터가 원본서버 데이터와 동일하건 말건 간에)

proxy-revalidate

must-revalidate와 동일하지만 공유 캐시(프록시 캐시)에만 적용된다.

immutable(!)

(만료되지 않는 한) 응답 데이터가 계속 변하지 않을 것이니, 원본을 확인하지 않고 “계~속 캐시해라” 오리진 서버에 데이터가 바뀌었는지 확인하지 않는 것으로 주로 변하지 않는 폰트, 이미지 등에 쓰인다. 이 속성의 강점은 사용자가 페이지를 새로고침 할 때마다 매번 원본과 대조하지 않아도 되니, 불필요한 요청을 하지 않을 수 있다는 것이다.

public

- 공유캐시(프록시 캐시)

공유서버에 저장해도 되는지 여부를 나타냄

private

- 사설캐시

공용 응답이 아닌 유저 개개인의 민감한 정보를 저장해야 할 경우 사용하는 속성으로, 공유 서버(프록시)에 저장하지 않고 개인 공간(브라우저 등)에 저장한다.

only-if-cached

최신본이 있을 때 서버에 확인하지 않음

no-cache

캐시를 쓰기 전에 오리진 서버에 “이거 써도 되냐”고 컨펌 받음(재검증) → 캐시를 안한다는 뜻이 아님

no-store

캐시를 끄는 설정 → 이게 캐시 안한다는 뜻 

 

요약

  • 캐시 끄는 설정: no-cache, no-store, must-revalidate
  • 캐시 키는 설정: public, max-age=31536000 // 1년

 

보너스

What is E-tag?

각 캐시를 구분짓기 위해 붙이는 숫자, 문자가 섞인 string값으로 원본 데이터가 변하면 E-tag도 변한다. 따라서 E-tag가 변하면 원본 서버에 다시 요청한다.

** E-tag의 단점은 서버 간에 값을 공유할 수 없다는 것이다. 즉, 서버마다 고유한 E-tag 값을 가지고 있어서 똑같은 데이터여도 서버가 여러개면 E-tag가 달라진다. 그러면 원본은 그대로인데 매번 상태가 바뀌었다고 판단하여 캐시가 무용지물이 된다. 이를 방지하기 위해 E-tag을 끄는 방법도 있다. E-tag을 꺼도 Last-Modified라는 타임스탬프로 최신 데이터인지를 판단할 수 있다.

 HIT || MISS ?

네트워크 탭을 보면 이렇게 cache-status가 HIT 또는 MISS 등으로 나오는데, 캐시를 했는지, 안했는지 여부다. (cf-cache-status은 클라우드플레어 캐시임)

  • HIT: 캐시메모리에 있어서 캐시데이터를 가져온 상태
  • MISS: 캐시메모리에서 찾지 못해서 원본 서버에서 가져온 상태

HIT, MISS는 깊게 파보면 Hit latency(rate), Miss latency(rate) 등 과연 얼마나 잘 캐시하고 있는지 성능을 측정하는 척도로 쓰이는 개념이 있는데, 이번 글에서는 이정도로만 마치고 다음에 이어서 파보겠다.