Next.js App Router의 핵심은 "모든 컴포넌트는 기본적으로 서버 컴포넌트(Server Components)다"라는 선언입니다. 과거 Pages Router 시절에는 모든 컴포넌트가 브라우저로 전송되어 하이드레이션(Hydration) 과정을 거쳐야 했지만, 이제는 서버에서만 실행되고 결과물인 HTML만 브라우저로 전달되는 컴포넌트를 만들 수 있게 되었습니다.
하지만 개발을 하다 보면 인터랙션(클릭, 상태 관리)이 필요한 시점이 오고, 자연스럽게 'use client'를 선언하게 됩니다. 이때 가장 중요한 역량은 "어디까지를 서버 영역으로 두고, 어디서부터 클라이언트 영역으로 나눌 것인가"를 결정하는 설계 능력입니다.
1. 서버 컴포넌트(RSC) vs 클라이언트 컴포넌트(RCC)
먼저 두 컴포넌트의 역할과 제약을 명확히 구분해야 합니다.
1.1 서버 컴포넌트 (Default)
- 실행 위치: 서버에서만 실행됩니다.
- 장점: 브라우저로 전송되는 자바스크립트 번들 크기가 줄어들며, 서버 자원(DB, 파일 시스템)에 직접 접근할 수 있습니다. 보안에 민감한 API 키 등을 숨기기에도 유리합니다.
- 제약: useState, useEffect 같은 훅이나 onClick 같은 이벤트 핸들러를 사용할 수 없습니다. 브라우저 API(window, localStorage)에도 접근할 수 없습니다.
1.2 클라이언트 컴포넌트 ('use client')
- 실행 위치: 서버에서 프리렌더링된 후 브라우저에서 하이드레이션됩니다.
- 장점: 사용자와의 인터랙션이 가능하고 브라우저 API를 모두 사용할 수 있습니다.
- 제약: 자바스크립트 번들 용량을 차지하며, 데이터 패칭 시 서버 컴포넌트보다 네트워크 비용이 더 발생할 수 있습니다.
2. 효율적인 경계 설계 전략 (Component Pattern)
성능을 최적화하기 위해서는 "클라이언트 컴포넌트를 컴포넌트 트리의 가장 말단으로 밀어내는 것"이 핵심입니다.
2.1 리프 컴포넌트 패턴 (Leaf Component Pattern)
페이지 전체를 'use client'로 만드는 대신, 상태가 필요한 작은 조각만 별도의 클라이언트 컴포넌트로 분리하세요.
- Bad: 검색 결과 페이지 전체를 클라이언트 컴포넌트로 선언 (검색 결과 리스트까지 모두 JS 번들에 포함됨)
- Good: 검색어 입력을 받는 SearchBar만 클라이언트 컴포넌트로 만들고, 결과 리스트는 서버 컴포넌트로 유지
// SearchPage.tsx (Server Component)
import SearchBar from './SearchBar'; // 'use client'
import SearchResults from './SearchResults'; // Server Component
export default function SearchPage() {
return (
<main>
<h1>검색 페이지</h1>
{/* 인터랙션이 필요한 부분만 클라이언트 영역 */}
<SearchBar />
{/* 데이터 렌더링은 서버 영역 */}
<SearchResults />
</main>
);
}
3. 서버 컴포넌트 안에 클라이언트 컴포넌트 넣기 (Composition)
서버 컴포넌트에서 클라이언트 컴포넌트를 불러오는 것은 쉽지만, 그 반대는 불가능하다고 생각하는 경우가 많습니다. 하지만 컴포넌트 합성(Composition)을 이용하면 클라이언트 컴포넌트 '내부'에 서버 컴포넌트를 배치할 수 있습니다.
// ClientWrapper.tsx ('use client')
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>토글</button>
{/* children으로 들어온 서버 컴포넌트는 여전히 서버에서 실행됨 */}
{isOpen && children}
</div>
);
}
// Page.tsx (Server Component)
export default function Page() {
return (
<ClientWrapper>
<HeavyServerComponent /> {/* 클라이언트 컴포넌트 안에서도 서버 컴포넌트의 이점 유지 */}
</ClientWrapper>
);
}
이 패턴을 활용하면 레이아웃이나 상태를 관리하는 래퍼(Wrapper)는 클라이언트 쪽에 두면서도, 실제 무거운 데이터 렌더링은 서버 컴포넌트로 처리하는 고도화된 설계가 가능해집니다.
4. 데이터 패칭의 최적 위치
App Router에서는 데이터 패칭을 서버 컴포넌트에서 수행하는 것을 강력히 권장합니다.
- 이유: 서버에서 직접 DB에 접근하므로 속도가 빠르고, 클라이언트-서버 간의 Waterfall 현상을 방지할 수 있습니다. 또한 패칭된 데이터를 하위 클라이언트 컴포넌트로 Props를 통해 쉽게 전달할 수 있습니다.
5. 실무 체크리스트: 언제 'use client'를 쓸까?
| 필요한 기능 | 서버 컴포넌트 | 클라이언트 컴포넌트 |
| 데이터 패칭 (Fetching Data) | ✅ | ❌ (권장 안 함) |
| 백엔드 자원 직접 접근 (DB, API) | ✅ | ❌ |
| 보안 정보 유지 (API Keys, Tokens) | ✅ | ❌ |
| 상태 관리 및 생명주기 (useState, useEffect) | ❌ | ✅ |
| 브라우저 API 사용 (window, localStorage) | ❌ | ✅ |
| 커스텀 훅 사용 (대부분의 UI 관련 훅) | ❌ | ✅ |
6. 결론: "서버가 먼저, 클라이언트는 나중에"
Next.js App Router 설계의 미학은 '자바스크립트 다이어트'에 있습니다. 최대한 많은 로직을 서버로 옮기고, 클라이언트는 오직 사용자의 손가락이 닿는 곳(Click, Type, Swipe)에만 집중하게 하세요.
오늘의 요약:
- 기본은 서버: 모든 파일은 일단 서버 컴포넌트로 시작하세요.
- 말단 분리: 상태가 필요한 부분만 핀셋으로 집어내듯 클라이언트 컴포넌트로 만드세요.
- 합성 활용: 클라이언트 컴포넌트가 서버 컴포넌트를 children으로 받을 수 있음을 잊지 마세요.
- 데이터는 상단 서버에서: 데이터 패칭은 가능한 트리 상단의 서버 컴포넌트에서 해결하세요.
'개발' 카테고리의 다른 글
| [TS] API 응답 데이터에 타입 안전성 입히기: Zod를 활용한 런타임 유효성 검사 (0) | 2026.04.28 |
|---|---|
| [TS] 제네릭(Generic)을 활용한 재사용 가능한 고차 컴포넌트(HOC) 설계 (0) | 2026.04.27 |
| [TS] Utility Types 정복하기: Pick, Omit, Partial로 중복 없는 타입 정의 (0) | 2026.04.27 |
| [TS] any 대신 unknown을 써야 하는 이유와 타입 가드 활용법 (0) | 2026.04.27 |
| [Architecture] 프론트엔드 클린 아키텍처: 도메인 로직과 UI 컴포넌트 분리하기 (0) | 2026.04.27 |