2026.05.28·8

Next.js의 use client는 CSR이 아니다

Next.js App Router의 use client가 SPA의 CSR과 어떻게 다른지, hydration과 data fetching 관점에서 정리합니다.

Next.js의 use client는 CSR이 아니다 대표 이미지

"use client"는 "이 컴포넌트는 CSR로만 렌더링한다"는 뜻이 아닙니다. 더 정확히는 서버 컴포넌트 트리 안에서 클라이언트가 이어받을 진입점을 선언하는 경계입니다.

들어가며

Next.js App Router를 처음 쓰면 "use client"를 SPA의 CSR과 비슷하게 이해하기 쉽습니다. 파일 맨 위에 "use client"를 붙였으니, 이 컴포넌트는 브라우저에서만 렌더링되고 서버 렌더링과는 무관하다고 생각하는 식입니다.

하지만 실제 메커니즘은 다릅니다. App Router의 초기 진입에서는 Server Component뿐 아니라 Client Component도 HTML을 만들기 위한 prerender 과정에 참여합니다. 브라우저는 먼저 서버가 만든 HTML을 받아 빠르게 화면을 볼 수 있고, 이후 클라이언트 JavaScript가 로드되면 해당 Client Component가 hydration되어 이벤트 핸들러, 상태 변경, 브라우저 API 사용 같은 상호작용이 가능해집니다.

즉 "use client"는 "CSR로 바꾼다"보다 "여기부터는 클라이언트 번들과 hydration이 필요한 경계다"에 가깝습니다.

이 차이를 제대로 이해하면 App Router에서 데이터를 어디서 가져올지, 로딩 UI를 어디에 둘지, 어떤 컴포넌트에만 "use client"를 붙일지 판단하기가 훨씬 쉬워집니다.

먼저 용어를 나눠야 합니다

SPA는 Single-Page Application의 줄임말입니다. 하나의 HTML 문서 안에서 JavaScript가 라우팅과 화면 전환을 처리하고, 필요한 데이터를 브라우저에서 요청해 화면을 갱신하는 애플리케이션 구조를 가리킵니다.

CSR은 Client-Side Rendering의 줄임말입니다. 화면을 구성하는 주요 렌더링 작업이 브라우저에서 실행된다는 뜻입니다. 엄격한 SPA에서는 보통 index.html 같은 빈 껍데기를 먼저 받고, JavaScript가 로드된 뒤 현재 URL에 맞는 라우트와 컴포넌트를 실행해 화면을 만듭니다.

여기서 한 가지는 바로잡아야 합니다. 현대 SPA가 항상 "모든 스크립트"를 처음부터 전부 내려받는 것은 아닙니다. Vite, Webpack, Next.js 같은 도구는 route 단위 code splitting을 할 수 있습니다. 다만 엄격한 CSR 구조에서는 초기 화면을 의미 있게 만들기 위해 필요한 JavaScript가 브라우저에서 먼저 실행되어야 한다는 점이 핵심입니다.

반면 App Router의 "use client"는 렌더링 방식을 CSR로 통째로 바꾸는 스위치가 아닙니다. React Server Components 구조 안에서 클라이언트 컴포넌트의 진입점을 표시하는 지시어입니다. 이 파일에서 import하는 하위 모듈과 자식 컴포넌트는 클라이언트 번들에 포함되고, 서버에서 클라이언트로 넘기는 props는 직렬화 가능한 값이어야 합니다.

Keypoint

"use client"는 페이지 전체를 SPA처럼 만드는 표시가 아닙니다.

서버가 먼저 그릴 수 있는 화면 위에 클라이언트 상호작용을 붙일 위치를 표시하는 경계

입니다.

use client는 초기 HTML을 포기하지 않습니다

Next.js App Router의 초기 로드에서는 대략 이런 일이 일어납니다.

  1. 서버에서 Server Component가 React Server Component Payload로 렌더링됩니다.
  2. Client Component와 RSC Payload를 사용해 초기 HTML이 prerender됩니다.
  3. 브라우저는 HTML을 받아 먼저 화면을 보여 줍니다.
  4. RSC Payload로 서버/클라이언트 컴포넌트 트리를 맞춥니다.
  5. Client Component에 필요한 JavaScript가 로드되면 hydration이 일어나고 상호작용이 가능해집니다.

여기서 hydration은 이미 만들어진 HTML에 React가 이벤트 핸들러와 상태 로직을 연결하는 과정입니다. 버튼이 화면에 보이는 것과 버튼을 눌렀을 때 상태가 바뀌는 것은 다른 단계입니다. 화면은 HTML로 먼저 보일 수 있지만, 클릭 이벤트는 hydration 이후에 제대로 동작합니다.

