Next.js App Router + shadcn/ui: 서버와 클라이언트 컴포넌트 혼용 가이드

화면에 다음 오류 메시지가 나타납니다. Error: You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client".
이미 layout.tsx에 "use client"를 추가했는데 왜 여전히 오류가 날까요?
문서를 한참 살펴본 뒤에야 문제가 컴포넌트 import 경계에 있다는 것을 알게 됩니다. App Router의 Server Components와 Client Components 경계는 생각보다 훨씬 복잡합니다.
많은 개발자가 App Router로 마이그레이션할 때 실제로 이런 상황을 겪습니다. 프레임워크에서는 모든 컴포넌트가 기본적으로 Server Component이지만, shadcn/ui 같은 UI 라이브러리의 대부분은 Client Component가 필요합니다. 둘 사이의 경계를 어떻게 나눠야 할까요? 데이터는 어떻게 전달하고 성능은 어떻게 최적화해야 할까요?
이 글에서 이러한 문제를 명확하게 정리해 보겠습니다.
Server Components vs Client Components: 근본적인 차이
가장 기본적인 내용부터 살펴보겠습니다. App Router에서는 모든 컴포넌트가 기본적으로 Server Component입니다.
무슨 뜻일까요? page.tsx와 layout.tsx는 기본적으로 서버에서 렌더링되며 브라우저로 JavaScript 코드를 전송하지 않습니다.
Server Components로 할 수 있는 일
Server Components의 핵심 장점은 ‘데이터에 더 가깝다’는 점입니다.
// app/products/page.tsx - Server Component (기본값)
async function ProductsPage() {
// 컴포넌트 안에서 바로 데이터를 await
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // 1시간 캐시
}).then(res => res.json())
return (
<div>
{products.map(p => (
<div key={p.id}>{p.name} - ${p.price}</div>
))}
</div>
)
}
보시다시피 useEffect도 useState도 없이 바로 await로 데이터를 가져옵니다. 이것이 Server Components의 ‘async 컴포넌트’ 특성입니다.
적합한 사용 사례:
- 데이터 가져오기(fetch, 데이터베이스 쿼리)
- 백엔드 전용 API 접근(headers(), cookies())
- 용량이 큰 의존성 라이브러리(예: 100KB가 넘는 markdown 파서도 Server Component를 사용하면 브라우저 번들에 포함되지 않음)
- 민감한 정보 처리(API key가 프론트엔드에 노출되지 않음)
Client Components로 할 수 있는 일
Client Components는 우리가 익숙하게 사용해 온 ‘전통적인 React 컴포넌트’입니다. 파일 맨 위에 "use client"를 추가하면 됩니다.
// components/like-button.tsx
'use client'
import { useState } from 'react'
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
const [count, setCount] = useState(0)
const handleClick = () => {
setLiked(!liked)
setCount(prev => liked ? prev - 1 : prev + 1)
}
return (
<button onClick={handleClick}>
{liked ? '❤️' : '🤍'} {count}
</button>
)
}
적합한 사용 사례:
- 이벤트 처리(onClick, onChange, onSubmit)
- React hooks(useState, useEffect, useRef, useContext)
- 브라우저 API(localStorage, window, document)
- Context Provider
직관과 다른 점이 하나 있습니다. Client Components도 서버에서 HTML로 사전 렌더링됩니다. 이후 브라우저에서 hydrate되어 상호작용 기능이 활성화될 뿐입니다. 따라서 사용자가 처음 접속했을 때도 완전한 콘텐츠를 바로 볼 수 있으며 ‘JavaScript가 로드될 때까지 빈 화면’을 보게 되지는 않습니다.
핵심 규칙: 무엇이 무엇을 import할 수 있는가
가장 실수하기 쉬운 부분입니다.
규칙은 간단하지만 반대로 기억하는 사람이 많습니다.
- Server Component는 Client Component를 import할 수 있습니다. ✅
- Client Component는 Server Component를 import할 수 없습니다. ❌
- Server Component를 children으로 Client Component에 전달할 수 있습니다. ✅
세 번째 규칙은 조금 복잡해 보이지만 코드를 보면 쉽게 이해할 수 있습니다.
// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'
export default function Page() {
return (
<ClientContainer>
{/* ServerData를 children으로 전달 */}
<ServerData />
</ClientContainer>
)
}
// client-container.tsx
'use client'
export function ClientContainer({ children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children}
</div>
)
}
// server-data.tsx - Server Component
async function ServerData() {
const data = await fetch('/api/data').then(r => r.json())
return <div>{data.title}</div>
}
이 패턴은 자주 사용됩니다. Client Container가 상호작용 로직을 담당하고 Server Data가 데이터 가져오기를 담당합니다. 둘은 children으로 분리되며 서로 직접 import하지 않습니다.
shadcn/ui 통합: 왜 이렇게 ‘번거로울까’
shadcn/ui는 제가 가장 좋아하는 UI 라이브러리지만 App Router에서 사용하려면 몇 가지 요령이 필요합니다.
근본적인 이유는 shadcn/ui가 Radix UI를 기반으로 하며 대부분의 컴포넌트에서 React hooks를 사용하기 때문입니다.
Button, Dialog, Dropdown Menu 같은 컴포넌트 내부에는 useState나 useEffect가 있습니다. 따라서 반드시 Client Component여야 합니다.
잘못된 예: Server Component에서 shadcn/ui를 직접 사용
// ❌ 잘못된 예: Server Component에서 Client Component import
import { Button } from '@/components/ui/button'
async function ProductPage() {
const product = await fetchProduct()
return (
<div>
<h1>{product.name}</h1>
{/* 오류 발생: Button에는 "use client"가 필요함 */}
<Button onClick={() => addToCart(product.id)}>
Add to Cart
</Button>
</div>
)
}
오류 메시지는 Button이 useState를 사용하므로 "use client" 표시가 필요하다는 내용입니다.
올바른 방법 1: 상호작용 부분을 Client Component로 분리
가장 자주 사용하며 간단한 방법입니다.
// app/product/page.tsx - Server Component
import { ProductInfo } from './product-info'
import { AddToCartButton } from './add-to-cart-button'
async function ProductPage({ params }) {
const product = await fetchProduct(params.id)
return (
<div>
{/* Server Component: 데이터 표시 담당 */}
<ProductInfo product={product} />
{/* Client Component: 상호작용 담당 */}
<AddToCartButton productId={product.id} />
</div>
)
}
// product-info.tsx - Server Component
export function ProductInfo({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
)
}
// add-to-cart-button.tsx - Client Component
'use client'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
export function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false)
const handleAdd = async () => {
setLoading(true)
await addToCart(productId)
setLoading(false)
}
return (
<Button onClick={handleAdd} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</Button>
)
}
핵심은 상호작용이 필요한 부분만 별도의 리프 노드로 분리하고 나머지는 Server Component로 유지하는 것입니다.
올바른 방법 2: 합성 패턴(Server가 Client에 데이터 전달)
Client Component에 초기 데이터가 필요하다면 다음처럼 구성할 수 있습니다.
// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'
async function DashboardPage() {
const users = await fetchUsers() // Server Component에서 데이터 가져오기
return <DataTable data={users} /> // Client Component에 전달
}
// data-table.tsx - Client Component
'use client'
import { Table } from '@/components/ui/table'
import { useState } from 'react'
export function DataTable({ data }) {
const [selectedRows, setSelectedRows] = useState([])
return (
<Table>
{/* shadcn/ui Table 컴포넌트 */}
<TableBody>
{data.map(user => (
<TableRow
key={user.id}
selected={selectedRows.includes(user.id)}
onClick={() => toggleSelection(user.id)}
>
<TableCell>{user.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
이렇게 하면 Server Component의 데이터 가져오기 이점을 누리면서 Client Component의 상호작용 기능도 유지할 수 있습니다.
Context Provider는 어디에 배치해야 할까
또 하나 자주 나오는 질문은 전역 Context Provider(예: ThemeProvider, AuthProvider)를 어디에 배치해야 하느냐는 것입니다.
답은 반드시 Client Component에 두되 ‘가능한 한 깊게’ 배치하는 것입니다.
// app/layout.tsx - Server Component (root layout)
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* 여기에 Provider를 두지 마세요 */}
{children}
</body>
</html>
)
}
// app/providers.tsx - Client Component
'use client'
import { ThemeProvider } from 'next-themes'
import { AuthProvider } from './auth-context'
export function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
)
}
// app/dashboard/layout.tsx - Server Component
import { Providers } from '../providers'
export default function DashboardLayout({ children }) {
return (
<Providers>
{children}
</Providers>
)
}
왜 ‘깊게’ 배치해야 할까요? Provider로 감싼 모든 컴포넌트는 Client Component의 하위 트리가 되기 때문입니다. root layout에 배치하면 애플리케이션 전체가 클라이언트 렌더링을 강요받게 됩니다.
특정 라우트의 layout처럼 더 깊은 계층에 배치하면 Provider의 영향 범위를 최소화할 수 있습니다.
데이터 흐름: Server에서 Client로 전달하기
Props가 가장 간단하고 안정적인 방법입니다.
// Server Component에서 데이터 가져오기
const data = await fetchData()
// Client Component에 전달하기
<ClientComponent initialData={data} />
여기에 한 가지 성능 최적화 포인트가 있습니다. 바로 React.cache() 함수입니다.
여러 Server Component에서 같은 데이터가 필요하다면 cache를 사용해 중복 요청을 방지할 수 있습니다.
// lib/get-user.ts
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return await db.query('SELECT * FROM users WHERE id = ?', [id])
})
// app/layout.tsx
async function Layout() {
const user = await getUser('123') // 첫 번째 요청
return <header>{user.name}</header>
}
// app/page.tsx
async function Page() {
const user = await getUser('123') // 같은 인수이므로 중복 요청하지 않음
return <main>Welcome {user.name}</main>
}
cache는 단일 렌더링 주기 안에서 같은 인수로 호출한 작업을 자동으로 중복 제거합니다.
가장 자주 발생하는 오류 네 가지
오류 1: 상위 계층에서 “use client”를 남용
// ❌ app/layout.tsx에 "use client" 추가
'use client'
export default function Layout({ children }) {
return <div>{children}</div>
}
이렇게 하면 애플리케이션의 전체 하위 트리가 Client Component가 되어 Server Component의 성능상 이점을 잃습니다.
해결 방법: 실제로 상호작용이 필요한 컴포넌트에만 "use client"를 추가하고 리프 노드에 유지하세요.
오류 2: Server Component에서 hooks 사용
// ❌ Server Component에서 useState 사용
async function Page() {
const [count, setCount] = useState(0) // 오류 발생!
return <div>{count}</div>
}
해결 방법: hooks가 필요한 부분을 Client Component로 분리하세요.
오류 3: Client Component에서 headers()/cookies() 사용
// ❌ Client Component에서 서버 API 사용
'use client'
import { headers } from 'next/headers'
function UserProfile() {
const headersList = headers() // 오류 발생! Server Component에서만 사용 가능
return <div>...</div>
}
해결 방법: Server Component에서 데이터를 가져온 뒤 Client Component에 전달하세요.
// Server Component에서 headers 가져오기
async function Page() {
const userAgent = headers().get('user-agent')
return <UserProfile userAgent={userAgent} />
}
// Client Component에서 데이터 받기
'use client'
function UserProfile({ userAgent }) {
return <div>Browser: {userAgent}</div>
}
오류 4: 서드파티 컴포넌트에 “use client” 표시가 없음
// ❌ Server Component에서 표시되지 않은 서드파티 컴포넌트 import
import { AcmeCarousel } from 'acme-carousel'
async function Page() {
return <AcmeCarousel /> // 오류 발생! AcmeCarousel 내부에서 hooks 사용
}
해결 방법: wrapper를 만드세요.
// components/carousel-wrapper.tsx
'use client'
import { AcmeCarousel } from 'acme-carousel'
export function CarouselWrapper(props) {
return <AcmeCarousel {...props} />
}
// page.tsx - Server Component
import { CarouselWrapper } from './carousel-wrapper'
async function Page() {
return <CarouselWrapper /> // 정상 작동
}
성능 최적화 팁
마지막으로 몇 가지 실용적인 팁을 살펴보겠습니다.
1. Client Components는 리프 노드에 배치
이 규칙을 따르면 클라이언트 JavaScript를 70% 줄일 수 있습니다.
예를 들어 제품 목록 페이지라면 다음과 같이 나눕니다.
- 제품 그리드: Server Component
- 각 제품 카드: Server Component
- 카드의 수량 선택기: Client Component(유일한 상호작용 부분)
2. Suspense로 스트리밍 렌더링
// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'
export default function Page() {
return (
<div>
{/* 먼저 스켈레톤을 표시하고 데이터가 도착하면 교체 */}
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
{/* 보조 콘텐츠를 독립적으로 스트리밍 렌더링 */}
<Suspense fallback={<RecSkeleton />}>
<Recommendations />
</Suspense>
</div>
)
}
사용자는 먼저 페이지 프레임을 보고 데이터는 점진적으로 채워집니다. ‘모든 데이터가 로드될 때까지 기다리는 방식’보다 훨씬 나은 경험을 제공합니다.
3. fetch 캐시 전략
// 정적 데이터(빌드 시 가져오기)
await fetch(url, { cache: 'force-cache' })
// ISR: 1시간마다 재검증
await fetch(url, { next: { revalidate: 3600 } })
// 동적 데이터(요청마다 가져오기)
await fetch(url, { cache: 'no-store' })
캐시 전략을 적절하게 선택해 지나친 동적 렌더링을 피하세요.
정리
지금까지 설명한 내용의 핵심은 다음과 같습니다.
- 기본적으로 Server Components를 사용하고 상호작용이 필요할 때만 Client Components를 사용합니다.
- Server는 Client를 import할 수 있지만 Client는 Server를 import할 수 없습니다.
- children 또는 props로 데이터를 전달해 경계를 명확히 유지합니다.
- “use client”는 리프 노드에 추가하고 상위 계층에서 남용하지 않습니다.
- shadcn/ui 컴포넌트는 별도로 분리하고 Server Component 안에 섞어 넣지 않습니다.
App Router의 Server/Client 경계는 개발자가 ‘데이터에는 더 가깝고 브라우저에서는 더 멀리’ 있도록 설계되었습니다. 이 점을 이해하면 많은 혼란이 자연스럽게 해결됩니다.
간단한 페이지부터 연습해 보세요. 먼저 Server Component에서 데이터를 가져온 다음 상호작용 부분을 단계적으로 추가하면 됩니다. 오류가 나더라도 당황하지 마세요. 대부분 경계 문제이므로 컴포넌트 import 관계를 살펴보면 금방 원인을 찾을 수 있습니다.
시리즈: 이 글은 Next.js 완벽 가이드 시리즈의 46번째 글입니다. Next.js App Router를 학습하고 있다면 시리즈의 다른 글도 확인해 보세요. shadcn/ui의 더 많은 실전 팁은 Tailwind와 shadcn/ui 실전 가이드 시리즈를 추천합니다.
Server와 Client Components를 올바르게 함께 사용하기
Next.js App Router 프로젝트에 shadcn/ui를 통합하는 모범 사례
⏱️ Estimated time: 30 min
- 1
Step 1: 컴포넌트 유형 요구 사항 파악하기
각 컴포넌트에 상호작용 기능이 필요한지 판단합니다.
• 이벤트 처리(onClick, onChange)가 필요함 → Client Component
• React hooks(useState, useEffect)가 필요함 → Client Component
• 브라우저 API(localStorage, window)가 필요함 → Client Component
• 데이터만 표시하고 상호작용이 없음 → Server Component(기본값) - 2
Step 2: 상호작용 부분을 리프 노드로 분리하기
상호작용이 필요한 부분을 별도의 Client Component로 분리합니다.
• 새 파일을 만들고 맨 위에 'use client' 추가
• shadcn/ui 컴포넌트(Button, Dialog 등) import
• Server Component에서 이 Client Component import
• props로 데이터 전달 - 3
Step 3: 데이터 흐름 설계하기
Server Component가 데이터를 가져와 Client Component에 전달합니다.
• Server Component에서 async/await로 데이터 가져오기
• props로 Client Component에 전달하기
• 여러 곳에서 같은 데이터가 필요하면 React.cache()로 중복 요청 방지하기
• Client Component에서 headers()/cookies()를 직접 사용하지 않기 - 4
Step 4: Context Provider 배치하기
Provider는 반드시 Client Component여야 하지만 깊은 layout에 배치해야 합니다.
• providers.tsx를 만들고 'use client' 표시
• ThemeProvider, AuthProvider 등을 감싸기
• root layout이 아닌 특정 라우트의 layout.tsx에서 import
• Client Component 하위 트리 범위를 최소화하기 - 5
Step 5: 검증 및 최적화하기
컴포넌트 경계가 올바른지 확인합니다.
• 'use client'가 리프 노드에만 있는지 확인
• Client Component가 Server Component를 import하지 않는지 확인
• 비동기 컴포넌트를 Suspense로 감싸기
• 적절한 fetch 캐시 전략 설정하기
FAQ
왜 shadcn/ui 컴포넌트는 Client Component를 사용해야 하나요?
Server Component와 Client Component는 서로 import할 수 있나요?
여러 Server Component에서 같은 데이터를 중복 요청하지 않으려면 어떻게 해야 하나요?
Context Provider는 어디에 배치해야 하나요?
'useEffect는 Client Component에서만 사용할 수 있습니다' 오류가 발생하면 어떻게 해야 하나요?
컴포넌트를 Server와 Client 중 어느 쪽으로 만들어야 하는지 어떻게 판단하나요?
5분 읽기 · 게시일: 2026년 3월 31일 · 수정일: 2026년 9월 4일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js + Tailwind CSS 모범 사례: 설정부터 다크 모드까지 완벽 가이드(2025년판)
2025년 최신 Next.js + Tailwind CSS v4 실전 가이드입니다. 지나치게 긴 클래스명, 사용자 정의 테마 설정, 다크 모드 구현, 성능 최적화 문제를 해결하고 CSS를 500KB에서 50KB로 줄인 실제 사례와 전체 코드 예제를 소개합니다.
45편 중 40편
다음
NextAuth.js 입문 가이드: Credentials 로그인 설정과 세션 관리 완벽 정리
NextAuth.js 인증이 너무 복잡하게 느껴지나요? 이 글은 Credentials 로그인과 JWT vs Session 선택을 중심으로, 전체 코드 예제와 자주 겪는 문제를 함께 정리해 Next.js 개발자가 사용자 인증 시스템을 빠르게 시작할 수 있도록 돕습니다.
45편 중 42편



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