8일 전
현재 블로그가 아닌 예전의 블로그의 문제 상황과 해결 과정입니다.
로컬 환경에서는 애플리케이션의 TTFB
(Time To First Byte)가 70~150ms정도 걸리는데, 배포 환경에서는 TTFB
가 느리면 1500ms, 빠르면 400ms정도가 걸렸다. 그래서 최적화를 위해 여러가지를 시도해보았다.
[배포 환경]
프론트 서버 : Vercel
백엔드 서버 : AWS EC2 + Express
DB 서버 : MongoDB Atlas Free Cluster
Function Region
은 서버리스 함수가 실행되는 데이터 센터의 물리적인 위치이다. 기본적으로 Vercel
은 전 세계 여러 지역에 배포된 엣지 네트워크를 활용하여 서버리스 함수를 실행하는데, 특정 지역을 선택하면 해당 지역에서 함수가 실행된다.
-> 사용자의 주요 트래픽이 발생하는 지역과 가까운 곳을 선택하면 지연 시간을 최소화할 수 있다.
당연히 이전에 설정한 Seoul로 설정되어 있는 걸 보아하니 이 문제는 아니다.
자바스크립트 번들 크기가 직접적으로 TTFB
에 영향을 미치지는 않지만, 번들 크기가 클 수록 서버가 처리해야할 작업이 많아지고, 결과적으로 TTFB에 영향을 줄 수 있다. 특히 SSR 환경에서 JS 번들 크기가 크다면, 서버가 페이지를 구성하는 데 더 오랜 시간이 걸릴 수 있다.
위에서 설명한 것과 같이, 자바스크립트 번들링 크기가 커서 문제가 발생하는가 싶어서 번들링의 크기를 최소화했다.
npm run build
로 빌드를 해봤는데 /admin/newpost
, /admin/newpost/[id]
, /admin/newproject
, /admin/newproject/[id]
의 First Load JS가 크게 나왔다.
그 이유는 어드민 페이지의 포스팅, 프로젝트 추가 및 수정 페이지에는 번들 크기가 큰 마크다운 뷰어인 react-markdown
라이브러리가 들어있었기 때문이다.
어드민 페이지는 사용자를 위한 페이지가 아니므로 즉, 미리 로드할 필요가 없으므로 next/dynamic 을 이용해서 dynamic import를 사용했다.
import dynamic from "next/dynamic";
const MDViewer = dynamic(
() => import("../../../components/Common/Markdown/MDViewer"),
{
ssr: false,
loading: () => null,
}
);
-> First Load JS가 평균 431kB
가 나오던 페이지가 167kB
까지 줄였다.
하지만 TTFB 문제는 해결되지 않았다.
"MongoDB Atlas Free Cluster를 사용해서 EC2 Express ~ MongoDB Atlas까지 응답 시간이 오래 걸리는게 문제인가?"라고 생각해서 Express 서버의 EC2에 같이 MongoDB를 설치해 사용해보았다.
→ 27017번 포트에 MongoDB를 설치해 연결하고 기존 Atlas의 DB를 모두 백업해 불러왔다.
하지만 Express에서 DB 정보를 가져오는 속도가 기존과 차이가 없었다.
사이트에 처음 접근하면 TTFB
가 1500ms
정도가 걸리고, 계속 페이지를 새로고침하면 평균적으로 150ms
정도로 걸렸다. 이 증상이 Serverless Function
의 Cold Start
와 유사해보였다. 그래서 Vercel
의 Logs를 살펴 봤는데 runtime이 Serverless Function
으로 작동해서 Execution Duration
이 149ms
걸렸다.
runtime을 Edge Function
으로 작동시키기 위해 /app/layout.tsx 와 /app/page.tsx 에 다음 코드를 추가했다.
export const runtime = 'edge'
Edge Function은 Edge Network에 전세계적으로 배포되며 이를 트리거하는 사용자와 가장 가까운 지역에서 자동으로 실행할 수 있다. 콜드 부팅 기능도 없어 시작하는 데 추가 시간이 필요하지 않다. 이는 네트워크를 통해 최대한 빠르게 상호작용해야 할 때 유용하다.
그 결과 TTFB가 80ms
정도 밖에 걸리지 않았다. Vercel의 Hobby Tier는 Edge Middleware Invocation이 100만회로 제한이 있지만 개인 블로그 용도로는 차고 넘치기 때문에 이렇게 사용하기로 했다.
하지만 Express를 Next.js로 마이그레이션 후에는 Edge runtime을 사용할 수 없어서 다른 방법으로 문제를 해결했다.
다른 프로젝트에서는 TTFB 문제가 발생하지 않았는데 블로그에서만 TTFB 문제가 발생했다. 그래서 프로젝트들의 빌드 로그를 살펴봤는데, 블로그에서만 모든 라우트가 Dynamic으로 동작하고 있었다.
블로그
블로그는 모든 라우트가 Dynamic
으로 동작하여 서버에서 렌더링된다.
다른 프로젝트
다른 프로젝트는 Dynamic Route들만 Dynamic
으로 동작하고, 나머지는 Static
으로 동작한다.
Vercel + Node.js Runtime은 cold starts로 인해 함수를 처음 호출할 때 지연이 발생한다. 그래서 TTFB 문제가 발생했던 것이었다.
라우트의 동적, 정적 렌더링은 Next.js의 Full Route Cache 기능과 연관이 있다.
이 기능이 적힌 문서를 보고, 왜 모든 라우트가 Dynamic으로 동작하는지 일단 확인해보았다.
동적 함수인 cookies
, headers
그리고 searchParams
를 사용했을 때
dynamic = 'force-dynamic'
또는 revalidate = 0
라우트 segment 구성 옵션을 사용했을 때
캐시되지 않은 fetch
요청이 있을 때
루트 Layout에서 전역으로 쿠키를 불러오고 저장할 수 있게하는 next-client-cookies
라이브러리를 사용하고 있었는데, Provider을 설정할 때, cookies
동적 함수를 사용하고 있었다.
→ 루트 Layout에서 동적 함수를 사용하고 있어서 모든 라우트가 동적으로 렌더링되고 있었다.
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="kr" suppressHydrationWarning={true}>
<body>
<script dangerouslySetInnerHTML={{ __html: setInitialThemeMode }} />
);
}
"use client";
import { CookiesProvider } from "next-client-cookies";
export const ClientCookiesProvider: typeof CookiesProvider = (props) => (
<CookiesProvider {...props} />
);
Express에서 Next.js로 이미그레이션 후에 Next.js의 Route Handlers를 사용하고 있는데, Route Handlers에서 브라우저에 쿠키를 직접 설정할 수 있으므로, 라이브러리를 제거하고 동적 함수를 제외했다.
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="kr" suppressHydrationWarning={true}>
<body>
<script dangerouslySetInnerHTML={{ __html: setInitialThemeMode }} />
);
}