Next.js App Router: Server/Client Component 완벽 이해하기 - shadcn/ui Carousel 최적화 여정

최종욱·2025년 10월 15일

🤔 시작: 왜 'use client'를 또 써야 하지?

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 Module의 내부를 "들여다볼 수 없다"

1. Next.js의 Server/Client 경계

Server Component → Client Component (O)  // 참조 가능
Server Component → Client Component 내부 export (X)  // 접근 불가

2. 왜 에러가 발생했나?

// 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를 개별적으로 사용할 수 없습니다!

3. 실행 환경의 차이

서버 (Node.js 환경)
  ↓
  buy-section.tsx를 실행
  ↓
  <Carousel>를 만나면?
  → "아, 이건 Client Component구나!" ✅
  → Placeholder 생성
  ↓
  <CarouselContent>를 만나면?
  → ❌ "어? 이게 뭐지? carousel.tsx 파일 내부를 봐야 하는데..."
  → ❌ "하지만 이건 Client 모듈이라 내부를 볼 수 없어!"

4. 번들링 관점에서 보기

빌드 시:

Server Bundle (서버용)          Client Bundle (브라우저용)
├─ buy-section.tsx            ├─ carousel.tsx (전체!)
│  └─ Carousel 참조만           │  ├─ Carousel
│     (실제 코드는 X)            │  ├─ CarouselContent
                               │  ├─ CarouselItem
                               │  └─ useEmblaCarousel
                               │  └─ React hooks
                               │  └─ 브라우저 API

Server에서 CarouselContent를 직접 사용하려면 carousel.tsx 파일을 파싱해야 하지만, 이 파일은 브라우저 전용 코드를 포함하고 있어 Server 번들에 넣을 수 없습니다.


🎨 shadcn/ui가 Next.js와 잘 맞는 진짜 이유

처음에는 "Carousel을 쓰는 곳마다 'use client'를 써야 하는데 왜 Next.js랑 잘 맞다고 하지?"라는 의문이 들었습니다.

오해: "모든 것을 Server Component로"가 아닙니다

Next.js의 철학은:

  • 기본은 Server Component
  • 인터랙티브한 부분만 Client Component
  • 적재적소에 사용

Carousel은 당연히 Client Component여야 합니다

// 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 등)

shadcn/ui의 진짜 장점

다른 UI 라이브러리:

import { Carousel } from 'some-ui-library';
// 라이브러리 내부가 블랙박스
// 최적화 불가능

shadcn/ui:

// 코드를 직접 소유하므로:
// 1. 필요 없는 부분 제거 가능
// 2. Server/Client 경계 조정 가능
// 3. 프로젝트에 맞게 커스터마이징
// 4. 번들 사이즈 최적화 가능

🚀 해결책 1: 섹션 전체를 Client Component로

가장 간단한 해결책:

// 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
  • ❌ 불필요한 JavaScript 번들 증가

더 나은 방법은 필요한 부분만 Client Component로 분리하는 것입니다.

Step 1: 범용적인 MobileCarousel 컴포넌트 생성

// 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>
  );
};

Step 2: Server Component에서 사용

// 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% 감소! 🚀

🚨 또 다른 문제: 함수를 prop으로 전달할 수 없다

처음 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>
      }
    }
  ]
}

Server → Client 경계에서 전달 가능한 것들

// ❌ 전달 불가능
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>

💡 해결책: Composition Pattern

// ❌ 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 페이지는 왜 에러가 안 났지?

흥미로운 발견: 원래 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>

왜 작동했을까?

JSX는 "함수 호출"이 아니라 "객체"입니다

// 이렇게 쓰면:
<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가 하는 일:

  • 컴포넌트를 "실행"하지 않습니다
  • "어떤 컴포넌트를 써야 하는지" 참조만 전달합니다
  • Next.js가 내부적으로 컴포넌트 참조를 ID로 변환합니다
// 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>

💡 핵심 요약

1. Server/Client 경계 규칙

✅ Server Component → Client Component (children 패턴)
❌ Server Component → Client Component 내부 exports
❌ Server Component → Client Component (함수 prop)
✅ Server Component → Client Component (직렬화 가능한 데이터 prop)

2. 전달 가능한 것

  • ✅ 문자열, 숫자, 객체, 배열 (직렬화 가능)
  • ✅ React Elements (<Component /> 형태)
  • ❌ 함수, 클래스, Symbol

3. SVG = 함수 = 전달 불가

// ❌ 이렇게 하지 말고:
<ClientComp icon={IconSvg} />

// ✅ 이렇게 하세요:
<ClientComp>
  <IconSvg />
</ClientComp>

4. shadcn/ui + Next.js = 완벽한 조합

  • 인터랙티브한 부분만 Client Component
  • 나머지 80-90%는 Server Component
  • 코드를 직접 소유해서 최적화 가능
  • 번들 크기 최소화

🚀 결론

Next.js App Router의 Server/Client Component 경계를 이해하는 것은 처음에는 복잡해 보이지만, 핵심 원칙을 이해하면:

  1. 적재적소에 Client Component 사용: 인터랙션이 필요한 부분만
  2. Composition Pattern 활용: 함수 대신 children으로 전달
  3. 범용 컴포넌트 추상화: MobileCarousel처럼 재사용 가능하게

이런 패턴을 따르면 성능이 뛰어나고 유지보수가 쉬운 Next.js 애플리케이션을 만들 수 있습니다!

최종 결과:

  • ✅ 코드 재사용 극대화
  • ✅ 번들 크기 60% 감소
  • ✅ 일관된 UX
  • ✅ Server Component 최대 활용

참고 자료:

profile
항상 “Why?”로 시작하는 프론트엔드 개발자

0개의 댓글