테마 전환

Next.js App Router에서 자주 겪는 함정과 해결책: 시행착오를 줄이는 실전 경험 8가지

Easton editorial illustration: route-map drafting table

Next.js App Router를 처음 쓰기 시작했을 때는 정말 제대로 당했습니다.

작년 말 회사 프로젝트를 Next.js 15로 업그레이드해야 했습니다. 어차피 업그레이드하는 김에 Pages Router도 App Router로 옮기자는 생각이 들었습니다. 공식 문서에서 말하는 “더 빠른 성능”, “더 나은 개발 경험”, “Server Components의 혁신적인 아키텍처”가 꽤 매력적으로 보였기 때문입니다. 결과는 어땠을까요? 시작 첫날부터 이해하기 어려운 문제가 쏟아졌습니다.

데이터가 업데이트되지 않고, 페이지는 계속 로딩만 되고, 캐시를 썼는데도 동작하지 않고, Server Component와 Client Component가 헷갈렸습니다. 가장 심했던 것은 어떤 bug를 3시간 동안 debug한 뒤에야 error.tsx 파일에 'use client'를 빠뜨렸다는 사실을 발견한 일이었습니다. 그때의 기분은 정말 말로 설명하기 어려웠습니다.

나중에 팀 안에서 간단히 통계를 내 보니, 우리가 만난 문제의 80%는 반복되는 문제였습니다. 그래서 직접 밟았던 함정들을 정리했습니다. 이 글이 당신의 시행착오를 조금이라도 줄이는 데 도움이 되었으면 합니다.

이 글은 이론 설명보다 실전 경험에 집중합니다. 각 문제마다 왜 함정에 빠지는지, 어떻게 발견했는지, 어떻게 해결했는지를 설명하겠습니다. 읽고 나면 App Router의 여러 “함정”을 어떻게 피해야 하는지 감이 올 것입니다.

데이터 가져오기에서 만나는 함정

함정 1: 클라이언트에서 데이터를 반복해서 가져오기

상황 재현:

한번은 페이지에 사용자 정보를 보여줘야 했습니다. 습관적으로 이런 코드를 작성했습니다.

// app/profile/page.tsx
'use client'
import { useEffect, useState } from 'react'

export default function ProfilePage() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data))
  }, [])

  if (!user) return <div>Loading...</div>
  return <div>Hello, {user.name}</div>
}

겉보기에는 문제가 없어 보입니다. 하지만 실제로는 전형적인 안티 패턴입니다. 데이터가 데이터베이스 → API Route → 클라이언트로 이동하면서 불필요한 네트워크 왕복이 한 번 더 생깁니다.

왜 함정에 빠질까:

Pages Router 시절에는 useEffect로 클라이언트에서 데이터를 가져오는 방식에 익숙했습니다. 하지만 App Router의 Server Components는 서버에서 직접 데이터를 가져올 수 있으므로, 중간 API 계층이 꼭 필요하지 않습니다.

올바른 방법:

// app/profile/page.tsx(기본값은 Server Component)
import { db } from '@/lib/db'

export default async function ProfilePage() {
  // 서버에서 데이터베이스를 직접 조회
  const user = await db.user.findFirst()

  return <div>Hello, {user.name}</div>
}

성능 향상은 바로 체감됩니다.

  • API 요청이 한 번 줄어듭니다
  • 서버에서 데이터베이스까지의 지연은 보통 10ms 미만입니다(클라이언트에서 서버까지는 100ms 이상일 수 있습니다)
  • 클라이언트 JavaScript bundle 크기가 줄어듭니다

핵심 포인트:

Server Component에서 가져올 수 있는 데이터라면 클라이언트로 내려가서 fetch하지 마세요. 사용자 상호작용(검색, 필터링, 실시간 업데이트)이 필요한 경우에만 클라이언트 가져오기를 고려하면 됩니다.

함정 2: Route Handler의 기본 캐시 동작

상황 재현:

