Next.js "Event handlers cannot be passed to Client Component" 에러 해결

Next.js App Router에서 loading.tsx를 추가했더니 빌드 에러가 발생했습니다. 'use client' 컴포넌트의 prerendering 동작을 이해하고 Server/Client 분리 패턴으로 해결한 경험을 공유합니다.

1. 문제 상황

1.1 에러 메시지

pnpm build

> next build

   ▲ Next.js 16.0.10 (Turbopack)

   Creating an optimized production build ...
 ✓ Compiled successfully in 5.0s

Error occurred prerendering page "/items/new". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Event handlers cannot be passed to Client Component props.
  {onClick: function onClick, className: ..., children: ...}
            ^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

Export encountered an error on /(app)/items/new/page: /items/new, exiting the build.

1.2 발생 시점

  • 정상 작동했던 커밋: 키보드 단축키 추가 커밋
  • 에러 발생 커밋: loading/error/not-found UI 패턴 추가 커밋
  • 변경 내용: loading.tsx, error.tsx, not-found.tsx 파일 추가

1.3 영향 범위

에러 발생 페이지:
- /items/new
- /records
- /dashboard
- (기타 route group 내 모든 페이지)

2. 원인 분석

2.1 Next.js App Router의 렌더링 전략

Next.js App Router는 페이지를 3가지 방식으로 렌더링합니다:

전략 설명 언제 결정?
Static (정적) 빌드 시 HTML 미리 생성 빌드 타임
Dynamic (동적) 요청마다 서버에서 렌더링 런타임
Streaming 점진적 렌더링 런타임

2.2 Prerendering이란?

Prerendering = 빌드 시 페이지를 미리 렌더링하여 정적 HTML 생성

빌드 과정:
1. next build 실행
2. Next.js가 각 페이지를 서버에서 렌더링 시도
3. 렌더링된 HTML을 .next/static/에 저장
4. 배포 시 이 HTML을 CDN에서 즉시 제공

장점:

  • 초기 로딩 속도 빠름 (서버 렌더링 대기 없음)
  • SEO 최적화 (크롤러가 완성된 HTML 수집)
  • 서버 부하 감소

2.3 왜 에러가 발생했나?

Step 1: loading.tsx 추가의 영향

Before (loading.tsx 없음):
(app)/
├── layout.tsx        ('use client')
├── items/
│   └── new/
│       └── page.tsx  ('use client')

→ Next.js: "layout이 'use client'네, 전체를 클라이언트에서 처리하자"
→ Prerendering 스킵
After (loading.tsx 추가):
(app)/
├── layout.tsx        ('use client')
├── loading.tsx       (Server Component - 기본값)
├── error.tsx         ('use client')
├── items/
│   └── new/
│       └── page.tsx  ('use client')

→ Next.js: "loading.tsx가 있네? Suspense 경계를 설정해야겠다"
→ Next.js: "Suspense를 위해 페이지를 미리 렌더링해보자"
→ Prerendering 시도!

Step 2: Prerendering 과정에서 충돌

// items/new/page.tsx
'use client';

export default function NewItemPage() {
  const handleSubmit = async (data) => {
    // API 호출 로직
  };

  return (
    <ItemForm
      onSubmit={handleSubmit}  // ← 함수!
    />
  );
}

Prerendering 시 내부 동작:

1. Next.js가 서버에서 NewItemPage 렌더링 시도

2. React가 컴포넌트 트리를 직렬화(serialize)하려고 함
   - HTML로 변환하기 위해 모든 props를 문자열로 변환 필요

3. onSubmit={handleSubmit}를 직렬화하려고 함
   - handleSubmit은 함수(function)
   - 함수는 직렬화 불가능!

4. 에러 발생:
   "Event handlers cannot be passed to Client Component props"

Step 3: 'use client'가 있는데 왜?

흔한 오해: "'use client'가 있으면 서버에서 실행 안 되는 거 아니야?"

실제 동작:

'use client' 의미:
- "이 컴포넌트의 JavaScript는 클라이언트에서 실행해줘"
- "하지만 초기 HTML은 서버에서 생성할 수 있어"

Prerendering 시:
1. 서버에서 React 컴포넌트 트리 생성
2. 함수, 이벤트 핸들러는 placeholder로 대체
3. HTML 생성
4. 클라이언트에서 hydration 시 실제 함수 연결

문제:
- onSubmit={handleSubmit}에서 handleSubmit을 직렬화하려다 실패
- 'use client'여도 prerendering 시 서버에서 컴포넌트 평가는 발생

2.4 핵심 원인 요약

loading.tsx 추가
    ↓
Next.js가 Suspense 경계 설정 필요로 인식
    ↓
해당 route의 prerendering 시도
    ↓
'use client' 컴포넌트도 서버에서 평가
    ↓
이벤트 핸들러(함수) 직렬화 시도
    ↓
함수는 직렬화 불가 → 에러!

3. 해결 과정

3.1 문제 커밋 특정 (git bisect 대신 수동)

