Next.js App Router 프로덕션: 상태 관리, 캐싱, 테스트 과제

Ethan MercerEthan Mercer
15 분 읽기
Dec 4, 2025

1. 진화의 여정: App Router가 필요한 이유

Pages Router의 한계

App Router가 등장하기 전, Next.js는 Pages Router(pages/ 디렉토리)를 라우팅 시스템으로 사용했습니다. 간단하고 직관적이었지만, 애플리케이션의 복잡도가 증가하면서 점차 한계가 드러났습니다:

  • 레이아웃 구성의 어려움: 중첩된 레이아웃을 위해 HOC나 수동 래핑이 필요
  • 데이터 페칭의 분산: getServerSideProps, getStaticProps, getInitialProps가 컴포넌트 로직과 분리
  • 큰 클라이언트 번들: 모든 인터랙티브 로직을 클라이언트로 전송해야 함
  • 높은 하이드레이션 비용: 정적 콘텐츠도 전체 클라이언트 측 React 런타임이 필요

2022년, React 18이 서버 컴포넌트(Server Components)를 도입하면서, Next.js 팀은 라우팅 시스템을 재설계할 기회를 발견했습니다. 그렇게 App Router가 탄생했습니다.

다섯 가지 핵심 변화

1. React 서버 컴포넌트(RSC)가 기본

이것은 가장 근본적인 패러다임 전환입니다. App Router에서는:

// 기본: 서버 컴포넌트, 서버에서 실행
export default async function Page() {
  const data = await db.query(); // 데이터베이스 직접 액세스
  return <div>{data}</div>;
}

// 클라이언트 인터랙션이 필요할 때, 명시적 선언
'use client'
export default function ClientPage() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

영향:

  • 정적 콘텐츠에 대한 제로 클라이언트 JS 가능
  • 서버 컴포넌트가 데이터베이스와 파일 시스템에 직접 액세스 가능
  • 개발자는 서버/클라이언트 경계를 명확히 이해해야 함

2. 레이아웃이 있는 파일 기반 중첩 라우팅

app/
├── layout.tsx          # 루트 레이아웃 (모든 페이지 공유)
├── page.tsx            # 홈 페이지
├── dashboard/
│   ├── layout.tsx      # Dashboard 전용 레이아웃
│   ├── page.tsx        # /dashboard
│   └── settings/
│       └── page.tsx    # /dashboard/settings

영향:

  • 자동 레이아웃 중첩, 내장된 지속성 (라우트 변경 시 레이아웃이 언마운트되지 않음)
  • 더 깔끔한 코드 구성, 특수 파일(loading.tsx, error.tsx)이 함께 배치됨
  • 상태 관리 재고가 필요 (아래 문제점 참조)

3. Suspense 기반 스트리밍 렌더링

// 페이지를 점진적으로 렌더링 가능, 모든 데이터를 기다릴 필요 없음
export default function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <SlowComponent />
    </Suspense>
  );
}

영향:

  • TTFB 크게 감소, 체감 성능 향상
  • 모니터링 메트릭 조정 필요 (TTFB의 의미가 감소)

4. Server Actions: API 라우트 없는 데이터 변경

// 컴포넌트에서 직접 서버 로직 정의
async function createPost(formData: FormData) {
  'use server'
  await db.post.create(...)
}

export default function Form() {
  return <form action={createPost}>...</form>
}

영향:

  • 보일러플레이트 감소, 기본적으로 타입 안전
  • 테스트가 더 복잡해짐 (아래에서 자세히 설명)

5. 새로운 캐싱 아키텍처

App Router는 4계층 캐싱 시스템(요청 메모이제이션, 데이터 캐시, 전체 라우트 캐시, 라우터 캐시)을 도입했습니다. 이는 강력한 최적화 도구인 동시에 가장 큰 혼란의 원인이기도 합니다.

현실 점검

