teklog

Nextjs Cache 적용하기 (SSR, i18n)

2023/02/03

n°31

category : Next.js

img


회사 프로젝트 개발 중 최적화 이슈가 생겼다. getServerSideProps가 적용된 페이지의 로드가 17s 가량 걸리는 문제였다. 해결하는데 꽉찬 하루가 소요됐는데 (야근 포함 1.5일), 숙지 안하고 넘어가면 상당히 아쉬울 것 같다. 그리고 캐시 설정에 여러가지 시도를 했었는데, 급해서 이것저것 한꺼번에 시도하다보니 어떤 부분 때문에 해결됐는지 정확히 파악하지 못했다. 성능 최적화에 중요한 부분이라 강조될 만한 내용이겠다. 그러니 정리를 해보자.



목차



  1. Web api - Cache-Control
  2. Next.js Cache 설정
  3. i18next/next Cache 설정



1 Web api : Cache-Control


mdn (en)

mdn (kr)


Cache-Control은 서버와 통신하는 비동기 함수의 헤더 영역에 들어가는 필드다. MDN에 캐시에 대한 설명이 잘 나와있다.


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


요청과 응답의 캐싱 디렉티브가 동일하지 않다는 점은 숙지할만 하다. 예를 들어 네트워크에서 SSR 페이지의 요청/응답의 헤더를 보면, cache-control이 다른 것을 확인할 수 있다. 처음엔 요청 헤더에 계속 no-store가 들어있어 무슨 의미인지 잘 몰랐다. 서버사이드 렌더링된 html 페이지를 캐싱하려면 요청 헤더에 캐시 설정을 해주어야 했는지도 궁금하다.



캐시 요청 디렉티브


Cache-Control: max-age=<seconds>
Cache-Control: max-stale[=<seconds>]
Cache-Control: min-fresh=<seconds>
Cache-control: no-cache
Cache-control: no-store
Cache-control: no-transform
Cache-control: only-if-cached


캐시 응답 디렉티브


Cache-control: must-revalidate
Cache-control: no-cache
Cache-control: no-store
Cache-control: no-transform
Cache-control: public
Cache-control: private
Cache-control: proxy-revalidate
Cache-Control: max-age=<seconds>
Cache-control: s-maxage=<seconds>



이하는 MDN에 적힌 각 디렉티브에 대한 설명이다.



public : 응답이 어떤 캐시에 의해서든 캐시된다는 것을 나타냅니다.
private : 응답이 단일 사용자를 위한 것이며 공유 캐시에 의해 저장되지 않아야 한다는 것을 나타냅니다. 사설 캐시는 응답을 저장할 수도 있습니다.


공유/사설 캐싱에 대한 설정


public은 프록시 캐시 서버를 사용 가능하게 해준다고 한다. 서버와 클라이언트 사이에서 대리로 통신을 수행하는 것을 가리켜 '프록시', 그 중계 기능을 하는 것을 프록시 서버라고 부른다. public은 프록시 캐시 서버의 캐시 사용을 가능케하는 설정으로 이해할 수 있겠다.


공용public 또는 공유 캐시는 둘 이상의 클라이언트에 의해 사용됩니다. 이에 따라, 사용자는 원 서버에서 직접 복사본을 얻지 않아도 캐시된 표현의 복사본을 받을 수 있으므로 더 큰 성능 향상과 확장성 향상을 제공합니다.


private 캐시는 한 클라이언트에 의해서만 사용되며, 해당 클라이언트가 생성한 IP에 대해서만 적용됩니다. 일반적으로 이는 해당 클라이언트 자체에 의해 유지되는 캐시에 적용되지만, 한 클라이언트만 사용하는 프록시를 가지고 있다면 그것을 개인 캐시로 구성하는 것도 가능합니다. 개인 캐시는 공용 캐시보다는 확장성이 조금 떨어지지만, 공용 캐시보다 몇 가지 중요한 장점이 있습니다.


Public 캐시


  • 변경 빈도가 낮음
  • 인기있는 요청이 자주 발생함
  • 여러 사용자/클라이언트에 의해 사용됨


