처음 Next.js App Router를 배웠을 때, 나는 거의 모든 컴포넌트 파일 맨 위에 'use client'를 붙였다. 이유는 단순했다. useState를 쓰거나, onClick을 달거나, 어디선가 에러가 나면 일단 'use client'를 붙이면 해결됐기 때문이다. 오류 메시지가 사라지고, 앱이 돌아갔다. 그게 맞는 방법인 줄 알았다.
그런데 어느 날 Lighthouse로 성능 점수를 측정해보니, JavaScript 번들 크기가 예상보다 훨씬 컸다. 데이터를 가져오는 컴포넌트, 헤더, 카드 레이아웃, 심지어 정적인 텍스트 컴포넌트도 전부 클라이언트 사이드로 동작하고 있었다. Next.js App Router가 왜 서버 컴포넌트를 기본값으로 만들었는지, 그제야 제대로 이해하기 시작했다.
서버 컴포넌트와 클라이언트 컴포넌트 — 왜 구분이 필요한가
React가 Server Components를 도입한 철학은 단순하다. "브라우저가 하지 않아도 될 일을 서버에서 끝내자." 예전 MPA(Multi-Page Application) 시절에는 서버가 HTML을 완성해서 내려줬다. SPA가 등장하면서 모든 것이 클라이언트로 넘어왔고, 초기 번들 크기가 폭발했다. Server Components는 그 중간 어딘가의 답이다.
핵심 개념은 이렇다. 서버 컴포넌트는 서버에서 렌더링되어 HTML만 클라이언트로 내려온다. 자바스크립트 코드 자체는 브라우저로 전송되지 않는다. 반면 클라이언트 컴포넌트는 브라우저에서 hydration이 일어나고, 상태와 이벤트를 처리한다.
App Router에서 'use client'를 적지 않으면 기본적으로 서버 컴포넌트다. 이 기본값이 의미하는 건, 웬만하면 서버 컴포넌트로 두는 게 낫다는 신호다.
Before: 'use client'를 남발한 코드
아래는 내가 처음 App Router로 작업하던 코드다. 데이터를 fetch해서 카드 목록을 보여주는 컴포넌트인데, 'use client'가 최상단에 붙어 있다.
'use client';
import { useEffect, useState } from 'react';
export default function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(data => setPosts(data));
}, []);
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
이 코드의 문제는 무엇인가? 이 컴포넌트는 인터랙션이 전혀 없다. 버튼도 없고, 입력도 없다. 그런데 클라이언트에서 useEffect로 데이터를 불러오니, 초기 렌더링 시 화면이 비어있다가 데이터가 도착하면 채워지는 깜빡임이 생긴다. 게다가 fetch 로직이 브라우저로 내려간다.
After: 서버 컴포넌트로 전환
서버 컴포넌트에서는 async/await를 컴포넌트 함수 자체에서 사용할 수 있다. 'use client'를 제거하고 다음처럼 바꾸면 된다.
// 'use client' 없음 → 기본적으로 서버 컴포넌트
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // ISR: 1시간마다 갱신
});
return res.json();
}
export default async function PostList() {
const posts = await getPosts();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
이 버전은 서버에서 데이터를 가져와 완성된 HTML을 클라이언트로 내려보낸다. 클라이언트 자바스크립트가 전혀 없다. 초기 로딩이 빠르고, 깜빡임도 없다. 그리고 API 키나 데이터베이스 접근 코드가 브라우저에 노출되지 않는다.
실전 원칙 1 — 인터랙션이 있어야만 'use client'
Next.js 서버 컴포넌트 vs 클라이언트 컴포넌트를 구분하는 가장 명확한 기준은 인터랙션의 유무다. useState, useReducer, onClick, onChange, onSubmit 같은 이벤트 핸들러가 필요한가? 그렇다면 'use client'. 그렇지 않다면 서버 컴포넌트로 충분하다.
실제로 한 페이지를 분석해보면 대부분의 컴포넌트는 데이터를 보여주기만 한다. 헤더, 푸터, 카드, 목록, 본문 — 이것들은 서버 컴포넌트가 어울린다. 검색창, 좋아요 버튼, 모달, 폼 — 이것들이 클라이언트 컴포넌트다.
실전 원칙 2 — 'use client' 경계를 가능한 한 잎 노드(leaf)로 밀어라
컴포넌트 트리에서 'use client'를 선언하면 그 하위 컴포넌트 전체가 클라이언트 번들에 포함된다. 따라서 인터랙션이 필요한 부분을 최대한 작은 단위의 컴포넌트로 분리하고, 그 컴포넌트에만 'use client'를 붙이는 게 yC88B;다.
// 나쁜 구조: 페이지 전체가 클라이언트 컴포넌트
'use client';
export default function ArticlePage({ article }) {
const [liked, setLiked] = useState(false);
return (
<article>
<h1>{article.title}</h1>
<p>{article.content}</p>
<button onClick={() => setLiked(!liked)}>좋아요</button>
</article>
);
}
// 좋은 구조: 좋아요 버튼만 클라이언트 컴포넌트
// LikeButton.tsx
'use client';
export function LikeButton() {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>좋아요</button>;
}
// ArticlePage.tsx (서버 컴포넌트)
import { LikeButton } from './LikeButton';
export default async function ArticlePage({ params }) {
const article = await getArticle(params.id);
return (
<article>
<h1>{article.title}</h1>
<p>{article.content}</p>
<LikeButton />
</article>
);
}
실yC804; yC6D0;칙 3 — 데이터 fetch는 서버 컴포넌트에서, 상태는 클라이언트에서
Next.js App Router에서 데이터를 가져오는 가장 좋은 패턴은 서버 컴포넌트에서 async/await로 직접 fetch하는 것이다. 이렇게 하면 워터폴(waterfall) 없이 병렬 데이터 fetch가 가능하고, API 키를 서버에서만 사용하게 된다.
클라이언트 컴포넌트에서 데이터가 필요하다면, 서버 컴포넌트에서 가져온 데이터를 props로 내려받거나, SWR/React Query를 활용해 클라이언트 캐시를 관리하는 방식이 적합하다.
실전 원칙 4 — useEffect로 데이터 fetch는 최후의 수단
App Router 이전 Pages Router 시절의 습관 중 하나가 useEffect 안에서 fetch를 하는 것이었다. 이 패턴은 App Router에서는 안티패턴에 가깝다. 초기 렌더링 시 빈 화면, 불필요한 클라이언트 번들, SEO 불이익이 따른다. 실시간 업데이트가 필요한 경우가 아니라면, 서버 컴포넌트 또는 서버 액션(Server Actions)으로 대체하는 것이 낫다.
실전 원칙 5 — 브라우저 API, 서드파티 라이브러리는 클라이언트 컴포넌트로
window, document, localStorage에 접근하거나, 애니메이션 라이브러리(Framer Motion, GSAP), 차트 라이브러리처럼 브라우저 환경을 전제로 하는 코드는 서버 컴포넌트에서 실행할 수 없다. 이 경우에는 명확하게 'use client'를 붙이고, 필요하다면 dynamic import와 ssr: false를 조합해서 사용한다.
결국, 기본값을 믿어라
Next.js가 App Router에서 서버 컴포넌트를 기본값으로 설정한 건 이유가 있다. 대부분의 컴포넌트는 서버에서 렌더링되는 게 더 빠르고, 안전하고, 효율적이다. 'use client'는 예외적인 경우에 쓰는 탈출구다.
Next.js 서버 컴포넌트와 클라이언트 컴포넌트를 구분하는 기준을 한 줄로 요약하면 이렇다. "이 컴포넌트가 브라우저에서 돌아야 하는 이유가 있는가?" 이유가 없다면, 서버 컴포넌트로 두면 된다.
처음엔 낯설어도, 이 원칙에 익숙해지면 번들 크기가 줄고 성능이 눈에 띄게 달라진다. 그리고 무엇보다, 왜 그렇게 만들었는지 스스로 설명할 수 있는 코드가 된다.
댓글