2023년, Next.js 13.4는 App Router를 공식적으로 "안정"으로 표시했습니다. 그러나 개발자들이 실제 프로젝트에서 이러한 기능을 열심히 채택했을 때, 현실은 기대와 완전히 일치하지 않았습니다. 커뮤니티의 일반적인 불만:

"왜 라우트 탐색 후 상태가 리셋되지 않나요?"
"프로덕션 데이터가 계속 오래된 상태로 있고, 새로고침해도 업데이트되지 않아요!"
"Server Actions를 어떻게 테스트하나요? Cypress도 뭘 해야 할지 모르겠어요!"
"결국 RSC를 포기하고 Route Handlers로 돌아갔어요..."

이것들은 고립된 사건이 아닙니다—빈번하고 구체적이며 매우 현실적입니다. 이 글은 프로덕션 경험을 바탕으로 이러한 문제점의 근본 원인을 분석하고 실행 가능한 해결책을 제공합니다.

2. 문제점 #1: 상태 관리 혼란

문제

두 페이지 /page-a/page-b가 있고, 각각 자체 클라이언트 상태(폼 입력, 스크롤 위치 등)를 가지고 있다고 상상해보세요. 사용자가 Page A에서 Page B로 이동한 후 다시 Page A로 돌아올 때, Page A의 상태가 리셋될 것으로 예상하지만, 실제로는 그렇지 않습니다.

더 혼란스러운 점은:

  • 인터셉팅 라우트(Intercepting Routes)의 상태가 일반 라우트와 일관되지 않음
  • 로그인 성공 후 redirect('/')가 실행되지만, 내비게이션 바의 로그인 상태가 수동 새로고침 없이 업데이트되지 않음

근본 원인

App Router의 핵심 설계 원칙 중 하나는 "레이아웃 지속성"입니다. 동일한 Layout 하의 라우트 간 탐색 시, Layout 컴포넌트는 언마운트 및 재마운트되지 않습니다. 이는 UX와 성능 향상을 목적으로 한 "기능"이지만, 상태의 "끈적임"도 유발합니다.

또 다른 일반적인 원인은 서버/클라이언트 컴포넌트 경계가 불분명하다는 것입니다. 개발자는 서버 컴포넌트 내에 클라이언트 컴포넌트를 중첩하면서, 서버 컴포넌트의 출력이 라우트 전환 중에 캐시되고 재사용될 수 있다는 것을 인식하지 못할 수 있습니다.

해결책

1. 명확한 컴포넌트 경계 정의

상태를 가진 로직을 클라이언트 컴포넌트에 캡슐화하고, 라우트 변경 시 적절히 언마운트되도록 합니다.

// 잘못됨: 공유 Layout에 상태 배치
// app/layout.tsx
'use client'
export default function Layout({ children }) {
  const [count, setCount] = useState(0); // 라우트 변경 시 리셋되지 않음
  return <div>{children}</div>;
}

// 올바름: 특정 페이지 컴포넌트에 상태 배치
// app/page-a/page.tsx
'use client'
export default function PageA() {
  const [count, setCount] = useState(0); // PageA 이탈 시 언마운트됨
  return <div>Count: {count}</div>;
}

2. key prop으로 강제 재마운트

라우트 변경 시 클라이언트 컴포넌트의 상태를 리셋해야 할 때, key를 변경하여 React가 강제로 재마운트하도록 합니다:

// pathname을 key로 사용
import { usePathname } from 'next/navigation';

export default function Layout({ children }) {
  const pathname = usePathname();
  return <ClientComponent key={pathname} />;
}

3. router.refresh()로 서버 데이터 갱신

문제가 오래된 서버 측 데이터(로그인 후 사용자 정보 등)인 경우, 클라이언트에서 router.refresh()를 호출합니다:

import { useRouter } from 'next/navigation';

function LoginButton() {
  const router = useRouter();
  
  async function handleLogin() {
    await loginAction();
    router.refresh(); // 서버 컴포넌트 강제 새로고침
  }
}

3. 문제점 #2: 캐싱의 폭주

4계층 시스템