Private 캐시

  • 한 사용자/클라이언트만 사용할 수 있음
  • 예를 들어 웹 사이트의 개인 정보(인가된 사용자용)와 같은 것
  • 특정 사용자 또는 인가된 사용자만 사용 가능한 문서 등의 리소스
  • HTTPS 프로토콜을 통해 제공되는 리소스
  • 쿠키가 있는 응답


*캐시하지 않음*

  • POST 요청 비밀번호, 개인정보 등
  • 동적 콘텐츠(시간에 민감한 정보 등) = stale한 정보를 보여줘선 안되는 경우
  • 변경 빈도가 높은 객체들


- Public Cache vs. Private Cache


no-cache: 캐시된 복사본을 사용자에게 보여주기 이전에, 재검증을 위한 요청을 원 서버로 보내도록 강제합니다.

캐시를 보여주기 이전에 revalidate를 위해 강제로 요청을 보낸다-고 파악된다. (서버 상 데이터의 상태 확인을 위해) 요청을 다시 보내기에 결과적으로 'no-cache'가 된다. 처음엔 응답 cache-control의 no-store랑 혼동했다.


전에 블로그 방명록 작업에서 새로운 게시글 post 이후 revalidate가 안되었던 것 때문에 고생했는데, 게시글 리스트의 get 요청 헤더에 'no-cache'를 추가해주었더니 잘 작동하였다. 일견 캐시를 모두 무효화하는 것처럼 보이기 때문에 리액트 쿼리 사용 목적에 어긋나는 것처럼 보여 끝까지 시도하지 않았다. 하지만 네트워크를 보면, 응답 헤더에는 "max-age"가 찍혀있는 것을 확인할 수 있었다. 요청과 응답의 cache-control이 다를 수 있다는 사실을 몰랐기 때문이다. 리액트 쿼리에서 따로 cache time을 설정하지 않았는데도 응답 캐시에 max-age가 적용된 것은 리액트 쿼리 인스턴스 때문이 아닐까 생각이 들었다. 추후 확인이 필요한 부분(*Q1)


리액트 쿼리 같은 상태 관리 툴이 없다면, 서버 상태를 최신으로 보여주면서, 적당한 기간 동안 캐시를 유지하는 것이 no-cache를 쓰는 것보다 좋지 않을까 싶다.


no-store:캐시는 클라이언트 요청 혹은 서버 응답에 관해서 어떤 것도 저장해서는 안됩니다.

no-cache와 달리, 요청을 새로 보내어 revalidate 여부와 상관없이 캐시를 아예 저장하지 않는다. 아주 큰 차이점으로 느껴진다.


only-if-cached : 새로운 데이터를 내려받지 않음을 나타냅니다. 클라이언트는 캐시된 응답만을 원하며, 더 최신 복사본이 존재하는지를 알아보기 위해 서버에 요청해선 안됩니다.

반대로 캐시된 결과만 보여주는 설정이다. 좀처럼 바뀌지 않는 데이터를 보여주어야 할 때 사용할 수 있겠다. SSG가 적용된 페이지를 떠올리면 쉽다. 꼭 CSR을 해야하는 상황이라면 유용할 수 있겠다.



이하는 만료 기간 디렉티브


만료 기간의 value는 초단위인데, 밀리세컨 단위가 아님을 기억해둘 필요가 있다.

max-age=<seconds> : 리소스가 최신 상태라고 판단할 최대 시간을 지정합니다. Expires에 반해, 이 디렉티브는 요청 시간과 관련이 있습니다.


리소스 = 서버 상의 데이터 상태로 이해하면 쉽다. 그러므로 결과적으로 캐시가 유지될 시간으로 이해할 수 있다. 하지만 사실 그보다 요청에서 '30초 동안은 서버 데이터가 최신상태다'라고 가정하는 시간으로 이해하는 것이 더 정확하겠다.


s-maxage=<seconds> : max-age 혹은 Expires 헤더를 재정의하나, (프록시와 같은) 공유 캐시에만 적용되며 사설 캐시에 의해서는 무시됩니다.


