Skip to main content

Next.js 캐싱으로 웹 서버 성능 최적화

About 4 minNode.jsNext.jsArticle(s)blogfe-developers.kakaoent.comnodenodejsnode-jsnextnextjsnext-js

Next.js 캐싱으로 웹 서버 성능 최적화 관련

Next.js > Article(s)

Article(s)

Next.js 캐싱으로 웹 서버 성능 최적화 | 카카오엔터테인먼트 FE 기술블로그
Next.js의 Full Route Cache를 적극 활용함으로써, 서버 렌더링에 사용되는 CPU 리소스를 최소화한 경험을 공유합니다.

올해 초 저희 팀에서는 신규 커머스 서비스를 오픈했습니다. 아무래도 커머스에서는 SEO가 중요하다보니 Next.js 의 App Router를 사용해서 SSR(Server Side Rendering)을 구성했는데요.

<FontIcon icon="fas fa-globe"/>fanstore.melon.com
fanstore.melon.comopen in new window

오픈전 부하테스트 과정에서 예상보다 낮은 TPS(Transaction Per Second)가 확인되어 당황했었습니다. 이 글에서는 저희 팀이 Next.js의 Full Route Cache를 적극 활용함으로써, 서버 렌더링에 사용되는 CPU 리소스를 최소화한 경험을 소개합니다.


웹 서버의 성능은 왜 중요할까요?

축구경기에서 좋은 패스를 받은 공격수가 슛을 느리게 하는 바람에 골을 놓치는 경우가 종종 있는데요. 마찬가지로 웹 페이지에서도 좋은 컨텐츠, 좋은 상품을 느리게 로딩하는 바람에 고객을 놓치는 사례를 쉽게 확인할 수 있습니다.

웹 서버의 성능은 당연하게도 웹페이지의 로딩속도를 결정하는 중요한 요소 중 하나입니다. 웹 페이지 요청 과정을 표현한 아래 다이어그램에서 노란색 부분, 즉 TTFB(Time to First Byte)open in new window에 해당하는 영역이 웹 서버의 성능으로부터 영향을 받습니다.

<FontIcon icon="fa-brands fa-firefox"/>developer.mozilla.org
developer.mozilla.orgopen in new window

승리를 하는 데에 (이익을 내는 데에) 골을 잘 넣는 것만큼이나 중요한 게 골을 덜 먹히는 것인데요. 웹 서버의 성능을 개선하면 동일한 수준의 트래픽을 처리하는 데 필요한 인프라 비용을 낮춤으로써 이익에 기여할 수 있습니다.


웹 서버의 성능을 향상시키기 위해서 우리는 무엇을 해야 할까요?

웹 서버의 성능을 향상시키기 위해 알려진 방법 (@surksha8)open in new window들은 여러 가지 있지만, 우리의 에너지는 한정적이기 때문에 효과가 가장 높은 방법을 선택적으로 적용해야 합니다. 그러기 위해 앞에서 잠깐 언급한 저희 팀 신규 서비스의 특징을 살펴봤습니다.

  1. 공연 티켓 구매자들에게만 공연 관련 상품을 판매하는 서비스다 보니 상품의 개수가 적습니다. (그마저도 공연이 끝나면 사라집니다.)
  2. 핫한 공연은 오픈 시점에 갑자기 엄청난 트래픽이 발생할 수 있습니다.
  3. 트래픽은 상품 목록, 상품 상세 페이지에 집중될 것으로 예상됩니다.
  4. 상품 목록, 상품 상세 페이지는 상품 재고 값을 제외하면 데이터 변경이 거의 없습니다.

이미 눈치채셨겠지만, 저희는 상품 관련 페이지의 서버 렌더링 결과에 캐싱을 적용하는 것이 가장 효과가 클 것이라 의견을 모았습니다. 상품의 개수가 적고, 트래픽이 발생할 때 특정 상품으로 집중되는 특징은 Cache Hit Ratioopen in new window에 너무나 유리한 조건입니다.


Next.js의 캐싱 매커니즘

Next.js는 13 버전에서 App routeropen in new window와 함께 새로운 캐싱 매커니즘open in new window을 소개했습니다.

