Nextjs와 Nuxtjs를 이용한 SSR 및 SSG 성능 병목 현상 규명
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
빠르게 진화하는 웹 개발 환경에서 뛰어난 사용자 경험을 제공하는 것은 무엇보다 중요합니다. Next.js와 Nuxt.js와 같은 프레임워크가 지원하는 서버 측 렌더링(SSR) 및 정적 사이트 생성(SSG)이라는 두 가지 주요 패러다임은 초기 페이지 로드 시간과 검색 엔진 최적화를 개선하여 이를 달성하는 강력한 도구로 부상했습니다. 그러나 고유한 장점에도 불구하고 개발자는 종종 최적의 애플리케이션 제공을 방해하는 성능 병목 현상에 직면합니다. 이러한 병목 현상, 근본 원인 및 효과적인 완화 전략을 이해하는 것은 고성능 웹 애플리케이션을 구축하는 데 중요합니다. 이 글은 Next.js 및 Nuxt.js를 사용한 SSR 및 SSG 맥락에서 성능 문제의 복잡성을 살펴보고, 기본 메커니즘과 실용적인 솔루션에 대한 기술적인 심층 분석을 제공합니다.
핵심 개념 설명
성능 병목 현상을 분석하기 전에 논의의 중심이 되는 핵심 개념을 간략하게 정의해 보겠습니다.
- 서버 측 렌더링 (SSR): SSR에서는 각 요청에 대해 서버에서 HTML이 생성됩니다. 사용자가 페이지를 요청하면 서버는 데이터를 가져와 컴포넌트를 전체 HTML 문자열로 렌더링하여 클라이언트로 보냅니다. 그런 다음 클라이언트는 이 정적 HTML을 '하이드레이션'하여 JavaScript 이벤트 리스너를 연결하고 대화형으로 만듭니다.
- 정적 사이트 생성 (SSG): SSG는 빌드 시점에 모든 페이지를 미리 렌더링합니다. 각 페이지에 대해 필요한 데이터를 가져와 컴포넌트를 정적 HTML, CSS 및 JavaScript 파일로 렌더링합니다. 그런 다음 이러한 파일은 CDN에 배포되어 사용자에게 직접 제공됩니다.
- 하이드레이션: 클라이언트 측 JavaScript 애플리케이션이 미리 렌더링된 HTML(SSR 또는 SSG)을 인수하여 대화형으로 만드는 프로세스입니다. 이벤트 리스너 연결, DOM 동적 만들기, 상태 관리 등을 포함합니다.
- 첫 바이트까지의 시간 (TTFB): 사용자의 브라우저가 서버에서 페이지 콘텐츠의 첫 번째 바이트를 수신하는 데 걸리는 시간입니다. 낮은 TTFB는 반응성이 좋은 서버를 나타냅니다.
- 첫 콘텐츠풀 페인트 (FCP): 페이지 로드가 시작된 시점부터 화면에 페이지 콘텐츠의 일부가 렌더링된 시점까지의 시간입니다.
- 가장 큰 콘텐츠풀 페인트 (LCP): 페이지 로드가 시작된 시점부터 뷰포트 내에서 가장 큰 이미지 또는 텍스트 블록이 렌더링된 시점까지의 시간입니다.
- 누적 레이아웃 이동 (CLS): 페이지가 로드될 때 웹페이지 콘텐츠의 예기치 않은 이동을 측정한 것입니다.
- 총 차단 시간 (TBT): FCP와 상호 작용까지의 시간(TTI) 사이에 메인 스레드가 입력 응답성을 방지할 만큼 길게 차단된 총 시간입니다.
SSR/SSG 성능 병목 현상 및 해결책
SSR과 SSG는 모두 고유한 성능 특성을 제공하여 다양한 과제를 야기합니다.
서버 측 렌더링 (SSR) 병목 현상
SSR의 주요 병목 현상은 일반적으로 서버 측과 하이드레이션 단계에서 발생합니다.
1. 데이터 가져오기 오버헤드
문제: 각 요청에 대해 서버는 페이지를 렌더링하기 전에 데이터를 가져와야 합니다. 데이터 가져오기가 느리면(예: 여러 API 호출, 데이터베이스 쿼리 대기) TTFB에 직접적인 영향을 미칩니다. 페이지의 여러 컴포넌트가 독립적으로 데이터를 가져오면 이 문제가 악화되어 워터폴 효과가 발생할 수 있습니다.
Next.js 예시:
// pages/posts/[id].js export async function getServerSideProps(context) { const { id } = context.params; const postRes = await fetch(`https://api.example.com/posts/${id}`); const post = await postRes.json(); const authorRes = await fetch(`https://api.example.com/authors/${post.authorId}`); const author = await authorRes.json(); return { props: { post, author } }; }
이 예시에서 getServerSideProps
는 두 개의 순차적인 API 호출을 기다립니다.
해결책:
- 병렬 데이터 가져오기:
Promise.all
을 사용하여 여러 데이터 소스를 동시에 가져옵니다.export async function getServerSideProps(context) { const { id } = context.params; const [postRes, authorRes] = await Promise.all([ fetch(`https://api.example.com/posts/${id}`), fetch(`https://api.example.com/authors/${id}`) // 가정: 작성자 ID를 파생할 수 있음 ]); const post = await postRes.json(); const author = await authorRes.json(); return { props: { post, author } }; }
- 캐싱: 자주 액세스하는 데이터 또는 API 응답에 대해 서버 측 캐싱을 구현합니다. Redis 또는 인메모리 캐시와 같은 도구를 사용합니다.
- GraphQL 배치/지속 쿼리: GraphQL을 사용하는 경우 여러 쿼리를 단일 요청으로 배치하거나 지속 쿼리를 사용하여 네트워크 오버헤드를 줄입니다.
- 데이터베이스 쿼리 최적화: 적절한 인덱싱과 최적화된 스키마를 사용하여 데이터베이스 쿼리가 효율적인지 확인합니다.
2. 서버 리소스 소비
문제: 각 SSR 요청은 React/Vue 컴포넌트를 HTML로 렌더링하기 위해 서버의 CPU 및 메모리를 필요로 합니다. 트래픽이 많거나 복잡한 페이지가 있는 애플리케이션의 경우 서버 과부하, 지연 시간 증가, 심지어 서버 충돌로 이어질 수 있습니다.
해결책:
- 컴퓨팅 프로비저닝: 서버 리소스(CPU, RAM)를 확장하거나 수요에 따라 자동 확장되는 서버리스 함수(예: Next.js의 Vercel 서버리스 함수, Nuxt.js의 Netlify 함수)를 활용합니다.
- 엣지 캐싱 (CDN): CDN을 사용하여 엣지에서 렌더링된 HTML을 캐시합니다. 이렇게 하면 동일한 페이지에 대한 후속 요청에 대해 원본 서버의 부하가 크게 줄어듭니다.
- 부분 하이드레이션 / 아일랜드 아키텍처: 전체 페이지를 하이드레이션하는 대신 대화형 컴포넌트만 하이드레이션합니다. 이렇게 하면 클라이언트와 서버에서 처리되는 JavaScript 양이 줄어듭니다. Next.js/Nuxt.js에 기본 내장되어 있지는 않지만 실험적인 접근 방식이 있습니다.
- 코드 분할: 주로 클라이언트 측 기능이지만 효과적인 코드 분할은 SSR 번들을 단순화하여 서버 작업을 간접적으로 줄일 수 있습니다.
3. 하이드레이션 오버헤드
문제: 서버에서 HTML을 보낸 후 클라이언트 측 JavaScript가 페이지를 '하이드레이션'하기 위해 제어권을 가져옵니다. 여기에는 가상 DOM 재구축, 이벤트 리스너 연결, 서버 렌더링된 HTML과의 조정이 포함됩니다. JavaScript 번들이 크거나 DOM 구조가 복잡하면 이 프로세스가 느려져 페이지가 대화형으로 보이지만 실제로는 그렇지 않은 기간(일명 '끊김' 또는 '하이드레이션 중 끊김')이 발생할 수 있습니다. 이는 TBT 및 TTI에 영향을 미칩니다.
Nuxt.js 예시: 복잡한 페이지의 대화형 컴포넌트 수가 많으면 JavaScript 번들이 더 커지고 하이드레이션에 더 많은 작업이 필요합니다.
해결책:
- JavaScript 번들 크기 줄이기:
- 번들 분석:
webpack-bundle-analyzer
와 같은 도구를 사용하여 큰 종속성을 식별하고 사용되지 않는 코드를 제거합니다(트리 쉐이킹). - 동적 가져오기 (지연 로딩): 컴포넌트가 필요할 때만 로드합니다. 특히 화면 아래 있거나 사용자 상호 작용에 의해 트리거되는 컴포넌트의 경우 그렇습니다.
// Next.js import dynamic from 'next/dynamic'; const MyComponent = dynamic(() => import('../components/MyComponent')); // Nuxt.js // components/MyComponent.vue // 템플릿에서: <client-only><MyComponent /></client-only> // 스크립트에서: const MyComponent = () => import('@/components/MyComponent.vue')
- 번들 분석:
- DOM 복잡성 최소화: 더 평탄(flat)하고 작은 DOM 트리는 하이드레이션에 필요한 작업이 적습니다.
- 가상화: 긴 목록이나 테이블의 경우 가상화 라이브러리(예:
react-virtualized
,vue-virtual-scroller
)를 사용하여 보이는 항목만 렌더링하여 DOM 크기를 줄입니다. - 이벤트 핸들러 스로틀링/디바운싱: 클라이언트 측 이벤트 핸들러를 최적화하여 하이드레이션 중 과도한 리렌더링을 방지합니다.
정적 사이트 생성 (SSG) 병목 현상
SSG의 과제는 주로 빌드 단계와 동적 콘텐츠를 처리할 때 나타납니다.
1. 긴 빌드 시간
문제: 수천 또는 수백만 개의 페이지가 있는 대규모 웹사이트의 경우 모든 페이지를 빌드 시점에 생성하는 데 시간이 매우 오래 걸릴 수 있으며 때로는 몇 시간이 걸리기도 합니다. 이는 개발 속도와 지속적인 업데이트를 배포하는 능력에 영향을 미칩니다.
Next.js 예시:
// pages/blog/[slug].js export async function getStaticPaths() { const posts = await fetch('https://api.example.com/posts').then(res => res.json()); const paths = posts.map(post => ({ params: { slug: post.slug } })); return { paths, fallback: false }; } export async function getStaticProps({ params }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(res => res.json()); return { props: { post } }; }
posts
에 10,000개의 항목이 포함된 경우 빌드 중에 getStaticProps
가 10,000번 호출됩니다.
해결책:
- 증분 정적 재생성 (ISR): Next.js는 ISR을 제공하여 전체 재빌드가 필요 없이 빌드 및 배포 후에 정적 페이지를 업데이트할 수 있습니다. 요청이 들어올 때 백그라운드에서 페이지를 다시 생성할 수 있으며, 신선한 버전이 빌드되는 동안 이전 페이지를 제공합니다.
Nuxt.js 3는export async function getStaticProps({ params }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(res => res.json()); return { props: { post }, revalidate: 60 }; // 60초마다 다시 유효성 검사 }
useAsyncData
의revalidate
로 유사한 개념을 가지고 있습니다. - 분산 빌드: 여러 기계에 걸쳐 병렬 빌드를 지원하는 빌드 도구 및 CI/CD 파이프라인을 활용합니다.
- 빌드 아티팩트 캐싱: 종속성 및 이전 빌드 출력을 캐시하여 후속 빌드를 더 빠르게 만듭니다.
- 선택적 사전 렌더링: 가장 중요한 페이지만 빌드 시점에 사전 렌더링하고 덜 중요하거나 매우 동적인 페이지에는 SSR 또는 클라이언트 측 렌더링을 사용합니다(Next.js의
getStaticPaths
에서fallback: 'blocking'
또는fallback: true
). - 빌드 중 데이터 가져오기 최적화:
getStaticProps
에 대한 데이터 가져오기가 가능한 한 효율적인지 확인합니다(예: API 호출 배치).
2. 콘텐츠의 최신 상태 문제
문제: SSG 페이지는 배포 시점에 빌드되므로 기본 데이터가 자주 변경되면 콘텐츠가 최신 상태가 아닐 수 있습니다. 이를 반영하려면 전체 재빌드 및 재배포가 필요하며, 이는 주식 가격이나 실시간 점수와 같이 동적인 콘텐츠에는 비실용적일 수 있습니다.
해결책:
- 증분 정적 재생성 (ISR): 위에서 설명한 대로 ISR은 성능을 희생하지 않고 SSG 콘텐츠를 최신 상태로 유지하는 주요 솔루션입니다.
- 클라이언트 측 데이터 가져오기 (CSR): 정적 페이지의 매우 동적인 섹션의 경우 초기 페이지 로드 후 클라이언트 측 가져오기를 사용합니다. 페이지 구조는 정적이지만 특정 컴포넌트는 실시간 데이터를 가져와 표시합니다.
// pages/stock/[symbol].js // 주가만 클라이언트에서 가져오는 정적 페이지 구조 function StockPage({ initialData }) { // initialData는 회사 정보일 수 있음 const [price, setPrice] = useState(initialData.price); useEffect(() => { const interval = setInterval(async () => { const res = await fetch(`/api/realtime-price?symbol=${initialData.symbol}`); const newPrice = await res.json(); setPrice(newPrice); }, 5000); return () => clearInterval(interval); }, []); return ( <div> <h1>{initialData.companyName}</h1> <p>현재 가격: ${price}</p> </div> ); } export async function getStaticProps() { /* ...초기 회사 데이터 */ } export async function getStaticPaths() { /* ...인기 주식 사전 렌더링 */ }
- 웹훅을 통한 재빌드: CMS 또는 데이터 소스가 CI/CD 파이프라인에 웹훅을 트리거하도록 구성하여 콘텐츠가 변경될 때마다 빌드 및 배포를 시작합니다.
3. 큰 빌드 출력
문제: 매우 큰 사이트의 경우 생성된 정적 HTML 파일 수가 많으면 상당한 디스크 공간을 차지하고 배포 및 CDN 동기화에 더 오래 걸릴 수 있습니다.
해결책:
- 효율적인 애셋 최적화: 이미지가 최적화되고(WebP/AVIF, 지연 로딩) 다른 애셋(CSS, JS)이 압축 및 최소화되었는지 확인합니다.
- CDN을 통한 애셋 제공: CDN을 활용하여 정적 애셋을 제공하고 로드를 분산하며 전 세계 전송 속도를 향상시킵니다.
- 선택적 사전 렌더링: SSG의 이점을 실제로 누리는 페이지를 주의 깊게 선택합니다. 매우 동적이거나 개인화된 콘텐츠가 있는 페이지는 SSR 또는 CSR에 더 적합할 수 있습니다.
- 이전 콘텐츠 아카이브: 콘텐츠의 수명이 제한된 경우 거의 액세스되지 않는 매우 오래된 정적 페이지는 아카이브하거나 제거하는 것을 고려합니다.
결론
Next.js 또는 Nuxt.js로 구현된 SSR과 SSG는 웹 성능과 SEO에 매력적인 이점을 제공합니다. 그러나 이러한 이점에는 고유한 성능 병목 현상이 따릅니다. SSR의 경우 주요 관심사는 서버 부하, 데이터 가져오기 지연 시간 및 클라이언트 측 하이드레이션입니다. SSG는 뛰어난 초기 로드 시간을 제공하지만 대규모 사이트의 긴 빌드 시간과 콘텐츠 최신 상태 유지 문제에 직면합니다. 이러한 뉘앙스를 이해하고 병렬 데이터 가져오기, ISR, 동적 가져오기 및 CDN 활용과 같은 전략을 적용함으로써 개발자는 일반적인 병목 현상을 효과적으로 완화하여 고성능의 사용자 친화적인 애플리케이션을 보장할 수 있습니다. SSR과 SSG 또는 하이브리드 접근 방식 간의 선택은 궁극적으로 애플리케이션의 특정 요구 사항에 따라 달라지며, 빌드 시간, 데이터 최신 상태 및 상호 작용성을 최적의 성능과 균형을 이룹니다.