프로그래밍/Next

캐싱, Cache-Control 알아보기 🥹

라다디 2023. 2. 1. 00:23

이 글은 캐싱에 대해 공부한 것 정리 + 삽질 기록입니다.. 정확하지 않을 수 있습니다. 


얼마 전에 성능 최적화와 관련해서 cache-control이라는 것에 대해 알게 되었다. 

이것을 이용하면 한 번 요청했던 리소스를 다시 요청할 필요가 없으니 프로젝트의 성능 개선에 도움이 될 것 같았다. 

일단 프로젝트에 적용하기 전에 캐시가 무엇인지 공부하는 시간을 가졌다.

 

HTTP 캐시

HTTP 캐시리소스에 대한 특정 요청의 응답을 저장하고, 이를 재사용하는 것을 이야기한다.

따라서 이후에 동일한 요청이 발생했을 때 서버에 직접 요청을 보낼 필요 없이 캐시 된 응답을 반환하면 되므로 클라이언트 입장에서는 응답을 빠르게 받을 수 있고, 서버 입장에서는 트래픽을 줄일 수 있다는 이점이 있다. (성능 측면에서 효율적, 경제적)

HTTP에서 리소스(Resource)란 웹 브라우저가 HTTP 요청으로 가져올 수 있는 모든 종류의 파일을 말한다.
→ HTML, CSS, JS, 이미지, 비디오 파일 등

 

하지만 캐시를 사용할 때는 주의해야 한다. 잘못 설정된 캐시는 사용자가 새롭게 변경된 정보를 응답받지 못하는 부작용을 발생시킬 수 있다. 만일 캐시의 유효 기간이 1년이고, 적절한 재검증 설정이 되어 있지 않다면 한 번 캐시가 된 이후 웹 사이트가 새롭게 바뀌어도 사용자는 1년 동안 새로운 정보를 받을 수 없게 된다. 따라서 캐시를 적절하게 설정하는 것이 아주 중요하다.

 

Cache-Control

불필요한 네트워크 요청을 줄이기 위해 사용할 수 있는 해결책이 HTTP 헤더의 Cache-Control이다. 

적절하게 Cache-Control의 디렉티브들을 사용하여 캐시가 주는 장점을 취할 수 있다.

 

no-store

Cache-Control: no-store

no-store는 캐시 하지 말라는 의미이다.

 

max-age

Cache-Control: max-age=31536000

max-age캐시의 유효 기간을 설정할 수 있다.

초 단위이기 때문에 위 예제에서 캐시의 유효 기간은 1년이다. 1년이 지나면 이 응답 캐시는 만료된 것으로 여겨진다. 

 

유효한 캐시가 남아있을 경우 메모리 캐시에서 가져온다.

만일 리소스에 대한 캐시가 만료되기 전에 다시 요청을 한다면 서버에 요청을 하지 않고 메모리에서 캐시 된 응답을 가져와 사용한다.

브라우저에 캐시가 한번 저장이 되면 만료되기 전까지 캐시는 브라우저에 계속 남아있다. 때문에 CDN Invalidation을 포함한 서버의 어떤 작업이 있어도 브라우저의 유효한 캐시를 지우는 것은 어렵다. 

 

CDN invalidation은 CDN에 저장되어 있는 캐시를 삭제한다는 의미이다. 브라우저의 캐시와 위치가 다르기 때문에 CDN 캐시를 삭제한다고 브라우저의 캐시가 삭제되지는 않는다. 한번 저장된 캐시는 지우기 어렵기 때문에 max-age 값은 신중하게 설정해야 한다.  

 

E.g. max-age=5

만일 크기가 3.1KB인 파일이 있고 max-age의 값을 5로 설정했다고 해보자.

캐시 유효 기간인 5초가 지난 뒤 다시 해당 파일을 요청했을 때 불러온 파일의 사이즈를 보면 190B인 것을 볼 수 있다.

이는 최소한의 데이터 통신만 하고 해당 파일을 다시 다운로드하지 않았다는 것을 의미한다.

 

이제 파일의 내용을 변경하고 다시 요청을 해보자.

불러온 파일의 사이즈가 3.1KB가 찍힌다. 다운로드를 실제로 한 것이다.

 

요청한 파일의 응답 헤더를 보면 Last-Modified라는 것이 있다. 

이는 서버 쪽에 있는 해당 파일이 언제 마지막으로 수정되었는지에 대한 정보이다. 