현재 시간을 반환하는 API를 만들었는데, 아무리 새로고침해도 시간이 바뀌지 않았습니다.

// app/api/time/route.ts
export async function GET() {
  return Response.json({ time: new Date().toISOString() })
}

10번을 새로고침해도 반환 시간이 똑같았습니다. 그때는 코드가 반영되지 않은 것 아닌가 의심할 정도였습니다.

왜 함정에 빠질까:

Next.js는 기본적으로 GET 요청의 Route Handler를 캐시할 수 있습니다. 설정 정보처럼 정적인 데이터에는 좋은 일이지만, 동적 데이터에는 문제가 됩니다.

해결책 1: 명확하게 동적으로 표시하기

// app/api/time/route.ts
export const dynamic = 'force-dynamic' // 동적 렌더링 강제

export async function GET() {
  return Response.json({ time: new Date().toISOString() })
}

해결책 2: Next.js 15의 새 기본 동작 사용하기

다행히 Next.js 15에서는 GET Route Handler의 기본 동작이 캐시하지 않음으로 바뀌었습니다. 아직 Next.js 14를 사용한다면 이렇게 작성할 수 있습니다.

// app/api/time/route.ts
export async function GET() {
  return Response.json(
    { time: new Date().toISOString() },
    { headers: { 'Cache-Control': 'no-store' } }
  )
}

내 경험:

요즘 제 습관은 이렇습니다.

  • 정적 데이터(설정, 상수): export const revalidate = 3600을 명확히 표시합니다
  • 동적 데이터(사용자 정보, 실시간 데이터): export const dynamic = 'force-dynamic'으로 표시합니다

기본 동작에 의존하지 말고 의도를 명확히 적어 두면 코드도 더 읽기 쉬워집니다.

함정 3: 데이터 변경 후 재검증을 잊기

상황 재현:

간단한 Todo 앱을 만들었는데, 새 할 일을 추가해도 목록이 업데이트되지 않았습니다.

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await db.todo.findMany()
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  // 재검증을 잊음!
}

폼을 제출한 뒤에도 페이지에는 예전 데이터가 보였습니다. 새 작업을 보려면 직접 새로고침해야 했습니다.

왜 함정에 빠질까:

App Router의 캐시 메커니즘은 꽤 적극적입니다. 데이터가 바뀌어도 페이지가 자동으로 갱신되지 않으며, “이 경로의 데이터가 바뀌었으니 새로 가져와”라고 명확히 알려줘야 합니다.

올바른 방법:

// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidatePath('/todos') // /todos 경로 재검증
}

심화 팁:

여러 페이지에서 Todo 목록을 보여준다면(예: 홈, 아카이브 페이지), revalidateTag가 더 유연합니다.

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await fetch('http://localhost:3000/api/todos', {
    next: { tags: ['todos'] } // 데이터에 태그 부여
  })
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidateTag('todos') // 'todos' 태그가 붙은 모든 데이터 재검증
}

핵심 포인트:

데이터 변경 3단계: 데이터 쓰기 → revalidatePath / revalidateTag → 리디렉션(선택)

Server Components와 Client Components의 헷갈리는 동작

함정 4: Server Component에서 Context 사용하기

상황 재현:

전역 테마 전환 기능을 제공하고 싶어서 ThemeProvider를 작성했습니다.

