Nextjs Cache 적용하기 (SSR, i18n)
2023/02/03
n°31
category : Next.js
☼
2023/02/03
n°31
category : Next.js

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