매커니즘대상장소목적기간
Request Memoizationfetch 함수의 return값서버React Component tree에서 data의 재사용request 생명주기 동안
Data CacheData서버유저 요청이나 deployment에 의해 저장된 데이터영구적(revalidate 가능)
Full Route CacheHTML, RSC Payload서버렌더링 cost 감소 및 성능 향상영구적(revalidate 가능)
Router CacheRSC Payload클라이언트네비게이션에 의한 서버 요청 감소세션 또는 정해진 시간 동안

이 글에서는 웹 서버의 성능에 집중하고 있기 때문에, 서버에 적용되는 캐시 매커니즘에 대해서만 간단히 소개하겠습니다.

Request Memoization

웹 서버로 페이지 요청이 들어오면 페이지에 필요한 데이터들을 fetch하게 되는데, 이때 동일한 endpoint로의 API fetch를 여러 컴포넌트에서 수행할 필요가 있다면 Request Memoization이 동작합니다. (React가 fetch 함수를 확장해놓았기 때문에 별도 설정은 필요 없습니다.)

상위 컴포넌트에서 API fetch 결과를 prop drilling 하는것 대신, 각 컴포넌트에서 fetch를 수행하도록 구현해도 실제 API 요청은 최초 1회만 전송되고 나머지는 응답값을 재사용합니다.

<FontIcon icon="iconfont icon-nextjs"/>request-memoization
request-memoizationopen in new window

Request Memoization은 서버에서 호출되는 GET 메서드에만 적용되므로, POSTDELETE API 또는 클라이언트에서 호출되는 API에는 적용되지 않습니다. 그리고 한 번의 서버 렌더링 동안만 유효하기 때문에 따로 revalidate 할 필요가 없을 뿐 아니라 할 수도 없습니다.

Data Cache

우리가 일반적으로 생각할 수 있는 API 캐싱입니다.

// Revalidate at most every hour
fetch('https://...', { next: { revalidate: 3600 } })

Next.js가 확장해놓은 fetch 함수에 next.revalidate 옵션을 넘기면 Data Cache가 동작합니다. 성공적으로 데이터를 가져왔다면 그 응답값을 저장해두었다가 동일한 경로로 fetch 함수를 실행할 때 실제 API 호출은 건너뛰고 저장해놓은 응답값을 반환합니다.

하나의 요청 동안만 유효한 Request Memoization과 다르게 Data Cache는 일정 시간 동안에 웹 서버로 들어오는 모든 요청에 대해 동작합니다. 만약 next.revalidate를 1초로 설정했다면, 1초에 1000명의 사용자가 접속해도 실제 API 요청은 1회 전송됩니다.

<FontIcon icon="iconfont icon-nextjs"/>caching#revalidating
caching#revalidatingopen in new window

Data Cache를 설명하는 위 이미지에서 한 가지 짚고 싶은 부분은 revalidate 시간이 지나더라도 첫 요청은 캐싱된 값을 (STALE 상태여도) 반환한다는 것입니다. 반환 후 백그라운드에서 API를 호출해서 값을 업데이트하는데, 개발자 의도와 다르게 동작할 수 있기 때문에 캐시를 적용할 때 주의가 필요합니다.

router.refresh로는 Data Cache가 revalidate되지 않고, revalidatePathopen in new window를 사용해야 합니다. (이때는 즉시 revalidate 되기 때문에, 다음 첫 요청에도 새로운 값을 반환합니다.)

Full Route Cache

웹 서버의 성능을 눈에 띄게 향상시키려면 Full Route Cache를 적용해야 합니다. 서버 렌더링 과정에서 웹 서버의 리소스(특히 CPU)를 대부분 사용하게 되는데, Full Route Cache는 서버 렌더링 결과를 재사용함으로써 이를 줄일 수 있습니다.

<FontIcon icon="iconfont icon-nextjs"/>static-and-dynamic-rendering
static-and-dynamic-renderingopen in new window

Full Route Cache를 적용하려면 페이지를 Static 렌더링 되도록 구성해야 합니다. 다시 말해 Dynamic Functionopen in new window을 사용하지 않아야 하는데, 그렇지 않으면 그림과 같이 Full Route Cache 단계가 SKIP 됩니다. Full Route Cache를 좀 더 자세히 알고 싶다면 공식문서open in new window를 참고하시길 바랍니다.


Next.js의 캐싱 적용하기

1. 캐싱 대상 정하기