예를 들어 검색 입력이 있는 글 목록을 생각해 보겠습니다. 목록 데이터는 서버에서 먼저 읽고, 검색어 입력과 필터링만 클라이언트에 맡길 수 있습니다.

// app/posts/page.tsx
import { getPosts } from "@/data/posts";
import { PostExplorer } from "./post-explorer";
 
export default async function Page() {
  // 첫 화면에 필요한 데이터는 서버에서 먼저 준비합니다.
  const posts = await getPosts();
 
  return <PostExplorer initialPosts={posts} />;
}
// app/posts/post-explorer.tsx
"use client";
 
import { useMemo, useState } from "react";
 
type Post = {
  id: string;
  title: string;
  description: string;
};
 
export function PostExplorer({ initialPosts }: { initialPosts: Post[] }) {
  const [query, setQuery] = useState("");
 
  const filteredPosts = useMemo(() => {
    return initialPosts.filter((post) =>
      post.title.toLowerCase().includes(query.toLowerCase()),
    );
  }, [initialPosts, query]);
 
  return (
    <section>
      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="검색어를 입력하세요"
      />
 
      <ul>
        {filteredPosts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </section>
  );
}

이 코드는 "use client"가 붙어 있지만, 초기 목록을 반드시 브라우저에서 fetch해야 한다는 뜻은 아닙니다. 서버가 posts를 먼저 읽고 HTML을 만들 수 있습니다. 클라이언트는 그 결과를 props로 이어받아 검색 입력이라는 상호작용을 담당합니다.

다만 PostExplorer는 클라이언트 컴포넌트이므로 몇 가지 제한을 가집니다.

  • props로 함수나 클래스 인스턴스처럼 직렬화할 수 없는 값을 넘길 수 없습니다.
  • 서버 전용 모듈, 비밀값, 데이터베이스 접근 코드를 import하면 안 됩니다.
  • 브라우저 API인 window와 document는 렌더 중 바로 쓰기보다 effect나 browser-only 패턴으로 다뤄야 합니다.

이 제한은 불편한 규칙이라기보다 경계를 분명하게 만드는 장치입니다. 서버에서 읽고 정리할 데이터와 브라우저에서 계속 살아야 할 상호작용을 섞지 않게 해 줍니다.

SPA의 CSR은 출발점이 다릅니다

엄격한 SPA의 CSR은 출발점이 다릅니다. 서버는 보통 하나의 HTML 문서와 JavaScript 파일을 내려줍니다. 브라우저는 JavaScript를 실행한 뒤 현재 URL에 맞는 라우트 컴포넌트를 고르고, 필요한 데이터를 API로 요청하고, 그 결과로 DOM을 구성합니다.

단순화하면 이렇게 볼 수 있습니다.

구분App Router의 "use client"엄격한 SPA의 CSR
첫 화면 HTML서버가 만든 HTML을 먼저 받을 수 있음대체로 빈 앱 껍데기에서 시작
상호작용hydration 이후 가능JavaScript 실행 이후 가능
데이터 시작 위치서버 컴포넌트, layout, page에서 먼저 시작 가능브라우저 컴포넌트 실행 이후 시작하는 경우가 많음
라우팅서버 컴포넌트 payload와 클라이언트 전환이 함께 작동브라우저 라우터가 현재 문서 안에서 처리
번들 전략클라이언트 경계를 좁히면 JS를 줄일 수 있음앱 구조에 따라 초기 JS 부담이 커질 수 있음

이 표에서 중요한 차이는 "어디에서 먼저 일을 시작할 수 있는가"입니다.

CSR 중심 SPA에서는 사용자가 페이지에 들어온 뒤 브라우저가 앱 JavaScript를 실행해야 화면 구성과 데이터 요청이 본격적으로 시작됩니다. 물론 code splitting, prefetch, skeleton UI, service worker 같은 전략으로 체감을 개선할 수 있습니다. 하지만 기본 흐름은 브라우저가 주도합니다.

App Router에서는 서버가 더 일찍 일을 시작할 수 있습니다. 페이지나 layout에서 데이터를 읽고, HTML을 먼저 만들고, 필요한 부분만 클라이언트 컴포넌트로 넘길 수 있습니다. 그래서 "use client"가 들어가도 페이지 전체가 곧바로 CSR 앱이 되는 것은 아닙니다.

다만 이후 클라이언트 내 탐색은 첫 문서 요청과 다르게 동작합니다. Next.js는 새 HTML 문서를 매번 다시 받기보다 RSC Payload와 필요한 JavaScript를 이용해 화면을 전환합니다. 이 점에서는 SPA처럼 빠른 전환을 만들 수 있지만, strict SPA와 달리 서버 컴포넌트 결과가 라우트 전환에 계속 관여할 수 있습니다.

UX 차이는 로딩 위치에서 드러납니다

이 차이는 개발자가 코드를 어디에 두느냐보다 사용자가 어떤 화면을 먼저 보느냐에서 더 크게 드러납니다.

첫 진입에서 빈 화면을 줄일 수 있습니다

서버에서 이미 HTML을 만들 수 있으면 사용자는 JavaScript가 모두 준비되기 전에도 글 제목, 설명, 목록 같은 정적 내용을 먼저 볼 수 있습니다. CSR에서는 초기 JavaScript 실행과 첫 데이터 요청이 늦어지면 빈 화면이나 큰 loading 상태가 길어지기 쉽습니다.

상호작용 가능 시점은 별도로 봐야 합니다

HTML이 보인다고 곧바로 모든 버튼이 동작하는 것은 아닙니다. Client Component는 hydration이 끝나야 이벤트 핸들러가 붙습니다. 그래서 중요한 버튼이나 입력이 hydration 전에 눌릴 수 있는 화면에서는 pending 상태와 disabled 상태를 신중하게 설계해야 합니다.

데이터 waterfall을 앞에서 끊을 수 있습니다

서버에서 데이터를 먼저 요청하면 JavaScript 다운로드 이후에야 API 요청을 시작하는 구조를 피할 수 있습니다. 특히 layout이나 page에서 중요한 요청을 먼저 시작하면 중첩 컴포넌트마다 뒤늦게 fetch하는 waterfall을 줄일 수 있습니다.

반복 상호작용은 클라이언트 캐시가 여전히 중요합니다

서버가 첫 화면을 잘 준비해도, 필터 변경, 무한 스크롤, 자동 갱신, optimistic update 같은 흐름은 브라우저 안에서 계속 살아야 합니다. 이때는 SWR이나 React Query 같은 클라이언트 캐시가 여전히 좋은 선택이 됩니다.

즉 App Router의 UX는 "서버냐 클라이언트냐"의 이분법보다, 첫 화면을 서버가 어디까지 책임지고 이후 상호작용을 클라이언트가 어디서 이어받을지에 가깝습니다.

fetch 위치를 나누는 기준

"use client"를 CSR처럼 이해하면 데이터를 전부 클라이언트 컴포넌트에서 가져오려는 습관이 생깁니다.

"use client";
 
import { useEffect, useState } from "react";
 
export function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [isLoading, setIsLoading] = useState(true);
 
  useEffect(() => {
    fetch("/api/posts")
      .then((response) => response.json())
      .then(setPosts)
      .finally(() => setIsLoading(false));
  }, []);
 
  if (isLoading) {
    return <p>글을 불러오는 중입니다.</p>;
  }
 
  return <PostItems posts={posts} />;
}