// app/providers.tsx
import { createContext } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }) {
  return (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

결과는 오류였습니다. You're importing a component that needs createContext. This only works in a Client Component.

왜 함정에 빠질까:

Server Components는 React Context를 지원하지 않습니다. 서버에서 렌더링되며 클라이언트 상태 관리 메커니즘이 없기 때문입니다.

올바른 방법:

Provider는 반드시 Client Component여야 하며, 별도 파일로 분리하는 것이 좋습니다.

// app/providers.tsx
'use client' // 핵심: Client Component로 표시

import { createContext, useState } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light')

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx(여전히 Server Component)
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

내가 밟았던 함정:

처음에는 'use client'를 layout.tsx에 붙였습니다. 그 결과 전체 애플리케이션이 Client Component가 되어 Server Components의 장점이 모두 사라졌습니다. 기억하세요. Provider만 Client Component로 표시하고, Layout은 Server Component로 유지해야 합니다.

함정 5: Client Component의 SSR 오해

상황 재현:

Client Component에서 localStorage를 사용했습니다. 로컬 개발에서는 문제가 없었는데 배포 후 localStorage is not defined 오류가 났습니다.

// app/components/user-info.tsx
'use client'

export default function UserInfo() {
  const user = JSON.parse(localStorage.getItem('user') || '{}')
  return <div>{user.name}</div>
}

왜 함정에 빠질까:

많은 사람이 'use client'를 “클라이언트에서만 실행된다”는 뜻으로 오해합니다. 하지만 실제로 Client Components도 서버에서 사전 렌더링됩니다(SSR). localStorage는 브라우저 환경에만 있으므로 서버에서 접근하면 당연히 오류가 납니다.

해결책 1: 환경 확인하기

'use client'
import { useEffect, useState } from 'react'

export default function UserInfo() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    // useEffect는 클라이언트에서만 실행
    const userData = JSON.parse(localStorage.getItem('user') || '{}')
    setUser(userData)
  }, [])

  if (!user) return null
  return <div>{user.name}</div>
}

해결책 2: 조건문 사용하기

'use client'

export default function UserInfo() {
  const user = typeof window !== 'undefined'
    ? JSON.parse(localStorage.getItem('user') || '{}')
    : null

  if (!user) return null
  return <div>{user.name}</div>
}

핵심 포인트:

Client Component = 클라이언트에서 상호작용할 수 있는 컴포넌트입니다. 하지만 여전히 서버에서 사전 렌더링됩니다. 브라우저 API(localStorage, window, document)를 쓰는 코드는 반드시 useEffect 안에 두거나 환경 판단을 추가해야 합니다.

함정 6: 'use client' 과다 사용

상황 재현:

App Router를 처음 쓸 때는 오류가 나면 습관적으로 'use client'를 붙였습니다. 결국 프로젝트 대부분이 Client Components가 되었고, Server Components의 장점은 거의 사라졌습니다.

왜 함정에 빠질까:

어떤 문제는 “Client Component가 필요해 보이는” 문제처럼 보이지만, 실제로는 코드 구성의 문제인 경우가 많습니다.

반례:

// app/dashboard/page.tsx
'use client' // 붙이면 안 됩니다!

import { useState } from 'react'

export default function Dashboard() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Header /> {/* 정적 */}
      <Stats /> {/* 서버 데이터 필요 */}
      <Counter count={count} setCount={setCount} /> {/* 상호작용 필요 */}
    </div>
  )
}

이렇게 쓰면 전체 페이지가 Client Component가 되고, Stats 데이터도 클라이언트 fetch를 거쳐야 합니다.

올바른 방법:

// app/dashboard/page.tsx(Server Component)
import { db } from '@/lib/db'
import { Counter } from './counter'

export default async function Dashboard() {
  const stats = await db.stats.findFirst() // 서버에서 데이터 가져오기

  return (
    <div>
      <Header /> {/* Server Component */}
      <Stats data={stats} /> {/* Server Component */}
      <Counter /> {/* Client Component */}
    </div>
  )
}

// app/dashboard/counter.tsx
'use client' // 이 컴포넌트만 Client Component

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

내 경험:

컴포넌트에 'use client'가 필요한지 판단하는 기준은 세 가지입니다.

  1. React Hooks를 사용한다(useState, useEffect, useContext 등)
  2. 브라우저 이벤트를 듣는다(onClick, onChange 등)
  3. 브라우저 API를 사용한다(localStorage, window 등)

이 세 가지에 해당하지 않는다면 Server Component로 유지하세요.

캐시 메커니즘에서 만나는 함정