개인화된 페이지(장바구니, 결제 등)은 폐쇄형 쇼핑몰 특성상 트래픽이 적을 뿐 아니라, 동일한 응답을 내려줘서는 안 되기 때문에 캐싱 적용 대상에서 제외했습니다. 앞서 말했듯 상품 목록/상세 페이지는 비로그인 상태에서도 누구나 조회 가능하고 트래픽이 몰릴 가능성이 있기 때문에 아주 적절한 대상입니다.

상품 목록 페이지
상품 목록 페이지
상품 상세 페이지
상품 상세 페이지

2. 변경 범위 파악 및 테스트 코드 보강

Full Route Cache가 동작하게 하려면 Dynamic routesopen in new windowISRopen in new window을 적용하고, 사용 중인 Dynamic Functionopen in new window을 제거해야 했습니다. cookies, headers, pathParams, searchParams 모두 여기저기에서 사용 중이다 보니 광범위한 변경이 필요했고, 안정적인 변경을 위해서 단위를 쪼개고 관련해서 테스트 코드를 보강했습니다.

3. AS-IS 부하 테스트

성과를 수치화하는 것은 직장인에게 중요한 덕목입니다. 결과에 영향을 줄 수 있는 조건들을 통제하여 부하테스트를 진행했습니다. (with. nGrinderopen in new window)

곧 괜찮아 질거야
곧 괜찮아 질거야

4. 캐시 디버깅 컴포넌트 구현

브라우저에 렌더링 된 페이지가 Full Route Cache를 HIT 했는지를 확인하기 위해 간단한 서버 컴포넌트 하나를 추가했습니다.

function DebugCache({path}: {path: string}) {
  return (
    <div>
      {dayjs().valueOf()}
      <RevalidateButton path={path}/>
    </div>
  );
}
'use client'
function RevalidateButton({ path }: {path: string}) {
  return <button onClick={() => revalidateFullRouteCache(path)}>revalidate</button>
}
'use server'
import { revalidatePath } from 'next/cache';

export async function revalidateFullRouteCache(path) {
  if (path) {
    revalidatePath(path, 'layout');
  }
}

브라우저에서 새로고침을 해도 dayjs().valueOf()값이 동일하다면 Full Route Cache가 HIT 했다고 판단할 수 있습니다.

5. 코드 변경

목표는 generateStaticParams를 통해서 ISRopen in new window 방식의 static route로 바꿈으로써 Full Route Cache를 적용하는 것입니다.

5.1 URL PATH

저희 서비스는 다국어를 지원하며 url path에 언어값이 포함되어있습니다. 지원하는 언어는 고정돼 있기 때문에, generateStaticParams에 바로 적용해서 static 페이지로 만듭니다.

// app>[lang]>layout.tsx
export function generateStaticParams() {
  return SUPPORTED_LANGS.map(locale => ({ locale }));
}

상품 상세 페이지도 url path에 상품 ID 값이 포함되어있습니다. 하지만 다국어와는 다르게 상품의 ID 값은 고정된 값이 아니기 때문에 generateStaticParams에서 빈 배열을 리턴해줍니다. 이렇게 하면 ISR로 동작하게됩니다.

// app>[lang]>(static)>상품상세>[id]>page.tsx
export async function generateStaticParams() {
  return [];
}

그리고 상품 목록 페이지에서는 태그를 선택해서 상품들을 조회할 수 있는데요.

선택된 태그를 기존에 searchParams로 다뤘는데, static route를 위해서 pathParams로 변경했습니다.

/products?tagId={tagId} -> /{tagId}/products

그렇게 하면 상품 상세 페이지와 동일하게 generateStaticParams를 사용해서 ISR로 동작하게 할 수 있습니다.

// app>[lang]>(static)>상품목록>[tagId]>page.tsx
export async function generateStaticParams() {
  return [];
}

5.2 Dynamic Functions

저희 서비스는 여러 가지 인증 체계를 사용하기 때문에 일관된 인증 처리를 위해 middlewareopen in new window에서 cookies, headers, searchParams를 사용해서 전처리하고 있습니다. 모두 dynamic function이기 때문에 middleware 대신 클라이언트에서 전처리하도록 AuthProvider 추가했습니다.

// before
// middleware(Server)에서 Client로 마이그레이션 돼야 하는 코드 예시입니다.
export function middleware(request: NextRequest) {

  const { nextUrl } = request;
  nextUrl.searchParams.set(SEARCH_PARAM_KEYS.REGION_TYPE, regionType);
  
  const responseForSetCookie = NextResponse.redirect(nextUrl);
  responseForSetCookie.cookies.set(COOKIE_KEYS.USER_TYPE, getUserType());

  ...
  ...

  return responseForSetCookie;
}
// after
'use client';

