[Next.js] app.js와 _document.js의 역할과 차이점

반응형

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

Next.js 렌더링 기본 개념 이해하기

app.js와 _document.js의 차이를 이해하기 전에 Next.js의 렌더링 프로세스를 간략하게 알아봐야 한다. 많은 개발자들이 이 부분을 간과하고 바로 코드부터 작성하려고 하는데, 그러면 나중에 디버깅할 때 고생한다.

Next.js는 React 앱을 위한 프레임워크로, 서버 사이드 렌더링(SSR)과 정적 사이트 생성(SSG)을 지원한다. 렌더링 프로세스는 대략 이런 순서로 진행된다..

  1. 서버에서 요청 처리 (_document.js)
  2. 앱 초기화 (app.js)
  3. 페이지 컴포넌트 렌더링
  4. 클라이언트 측에서 수화(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의 주요 역할

  1. HTML, HEAD, BODY 태그 커스터마이징
  2. 전역 메타 태그 및 폰트 설정
  3. 서버 사이드 렌더링 스타일 처리 (styled-components, emotion 등)
  4. 언어 속성 및 접근성 속성 설정
  5. 외부 스크립트 및 애널리틱스 코드 추가
  6. 초기 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
📝 FAQ: _document.js는 언제 수정해야 할까?

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 모범 사례

  1. 필요할 때만 사용하기: 기본 HTML 구조가 적절하다면 굳이 _document.js를 만들지 않는다.
  2. CSS-in-JS 설정은 여기서 처리: styled-components, emotion 등을 서버 사이드에서 처리하는 코드를 넣는다.
  3. 언어 및 방향성 설정: HTML lang 속성, dir 속성 등을 설정한다.
  4. 성능 최적화 메타 태그: preconnect, preload 등의 리소스 힌트를 추가한다.
  5. 폰트 최적화: 웹 폰트 로딩 전략을 구현한다.
// 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 모두에 중복 코드 작성

    동일한 기능을 양쪽에 구현하면 예측할 수 없는 동작이 발생할 수 있다. 각 파일의 역할을 명확히 구분해 중복을 피해야 한다.

📝 FAQ: 성능 최적화 관련 질문

Q: app.js가 너무 무거워지면 성능에 영향이 있나요?
A: 그렇다. app.js는 모든 페이지에 영향을 미치므로 여기에 무거운 로직이나 큰 라이브러리를 추가하면 전체 앱의 성능이 저하될 수 있다. 동적 임포트를 활용해 필요할 때만 코드를 로드하는 것이 좋다.


Q: _document.js에서 폰트를 로드하는 것이 좋을까요, app.js에서 로드하는 것이 좋을까요?
A: 웹 폰트는 _document.js에서 로드하는 것이 좋다. 이렇게 하면 초기 HTML에 폰트 로딩 코드가 포함되어 FOUT(Flash of Unstyled Text) 문제를 줄일 수 있다. 하지만 font-display: swap을 함께 사용하는 것을 권장한다.

📝 FAQ: Next.js 13의 app 디렉토리와의 관계

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와 같은 프레임워크에서는 각 파일의 역할과 실행 순서를 이해하는 것이 성능 최적화와 유지보수성을 크게 향상시킨다."

Designed by JB FACTORY