함정 7: Client Router Cache의 혼란

상황 재현:

사용자가 /posts/1 페이지에서 글을 수정하고 저장한 뒤 /posts 목록 페이지로 이동했습니다. 그런데 목록의 글 제목은 여전히 예전 값이었습니다. 페이지를 새로고침해야 업데이트가 보였습니다.

왜 함정에 빠질까:

App Router에는 Client Router Cache(클라이언트 라우터 캐시)가 있습니다. 방문했던 페이지를 캐시하므로, 데이터가 업데이트되어도 이동할 때 예전 캐시 데이터가 보일 수 있습니다.

해결책 1: 이동할 때 새로고침하기

// app/posts/[id]/edit/page.tsx
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function updatePost(id: string, title: string) {
  await db.post.update({ where: { id }, data: { title } })

  revalidatePath('/posts') // 목록 페이지 재검증
  revalidatePath(`/posts/${id}`) // 상세 페이지 재검증

  redirect('/posts') // 목록으로 이동
}

해결책 2: router.refresh() 사용하기

'use client'
import { useRouter } from 'next/navigation'

export function EditForm() {
  const router = useRouter()

  async function handleSubmit() {
    await updatePost(...)
    router.refresh() // 현재 라우트의 데이터 새로고침
    router.push('/posts')
  }
}

Next.js 15의 반가운 변화:

Next.js 15에서는 Client Router Cache의 기본 동작이 캐시하지 않음으로 바뀌어 이 문제를 크게 걱정하지 않아도 됩니다. 다만 Next.js 14를 사용한다면 직접 처리해야 합니다.

함정 8: revalidate가 동작하지 않음

상황 재현:

Server Component에서 revalidate = 60을 설정하고 60초마다 데이터가 자동으로 갱신되기를 기대했습니다. 하지만 실제로는 데이터가 계속 바뀌지 않았습니다.

// app/news/page.tsx
export const revalidate = 60 // 60초 후 다시 생성 기대

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news')
  return <NewsList news={news} />
}

배포 후 뉴스 목록이 하루 종일 업데이트되지 않는 것을 발견했습니다.

왜 함정에 빠질까:

revalidate는 프로덕션 환경에서만 동작합니다. 개발 환경(npm run dev)에서는 캐시하지 않습니다. 또한 정적 생성 페이지에만 유효합니다. 페이지가 동적 렌더링으로 인식되면 revalidate는 효과가 없습니다.

점검 단계:

  1. 프로덕션 환경인지 확인하기:
npm run build
npm run start
  1. 페이지가 정적인지 확인하기:

빌드 출력에서 ○ Static 또는 ● SSG 표시가 보여야 합니다. λ Dynamic이라면 페이지가 동적 렌더링으로 인식된 것입니다.

  1. 동적 렌더링을 유발한 원인 찾기:

자주 보는 원인:

  • cookies() 또는 headers() 사용
  • searchParams 사용(동적 라우트 파라미터)
  • Route Handler에서 revalidate를 명확히 설정하지 않음

해결책:

// app/news/page.tsx
export const revalidate = 60

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news', {
    next: { revalidate: 60 } // fetch 레벨의 revalidate
  })

  return <NewsList news={news} />
}

내 경험:

요즘 저는 이렇게 나눕니다.

  • 순수 정적 콘텐츠: generateStaticParams + revalidate
  • 동적 파라미터가 필요함: ISR(Incremental Static Regeneration)
  • 실시간 데이터: revalidate 대신 바로 dynamic = 'force-dynamic'으로 표시

오류 처리에서 만나는 함정

함정 9: error.tsx에 'use client'를 빼먹기

상황 재현:

페이지 오류를 처리하려고 error.tsx를 만들었는데, ReactServerComponentsError: Client Component must be used in a Client Component boundary. 오류가 났습니다.

// app/error.tsx(잘못된 작성)
export default function Error({ error, reset }) {
  return (
    <div>
      <h2>오류가 발생했습니다!</h2>
      <button onClick={reset}>다시 시도</button>
    </div>
  )
}