이 방식이 항상 틀린 것은 아닙니다. 로그인 이후에만 보이는 개인화 데이터, 주기적으로 갱신되는 데이터, 사용자의 입력에 따라 계속 바뀌는 데이터라면 클라이언트에서 가져오는 편이 자연스럽습니다.

문제는 첫 화면에 반드시 필요한 데이터까지 관성적으로 클라이언트 fetch로 밀어 넣을 때 생깁니다. 그러면 브라우저는 JavaScript를 받은 뒤에야 API 요청을 시작하고, 사용자는 같은 데이터를 기다리는 loading UI를 보게 됩니다.

App Router에서는 먼저 이렇게 질문하는 편이 좋습니다.

  • 이 데이터가 첫 화면을 구성하는 데 꼭 필요한가?
  • 서버에서 안전하게 읽을 수 있는가?
  • 브라우저에 넘겨도 되는 형태로 좁힐 수 있는가?
  • 이후에도 사용자가 계속 조작하고 다시 동기화해야 하는가?

첫 화면에 필요하고 서버에서 안전하게 읽을 수 있다면 page나 layout에서 먼저 가져오는 편이 좋습니다.

// app/posts/page.tsx
import { getPostSummaries } from "@/data/posts";
import { PostList } from "./post-list";
 
export default async function Page() {
  const posts = await getPostSummaries();
 
  return <PostList posts={posts} />;
}
// app/posts/post-list.tsx
"use client";
 
type PostSummary = {
  id: string;
  title: string;
  description: string;
};
 