'프록시와 같은' 공유 캐시에 적용되는 max-age라고 이해하면 된다. 앞서 '공유/사설' 캐시와 연관된다. 서버사이드 페이지html을 응답으로 보내주는 프론트 서버는 공유 캐시가 적용되는 걸까? public/private의 경계가 궁금하다. 역시 추후 알 필요가 있다. (*Q2)



max-stale[=<seconds>] : 클라이언트가 캐시의 만료 시간을 초과한 응답을 받아들일지를 나타냅니다. 부가적으로, 초 단위의 값을 할당할 수 있는데, 이는 응답이 결코 만료되서는 안되는 시간을 나타냅니다.


번역탓인지 약간 난해하게 느껴진다. 캐시의 만료기간(=max-age) 이후의 응답을 받아들이는 여부로 이해된다. boolean 대신 초 단위의 value가 들어간다. 그 시간은 '응답이 만료되서는 안되는 시간'이라는데 이 부분이 난해하다. 리액트 쿼리의 글에서 살핀 stale time과 대응되는 개념일 것을 파악된다. 리액트 쿼리에선 stale time을 초과하면 데이터는 '오래된 것'으로 판명된다. min-fresh에서 데이터의 fresh 상태를 지정할 수 있기 때문에, max-age처럼 약간은 다른 것으로 파악된다. 역시 확인이 필요. (*Q3)


min-fresh=<seconds> : 클라이언트가 지정된 시간(초단위) 동안 신선한 상태로 유지될 응답을 원한다는 것을 나타냅니다.


반대로, 데이터의 fresh state를 직접 지정해줄 수도 있다.



stale-while-revalidate=<seconds> *Experimental :비동기 적으로 백그라운드에서 새로운 것으로 체크인하는 동안 클라이언트가 최신이 아닌 응답을 받아 들일 것임을 나타냅니다. 초 값은 클라이언트가 최신이 아닌 응답을 받아 들일 시간을 나타냅니다.


자주 쓰는 디렉티브이다. 하지만 "핵심 HTTP 캐싱 표준 문서에 속하지 않습니다. 지원 여부는 호환성 테이블을 확인하시기 바랍니다."


윈도우 포커스 (사용자가 탭을 닫거나, 브라우저를 접어두었다가 다시 페이지를 돌아왔을 때) 데이터가 오래된(stale) 상태라면 새로운 응답을 받아들일지와 연관된다. value(초second)를 초과한 데이터는 stale한 상태로 판명되어 다시 요청을 보내 refresh 될 것이다. 리액트 쿼리에서도 자주 쓰이는 방식.


이외로 immutable, must-revalidate.. 등등이 있다. 건너 뛴 부분들이 있으니, 더 깊게 알고 싶으신 분들은 mdn 참고.


2 Next.js SSR 캐시 설정



공식문서에도 설명하듯이 ssr이 적용된 페이지에 캐시 설정을 할 수 있다. 타입스크립트를 사용 중이라면, GetServerSideContext를 params의 타입으로 정의하여 res를 가져올 수 있다. res.setHeader() 메서드를 통해 위에서 살펴본 캐시 디렉티브를 넣어주어 설정할 수 있다.


export async function getServerSideProps({ req, res }) {
  res.setHeader(
    'Cache-Control',
    'public, s-maxage=10, stale-while-revalidate=59'
  )

  return {
    props: {},
  }
}


코드에 대한 다음 설명을 읽어보자.


이 예제에서는 서버 렌더링을 위해 getServerSideProps와 함께 stale-while-revalidate 캐시 제어 헤더를 사용합니다. pages/index.tsx는 getServerSideProps를 사용하여 요청 헤더를 React 구성 요소로 전달하고 응답 헤더를 설정합니다. 이 캐시 제어 헤더는 재검증 동안 부실을 사용하여 서버 응답을 캐시합니다.


pages/index.tsx는 10초 동안 신선한 것으로 간주됩니다(s-maxage=10). 다음 10초 이내에 요청이 반복되면 이전에 캐시된 값이 여전히 최신 상태입니다. 요청이 59초 전에 반복되면 캐시된 값은 stale하지만 여전히 렌더링됩니다(stale-while-revalidate=59).