왜 함정에 빠질까:

error.tsx는 반드시 Client Component여야 합니다. React의 Error Boundary 메커니즘이 필요하고, Error Boundary는 클라이언트에서만 유효하기 때문입니다.

올바른 작성:

// app/error.tsx
'use client' // 반드시 필요

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>오류가 발생했습니다!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  )
}

핵심 포인트:

error.tsx, loading.tsx, not-found.tsx 같은 특수 파일 중에서 반드시 Client Component여야 하는 것은 error.tsx뿐입니다. 나머지는 Server Component여도 됩니다.

함정 10: try/catch 안에서 redirect를 호출하는 위치 문제

상황 재현:

Server Action에서 폼 검증을 하고, 검증 후 오류 페이지로 이동하려고 했습니다. 그런데 redirect가 catch에 잡혀 이동이 실패했습니다.

// app/actions.ts(잘못된 작성)
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    redirect(`/users/${user.id}`) // 여기서 catch에 잡힙니다!
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }
}

왜 함정에 빠질까:

redirect()는 내부적으로 특수한 오류를 던지는 방식으로 구현됩니다. Next.js가 이 오류를 잡아 실제 이동을 수행합니다. 그런데 try/catch 안에서 redirect를 쓰면 내가 작성한 catch가 먼저 잡아 버려 이동이 실패합니다.

올바른 작성:

// app/actions.ts
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    // 여기서는 redirect하지 않음
    return { success: true, userId: user.id }
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }
}

// 호출하는 쪽에서 redirect
export async function handleSubmit(data: FormData) {
  const result = await createUser(data)
  if (result.success) {
    redirect(`/users/${result.userId}`) // try/catch 바깥
  }
}

또는 이렇게 작성할 수 있습니다:

'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }

  redirect(`/users/${user.id}`) // try/catch 뒤에서 호출
}

마이그레이션할 때 만나는 함정

함정 11: 404.js와 500.js를 더 이상 쓸 수 없음

상황 재현:

Pages Router에서 마이그레이션하면서 pages/404.js와 pages/500.js를 그대로 남겨 두었습니다. 그런데 이 두 페이지가 계속 동작하지 않았습니다.

왜 함정에 빠질까:

App Router의 오류 처리 메커니즘은 완전히 달라졌습니다.

  • 404.js → not-found.tsx
  • 500.js → error.tsx
  • 전역 오류 → global-error.tsx

올바른 방법:

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>404 - 페이지를 찾을 수 없습니다</h2>
      <Link href="/">홈으로 돌아가기</Link>
    </div>
  )
}

// app/error.tsx
'use client'

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>500 - 서버 오류</h2>
      <p>{error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  )
}

// app/global-error.tsx(루트 레이아웃 오류 포착)
'use client'

export default function GlobalError({ error, reset }) {
  return (
    <html>
      <body>
        <h2>전역 오류</h2>
        <p>{error.message}</p>
        <button onClick={reset}>다시 시도</button>
      </body>
    </html>
  )
}

함정 12: next-seo를 더 이상 쓸 수 없음

상황 재현:

프로젝트에서 SEO 메타데이터 관리를 위해 next-seo를 많이 쓰고 있었습니다. App Router로 마이그레이션한 뒤에는 제대로 동작하지 않았습니다.

// pages/blog/[slug].tsx(Pages Router 시절)
import { NextSeo } from 'next-seo'

export default function BlogPost({ post }) {
  return (
    <>
      <NextSeo
        title={post.title}
        description={post.excerpt}
        openGraph={{
          title: post.title,
          description: post.excerpt,
          images: [{ url: post.coverImage }],
        }}
      />
      <article>{post.content}</article>
    </>
  )
}

왜 함정에 빠질까:

App Router에는 네이티브 generateMetadata API가 생겼습니다. 이제 next-seo는 권장되지 않습니다.