Next.js 캐싱은 개발자들이 가장 자주 걸려 넘어지는 부분입니다. 4계층 아키텍처를 이해하는 것이 핵심입니다:

메커니즘위치범위지속 시간
요청 메모이제이션서버단일 렌더렌더 완료까지
데이터 캐시서버요청/배포 간영구적 (재검증 가능)
전체 라우트 캐시서버정적 라우트영구적 (재검증 가능)
라우터 캐시클라이언트사용자 세션세션 또는 시간 제한
  • 요청 메모이제이션: 단일 렌더 중 동일한 URL에 대한 중복 fetch 요청이 중복 제거되어 하나의 실제 요청만 전송됩니다.
  • 데이터 캐시: fetch 결과가 영구적으로 캐시되어 사용자와 요청 간에 재사용됩니다.
  • 전체 라우트 캐시: 정적으로 렌더링된 페이지(HTML 및 RSC Payload)가 서버에 캐시됩니다.
  • 라우터 캐시: 클라이언트가 방문한 페이지의 RSC Payload를 메모리에 캐시하여 즉각적인 탐색을 실현합니다.

일반적인 함정

시나리오 1: 프로덕션 데이터가 업데이트되지 않음

// 이 시간은 개발 환경에서 매번 새로고침 시 업데이트됨
// 하지만 프로덕션에서는 빌드 시점에 고정됨!
export default async function Page() {
  const time = new Date().toLocaleTimeString();
  return <div>현재 시간: {time}</div>;
}

왜? Next.js는 빌드 시점에 모든 적격 페이지를 정적으로 사전 렌더링하려고 시도합니다. 동적 함수(cookies(), headers())나 명시적인 동적 구성이 없으면, 페이지는 정적으로 생성됩니다.

시나리오 2: 클라이언트 탐색이 오래된 데이터 표시

사용자가 Page A에서 데이터를 편집하고, Page B로 이동한 후 Page A로 돌아옴—Page A는 여전히 오래된 데이터를 표시합니다!

왜? 라우터 캐시가 클라이언트에서 Page A의 RSC Payload를 캐시하고, 서버에서 다시 가져오지 않고 직접 사용합니다.

시나리오 3: Next.js 15 vs 14 동작 차이

Next.js 15는 기본 fetch 캐싱 전략을 변경했습니다:

  • 14 및 이전: 기본값 cache: 'force-cache'
  • 15 이후: 기본값 cache: 'no-store'

Next.js 15로 업그레이드하면 이전에 캐시된 페이지가 동적 렌더링으로 전환되어 성능이 저하될 수 있습니다.

모범 사례

1. 명시적 캐시 구성

기본값에 의존하지 말고, 캐싱 의도를 명시적으로 선언하세요:

// 정적 데이터, 1시간 캐시
fetch('https://api.example.com/static-data', {
  next: { revalidate: 3600 }
});

// 실시간 데이터, 캐시 없음
fetch('https://api.example.com/realtime-data', {
  cache: 'no-store'
});

2. revalidatePath/revalidateTag로 적극적으로 캐시 무효화

데이터가 변경될 때(폼 제출 등), 적극적으로 캐시를 무효화합니다:

// Server Action 내에서
import { revalidatePath, revalidateTag } from 'next/cache'

async function updatePost(formData) {
  'use server'
  await db.post.update(...)
  revalidatePath('/posts')      // 이 경로를 무효화
  revalidateTag('posts')        // 이 태그를 가진 모든 캐시를 무효화
}

3. 라우트 세그먼트 구성

페이지 수준에서 동적 동작을 선언합니다:

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // 동적 렌더링 강제
export const revalidate = 60;           // 60초마다 재검증

4. 라우터 캐시 관리

Next.js는 라우터 캐시를 지우는 직접적인 API를 제공하지 않지만, 다음을 수행할 수 있습니다:

  • router.refresh()를 사용하여 현재 라우트 데이터 다시 가져오기
  • 서버 액션에서 revalidatePath() 호출—이는 서버 및 클라이언트 캐시 모두에 영향을 줌