# 현재 상태 (에러 발생)
git log --oneline -5
# abc1234 feat(item): implement Server Actions
# def5678 feat: add loading, error, and not-found UI patterns ← 의심
# ghi9012 docs: add PR review results
# jkl3456 chore(seed): add test account
# mno7890 feat: add keyboard shortcuts

# 한 커밋씩 checkout하며 빌드 테스트
git checkout mno7890
pnpm build  # ✅ 성공!

git checkout def5678
pnpm build  # ❌ 실패!

# → def5678 커밋에서 문제 발생 확인

3.2 시도한 해결책들

시도 1: 페이지에 dynamic = 'force-dynamic' 추가 (실패)

// items/new/page.tsx
'use client';

export const dynamic = 'force-dynamic';  // ← 추가

export default function NewItemPage() {
  // ...
}

결과: 에러 동일
원인: 'use client' 파일에서 export const dynamic무시됨

Next.js 문서:
"Route Segment Config options are only applicable to Server Components"

시도 2: layout.tsx에 dynamic 추가 (실패)

// (app)/layout.tsx
'use client';

export const dynamic = 'force-dynamic';  // ← 추가

export default function AppLayout({ children }) {
  // ...
}

결과: 에러 동일
원인: layout.tsx도 'use client'여서 동일하게 무시됨

3.3 최종 해결책: Server/Client 분리

핵심 아이디어:

  • export const dynamic은 Server Component에서만 작동
  • layout.tsx를 Server Component로 만들어야 함
  • Client 로직은 별도 파일로 분리

4. 해결 방법

4.1 파일 구조 변경

Before:
(app)/
├── layout.tsx          ('use client' - 모든 로직 포함)
├── loading.tsx
├── error.tsx
└── ...

After:
(app)/
├── layout.tsx          (Server Component - wrapper만)
├── AppLayoutClient.tsx  ('use client' - 실제 로직)
├── loading.tsx
├── error.tsx
└── ...

4.2 코드 변경

Before: layout.tsx (전체가 Client Component)

// (app)/layout.tsx
'use client';

import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { Sidebar, Header } from '@/components/layout';

export default function AppLayout({ children }: { children: React.ReactNode }) {
  const { data: session } = useSession();
  const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
  const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);

  return (
    <div className="min-h-screen bg-gray-50">
      {isMobileSidebarOpen && (
        <div
          className="fixed inset-0 bg-black/20"
          onClick={() => setIsMobileSidebarOpen(false)}  // ← 이벤트 핸들러
        />
      )}
      <Sidebar
        onToggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}  // ← 이벤트 핸들러
        onMobileClose={() => setIsMobileSidebarOpen(false)}  // ← 이벤트 핸들러
      />
      <Header
        onMobileMenuOpen={() => setIsMobileSidebarOpen(true)}  // ← 이벤트 핸들러
      />
      <main>{children}</main>
    </div>
  );
}

After: layout.tsx (Server Component - wrapper)

// (app)/layout.tsx
import AppLayoutClient from './AppLayoutClient';

export const dynamic = 'force-dynamic';  // ✅ 이제 작동!

export default function AppLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <AppLayoutClient>{children}</AppLayoutClient>;
}

After: AppLayoutClient.tsx (Client Component - 로직)

// (app)/AppLayoutClient.tsx
'use client';

import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { Sidebar, Header } from '@/components/layout';
import { ToastProvider } from '@/components/ui/Toast';

function AppLayoutContent({ children }: { children: React.ReactNode }) {
  const { data: session } = useSession();
  const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
  const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);

  const userRole = session?.user?.role || 'VIEWER';
  const userName = session?.user?.name || '사용자';
  const userEmail = session?.user?.email || '';

  return (
    <div className="min-h-screen bg-gray-50">
      {isMobileSidebarOpen && (
        <div
          className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 lg:hidden"
          onClick={() => setIsMobileSidebarOpen(false)}
        />
      )}
      <Sidebar
        userRole={userRole}
        userName={userName}
        userEmail={userEmail}
        isCollapsed={isSidebarCollapsed}
        isMobileOpen={isMobileSidebarOpen}
        onToggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
        onMobileClose={() => setIsMobileSidebarOpen(false)}
      />
      <div className={`transition-all duration-300 ${
        isSidebarCollapsed ? 'lg:pl-[72px]' : 'lg:pl-64'
      }`}>
        <Header
          userName={userName}
          userEmail={userEmail}
          userRole={userRole}
          onMobileMenuOpen={() => setIsMobileSidebarOpen(true)}
        />
        <main className="p-4 lg:p-6">{children}</main>
      </div>
    </div>
  );
}

export default function AppLayoutClient({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ToastProvider>
      <AppLayoutContent>{children}</AppLayoutContent>
    </ToastProvider>
  );
}

4.3 빌드 결과 비교

# Before (에러)
pnpm build

Error occurred prerendering page "/items/new"
Error: Event handlers cannot be passed to Client Component props.

# After (성공)
pnpm build

Route (app)                              Size     First Load JS
├ ƒ /records                             ...      ...
├ ƒ /dashboard                           ...      ...
├ ƒ /items                               ...      ...
├ ƒ /items/[id]                          ...      ...
├ ƒ /items/new                           ...      ...
├ ○ /login                               ...      ...

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