export function PostList({ posts }: { posts: PostSummary[] }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <strong>{post.title}</strong>
          <span>{post.description}</span>
        </li>
      ))}
    </ul>
  );
}

이 구조에서는 글 목록이 서버에서 먼저 준비됩니다. 클라이언트 컴포넌트는 목록을 다시 fetch하지 않고, 필요한 상호작용만 맡습니다.

반대로 실시간성이나 반복 동기화가 중요하다면 클라이언트 캐시를 선택할 수 있습니다.

"use client";
 
import useSWR from "swr";
 
const fetcher = (url: string) => fetch(url).then((response) => response.json());
 
export function NotificationBadge() {
  const { data } = useSWR("/api/notifications/count", fetcher, {
    refreshInterval: 30_000,
  });
 
  return <span>{data?.count ?? 0}</span>;
}

이 경우에는 서버가 첫 HTML을 만드는 일보다, 브라우저에서 주기적으로 최신 값을 맞추는 일이 더 중요합니다. 그래서 클라이언트 fetch가 자연스럽습니다.

핵심은 fetch 위치를 도구 이름으로 정하지 않는 것입니다. "use client"가 붙었다고 무조건 클라이언트 fetch가 필요한 것도 아니고, 서버 컴포넌트가 기본이라고 모든 데이터를 서버에 묶어야 하는 것도 아닙니다.

개발할 때의 판단 기준

실무에서는 아래 정도의 기준으로 나누면 충분히 명확해집니다.

처음부터 보여야 하는 콘텐츠는 서버에서 준비합니다

글 제목, 상세 본문, 상품 기본 정보, 공개 프로필처럼 첫 화면의 의미를 만드는 데이터는 page나 layout에서 먼저 가져오는 편이 좋습니다. 이렇게 하면 사용자는 JavaScript 실행 전에도 핵심 내용을 볼 수 있습니다.

브라우저 상태가 필요한 부분만 클라이언트 컴포넌트로 둡니다

입력값, 토글, 드롭다운, 드래그, 현재 viewport, localStorage처럼 브라우저와 직접 연결된 기능은 "use client" 경계 안에 둡니다. 대신 이 경계를 너무 위로 올리면 클라이언트 번들이 커질 수 있습니다.

계속 바뀌는 서버 상태는 클라이언트 캐시로 관리합니다

알림 수, 좋아요 상태, 댓글 목록, 검색 결과처럼 사용자가 머무는 동안 계속 다시 맞춰야 하는 데이터는 SWR이나 React Query가 잘 맞습니다. 서버가 첫 값을 제공하고 클라이언트 캐시가 이어받는 혼합 방식도 가능합니다.

진짜 browser-only 코드는 따로 격리합니다

어떤 라이브러리가 렌더 시점부터 window나 document를 요구한다면 단순히 "use client"만 붙여서는 충분하지 않을 수 있습니다. 이 경우에는 useEffect 안에서 실행하거나, 필요하면 dynamic import로 서버 prerender를 끄는 방식을 검토합니다.

이 기준을 적용하면 "use client"는 피해야 할 표시도 아니고, 아무 데나 붙여도 되는 표시도 아닙니다. 서버에서 먼저 준비한 화면에 어느 지점부터 브라우저 상호작용을 연결할지 정하는 설계 도구가 됩니다.

마치며

정리하면 이렇습니다.

  • "use client"는 CSR 선언이 아니라 클라이언트 컴포넌트 진입점 선언입니다.
  • 초기 로드에서 Client Component도 서버 prerender에 참여할 수 있고, 브라우저에서 hydration된 뒤 상호작용할 수 있습니다.
  • 엄격한 SPA의 CSR은 브라우저 JavaScript가 라우팅, 렌더링, 데이터 요청을 주도하는 구조에 가깝습니다.
  • App Router에서는 첫 화면 데이터는 서버에서 먼저 준비하고, 반복 상호작용과 재동기화가 필요한 부분만 클라이언트 캐시로 이어가는 선택지가 생깁니다.

그래서 App Router를 쓸 때 중요한 질문은 "이 컴포넌트가 서버냐 클라이언트냐"만이 아닙니다. 더 중요한 질문은 처음 보여줄 화면은 어디서 준비하고, 이후 사용자의 조작은 어디서 이어받을 것인가입니다.

"use client"를 CSR의 다른 이름으로 이해하면 이 경계가 흐려집니다. 반대로 hydration과 RSC Payload, 클라이언트 번들 경계를 함께 보면 App Router가 왜 서버와 클라이언트를 섞는지 더 분명하게 보입니다.