function AuthProvider({ children }: PropsWithChildren) {
  useEffect(() => {
    document.cookie = `${COOKIE_KEYS.USER_TYPE}=${getUserType()}; domain=.melon.com; path:/;`;
    
    const searchParams = new URLSearchParams(window.location.search);
    searchParams.set(SEARCH_PARAM_KEYS.REGION_TYPE, getRegionType());
    
    ...
    ...

  }, []);

  return children;
}

이외에도 API를 호출하는 함수에서 인증을 위해 사용하고 있는 dynamic function을 (인증이 필요 없는) 상품 목록/상세 페이지에서는 사용하지 않도록 처리했습니다.

// before
// API 호출하는 부분에서 제거돼야 하는 코드 예시입니다.
  const { cookies } = await import('next/headers');
  const cookieStore = cookies();

  return {
    [HEADER_KEYS.CHANNEL_TYPE]: cookieStore.get(COOKIE_KEYS.CHANNEL_TYPE)?.value as ChannelTypes,
    [HEADER_KEYS.REGION_TYPE]: cookieStore.get(COOKIE_KEYS.REGION_TYPE)?.value as RegionTypes,
  };

Header/Footer 컴포넌트에서는 인증이 필요한(개인화된) 데이터가 사용되고 있었습니다. 때문에 캐시 적용이 어려워서 서버 컴포넌트에서 클라이언트 컴포넌트로 변경했습니다. 이렇게 되면 Header와 Footer가 클라이언트에서 dynamic import 되는데, Layout Shift가 발생하지 않도록 Placeholder 컴포넌트도 추가해 줬습니다.

const Footer = dynamic(
  () => import('@/common/Footer.client'),
  { ssr: false, loading: () => <ComponentPlaceholder /> },
);

5.3 Full Route Cache 적용

static route가 가능하게 되었다면, layout.tsxrevalidateopen in new window 시간을 설정해서 Full Route Cache가 동작하도록 해줍니다.

// app>[lang]>(static)>상품상세>[id]>layout.tsx
export const revalidate = 1; // seconds

Info

Full Route Cache는 Data Cache가 HIT 되었을 때에만 동작합니다. 따라서 Full Route Cache를 적용하려는 페이지의 모든 fetch에는 next.revalidate 값이 (Full Route Cache의 revalidate 값보다 크거나 같게) 설정돼야 합니다.

next.js 14.0.2 (vercel/next.js)open in new window 이전 버전에서 standaloneopen in new window 빌드 하면, 304open in new window 응답을 무한으로 캐싱 하는 버그 (vercel/next.js)open in new window가 존재합니다. 저희는 13대 버전을 사용하고 있기 때문에, major 버전업 대신 해당 부분만 몽키패치해서 해결했습니다.

효과는 굉장했다

Full Route Cache 적용 외에도 정적 리소스들(js, css)을 Load Balancer에서 캐싱하도록 적용한 후에 부하테스트를 진행했습니다.

before - 평균 TPS 34
before - 평균 TPS 34
after - 평균 TPS 220
after - 평균 TPS 220

여러 번의 부하테스트 비교군 중 대표 한 쌍을 가져와 봤습니다. 다른 조건의 부하테스트도 비슷한 수준의 차이를 보입니다. 적게는 5배에서 많게는 10배까지도 TPS가 개선된 것을 확인할 수 있었습니다. TPS의 개선은 당연하게도 응답시간(Mean Test Time)과 CPU 사용률이 개선되었음을 의미합니다.

before
before
after
after

정리

이 글에서는 자세히 다루지 않았지만 React Server Componentopen in new window를 비롯해서 Next.js의 App Router가 아직은 공식적으로 experimental 단계인 기능이나 숨어있는 버그들이 있다 보니 프로덕션 단계까지 구현하는 데에 어려움이 많았습니다. 디버깅 툴이 더 고도화되면 좋겠다 생각했고, 다른 라이브러리들의 적절한 대응도 필요해 보였습니다. 그럼에도 이 글의 사례처럼 장점을 적절하게 활용한다면 골을 더 많이 넣는 공격수가 될 수 있지 않을까 생각하며 글을 마칩니다.


이찬희 (MarkiiimarK)
Never Stop Learning.