4. 문제점 #3: Server Actions 테스트

과제

Server Actions를 사용하면 별도의 API 라우트를 만들지 않고 React 컴포넌트에서 직접 서버 로직을 정의할 수 있습니다:

// app/actions.ts
'use server'

export async function createUser(formData: FormData) {
  const name = formData.get('name');
  await db.user.create({ data: { name } });
  revalidatePath('/users');
}

이것은 코드 구조를 단순화하고 API 보일러플레이트를 줄입니다. 그러나 테스트 과제를 도입합니다:

  • 순수 프론트엔드 환경에서 시뮬레이션 불가: Server Actions는 서버 측에서 실행되어야 하며, Jest에서 일반 함수처럼 테스트할 수 없음
  • 전통적인 API 테스트 워크플로 중단: REST API처럼 Postman이나 curl로 디버그할 수 없음
  • 의존성 모킹 어려움: 데이터베이스 연결 및 서드파티 서비스를 테스트 환경에서 격리하기 어려움

테스트 전략

전략 1: 계층화된 아키텍처—비즈니스 로직 추출

비즈니스 로직을 Server Actions에서 분리하여 독립적으로 테스트 가능하게 만듭니다:

// lib/user-service.ts (순수 비즈니스 로직, 테스트 가능)
export async function createUserLogic(name: string) {
  // 검증 로직
  if (!name || name.length < 2) {
    throw new Error('이름이 너무 짧습니다');
  }
  return await db.user.create({ data: { name } });
}

// app/actions.ts (얇은 래퍼)
'use server'
import { createUserLogic } from '@/lib/user-service';

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string;
  await createUserLogic(name);
  revalidatePath('/users');
}
// __tests__/user-service.test.ts
import { createUserLogic } from '@/lib/user-service';

// 데이터베이스 모킹
jest.mock('@/lib/db', () => ({
  user: { create: jest.fn() }
}));

test('createUserLogic은 이름 길이를 검증한다', async () => {
  await expect(createUserLogic('a')).rejects.toThrow('이름이 너무 짧습니다');
});

전략 2: 전체 플로우를 위한 E2E 테스트

완전한 Server Action 플로우 테스트가 필요한 시나리오에서는 Playwright 또는 Cypress를 사용합니다:

// e2e/user.spec.ts (Playwright)
import { test, expect } from '@playwright/test';

test('사용자는 Server Action을 통해 계정을 만들 수 있다', async ({ page }) => {
  await page.goto('/signup');
  await page.fill('input[name="name"]', 'John Doe');
  await page.click('button[type="submit"]');
  
  // 결과 검증
  await expect(page.locator('.success-message')).toBeVisible();
  await expect(page).toHaveURL('/users');
});

전략 3: Server Actions 통합 테스트

Node.js에서 Server Actions 자체를 실제로 테스트해야 하는 경우:

  1. 테스트용 Next.js 서버 시작
  2. fetch를 사용하여 폼 제출 시뮬레이션
  3. 또는 테스트에서 직접 import하여 호출 (적절한 Node 환경 설정 필요)
// Server Actions를 지원하는 환경에서 실행해야 함
import { createUser } from '@/app/actions';

test('createUser는 데이터베이스에 저장한다', async () => {
  const formData = new FormData();
  formData.set('name', 'Test User');
  
  await createUser(formData);
  
  const user = await db.user.findFirst({ where: { name: 'Test User' } });
  expect(user).toBeDefined();
});

5. RSC와 Route Handlers 조율

언제 무엇을 사용할까?

기능Server ActionsRoute Handlers
사용 사례폼 제출, 변경외부 API, 웹훅, 컴포넌트 간 재사용
코드 구성컴포넌트와 밀접하게 결합독립적인 API 엔드포인트
타입 안전성기본적으로 타입 안전수동 타입 정의
테스트 난이도높음낮음 (전통적인 API처럼)
캐시 제어제한적완전한 제어
외부 호출지원 안 됨지원됨