백그라운드에서 캐시를 새로운 값으로 채우도록 재검증(revalidate) 요청이 이루어집니다. 페이지를 새로 고치면 새 값이 표시됩니다.


하지만 getStaticProps(GetStaticPropsContext)의 param에서는 res에 접근할 수 없다. 그렇다면 SSG에서 캐시 설정은? 다행히도 자동으로 캐싱 설정이 추가된다.


Next.js는 JavaScript, CSS, 정적 이미지 및 기타 미디어를 포함하여 /_next/static에서 제공되는 변경 불가능한 자산에 캐싱 헤더를 자동으로 추가합니다. - 공식문서


만일 이 설정을 바꾸고 싶다면, next.config.js에 헤더를 오버라이드 해주면 된다.


**한가지 주의점은 로컬로 dev로 실행됐을 시에 캐시 설정은 모두 꺼지게 된다. 즉 배포 이후에야 캐시가 적용된 걸 확인할 수 있다.



참고: next dev를 사용하여 응용 프로그램을 로컬로 실행할 때 헤더를 덮어쓰면 로컬로 캐싱되지 않습니다.



Question (*Q4)


회사에서 겪어던 문제는 사실 서버사이드의 응답 캐시 적용 이후에 발생한 것이었다. 루트 경로의 index 페이지 html 서버 응답이 늦어진 문제였다. 아무리 fftb 지연이 (스트리밍 ssr이 적용되지 않은) ssr의 단점이더라도, 페이지 로드의 17초는 너무한 거 아닌가? 서버사이드 함수 내부에서 요청하는 모든 데이터에도 캐시를 적용해도 해결되지 않았다. 문제 원인에 대해선 두가지 가설이 있었다.


1) index.html 요청 헤더에 대한 캐싱 미적용


배포 이후 index.html의 요청 헤더에 'no-store'가 적용되어있어서, 요청에도 캐시 설정을 추가하였다.


여기서 생기는 궁금증은


a) 왜 요청 헤더에 캐싱이 적용되지 않았을까?


b) 어딘가의 깃헙 이슈에서, res 셋헤더는 서버 사이드 내부의 비동기 데이터에 대한 캐싱이고 html 자체에 대한 캐싱이 아니란 얘기를 봤는데 사실일까?


2) 다국어 (i18n) 캐싱 미적용


다음 항목에서 예시와 함께 다뤄봅니다.


결국엔 두가지 모두 시도하여서 문제를 개선하였다. (캐싱 적용 이후 페이지 로드를 0.83s까지 줄였다.) 마감에 쫓기고 있었기 때문에 휘몰아치듯이 pr을 올려대서 정확히 어떤 머지 시점에 캐싱이 적용되기 시작했는지 파악하지 못한 것이 매우 아쉽다. 이런 궁금증을 다 해결할 수 있었을텐데 매우 매우 아쉽다. 아시는 분은 leetekwoo@gmail.com 혹은 방명록에 답변주시면 감사하겠습니다!!



3 i18next-next (Next.js 다국어) 캐시 설정


캐싱을 적용하기 위해선 다음을 수정해주면된다. 순서무관.


1) appWithTranslation, serverSideTranslations을 수정해주어야 한다.

2) i18n config 파일을 수정해주어야 한다.

3) locales_cache 경로에 캐시로 나타낼 json 파일들을 추가해주어야 한다



공식문서는 i18n과 i18next를 둘다 봐야하고, 여전히 부실한 부분이 있다. 그런건 어쩔 수 없다지만, 예시로 제시된 snippet이 잘못되어있다. 이건 좀.. 여튼 이미 많은 자료가 있으니, i18n 적용하기 부분은 건너뛰고 캐싱 적용 부분의 코드를 보자.


**회사 코드와 무관한 예시 코드입니다.

_app.tsx

export default appWithTranslation(App, nextI18NextConfig);

appWithTranslation의 두번째 인자에 config를 넣어 설정을 오버라이드할 수 있다. 서버사이드 함수에도 동일하게 적용해준다.


pages/index.tsx