마지막으로 수정된 시간 정보를 서버가 브라우저에게 알려주고, 브라우저는 해당 정보를 캐시에 함께 저장한다. 

 

캐시 유효 기간인 5초가 지나고 재요청이 들어오면 브라우저는 서버 쪽에 파일의 수정 여부에 관한 검증 요청을 보낸다.

만일 서버쪽의 마지막 수정 시간과 브라우저쪽의 마지막 수정 시간이 동일한 경우 변경된 것이 없다는 의미로 304 Not Modified 응답을 받는다. HTTP 본문은 포함하지 않고 헤더만 주고받는다.

반면 파일의 내용이 바뀐 뒤 재요청을 할 경우 서버는 200 OK 또는 적합한 상태 코드를 HTTP 본문과 함께 내려준다. 

 

이런 식으로 max-age의 값을 조정해서 캐시를 신선하게 만들 수 있다.

동일한 원리로 요청을 할 때마다 재검증 과정을 거치고 파일이 수정되었을 때만 다운로드가 되게 하고 싶다면 max-age를 0으로 두면 된다.

이와 동일한 디렉티브는 no-cache이다. 

 

no-cache

Cache-Control: no-cache

no-cache캐시를 재사용하기 전에 서버에 재검증 요청을 보낸다.

 

must-revalidate

Cache-Control: must-revalidate

must-revalidate유효한 캐시는 재사용 가능하고 만료된 캐시만 서버에 재검증 요청을 보낸다. 

 

public, private

CDN을 사용하면 캐시는 여러 곳에 생길 수 있다. 서버가 가지고 있는 응답을 CDN이 캐시 하고, CDN의 캐시 된 응답을 브라우저가 가져와서 캐시 한다. 이처럼 HTTP 캐시는 여러 곳에 저장될 수 있기 때문에 세심히 다뤄야 한다. CDN의 캐시를 제거한다고 브라우저의 캐시가 제거되는 것이 아니고, 브라우저의 캐시를 지운다고 CDN의 캐시가 지워지는 것은 아니기 때문이다.

 

Cache-Control: public 또는 private

public 혹은 private 디렉티브를 이용하여 CDN과 같은 중간 서버가 리소스를 캐시 할 수 있는지 여부를 지정한다. 

public은 모든 사람과 CDN이 캐시를 저장할 수 있음을 의미하고, private은 브라우저만 캐시를 저장할 수 있음을 나타낸다. 

max-age 값과 조합하여 Cache-Control: public, max-age=86400과 같이 사용할 수 있다.

 

s-maxage

Cache-Control: s-maxage=604800

s-maxage중간 서버에서만 적용되는 max-age 값을 설정한다.

만일 Cache-Control: s-maxage=31536000, max-age=0과 같이 설정하면 CDN에서는 1년 동안 캐시되지만 브라우저에서는 매번 재검증 요청을 보내도록 설정할 수 있다.

 

ETag

위에서 이야기한 것처럼 파일의 응답 헤더에 있는 Last-Modified를 통해 파일의 최신 여부를 확인할 수 있다. 

하지만 컴퓨터는 굉장히 빠르고 Last-Modified는 초까지만 표시해 주기 때문에 부정확한 경우가 생길 수도 있다.

이러한 상황에서 사용할 수 있는 것이 ETag다. ETag는 랜덤의 고유한 값을 가진다.

 

웹 브라우저는 웹 서버가 알려준 ETag의 정보와 함께 파일을 캐시로 저장한다.

해당 파일을 재요청할 경우 요청 헤더의 If-None-Match 값으로 ETag를 보낸다.

그럼 웹 서버는 받은 ETag 값과 서버에 있는 파일의 ETag 값을 서로 비교하여 동일하다면 304를 보내고, 다르다면 200 status code와 함께 새로운 내용을 보내준다. 

 

위에서 이야기한 If-None-Match를 포함해 대표적인 재검증 요청 헤더들로는 아래와 같은 헤더가 있다.

  • If-None-Match: 캐시 된 리소스의 ETag 값과 현재 서버 리소스의 ETag 값이 같은지 확인한다.
  • If-Modified-Since: 캐시된 리소스의 Last-Modified 값 이후에 서버 리소스가 수정되었는지 확인한다.

웹 서버는 둘 중 하나라도 값이 다르다면 새로운 파일을 보내준다. 

 

Cache-Control 흐름도

 

참고 자료

 

