안녕하세요, Next.js로 여러 프로젝트를 진행하면서 가장 혼란스러워하는 부분이 바로 app.js와 _document.js의 차이점이더군요. 오늘은 이 두 파일의 차이점과 각각의 활용 방법에 대해 실용적인 관점에서 살펴보겠습니다.

목차
Next.js 렌더링 기본 개념 이해하기
app.js와 _document.js의 차이를 이해하기 전에 Next.js의 렌더링 프로세스를 간략하게 알아봐야 한다. 많은 개발자들이 이 부분을 간과하고 바로 코드부터 작성하려고 하는데, 그러면 나중에 디버깅할 때 고생한다.
Next.js는 React 앱을 위한 프레임워크로, 서버 사이드 렌더링(SSR)과 정적 사이트 생성(SSG)을 지원한다. 렌더링 프로세스는 대략 이런 순서로 진행된다..
- 서버에서 요청 처리 (_document.js)
- 앱 초기화 (app.js)
- 페이지 컴포넌트 렌더링
- 클라이언트 측에서 수화(Hydration) 과정 진행
이 과정에서 app.js와 _document.js는 서로 다른 시점에 작동하며, 각각 다른 책임을 가진다. 이 차이를 제대로 이해하지 못하면 전역 상태 관리나 스타일링에서 이상한 버그를 만날 수 있다.
app.js 파일의 역할과 사용 사례
app.js(정확히는 _app.js)는 Next.js에서 모든 페이지의 공통 요소를 정의하는 역할을 한다. 페이지를 렌더링하기 전에 실행되며, 한마디로 "페이지 전체를 감싸는 컴포넌트"라고 생각하면 된다.
app.js의 주요 역할
- 전역 상태 관리 구성 (Redux, Context API 등)
- 전역 CSS 및 스타일 주입
- 공통 레이아웃 컴포넌트 적용 (헤더, 푸터 등)
- 페이지 전환 애니메이션 설정
- 에러 핸들링 및 글로벌 에러 바운더리 설정
사용 사례 | 코드 예시 | 주의사항 |
---|---|---|
전역 스타일 적용 | import '../styles/globals.css' |
CSS-in-JS 라이브러리와 충돌 가능성 |
상태 관리 Provider | <Provider store={store}><Component /></Provider> |
SSR에서 상태 초기화 필요 |
페이지 전환 처리 | Router.events.on('routeChangeStart', ...) |
useEffect 내에서 이벤트 정리 필수 |
API 클라이언트 설정 | axios.defaults.baseURL = '...' |
환경별 설정 관리 필요 |
인증 상태 관리 | <AuthProvider><Component /></AuthProvider> |
토큰 갱신 로직 필요 |
app.js의 기본 구조
// pages/_app.js
import '../styles/globals.css'
import Layout from '../components/Layout'
function MyApp({ Component, pageProps }) {
// 여기서 페이지별 레이아웃을 결정할 수 있다
const getLayout = Component.getLayout || ((page) => <Layout>{page}</Layout>)
return getLayout(<Component {...pageProps} />)
}
export default MyApp
app.js 파일은 모든 페이지 컴포넌트를 렌더링하기 전에 실행되며, 페이지를 감싸는 공통 로직(예: 레이아웃, 상태 관리, 테마 설정 등)을 구현하는 곳이다. 하지만 모든 것을 app.js에 넣으면 유지보수가 어려워질 수 있으니 적절한 분리가 중요하다.
_document.js 파일의 역할과 사용 사례
_document.js는 app.js보다 더 낮은 레벨에서 작동하는 파일이다. HTML 문서의 <html>, <head>, <body> 태그를 커스터마이징할 수 있게 해준다. 서버 사이드에서만 렌더링되며, 클라이언트 사이드 이벤트 핸들러는 여기에 추가하면 안 된다. _document.js는 페이지를 요청할 때마다 실행되지만, 클라이언트에서의 페이지 전환 시에는 실행되지 않는다.
_document.js의 주요 역할
- HTML, HEAD, BODY 태그 커스터마이징
- 전역 메타 태그 및 폰트 설정
- 서버 사이드 렌더링 스타일 처리 (styled-components, emotion 등)
- 언어 속성 및 접근성 속성 설정
- 외부 스크립트 및 애널리틱스 코드 추가
- 초기 HTML 구조 제어
_document.js의 기본 구조
// pages/_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render() {
return (
<Html lang="ko">
<Head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
Q: _document.js 파일은 꼭 필요한가요?
A: 기본 HTML 구조로 충분하다면 필요 없습니다. 하지만 meta 태그 추가, 폰트 로딩, 서드파티 스크립트 삽입 등이 필요하면 만들어야 합니다.
Q: CSS-in-JS 라이브러리를 사용할 때 _document.js가 필요한 이유는?
A: styled-components나 emotion 같은 라이브러리는 서버 사이드 렌더링 시 스타일을 추출해 HTML에 삽입해야 합니다. 그렇지 않으면 스타일이 적용되지 않은 HTML이. 잠깐 표시되는 깜빡임 현상(FOUC)이 발생합니다.
app.js와 _document.js의 핵심 차이점
대부분의 Next.js 초보자들은 app.js와 _document.js의 차이점을 명확하게 구분하지 못한다. 이로 인해 잘못된 파일에 코드를 작성하고 예상치 못한 오류를 만나게 된다. 두 파일 간의 핵심 차이점을 명확히 이해하면 이런 실수를 줄일 수 있다.
특성 | app.js (_app.js) | _document.js |
---|---|---|
실행 시점 | 모든 페이지 라우팅 시 실행 | 서버에서 초기 HTML 생성 시 실행 |
역할 및 목적 | 페이지 컴포넌트 래핑, 전역 상태 관리 | HTML, Head, Body 태그 커스터마이징 |
클라이언트 이벤트 | 사용 가능 (onClick, useEffect 등) | 사용 불가 (클라이언트 이벤트 사용 금지) |
매 페이지 전환 시 | 매 페이지 전환마다 다시 실행됨 | 페이지 전환 시에는 실행되지 않음 |
상태 관리 | 전역 상태 Provider 설정에 적합 | 상태 관리 로직 추가 금지 |
스타일링 | 전역 CSS 임포트, 테마 Provider 추가 | CSS-in-JS 서버 렌더링 설정 |
레이아웃 | 애플리케이션 레이아웃 관리 | HTML 문서 구조 관리 |
핵심적인 차이는 실행 시점과 목적이다. _document.js는 서버에서 HTML 문서를 생성할 때 한 번 실행되고, app.js는 페이지가 전환될 때마다 실행된다. 이 차이를 이해하지 못하면 예상치 못한 렌더링 문제가 발생할 수 있다.
"app.js는 React 애플리케이션의 일부로 실행되는 반면, _document.js는 서버 사이드에서 HTML 문서를 생성하는 데 사용된다. 이 둘을 혼동하면 예상치 못한 렌더링 이슈가 발생한다." - Next.js 코어 팀
실무에서의 활용 패턴과 모범 사례
실제 프로젝트에서 app.js와 _document.js를 어떻게 활용하는 것이 좋을까? 수년간의 Next.js 프로젝트 경험을 바탕으로 가장 효과적인 패턴을 소개한다.
app.js 모범 사례
- 복잡한 로직은 별도 파일로 분리하기: app.js 파일이 너무 비대해지면 유지보수가 어려워진다. 인증, 테마, 상태 관리 등은 별도 컴포넌트나 훅으로 분리하는 것이 좋다.
- 페이지별 레이아웃 분기 처리: getLayout 패턴을 사용해 페이지마다 다른 레이아웃을 적용할 수 있게 한다.
- 성능 모니터링 설정: 페이지 로드 시간, 인터랙션 등을 측정하는 분석 코드를 여기에 넣는다.
- 에러 바운더리 활용: 전역 에러 핸들링으로 사용자 경험을 개선한다.
// pages/_app.js - 모범 사례 예시
import '../styles/globals.css'
import { ThemeProvider } from '../context/theme'
import { AuthProvider } from '../context/auth'
import ErrorBoundary from '../components/ErrorBoundary'
import AnalyticsProvider from '../components/Analytics'
function MyApp({ Component, pageProps }) {
// 페이지별 레이아웃 설정 (각 페이지 컴포넌트에서 정의 가능)
const getLayout = Component.getLayout || ((page) => page)
return (
<ErrorBoundary>
<AnalyticsProvider>
<ThemeProvider>
<AuthProvider>
{getLayout(<Component {...pageProps} />)}
</AuthProvider>
</ThemeProvider>
</AnalyticsProvider>
</ErrorBoundary>
)
}
export default MyApp
_document.js 모범 사례
- 필요할 때만 사용하기: 기본 HTML 구조가 적절하다면 굳이 _document.js를 만들지 않는다.
- CSS-in-JS 설정은 여기서 처리: styled-components, emotion 등을 서버 사이드에서 처리하는 코드를 넣는다.
- 언어 및 방향성 설정: HTML lang 속성, dir 속성 등을 설정한다.
- 성능 최적화 메타 태그: preconnect, preload 등의 리소스 힌트를 추가한다.
- 폰트 최적화: 웹 폰트 로딩 전략을 구현한다.
// pages/_document.js example
import Document, { Html, Head, Main, NextScript } from 'next/document'
import { ServerStyleSheet } from 'styled-components'
class MyDocument extends Document {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet()
const originalRenderPage = ctx.renderPage
try {
// styled-components 스타일 추출
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />),
})
const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
),
}
} finally {
sheet.seal()
}
}
render() {
return (
<Html lang="ko">
<Head>
{/* 리소스 프리로딩 최적화 */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* 웹 폰트 최적화 */}
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap"
rel="stylesheet"
/>
{/* 파비콘 및 PWA 관련 메타 태그 */}
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="theme-color" content="#ffffff" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
흔히 저지르는 실수와 해결 방법
수많은 Next.js 프로젝트를 검토하면서 발견한 app.js와 _document.js 관련 가장 흔한 실수들과 그 해결책을 공유한다. 이런 실수들은 디버깅하기 어려운 버그로 이어질 수 있으므로 주의해야 한다.
-
실수 1: _document.js에 이벤트 핸들러 추가
_document.js는 서버에서만 실행되므로 onClick, onChange 같은 이벤트 핸들러가 작동하지 않는다. 사용자 인터랙션과 관련된 코드는 모두 app.js나 개별 컴포넌트에 넣어야 한다.
-
실수 2: _document.js에서 useState, useEffect 사용
리액트 훅은 _document.js에서 작동하지 않는다. _document 클래스는 Document 클래스를 확장하는 것이므로 리액트 훅을 사용할 수 없다.
-
실수 3: CSS 임포트를 잘못된 위치에 배치
전역 CSS는 app.js에 임포트해야 한다. _document.js에 임포트하면 개발 환경에서는 작동할 수 있지만, 프로덕션 빌드에서 문제가 발생할 수 있다.
-
실수 4: 서드파티 스크립트를 잘못된 위치에 배치
Google Analytics와 같은 외부 스크립트는 용도에 따라 배치가 달라진다. 페이지 전환을 트래킹해야 한다면 app.js, 문서 로드만 트래킹한다면 _document.js에 배치한다.
-
실수 5: app.js와 _document.js 모두에 중복 코드 작성
동일한 기능을 양쪽에 구현하면 예측할 수 없는 동작이 발생할 수 있다. 각 파일의 역할을 명확히 구분해 중복을 피해야 한다.
Q: app.js가 너무 무거워지면 성능에 영향이 있나요?
A: 그렇다. app.js는 모든 페이지에 영향을 미치므로 여기에 무거운 로직이나 큰 라이브러리를 추가하면 전체 앱의 성능이 저하될 수 있다. 동적 임포트를 활용해 필요할 때만 코드를 로드하는 것이 좋다.
Q: _document.js에서 폰트를 로드하는 것이 좋을까요, app.js에서 로드하는 것이 좋을까요?
A: 웹 폰트는 _document.js에서 로드하는 것이 좋다. 이렇게 하면 초기 HTML에 폰트 로딩 코드가 포함되어 FOUT(Flash of Unstyled Text) 문제를 줄일 수 있다. 하지만 font-display: swap을 함께 사용하는 것을 권장한다.
Q: Next.js 13의 app 디렉토리에서도 _app.js와 _document.js가 필요한가요?
A: 아니다. Next.js 13의 app 디렉토리는 새로운 App Router를 사용하며, 기존의 _app.js와 _document.js를 대체하는 새로운 파일 구조(layout.js, page.js 등)를 도입했다. 하지만 pages 디렉토리를 함께 사용하는 경우에는 여전히 필요하다.
Q: 기존 pages 디렉토리를 app 디렉토리로 마이그레이션할 때 주의할 점은?
A: _app.js의 역할은 주로 layout.js로, _document.js의 역할은 root layout.js로 이전된다. 특히 metadata API를 사용해 이전에 _document.js에 있던 head 태그 관련 설정을 대체해야 한다.
실전 코드 예제: app.js와 _document.js 올바르게 구현하기
아래 코드 예제는 실제 프로덕션에서 사용할 수 있는 app.js와 _document.js의 구현 방법을 보여준다. 이 예제는 다음과 같은 기능을 포함한다:
- 다크 모드/라이트 모드 전환 기능
- 인증 상태 관리
- 페이지 로딩 표시
- 스타일 컴포넌트 서버 사이드 렌더링
- SEO 최적화
app.js 구현해보기
// pages/_app.js
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import { ThemeProvider } from 'styled-components'
import { AuthProvider, useAuth } from '../contexts/auth'
import { lightTheme, darkTheme } from '../styles/theme'
import GlobalStyles from '../styles/GlobalStyles'
import LoadingScreen from '../components/LoadingScreen'
import DefaultLayout from '../layouts/DefaultLayout'
import AdminLayout from '../layouts/AdminLayout'
import '../styles/globals.css'
// 페이지 로딩 컴포넌트
function PageLoading() {
const router = useRouter()
const [loading, setLoading] = useState(false)
useEffect(() => {
const handleStart = () => setLoading(true)
const handleComplete = () => setLoading(false)
router.events.on('routeChangeStart', handleStart)
router.events.on('routeChangeComplete', handleComplete)
router.events.on('routeChangeError', handleComplete)
return () => {
router.events.off('routeChangeStart', handleStart)
router.events.off('routeChangeComplete', handleComplete)
router.events.off('routeChangeError', handleComplete)
}
}, [router])
return loading ? : null
}
// 루트 앱 컴포넌트
function MyApp({ Component, pageProps }) {
// 사용자 테마 설정 (로컬 스토리지에서 불러오기)
const [theme, setTheme] = useState('light')
useEffect(() => {
// 클라이언트에서만 실행되도록
const savedTheme = localStorage.getItem('theme') || 'light'
setTheme(savedTheme)
}, [])
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light'
setTheme(newTheme)
localStorage.setItem('theme', newTheme)
}
// 페이지별 레이아웃 설정
const getLayout = Component.getLayout || (
// 기본 레이아웃 (Admin 페이지는 다른 레이아웃 사용)
(page) => {
if (router.pathname.startsWith('/admin')) {
return {page}
}
return {page}
}
)
return (
{getLayout( )}
)
}
export default MyApp
_document.js 구현해보기
// pages/_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document'
import { ServerStyleSheet } from 'styled-components'
class MyDocument extends Document {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet()
const originalRenderPage = ctx.renderPage
try {
// styled-components 스타일 추출
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />),
})
const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
),
}
} finally {
sheet.seal()
}
}
render() {
return (
<Html lang="ko">
<Head>
{/* 기본 메타 태그 */}
<meta charSet="utf-8" />
<meta name="description" content="Next.js 애플리케이션" />
{/* 파비콘 */}
<link rel="icon" href="/favicon.ico" />
{/* 폰트 최적화 */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap"
rel="stylesheet"
media="print"
onLoad="this.media='all'"
/>
{/* OG 메타 태그 */}
<meta property="og:type" content="website" />
<meta property="og:title" content="Next.js 애플리케이션" />
<meta property="og:description" content="Next.js로 구현한 웹 애플리케이션입니다." />
<meta property="og:image" content="/og-image.jpg" />
{/* 애플리케이션 매니페스트 */}
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
위 코드 예제는 실제 프로덕션 환경에서 사용할 수 있는 패턴을 보여준다. 각 파일의 역할을 명확히 구분하고, 성능 최적화까지 고려했다. 이러한 구조를 기반으로 프로젝트의 요구사항에 맞게 확장하는 것이 좋다.
Next.js 13 App Router와의 비교
Next.js 13에서 도입된 App Router는 pages 디렉토리의 _app.js와 _document.js를 대체하는 새로운 방식을 제공한다. 아래 코드는 App Router에서 동일한 기능을 구현하는 방법을 보여준다.
// app/layout.js (Next.js 13 App Router)
import { Inter } from 'next/font/google'
import { Providers } from './providers'
import './globals.css'
// 폰트 최적화 (next/font)
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
export const metadata = {
title: 'Next.js 애플리케이션',
description: 'Next.js로 구현한 웹 애플리케이션입니다.',
openGraph: {
title: 'Next.js 애플리케이션',
description: 'Next.js로 구현한 웹 애플리케이션입니다.',
images: [
{
url: '/og-image.jpg',
},
],
},
}
export default function RootLayout({ children }) {
return (
<html lang="ko" className={inter.className}>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
// app/providers.jsx
'use client'
import { ThemeProvider } from 'next-themes'
import { AuthProvider } from '@/contexts/auth'
export function Providers({ children }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
)
}
위 코드에서 볼 수 있듯이, Next.js 13의 App Router에서는:
- app/layout.js가 _document.js와 _app.js의 기능을 결합한다.
- metadata 객체가 head 태그의 메타 정보를 대체한다.
- 클라이언트 컴포넌트는 'use client' 지시문으로 명시적으로 표시한다.
- next/font를 사용하여 폰트 최적화가 더 쉬워졌다.
마무리
이 글에서는 Next.js의 app.js와 _document.js 파일의 역할과 차이점에 대해 자세히 알아봤다. 두 파일이 Next.js 애플리케이션에서 서로 다른 책임을 가지고 있음을 이해하는 것이 중요하다.
요약하자면:
- app.js (_app.js)는 페이지 컴포넌트를 감싸는 역할을 하며, 전역 상태, 레이아웃, 스타일링 등을 관리한다. 모든 페이지 전환에서 실행되며 클라이언트 측 기능을 포함할 수 있다.
- _document.js는 HTML 문서 구조를 커스터마이징하는 역할을 하며, 서버 사이드에서만 렌더링된다. 메타 태그, 폰트 설정, 스타일 최적화 등의 작업에 사용된다.
Next.js 13 이후에는 App Router가 도입되어 pages 디렉토리의 _app.js와 _document.js를 app 디렉토리의 layout.js 등으로 대체할 수 있게 되었다. 새 프로젝트를 시작한다면 App Router 방식을 고려해보는 것도 좋다.
이 두 파일의 역할과 차이점을 명확히 이해함으로써, Next.js 애플리케이션을 더 효율적이고 유지보수하기 쉽게 구조화할 수 있다. 특히 대규모 프로젝트에서는 이러한 구조적 이해가 코드 품질과 성능에 큰 영향을 미친다.
"프레임워크의 기본 구조를 이해하는 것은 단순히 코드를 작성하는 것보다 훨씬 중요하다. 특히 Next.js와 같은 프레임워크에서는 각 파일의 역할과 실행 순서를 이해하는 것이 성능 최적화와 유지보수성을 크게 향상시킨다."
'Developer > Web Frontend' 카테고리의 다른 글
[Next.js] 첫 웹 애플리케이션 만들기. Hello World 페이지 (0) | 2025.04.03 |
---|---|
[Next.js] ESLint와 Prettier 설정하기 (0) | 2025.04.03 |
[Next.js] 동적 라우팅 (0) | 2025.04.02 |
[Next.js] Next.js 서버 사이드 렌더링(SSR) (0) | 2025.04.01 |
[Next.js] 정적 페이지 생성(SSG) 제대로 활용하기 (0) | 2025.03.31 |