실용적인 가이드라인:

  • 폼 제출, 데이터 변경 → Server Actions 선호
  • 외부 시스템용 API → Route Handlers 사용
  • 복잡한 쿼리, 세밀한 캐시 제어 → Route Handlers 사용
  • 웹훅, 파일 업로드 → Route Handlers 사용

통합 데이터 레이어

프로젝트가 성장함에 따라 데이터 액세스 로직이 분산됩니다: 서버 컴포넌트가 데이터베이스에 직접 쿼리하고, Server Actions도 그렇게 하고, Route Handlers도...

해결책: 통합 데이터 레이어

// lib/data/users.ts
export async function getUsers() {
  return await db.user.findMany();
}

export async function getUserById(id: string) {
  return await db.user.findUnique({ where: { id } });
}

export async function createUser(data: CreateUserInput) {
  return await db.user.create({ data });
}

서버 컴포넌트, Server Actions, Route Handlers 중 어디에서든 이 통합 레이어를 통해 데이터에 액세스합니다:

  • 비즈니스 로직 중앙화
  • 캐싱, 로깅, 권한 부여 등 횡단 관심사 추가가 용이
  • 테스트가 더 쉬움

6. 프로덕션 관찰 가능성

스트리밍 렌더링 모니터링

App Router는 기본적으로 스트리밍 렌더링을 사용하여 체감 성능을 향상시키지만, 모니터링을 복잡하게 만듭니다:

  • TTFB는 더 이상 전체 페이지 로드 시간을 정확하게 반영하지 않음
  • 전통적인 APM 도구는 스트리밍 응답을 올바르게 추적하지 못할 수 있음

권장 사항:

  • Web Vitals를 지원하는 모니터링 도구 사용 (EdgeOne Pages Analytics, Datadog RUM)
  • TTFB보다 LCP (Largest Contentful Paint)에 집중
  • 중요한 컴포넌트에 커스텀 성능 마크 추가

캐시 가시성

애플리케이션의 캐싱 동작을 이해하는 것은 성능 최적화에 중요합니다:

  • Vercel 배포: 응답 헤더 x-vercel-cache: HIT/MISS 확인
  • EdgeOne Pages 배포: 응답 헤더 Eo-Cache-Status: Cache HIT/MISS 확인
  • 자체 호스팅: 미들웨어 또는 Route Handlers에 캐시 로깅 추가 고려
  • 개발 디버깅: 브라우저 DevTools 네트워크 패널을 사용하여 캐시 히트 관찰
// 간단한 캐시 로깅 미들웨어
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  console.log(`[Cache] ${request.url} - ${response.headers.get('x-cache') || 'MISS'}`);
  return response;
}

7. 결론

App Router와 React 서버 컴포넌트는 React 생태계의 미래 방향을 나타냅니다. 현재의 문제점에도 불구하고, 이러한 문제들은 Next.js의 반복과 생태계의 성숙과 함께 점차 개선되고 있습니다.

핵심 요점:

  1. 전면 마이그레이션을 서두르지 마세요: 새 프로젝트에서 App Router를 시도하고; 기존 프로젝트에서 Pages Router를 유지하는 것은 완전히 괜찮습니다
  2. 기본 원리를 이해하세요: 많은 "버그"는 새로운 멘탈 모델에 대한 이해 부족에서 비롯됩니다
  3. 공식 업데이트를 따르세요: Next.js 팀은 적극적으로 DX를 개선하고 있으며; 각 버전마다 주목할 만한 변경 사항이 있습니다
  4. 커뮤니티에 참여하세요: GitHub Issues, Twitter, Discord는 정보와 피드백을 위한 훌륭한 채널입니다

기술 진화는 항상 성장통을 동반합니다. 핵심은 이상주의와 실용주의 사이에서 균형을 찾는 것입니다. 이 글이 Next.js App Router의 여정에서 시행착오를 줄이는 데 도움이 되기를 바랍니다.

리소스:

Next.js 공식 문서 - 캐싱

Next.js GitHub Discussions

EdgeOne Pages 문서 - Next.js