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 효과:
- 빌드 시 prerendering 건너뜀
- 모든 요청마다 서버에서 렌더링
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. 참고 자료
- Next.js - Route Segment Config
- Next.js - Prerendering Error
- Next.js - Server and Client Components
- React - 'use client' Directive
8. 다음 단계
Next.js App Router의 렌더링 패턴을 이해했다면, 더 복잡한 Server/Client 분리 패턴도 살펴보세요.
시리즈 목차:
- Next.js "Event handlers cannot be passed to Client Component" 에러 해결 ← 현재 글
- 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 로직이 있을 때 분리가 필요합니다.