마이그레이션 방안:

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)
  return <article>{post.content}</article>
}

장점:

  1. 타입 안전성(TypeScript 지원)
  2. async/await 지원(데이터베이스를 직접 조회 가능)
  3. 더 나은 성능(서버 렌더링)

성능 최적화 제안

불필요한 Client Components 피하기

문제: 전체 페이지가 Client Component가 되어 Server Components의 장점을 잃습니다.

해결책:

“리프 노드 Client Components” 전략을 사용하세요.

// ❌ 좋지 않은 방법
// app/dashboard/page.tsx
'use client'
export default function Dashboard() {
  return (
    <div>
      <Header />
      <Sidebar />
      <MainContent />
      <Footer />
    </div>
  )
}

// ✅ 좋은 방법
// app/dashboard/page.tsx(Server Component)
import { Header } from './header'
import { Sidebar } from './sidebar'
import { MainContent } from './main-content'
import { Footer } from './footer'

export default function Dashboard() {
  return (
    <div>
      <Header /> {/* Server Component */}
      <Sidebar /> {/* Client Component(상호작용) */}
      <MainContent /> {/* Server Component */}
      <Footer /> {/* Server Component */}
    </div>
  )
}

// app/dashboard/sidebar.tsx
'use client' // 이 컴포넌트만 Client Component
export function Sidebar() {
  const [collapsed, setCollapsed] = useState(false)
  return <aside>...</aside>
}

Suspense 경계 최적화

문제: 전체 페이지가 느린 데이터를 기다리느라 첫 화면이 오래 비어 있습니다.

해결책:

<Suspense>로 로딩을 분리하세요.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { FastComponent } from './fast'
import { SlowComponent } from './slow'

export default function Dashboard() {
  return (
    <div>
      {/* 빠른 데이터는 즉시 표시 */}
      <FastComponent />

      {/* 느린 데이터는 스켈레톤 화면 표시 */}
      <Suspense fallback={<div>로딩 중...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

병렬 데이터 가져오기를 합리적으로 사용하기

문제: 데이터를 순차적으로 가져오면 전체 소요 시간은 모든 요청 시간을 더한 값이 됩니다.

해결책:

// ❌ 순차 가져오기(느림)
export default async function Page() {
  const user = await getUser() // 100ms
  const posts = await getPosts() // 200ms
  const comments = await getComments() // 150ms
  // 총 소요 시간: 450ms
}

// ✅ 병렬 가져오기(빠름)
export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ])
  // 총 소요 시간: 200ms(가장 느린 요청 기준)
}

개발 환경에서 만나는 함정

함정 13: Hot Reload로 인한 연결 누수

상황 재현:

개발 환경에서 한동안 실행하고 나니 데이터베이스에서 too many connections 오류가 났습니다.

왜 함정에 빠질까:

Hot Reload는 모듈 코드를 다시 실행합니다. 전역에서 데이터베이스 연결을 만들면 Hot Reload가 발생할 때마다 새 연결이 생기고, 기존 연결은 닫히지 않습니다.

해결책:

// lib/db.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

이렇게 하면 개발 환경에서 Hot Reload가 일어나도 같은 Prisma 인스턴스를 재사용합니다.

함정 14: 개발 서버가 점점 느려짐

상황 재현:

npm run dev를 30분 정도 실행한 뒤 Hot Reload가 매우 느려졌습니다. 어떤 때는 거의 멈춘 것처럼 보이기도 했습니다.

왜 함정에 빠질까:

App Router의 개발 서버는 페이지가 많을 때 메모리를 많이 사용할 수 있습니다. 특히 동적 라우트가 많을수록 더 그렇습니다.

임시 해결책:

  1. 개발 서버 재시작(근본 해결은 아님)
  2. 불필요한 파일 감시 줄이기:
// next.config.js
module.exports = {
  webpack: (config) => {
    config.watchOptions = {
      poll: 1000, // 1초마다 파일 변경 확인(빈도 낮추기)
      aggregateTimeout: 300,
      ignored: /node_modules/,
    }
    return config
  },
}