export async function getServerSideProps({ res, locale = 'en' }: GetServerSidePropsContext) {
...

return {
  props: {
  ...
    ...(await serverSideTranslations(
      locale as string,
      ['landing'],
      nextI18NextConfig
    )),
  },

serverSideTranslations에는 locale, 다국어가 적용될 키, i18n config를 넘겨준다.


다음은 중요한 i18n config

/next-i18next.cofig.js

const ChainedBackend = require('i18next-chained-backend');
const LocizeBackend = require('i18next-locize-backend/cjs');
const FSBackend = require('i18next-fs-backend/cjs');
const path = require('path');


module.exports = {
    i18n: {
        locales: ['en'],
        defaultLocale: 'en',
    },
    localePath: path.resolve('./public/locales'), 
// 공식문서엔 localePath의 위치가 잘못되어있다. backend안에 들어가지 않는다.
    backend: {
        backends: [FSBackend, LocizeBackend],
        backendOptions: [
            {
                loadPath: './public/locales_cache/{{lng}}/{{ns}}.json',
                addPath: './public/locales_cache/{{lng}}/{{ns}}.json',
                expirationTime: 60 * 60 * 1000,
            },
            {
                projectId: 'd3b405cf-2532-46ae-adb8-99e88d876733'(?),
                  referenceLng: 'en',
              },
          ],
          use: [ChainedBackend],
          ns: ['landing'],
      },
      serializeConfig: false,
  };
  

ssr 사용시 config를 이와 같이 바꾸어 위에서 살펴본 것처럼 서버사이드, _app에 오버라이드 해주어야한다. 캐싱 설정을 위해 backend, locize, FSBackend 플러그인을 사용해야 한다고 한다. 특정 플러그인 사용 시 서버 에러가 나기 때문에 serializeConfig: false 로 해야 하는데, 위의 3 플러그인들은 그 서버 에러를 터뜨린다. 고로 serializeConfig는 false로 해준다.


공식문서의 snippet에 누락된 부분이 많다. 먼저 localePath에 path가 빠지면 ssr 페이지에서 에러가 난다. 또한 localePath의 위치 또한 잘못되었다. backend가 아니라, 그 상단의 config 키로 가야한다. 어딘가 잘 정리되어 있을거라 예상하지만, 혹여나 검색으로 이 글을 보시는 분들에게 도움이 되면 좋겠다. 저처럼 시간 소요가 발생하지 않길 바람..


아 그리고 locales이 있는 경로에 locales_cache 폴더를 만들어 캐시로 사용할 json 파일들을 넣어주어야 한다! 왜 이렇게 까지 해야하나 싶지만, 일단은 매뉴얼에 나온대로 해주었다.


+ Next.js의 shallow routing 옵션을 활용해서 약간의 성능 개선을 이룰 수 있다. 해당 경로로 이동할 때 서버사이드 함수를 실행시키지 않는 설정이라고 한다. 13에 추가된 기능인지 모르겠는데, 나쁘지 않다. 두번째 인자를 undefined로 넣어줘야 해서 약간 어색할 수 있다. 참고


마치며


결과적으로 위의 것들을 해서 17s -> 0.83s까지 페이지 로드 속도를 개선을 할 수 있었다! 코드 작성을 많이 해야하는 것은 아니지만, 에러 케이스 기록이 잘 안되어있고, web api 스펙을 잘 숙지하지 못하고 있으면 어렵게 느껴지는 작업들이다. 또한 i18n의 키와 serversideTranslation의 param이 제대로 바인딩 되어있지 않다면, 에러조차 터지지 않는다. 대신 페이지 로드 속도가 상당히 느려지고, 번역이 되지 않는 치명적인 문제가 생긴다. 상당히 골치 아프다.


사실 Next.js라고는 하였으나, 웹 api의 스펙이 더 중요한 것 같다. 라이브러리마다 캐싱은 많이 있지만, max-age, stale/fresh, revalidate 등 기본이 되는 구석은 결국 웹 api의 스펙을 따른다. 용어 또한 이를 따르는 경우가 많아 이번 기회를 통해 숙지를 하려 했다.