HTTP 캐싱 - HTTP | MDN

웹 사이트와 애플리케이션의 성능은 이전에 가져온 리소스들을 재사용함으로써 현저하게 향상될 수 있습니다. 웹 캐시는 레이턴시와 네트워크 트래픽을 줄여줌으로써 리소스를 보여주는 데에

developer.mozilla.org

 

성능 최적화 2 — 캐시 설정하기

웹에서 캐시(Cache)는 요청한 자원에 대한 응답의 사본을 보관하는 것을 의미한다. 이후에 동일한 요청이 발생했을 때 실제로 (Origin) 서버에 요청을 전달하는 대신 보관 중인 (= 캐시 된) 응답을 반

medium.com

 

HTTP 캐싱

HTTP 캐싱에 대해서 학습하고 정리한다.

velog.io

 

Cache-Control - HTTP | MDN

Cache-Control 일반 헤더 필드는 요청과 응답 내의 캐싱 메커니즘을 위한 디렉티브를 정하기 위해 사용됩니다. 캐싱 디렉티브는 단방향성이며, 이는 요청 내에 주어진 디렉티브가 응답 내에 주어진

developer.mozilla.org

 

웹 서비스 캐시 똑똑하게 다루기

웹 성능을 위해 꼭 필요한 캐시, 제대로 설정하기 쉽지 않습니다. 토스 프론트엔드 챕터에서 올바르게 캐시를 설정하기 위한 노하우를 공유합니다.

toss.tech

 

HTTP Cache

수업소개 이 수업은 웹의 성능을 향상시키는 핵심 메커니즘은 HTTP Cache 를 다루는 수업입니다.  수업대상 이 수업은 HTTP에 대한 기본적인 이해를 요구합니다. HTTP를 모르시는 분은 HTTP 수업을 먼

opentutorials.org

 

HTTP Cache로 불필요한 네트워크 요청 방지

불필요한 네트워크 요청을 어떻게 피할 수 있습니까? 브라우저의 HTTP 캐시는 첫 번째 방어선입니다. 이 방법이 반드시 가장 강력하고 유연한 방법은 아니며 캐시된 응답의 수명을 제한적으로 제

web.dev


삽질 기록

아래부터는 Next.js 프로젝트에 캐싱을 적용하고자 삽질한 기록(의식의 흐름)이다.

결론적으로 적용하지는 않았는데 해결하지 못한 의문점이 많이 남아있고 이해도가 부족하다고 느껴져 적용할 수 없다고 판단했기 때문이다.

 

프로젝트에 캐싱을 적용하고자 생각했던 이유는 새로고침할 때마다 웹 폰트를 다시 불러오는데 이 부분을 최적화하고 싶었기 때문이다. 

처음에는 단순히 <head>에 아래와 같은 meta 태그를 적용하면 될 줄 알았다. 

<meta httpEquiv="Cache-Control" content="max-age=44444" />
→ 의문점 1: 이런 식으로 메타 태그를 적용하는 거면 모든 리소스에 해당 max-age가 적용이 되는지, 폰트에만 적용하고 싶은데 어떻게 해야 할지?

→ 의문점 2: 위 태그가 정말로 작용을 하는지?

스택 오버플로우에서 해당 글을 보았다.
대부분의 답변이 meta 태그를 권장하지 않는 듯했다. 너무 옛날 글이었고 정확히 이해가 안 되어서 일단은 meta 태그의 http-equiv에 Cache-Control이 있는지 찾아보고자 했다. 

- https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv
- https://developer.mozilla.org/ko/docs/Web/HTML/Element/meta#attr-name
위 두 문서에서는 찾아볼 수 없었다.
하지만 구글링을 했을 때 비교적 최근에도 Cache-Control meta 태그에 관해서 이야기하는 글이 많아서.. 혼란스럽다.

 

하지만 네트워크 탭에서 폰트의 응답 헤더를 보니 분명 max-age를 0으로 설정해 주었음에도 다음과 같이 제대로 적용이 안 되는 모습을 볼 수 있었다. 

 

왜 안되는가 싶어서 찾아보다가 Next.js 같은 경우는 자동으로 정적인 리소스들에 Cache-Control 헤더를 추가해 준다는 사실을 알았다.

 

Going to Production | Next.js

Before taking your Next.js application to production, here are some recommendations to ensure the best user experience.

nextjs.org