장기 해결책:

Next.js 15로 업그레이드하고 Turbopack을 사용하세요.

npm run dev --turbo

Turbopack의 Hot Reload 속도는 10배 이상 빠르며, 큰 프로젝트에서도 덜 버벅입니다.

정리: 함정 피하기 체크리스트

여러 함정을 살펴본 뒤 빠른 점검표를 하나 정리했습니다. 새 프로젝트를 시작하기 전에 한 번 훑어보면 문제의 90%는 피할 수 있습니다.

데이터 가져오기

  • ☑ Server Component로 데이터를 가져올 수 있다면 Client Component + useEffect를 쓰지 않습니다
  • ☑ Route Handler에는 dynamic = 'force-dynamic' 또는 revalidate를 명확히 표시합니다
  • ☑ 데이터 변경 후에는 revalidatePath / revalidateTag를 잊지 않습니다

Server/Client Components

  • ☑ Provider는 반드시 Client Component여야 하지만 Layout은 Server Component로 유지합니다
  • ☑ 브라우저 API(localStorage, window)는 반드시 useEffect 안에서 쓰거나 환경 판단을 추가합니다
  • ☑ 정말 상호작용이 필요한 컴포넌트에만 'use client'를 붙이고, “편하니까” 전체 페이지에 붙이지 않습니다

오류 처리

  • ☑ error.tsx에는 반드시 'use client'를 추가합니다
  • ☑ redirect를 try/catch 안에 넣지 않습니다
  • ☑ 루트 레이아웃 오류는 error.tsx가 아니라 global-error.tsx로 처리합니다

캐시 메커니즘

  • ☑ Next.js 15로 업그레이드해 더 합리적인 기본 캐시 동작을 사용합니다
  • ☑ revalidate는 프로덕션 환경에서만 동작하므로 개발 환경에서는 의존하지 않습니다
  • ☑ 동적 페이지에는 revalidate 대신 바로 dynamic = 'force-dynamic'을 사용합니다

마이그레이션 관련

  • ☑ 404.js → not-found.tsx, 500.js → error.tsx
  • ☑ next-seo → generateMetadata
  • ☑ getServerSideProps → Server Component에서 직접 fetch
  • ☑ useRouter는 next/router → next/navigation

성능 최적화

  • ☑ Suspense로 빠른 컴포넌트와 느린 컴포넌트를 분리합니다
  • ☑ 데이터를 병렬로 가져옵니다(Promise.all)
  • ☑ 개발 환경의 데이터베이스 연결은 싱글턴 패턴을 사용합니다
  • ☑ Turbopack을 사용합니다(npm run dev --turbo)

마무리

솔직히 App Router에는 분명한 학습 곡선이 있습니다. 처음에 함정을 밟는 것은 자연스러운 일입니다. 하지만 이런 “패턴”을 익히고 나면 개발 효율은 꽤 좋아집니다.

요즘 제 습관은 이렇습니다.

  1. 새 기능을 만들기 전에 데이터 흐름부터 생각하기: 이 데이터는 서버 렌더링이 필요한가, 클라이언트 상호작용이 필요한가?
  2. 문제가 생기면 먼저 빌드 출력 보기: 페이지가 Static인가 Dynamic인가? 왜 그런가?
  3. DevTools 적극 활용하기: Network 패널에서 요청 수를 보고, Console에서 오류 스택을 확인합니다
  4. 기본 동작에 의존하지 않기: 캐시, 렌더링 방식, 데이터 재검증 의도를 명확히 표시합니다

가장 중요한 것은 이것입니다. 이런 함정에 겁먹지 말고 직접 시도해 보세요. 한 번 밟은 함정은 기억에 남습니다. Next.js App Router의 공식 문서는 꽤 자세합니다. 문제가 생기면 문서를 자주 찾아보세요. 답은 대부분 그 안에 있습니다.