변화:

  • /items/new: ○ (Static)ƒ (Dynamic)
  • 앱 내 모든 페이지가 동적 렌더링으로 변경

5. 핵심 개념 정리

5.1 'use client' vs Server Component

구분 Server Component Client Component
선언 기본값 (선언 불필요) 'use client' 필요
실행 위치 서버에서만 서버 + 클라이언트
상태(useState) ❌ 사용 불가 ✅ 사용 가능
이벤트 핸들러 ❌ 사용 불가 ✅ 사용 가능
hooks ❌ 대부분 불가 ✅ 모두 사용 가능
DB 직접 접근 ✅ 가능 ❌ 불가
export const dynamic ✅ 적용됨 ❌ 무시됨

5.2 dynamic = 'force-dynamic'의 의미

// Route Segment Config
export const dynamic = 'auto'           // 기본값: Next.js가 판단
export const dynamic = 'force-dynamic'  // 항상 동적 렌더링
export const dynamic = 'force-static'   // 항상 정적 생성
export const dynamic = 'error'          // 동적 함수 사용 시 에러

force-dynamic 효과:

  1. 빌드 시 prerendering 건너뜀
  2. 모든 요청마다 서버에서 렌더링
  3. cookies(), headers() 등 동적 함수 사용 가능

5.3 Prerendering 트리거 조건

Next.js가 prerendering을 시도하는 경우:

1. 페이지에 동적 함수(cookies, headers 등)가 없을 때
2. 페이지에 dynamic = 'force-static' 또는 'auto'일 때
3. loading.tsx, error.tsx가 있어 Suspense 경계가 필요할 때
4. generateStaticParams()가 있을 때

5.4 Server/Client 분리 패턴

언제 사용?
- layout에서 `export const dynamic` 설정이 필요할 때
- layout에 useState, useEffect 등 Client 로직이 있을 때
- loading.tsx/error.tsx 추가 후 prerender 에러 발생 시

구조:
layout.tsx (Server)
  └── LayoutClient.tsx (Client)
        └── 실제 UI 및 상태 관리

6. 예방 및 베스트 프랙티스

6.1 layout 설계 시 고려사항

// ✅ 권장: Server/Client 분리
// layout.tsx
export const dynamic = 'force-dynamic';
export default function Layout({ children }) {
  return <LayoutClient>{children}</LayoutClient>;
}

// ❌ 비권장: layout 전체를 Client로
'use client';
export default function Layout({ children }) {
  // dynamic 설정 무시됨
}

6.2 loading.tsx 추가 시 체크리스트

□ 빌드 테스트 (pnpm build) 실행
□ prerender 에러 발생 여부 확인
□ 에러 발생 시 layout Server/Client 분리 검토
□ dynamic = 'force-dynamic' 필요 여부 판단

6.3 디버깅 팁

# 1. 문제 커밋 특정
git log --oneline -10
git checkout <commit-hash>
pnpm build

# 2. 어떤 페이지에서 에러인지 확인
# 에러 메시지에 페이지 경로 표시됨

# 3. 해당 페이지의 컴포넌트 트리 확인
# - 어디서 이벤트 핸들러가 props로 전달되는지
# - layout → page → component 순으로 추적

# 4. dynamic 설정이 적용되는지 확인
# Server Component인지 확인 ('use client' 없어야 함)

7. 참고 자료


8. 다음 단계

Next.js App Router의 렌더링 패턴을 이해했다면, 더 복잡한 Server/Client 분리 패턴도 살펴보세요.

시리즈 목차:

  1. Next.js "Event handlers cannot be passed to Client Component" 에러 해결 ← 현재 글
  2. Next.js App Router에서 prerender 에러 해결: Server/Client 컴포넌트 분리

9. FAQ (자주 묻는 질문)

Q: 'use client'를 사용하면 서버에서 실행되지 않나요?

A: 아닙니다. 'use client'는 "JavaScript를 클라이언트에서 실행하라"는 의미이지만, 초기 HTML 생성을 위해 서버에서 컴포넌트가 평가될 수 있습니다. Prerendering 시 이벤트 핸들러 직렬화 문제가 발생할 수 있습니다.

Q: loading.tsx를 추가하면 왜 에러가 발생하나요?

A: loading.tsx가 추가되면 Next.js가 Suspense 경계를 설정하고 해당 route의 prerendering을 시도합니다. 이때 'use client' 컴포넌트의 이벤트 핸들러를 직렬화하려다 에러가 발생합니다.

Q: dynamic = 'force-dynamic'이 왜 작동하지 않나요?

A: Route Segment Config (dynamic, revalidate 등)는 Server Component에서만 적용됩니다. 'use client' 파일에서는 이 설정이 무시됩니다.

Q: Server/Client 분리가 필요한 경우는 언제인가요?

A: layout에서 export const dynamic 설정이 필요하거나, loading.tsx/error.tsx 추가 후 prerender 에러가 발생할 때, 그리고 layout에 useState 등 Client 로직이 있을 때 분리가 필요합니다.