만일 Cache-Control의 옵션 값을 변경하고 싶다면 next.config.js에서 Cache-Control header를 설정하여 프로덕션에서 설정된 헤더를 덮어쓸 수 있다. 

 

해당 글을 참고하여 next.config.js를 다음과 같이 작성하였다. 

NextJS allows custom HTTP headers by adding the headers key in next.config.js. You can easily add Cache-Control HTTP header by writing the code below:
// An example of the `next.config.js` file - your content can be different

module.exports = {
    headers: async () => [
        {
            // list more extensions here if needed; these are all the resources in the `public` folder including the subfolders
            source: '/:all*(svg|jpg|png)',
            locale: false,
            headers: [
                {
                    key: 'Cache-Control',
                    value: 'public, max-age=31536000, stale-while-revalidate',
                },
            ],
        },
    ],
}​​
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: false,
  swcMinify: true,
  images: {
    domains: ['img.youtube.com'],
  },
  i18n: {
    locales: ['ko'],
    defaultLocale: 'ko',
  },
  headers: async () => [
    {
      source: '/:all*(svg|jpg|png)',
      locale: false,
      headers: [
        {
          key: 'Cache-Control',
          value: 'public, max-age=99999',
        },
      ],
    },
  ],
};

module.exports = nextConfig;

next.config.js를 변경했으니 서버를 다시 키고 브라우저 캐시네트워크 탭을 확인했다.

source가 이미지들이므로 이미지 리소스의 응답 헤더를 확인해 봤는데 max-age가 99999로 설정이 된 것을 볼 수 있다. 

next.config.js 캐싱 헤더 추가 전 / 추가 후

여기서 또 생기는 여러 의문점..

→ 의문점 1: 여러 이미지 리소스들이 있는데 어느 이미지는 적용이 되고, 어느 이미지는 적용이 안된다.
그렇다면 적용이 되지 않은 리소스들의 no-store, must-revalidate 같은 값들은 어디에서 설정이 된 것인가?

추측하기로는 로컬 서버로 테스트하고 있어서 그런가 싶기도 하다.
When running your application locally with next dev, your headers are overwritten to prevent caching locally.
Cache-Control: no-cache, no-store, max-age=0, must-revalidate

그렇다면 배포 서버에서 동일한 파일을 확인해 보자.
build 후에 start 명령어를 통해 프로덕션 서버를 실행시켰는데 위와 같은 결과를 보여주고 있다.
이 과정이 잘못되어서 적용이 안된 건지, 다른 이유로 적용이 안된 건지는 모르겠는데 Next.js가 기본으로 설정해 주는 Cache-Control 헤더가 들어가 있는 것을 볼 수 있다.

→ 의문점 2: svg 파일 뒤에 붙은 저 이상한 문자열은 무엇인가? 붙은 것도 있고 아닌 것도 있다.
이상한 문자열이 붙지 않은 svg 파일은 max-age가 99999로 적용이 되어 있고, 붙은 것은 적용이 되어 있지 않고 위와 같이 no-store, must-revalidate가 적용이 되어 있다. 

차이점에 대해서 알아보고자 했다. 
사용하는 부분을 보니 문자열이 붙지 않은 이미지는 background-image url을 작성할 때 다음과 같이 작성하는 것을 볼 수 있었다.
background-image: url('/assets/icons/ic_speech_guide_corner.svg');
우리 프로젝트의 경우 이미지나 아이콘 파일들은 위 경로에 저장하고, index 파일을 두어 다음과 같은 이름으로 내보내고 있다. 
export { default as icSpeechGuideCorner } from './ic_speech_guide_corner.svg';
문자열이 붙은 svg 파일들의 경우 다음과 같은 방식으로 사용하고 있었다. 
background-image: url(${icSpeechGuideCorner.src});

두 방식의 동작에 어떤 차이점이 있길래 이런 차이가 생기는 건지 궁금하다. 
.next 폴더의 static/media 폴더 밑을 보면 파일명 뒤에 문자열이 붙은 것을 볼 수 있는데 뭔가 관련이 있나 싶기도 하고..

 

다시 폰트 이야기로 돌아와서 Cache-Control을 보면 Next.js가 자동으로 설정해 주는 헤더도 아니고, next.config.js에서 직접 설정한 헤더 값도 아닌 것을 볼 수 있다. (source: '/:all*(svg|jpg|png|woff2)')

