Next.js App Router로 프로젝트를 진행하면서 shadcn/ui의 Carousel 컴포넌트를 사용할 때 겪은 혼란과 그 해결 과정을 공유합니다.
// carousel.tsx - shadcn/ui 컴포넌트
'use client';
export const Carousel = ({ children }) => {
// ... carousel 로직
}
export const CarouselContent = ({ children }) => {
// ...
}
export const CarouselItem = ({ children }) => {
// ...
}
// buy-section.tsx
import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel';
export const BuySection = () => {
return (
<Carousel>
<CarouselContent>
<CarouselItem>...</CarouselItem>
</CarouselContent>
</Carousel>
);
};
이렇게 코드를 작성했더니 에러가 발생했습니다:
Error: Cannot access displayName.valueOf on the server.
You cannot dot into a client module from a server component.
첫 번째 의문: carousel.tsx에 이미 'use client'가 있는데, 왜 buy-section.tsx에도 써야 하는 걸까?
Server Component → Client Component (O) // 참조 가능
Server Component → Client Component 내부 export (X) // 접근 불가
// Server Component에서:
import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel';
// 이건 사실 이런 의미:
import * as CarouselModule from './carousel'; // Client 모듈
const Carousel = CarouselModule.Carousel; // ❌ 내부 접근
const CarouselContent = CarouselModule.CarouselContent; // ❌ 내부 접근
const CarouselItem = CarouselModule.CarouselItem; // ❌ 내부 접근
Server는 Client 모듈의 여러 export를 개별적으로 사용할 수 없습니다!
서버 (Node.js 환경)
↓
buy-section.tsx를 실행
↓
<Carousel>를 만나면?
→ "아, 이건 Client Component구나!" ✅
→ Placeholder 생성
↓
<CarouselContent>를 만나면?
→ ❌ "어? 이게 뭐지? carousel.tsx 파일 내부를 봐야 하는데..."
→ ❌ "하지만 이건 Client 모듈이라 내부를 볼 수 없어!"
빌드 시:
Server Bundle (서버용) Client Bundle (브라우저용)
├─ buy-section.tsx ├─ carousel.tsx (전체!)
│ └─ Carousel 참조만 │ ├─ Carousel
│ (실제 코드는 X) │ ├─ CarouselContent
│ ├─ CarouselItem
│ └─ useEmblaCarousel
│ └─ React hooks
│ └─ 브라우저 API
Server에서 CarouselContent를 직접 사용하려면 carousel.tsx 파일을 파싱해야 하지만, 이 파일은 브라우저 전용 코드를 포함하고 있어 Server 번들에 넣을 수 없습니다.
처음에는 "Carousel을 쓰는 곳마다 'use client'를 써야 하는데 왜 Next.js랑 잘 맞다고 하지?"라는 의문이 들었습니다.
Next.js의 철학은:
// Carousel의 본질
- 사용자가 슬라이드를 넘김 (onClick)
- 애니메이션 (useEffect)
- 현재 위치 추적 (useState)
- 드래그 이벤트 (onDrag)
이런 기능은 브라우저에서만 가능합니다!
Traditional React App (모두 Client):
└─ client.js (2.5MB)
├─ React
├─ All components
├─ All libraries
└─ Your code
Next.js + shadcn/ui (적재적소):
├─ Server rendered HTML (대부분)
└─ client.js (200KB) ← 90% 감소! 🚀
└─ Interactive parts만 (Carousel 등)
다른 UI 라이브러리:
import { Carousel } from 'some-ui-library';
// 라이브러리 내부가 블랙박스
// 최적화 불가능
shadcn/ui:
// 코드를 직접 소유하므로:
// 1. 필요 없는 부분 제거 가능
// 2. Server/Client 경계 조정 가능
// 3. 프로젝트에 맞게 커스터마이징
// 4. 번들 사이즈 최적화 가능
가장 간단한 해결책:
// buy-section.tsx
'use client';
import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel';
export const BuySection = () => {
return (
<div>
<GuideText /> {/* 같이 Client가 됨 */}
<GuideText />
<Carousel>
<CarouselContent>
<CarouselItem>...</CarouselItem>
</CarouselContent>
</Carousel>
</div>
);
};
장점:
단점:
더 나은 방법은 필요한 부분만 Client Component로 분리하는 것입니다.
// components/common/mobile-carousel.tsx
'use client';
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '@/components/ui/carousel';
import { ReactNode, Children } from 'react';
import type { EmblaOptionsType } from 'embla-carousel';
interface MobileCarouselProps {
children: ReactNode;
itemClassName?: string;
contentClassName?: string;
carouselClassName?: string;
setApi?: (api: CarouselApi) => void;
opts?: EmblaOptionsType;
orientation?: 'horizontal' | 'vertical';
}
export const MobileCarousel = ({
children,
itemClassName = 'basis-auto',
contentClassName = 'md:-ml-6 lg:-ml-6',
carouselClassName = 'w-full md:hidden lg:hidden',
setApi,
opts,
orientation = 'horizontal'
}: MobileCarouselProps) => {
const items = Children.toArray(children);
return (
<Carousel
className={carouselClassName}
setApi={setApi}
opts={opts}
orientation={orientation}
>
<CarouselContent className={contentClassName}>
{items.map((child, index) => (
<CarouselItem key={index} className={itemClassName}>
{child}
</CarouselItem>
))}
</CarouselContent>
</Carousel>
);
};
// buy-section.tsx - Server Component! ✅
import { MobileCarousel } from '@/components/common/mobile-carousel';
export const BuySection = () => {
return (
<div>
<GuideText /> {/* Server ✅ */}
<GuideText /> {/* Server ✅ */}
{/* Desktop: 정적 그리드 - Server ✅ */}
<div className="hidden lg:flex">
{BUY_STEPS.map(step => <StepCard key={step.title} {...step} />)}
</div>
{/* Mobile: Carousel - Client (필요한 부분만!) ✅ */}
<MobileCarousel>
{BUY_STEPS.map(step => <StepCard key={step.title} {...step} />)}
</MobileCarousel>
</div>
);
};
Before (전체 Client):
├─ buy-section.tsx (전체)
├─ guide-text.tsx
├─ step-card.tsx
└─ carousel.tsx
Total: ~50-60KB
After (필요한 부분만 Client):
├─ mobile-carousel.tsx (작은 wrapper만)
└─ carousel.tsx
Total: ~20-25KB
💡 약 60% 감소! 🚀
처음 MobileCarousel을 만들 때 이런 시도를 했습니다:
// buy-section.tsx - Server Component
const BUY_STEPS = [
{
icon: StepOneIcon, // SVG 컴포넌트
title: 'STEP 01',
description: '공고'
}
];
<MobileCarousel steps={BUY_STEPS} /> // ❌ 에러!
Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".
SVG 파일의 정체:
// SVG를 import하면:
import StepOneIcon from '@/assets/about/step1-icon.svg';
// 이건 사실 React 컴포넌트 = 함수입니다!
const StepOneIcon = (props) => {
return (
<svg width="24" height="24" {...props}>
<path d="M..." />
</svg>
);
};
문제의 원인:
// Server → Client로 전송하려는 내용:
{
steps: [
{
icon: function StepOneIcon(props) { // ❌ 함수를 직렬화할 수 없음!
return <svg>...</svg>
}
}
]
}
// ❌ 전달 불가능
const MyComponent = () => { /* ... */ }; // 함수
const handler = () => {}; // 함수
const icon = SomeIcon; // React Component (함수)
<ClientComp
component={MyComponent} // ❌
onClick={handler} // ❌
icon={icon} // ❌
/>
// ✅ 전달 가능
const data = { title: "Hi", count: 10 }; // 직렬화 가능한 데이터
const text = "Hello"; // 문자열
const jsx = <Icon />; // 렌더링된 React Element
<ClientComp
data={data} // ✅
text={text} // ✅
>
<Icon /> {/* ✅ children으로 전달 */}
</ClientComp>
// ❌ Before: 함수를 prop으로 전달
const steps = [{ icon: StepOneIcon }];
<MobileCarousel steps={steps} />
// ✅ After: children으로 렌더링된 Element 전달
<MobileCarousel>
{steps.map(step => {
const Icon = step.icon; // Server에서 함수 사용
return (
<div key={step.title}>
<StepCard>
<Icon /> {/* Server에서 렌더링 → Element로 전달 */}
</StepCard>
</div>
);
})}
</MobileCarousel>
1. 서버 실행:
Server Component가 실행됨
↓
const Icon = StepOneIcon;
<Icon /> ← 서버에서 실행, JSX로 변환
↓
결과: { type: 'svg', props: {...} } ← 직렬화 가능한 객체
2. 네트워크 전송:
JSON으로 직렬화
↓
{"type":"svg","props":{...}} ← 문자열
3. 클라이언트 수신:
브라우저가 받음
↓
React가 렌더링
흥미로운 발견: 원래 intro/page.tsx는 'use client' 없이도 Carousel을 사용하고 있었습니다.
// intro/page.tsx - 'use client' 없음 (Server Component)
<Carousel className="w-full pl-4 md:pl-6 lg:hidden">
<CarouselContent className="md:-ml-6">
{ABOUT_DATA.map((item) => (
<CarouselItem key={item.id} className="basis-auto">
<div>...</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
왜 작동했을까?
// 이렇게 쓰면:
<CarouselItem className="basis-auto">
<div>Hello</div>
</CarouselItem>
// 실제로는 이렇게 변환됩니다:
React.createElement(CarouselItem, { className: "basis-auto" },
React.createElement("div", {}, "Hello")
)
// 결과는 "객체"입니다:
{
type: CarouselItem, // 함수 참조
props: { className: "basis-auto", children: {...} }
}
Server가 하는 일:
// Server에서:
<Carousel>
<CarouselContent>
<div>Hello</div>
</CarouselContent>
</Carousel>
// ↓ Next.js가 변환
// Client로 전송:
{
"type": "ClientComponent#carousel-123", // ← 참조 ID
"props": {
"children": {
"type": "ClientComponent#carousel-124", // ← 참조 ID
"props": {
"children": { "type": "div", "props": { "children": "Hello" } }
}
}
}
}
| 패턴 | 작동 여부 | 이유 |
|---|---|---|
<ClientComp><ChildClientComp /></ClientComp> | ✅ | 컴포넌트 참조를 children으로 전달 |
<ClientComp component={ChildClientComp} /> | ❌ | 컴포넌트를 prop으로 직접 전달 |
<ClientComp><Icon /></ClientComp> | ✅ | JSX Element로 변환되어 전달 |
<ClientComp icon={Icon} /> | ❌ | 함수 자체를 prop으로 전달 |
<ClientComp data={{name: "John"}} /> | ✅ | 직렬화 가능한 데이터 |
<ClientComp onClick={() => {}} /> | ❌ | 함수 전달 불가 |
src/
├─ components/
│ └─ common/
│ └─ mobile-carousel.tsx 🎨 범용 컴포넌트 (Client)
│
├─ app/
├─ about/
│ ├─ intro/
│ │ └─ page.tsx ✅ Server Component
│ │
│ └─ collection-guide/
│ └─ _sections/
│ ├─ donation-section.tsx ✅ Server Component
│ └─ buy-section.tsx ✅ Server Component
│
└─ _sections/
├─ activity-section.tsx 🔵 Client (애니메이션 필요)
├─ highlight-section.tsx 🔵 Client (애니메이션 필요)
└─ timeline-section.tsx 🔵 Client (애니메이션 필요)
간단한 케이스:
// Server Component
<MobileCarousel>
{items.map(item => <Card key={item.id}>{item.name}</Card>)}
</MobileCarousel>
고급 케이스 (API 제어):
// Client Component
'use client';
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
<MobileCarousel
setApi={setApi}
opts={{ loop: true, align: 'center' }}
orientation="vertical"
>
{items.map(item => <Card key={item.id}>{item}</Card>)}
</MobileCarousel>
{/* 점 표시기 */}
<div>
{items.map((_, i) => (
<button
className={i === current ? 'active' : ''}
onClick={() => api?.scrollTo(i)}
/>
))}
</div>
✅ Server Component → Client Component (children 패턴)
❌ Server Component → Client Component 내부 exports
❌ Server Component → Client Component (함수 prop)
✅ Server Component → Client Component (직렬화 가능한 데이터 prop)
<Component /> 형태)// ❌ 이렇게 하지 말고:
<ClientComp icon={IconSvg} />
// ✅ 이렇게 하세요:
<ClientComp>
<IconSvg />
</ClientComp>
Next.js App Router의 Server/Client Component 경계를 이해하는 것은 처음에는 복잡해 보이지만, 핵심 원칙을 이해하면:
이런 패턴을 따르면 성능이 뛰어나고 유지보수가 쉬운 Next.js 애플리케이션을 만들 수 있습니다!
최종 결과:
참고 자료: