"런칭 하루 전, 사이트가 기어갑니다."
제가 처음으로 Next.js로 만든 커머스 사이트를 런칭하던 날이었습니다. 모든 기능 개발은 완벽했습니다. 결제도 잘 되고, 장바구니도 잘 담기고, 디자인은 아름다웠습니다. 마지막으로 AWS EC2 t3.small 인스턴스에 배포를 하고, 떨리는 마음으로 도메인을 입력했습니다.
엔터.
... ... ... (3초 경과) ... (5초 경과)
화면이 떴습니다. 5초 만에요.
"아니, 로컬에서는 빨랐는데?" 서버 리소스를 확인해보니 CPU가 100%를 치고 있었습니다. 사용자는 저 한 명뿐인데 말이죠. 단순한 소개 페이지(Landing Page)조차 로딩하는 데 2초가 넘게 걸렸습니다. 이미지는 최적화했고, 폰트도 서브셋을 썼습니다. 도대체 뭐가 문제일까?
밤을 새우며 원인을 찾던 중, 무심코 지나쳤던 빌드 로그가 눈에 들어왔습니다.
Route (app) Size First Load JS
┌ λ / 5.4 kB 89 kB
├ λ /about 2.1 kB 84 kB
├ λ /blog/[slug] 3.5 kB 87 kB
└ λ /products 1.5 kB 83 kB
모든 경로 앞에 λ (람다) 기호가 붙어 있었습니다.
저는 이 기호가 그냥 "Next.js 로고" 같은 건 줄 알았습니다. 하지만 이건 "너의 사이트는 지금 최악의 성능으로 동작하고 있어"라는 경고였습니다.
저는 정적 사이트(Static Site)를 만들었다고 생각했지만, 실제로는 모든 페이지가 SSR(Server-Side Rendering)로 돌고 있었던 겁니다.
암호 해독 - 기호가 말해주는 성능 성적표
Next.js의 npm run build 결과 화면은 단순한 로그가 아닙니다. 여러분의 서버 비용과 사용자 이탈률을 결정하는 성적표입니다.
이 기호들의 의미를 정확히 아는 것만으로도, 추가 비용 없이 성능을 10배 높일 수 있습니다.
1. ○ (Static) - 최상의 상태
"Automatically rendered as static HTML (uses no initial props)"
- 의미: "이 페이지는 사용자 누구에게나 똑같이 보입니다. 그래서 빌드할 때 미리 HTML 파일로 만들어뒀습니다."
- 동작: 사용자가 접속하면 서버는 아무 일도 안 합니다. 그냥 미리 만들어둔
index.html파일을 Nginx(또는 CDN)가 던져줍니다. - 성능: 가장 빠름 (Fastest). TTFB(Time to First Byte)가 10~50ms 수준입니다.
- 비용: 서버 CPU를 거의 쓰지 않습니다. S3 비용만 나갑니다.
- 목표: 마이페이지, 장바구니 같은 개인화 페이지를 제외한 모든 페이지는 이게 떠야 합니다.
2. ● (SSG / ISR) - 아주 좋은 상태
"Automatically generated as static HTML + JSON (uses getStaticProps)"
- 의미: "데이터(DB, API)가 필요하긴 한데, 빌드할 때 다 가져와서 정적으로 구워놨어."
- 동작:
getStaticProps나generateStaticParams를 사용한 경우입니다. 결과물은 역시 정적 파일입니다. - 성능: 매우 빠름. ○(Static)과 동일합니다.
3. λ (Dynamic / SSR) - 경계 대상, 비용의 주범
"Server-side renders at runtime (uses getInitialProps or getServerSideProps)"
- 의미: "이 페이지는 요청할 때마다 내용이 달라져. 접속할 때마다 서버가 로직을 돌려야 해."
- 동작:
- 요청 도착 (Request)
- Node.js 서버 깨어남 (Cold Start 가능성)
- DB 연결 및 데이터 조회 (Latency)
- React 컴포넌트 렌더링 (CPU 연산)
- HTML 생성 및 응답
- 성능: 느림. (최소 200ms ~ 수 초). DB 성능에 의존적입니다.
- 비용: 람다(Lambda) 호출 비용이나 EC2 CPU 사용량이 급증합니다. 트래픽이 몰리면 서버가 터집니다.
제 로그는 전부 λ였습니다. 정적인 회사 소개 페이지(/about)조차도요.
즉, 저는 Next.js를 쓴 게 아니라 비싼 PHP를 쓰고 있었던 셈입니다.
범인 찾기 - 나는 왜 λ가 되었나? (탈-최적화의 함정)
"잠깐, 저는 getServerSideProps를 안 썼어요! App Router 쓰면서 fetch만 썼는데요?"
억울했습니다. Next.js 13부터는 "Static by Default (기본적으로 정적)"라고 광고했잖아요? 하지만 Next.js에는 "Dynamic Functions(동적 함수)"라는 함정이 있습니다. 코드를 짜다가 무심코 이 함수들을 하나라도 쓰면, Next.js는 해당 페이지 전체를 Dynamic으로 강등시킵니다.
범인 1 - cookies()와 headers() (가장 흔한 실수)
로그인 여부에 따라 상단 네비게이션(GNB)을 다르게 보여주려고 layout.tsx에 이런 코드를 짰습니다.
// app/layout.tsx
import { cookies } from 'next/headers';
export default function RootLayout({ children }) {
const cookieStore = cookies(); // <-- 여기가 범인!
const accessToken = cookieStore.get('accessToken');
return (
<html>
<body>
<Navbar isLoggedIn={!!accessToken} />
{children}
</body>
</html>
);
}
이 코드는 재앙입니다.
RootLayout은 모든 페이지의 부모입니다. 여기서 cookies()를 호출한다는 건, "이 사이트의 모든 페이지는 요청(쿠키)마다 달라진다"고 선언한 것과 같습니다.
결과적으로 /, /about, /blog 모든 페이지가 λ (Dynamic)으로 변합니다. 정적 최적화가 전멸했습니다.
범인 2: searchParams Props
블로그 목록 페이지에서 "현재 페이지 번호"를 알기 위해 searchParams를 썼습니다.
// app/blog/page.tsx
export default function Blog({ searchParams }) { // <-- 범인
const page = searchParams.page || '1';
// ...
}
URL의 쿼리 스트링(?page=2)은 요청할 때마다 달라질 수 있습니다. Next.js는 "어? 너 쿼리 스트링 쓰네? 그럼 미리 빌드 못 하지."라며 Dynamic으로 전환합니다.
이게 합리적인 판단일까요? 블로그 글 목록 1페이지는 항상 똑같잖아요? 굳이 Dynamic일 필요가 없습니다.
범인 3 - fetch의 no-store
fetch('https://api.example.com/posts', { cache: 'no-store' });
"최신 글을 보여줘야지" 하고 무지성으로 캐시를 껐습니다. 데이터의 최신성을 얻은 대신, 페이지 로딩 속도를 잃었습니다. 이 코드 한 줄 때문에 페이지 전체가 SSR로 동작합니다.
탈출 전략 - 다시 ○(Static)으로 돌아가기
문제를 알았으니 코드를 뜯어고쳤습니다. 목표는 "개인화된 페이지 빼고는 전부 정적으로 만든다"였습니다.
전략 1 - cookies()는 클라이언트 컴포넌트로 격리한다
서버 컴포넌트(layout.tsx)에서 쿠키를 읽지 마세요.
로그인 상태에 따라 UI를 바꾸고 싶다면, Client Component 안에서 useEffect를 쓰거나 전용 라이브러리(Auth.js의 SessionProvider)를 써야 합니다.
// app/layout.tsx (Server Component)
// cookies() 삭제!
export default function RootLayout({ children }) {
return (
<html>
<body>
<AuthProvider> {/* 클라이언트 컴포넌트 */}
<Navbar />
</AuthProvider>
{children}
</body>
</html>
);
}
이렇게 하면 RootLayout은 다시 정적이 될 수 있습니다. 로그인 상태 확인은 브라우저(클라이언트)에서만 일어나니까요.
전략 2 - generateStaticParams 적극 활용
동적 라우트([slug])를 쓴다고 무조건 λ가 되는 건 아닙니다. "가능한 모든 경로"를 미리 알려주면 됩니다.
// app/blog/[slug]/page.tsx
// 이 함수가 핵심입니다!
export async function generateStaticParams() {
const posts = await getPosts(); // DB에서 모든 글 slug 가져옴
return posts.map((post) => ({ slug: post.slug }));
}
export default function Post({ params }) { ... }
이 함수가 있으면 Next.js는 빌드 타임에 getPosts()를 실행해서, blog/1, blog/2, blog/3... 모든 HTML을 미리 만들어둡니다(●).
전략 3 - 강제 정적 선언 (force-static)
가장 강력한 무기입니다.
"나는 headers()를 읽긴 하지만(예: IP 확인), 값이 없으면 그냥 기본값 보여줘도 되니까 제발 정적으로 만들어줘!"
이런 경우(하이브리드)가 필요할 때가 있습니다.
export const dynamic = 'force-static';
이 코드를 페이지 파일 상단에 추가하면, 동적 함수(cookies(), headers())가 있어도 빌드 타임에는 빈 값(undefined)을 반환하고 강제로 정적 페이지를 생성합니다.
적용 결과 - 녹색 불이 켜지다
며칠 간의 리팩토링 끝에 다시 빌드를 돌렸습니다.
Route (app) Size First Load JS
┌ ○ / 5.4 kB 89 kB
├ ○ /about 2.1 kB 84 kB
├ ● /blog/[slug] 3.5 kB 87 kB
├ ○ /login 1.5 kB 83 kB
└ λ /my-page 1.2 kB 80 kB
드디어!
메인, 소개, 블로그, 로그인 페이지까지 전부 ○ (Static) 또는 ● (SSG)로 돌아왔습니다. /my-page만 λ (Dynamic)으로 남았는데, 이건 사용자별로 정보가 다르니 당연한 겁니다.
배포 후 다시 성능 테스트를 했습니다.
- 초기 로딩: 5.2초 -> 0.3초 (17배 빨라짐)
- 서버 CPU: 100% -> 1%
- 비용: t3.small(월 $20)에서 t4g.nano(월 $3)로 줄여도 펑펑 놉니다.
요약
Next.js를 쓴다고 저절로 빨라지는 게 아닙니다. 잘못 쓰면 오히려 PHP보다 느려질 수 있습니다.
- 배포 전
npm run build로그를 꼭, 제발 확인하세요. - λ (람다) 기호가 보이면 공포를 느끼세요. "이 페이지가 정말 매번 새로 그려져야 하나?" 스스로에게 물어보세요.
cookies(),headers(),searchParams가 은밀한 범인일 확률이 90%입니다. 이 녀석들을Suspense경계 안으로 가두거나, 클라이언트로 보내버리세요.
○와 ●를 사랑하세요. 그리고 λ는 정말 필요할 때만 허락하세요. 그것이 여러분의 사용자와 지갑을 모두 지키는 길입니다.