왜 적용이 되지 않을까 싶어서 검색하다가 해당 글을 발견했는데 외부에서 가져오는 구글 폰트에 대한 link는 해당 네트워크에서 정해준 값을 사용하는 것 같다는 말이 있었다. 그렇다면 Pretendard 웹 폰트를 가져올 때도 이미 그쪽에서 지정되어 있는 Cache-Control 값이 있는 것이고 이는 조정할 수 없는 것일까?

 

여러 의문점이 많이 남았고, 캐시를 세심하게 조절하는 것도 굉장히 중요한데 잘못된 max-age 값을 주어서 혹시나 부작용이 발생할까 하는 걱정이 있었다. 또한, 이미 폰트에 Cache-Control이 지정이 되어 있는 것이라면 이것을 직접 커스텀할 필요가 있을까 라는 생각도 들었다. 결국 프로젝트에 캐싱을 적용하는 것은 아직 무리라고 판단했고 적용하지 않기로 결정했다. 

 

마지막으로 디스크 캐시와 메모리 캐시의 차이점, 그리고 또 생긴 의문점을 간략히 이야기하고 마무리하겠다.

 

디스크 캐시 vs 메모리 캐시

HTTP 캐시는 두 종류가 있다.

  1. 디스크 캐시
  2. 메모리 캐시

 

일반적으로 캐시는 HDD(하드 디스크)에 저장된다. 하지만 하드 디스크에 접근해서 캐시를 가져오는 것은 오랜 시간이 걸린다.

따라서 디스크 캐시, 즉 RAM에 저장해서 훨씬 빠르게 접근해 가져올 수 있다.

하지만 RAM에 접근하는 것조차 CPU 입장에는 오래 걸린다.

그래서 CPU와 매우 가까이에 위치해 있는 캐시 메모리에 저장해 더 빠르게 접근하여 가져 올 수 있다.

 

GitHub - FE-Lex-Kim/-TIL: 실력있는 프론트엔드 개발자가 되기 위한 TIL

실력있는 프론트엔드 개발자가 되기 위한 TIL. Contribute to FE-Lex-Kim/-TIL development by creating an account on GitHub.

github.com

 

브라우저 캐시 제거 후 새로고침
다시 새로고침

브라우저 캐시를 모두 지우고 새로고침을 하면 웹 폰트를 새롭게 불러오는 것을 볼 수 있다. 그 후 다시 새로고침을 하면 크기에 메모리 캐시라고 적히는 것을 볼 수 있다. 이제 생기는 의문점은 좀 오랫동안 작업을 하지 않다가 다시 새로고침을 하거나 하면 다음과 같이 디스크 캐시에서 폰트를 불러오는 경우가 있다는 것이다. 

 

왜 그런가 하고 검색하다 스택 오버 플로우 글을 발견했다.

"Memory Cache" stores and loads resources to and from Memory (RAM). So this is much faster but it is non-persistent. Content is available until you close the Browser.
"Disk Cache" is persistent. Cached resources are stored and loaded to and from disk.
Simple Test: Open Chrome Developper Tools / Network. Reload a page multiple times. The table column "Size" will tell you that some files are loaded "from memory cache". Now close the browser, open Developper Tools / Network again and load that page again. All cached files are loaded "from disk cache" now, because your memory cache is empty.

메모리 캐시는 빠르지만 비영구적이기 때문에 브라우저를 닫으면 사라진다. 반면, 디스크 캐시는 영구적이기 때문에 만일 브라우저를 닫았다가 다시 열면 디스크 캐시에서 캐시를 불러온다는 것으로 이해했다.

 

(바보같이 테스트해 본다고 브라우저를 닫았다가 작성하던 글을 날려서 다시 쓰고 있다.)

브라우저를 닫았다가 다시 페이지를 로드해도 메모리 캐시가 찍히는데.. 아예 컴퓨터를 껐다 키면 디스크 캐시에서 불러올지 나중에 테스트를 해봐야겠다.

 

또 다른 의문점은 왜 status code가 Not Modified를 의미하는 304가 아닌 200인지이다. 응답을 보낼 때 무조건 200으로 보내도록 설정을 해준 걸까 궁금하다..

 

삽질 기록은 여기까지이다. 정말 삽질만 했다.. 그래도 새롭게 알게 된 것들이 있어 만족스럽다.

의문점에 대해 답변을 주실 수 있는 분이 계시다면 댓글로 알려주시면 감사하겠습니다. 내용에 틀린 점이 있다면 알려주세요.