이 글이 도움이 되었다면 App Router에서 시행착오를 겪고 있는 친구에게 공유해 주세요. 새로 발견한 함정이 있다면 댓글로 알려 주세요. 이 목록은 계속 업데이트하겠습니다.

App Router를 쓰는 길에서 시행착오는 줄이고, bug는 많이 쓰지 말고, 우아한 코드를 더 많이 쓰길 바랍니다!

FAQ

Server Component와 Client Component는 어떻게 구분하나요?
Server Component(기본값):
• 서버에서 실행되며 클라이언트로 전송되지 않습니다
• useState, useEffect 같은 hooks를 사용할 수 없습니다
• 브라우저 API를 사용할 수 없습니다

Client Component(명시 필요):
• 'use client' 지시어로 표시합니다
• 모든 React hooks를 사용할 수 있습니다
• 브라우저 API를 사용할 수 있습니다

판단 기준: 상호작용이나 브라우저 API가 필요하면 Client Component를 사용합니다
데이터가 업데이트되지 않는 이유는 무엇인가요?
Next.js의 fetch는 기본적으로 캐시가 있습니다.

해결책:
• cache: 'no-store' 설정(요청할 때마다 새 데이터 가져오기)
• next: { revalidate: 60 } 사용(60초 후 재검증)
• Client Component에서 router.refresh()로 강제 새로고침

확인 방법: 빌드 출력을 보고 페이지가 Dynamic인지 Static인지 확인합니다
페이지가 계속 로딩만 될 때는 어떻게 하나요?
가능한 원인:
• async Server Component의 loading 상태 처리가 올바르지 않음
• Suspense 경계 설정 오류
• 데이터 가져오기가 실패했지만 오류 처리가 없음

해결 방법:
• loading.tsx 파일 추가
• 비동기 컴포넌트를 Suspense로 감싸기
• error.tsx를 추가해 오류 처리
error.tsx가 동작하지 않는 이유는 무엇인가요?
error.tsx는 반드시 Client Component여야 합니다.

반드시 'use client' 지시어를 추가해야 합니다:
'use client'

export default function Error({ error, reset }) {
return <div>오류 발생: {error.message}</div>
}

주의: error.tsx는 하위 컴포넌트의 오류만 잡을 수 있으며, 자기 자신의 오류는 잡을 수 없습니다
Pages Router에서 App Router로 어떻게 마이그레이션하나요?
주요 변화:
• getServerSideProps → async Server Component
• getStaticProps → 정적 생성(기본값)
• next/router → next/navigation
• _app.js → layout.tsx
• _document.js → 불필요(layout.tsx에서 처리)

권장 방식: 먼저 1~2개 페이지로 파일럿을 진행해 흐름을 검증한 뒤 전체로 확장하세요
캐시 메커니즘은 어떻게 이해하면 좋나요?
Next.js의 캐시 계층:
• Request Memoization: 같은 요청 안의 동일 fetch는 한 번만 실행됩니다
• Data Cache: fetch 응답이 캐시됩니다
• Full Route Cache: 전체 페이지가 캐시됩니다(정적 생성)
• Router Cache: 클라이언트 라우트 캐시입니다

제어 방법: cache: 'no-store', next: { revalidate } 같은 옵션을 사용합니다
App Router 문제는 어떻게 디버깅하나요?
디버깅 방법:
• 빌드 출력(npm run build)을 보고 페이지 유형 확인
• DevTools Network 패널에서 요청 확인
• Console 오류 메시지 확인
• Next.js 터미널 출력 확인

자주 보는 문제:
• 페이지가 Static인데 Dynamic이어야 함 → fetch 캐시 설정 확인
• 데이터가 업데이트되지 않음 → 캐시와 revalidate 설정 확인
• 페이지가 계속 로딩됨 → loading.tsx와 Suspense 확인

8분 읽기 · 게시일: 2025년 12월 25일 · 수정일: 2026년 10월 21일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog