Jeonghwan's Blog
‘use client’에 대한 오해

‘use client’에 대한 오해

Next.js App Router를 사용하다 보면 'use client'와 관련해 이런 질문을 종종 접할 수 있어요.

'use client'가 선언된 컴포넌트 하위에 선언된 컴포넌트는 모두 클라이언트 컴포넌트로 동작하나요?”

결론부터 이야기드리면, 그렇지 않아요. 왜 그렇지 않은지 이번 글에서 자세히 설명해 드릴게요.

‘use client’란?

먼저, 'use client' 란 무엇인지 간단히 짚고 넘어갈게요. Next.js App Router에서는 기본적으로 모든 컴포넌트가 서버 컴포넌트로 처리되어요. 하지만 useState, useEffect 같은 React 클라이언트 훅이나 브라우저 API를 사용하려면 해당 파일 맨 위에 'use client' 를 선언해야 해요.

이는 “이 파일은 클라이언트에서 실행돼야 해! 이건 클라이언트 컴포넌트야” 라고 Next.js에 알려주는 역할을 해요.

‘use client’ 하위 컴포넌트는 모두 클라이언트 컴포넌트?

'use client' 를 사용하다 보면 “'use client' 가 선언된 컴포넌트의 모든 하위 컴포넌트는 다 클라이언트 컴포넌트로 동작한다”라고 오해하는 경우가 많아요.

그런 오해 때문에 이런 걱정도 자주 생겨요:

TanStack Query 라이브러리를 사용할 때 ReactQueryProvider 컴포넌트를 'use client'로 선언해서 RootLayout에서 children을 감싸면, children으로 넘기는 페이지들도 전부 클라이언트 컴포넌트가 되는 거 아닌가요?

결론부터 이야기드리면, 그런 걱정은 하지 않으셔도 돼요! 🙅🏻‍♂️

ReactQueryProvider 파일 안에서 import한 컴포넌트는 클라이언트 컴포넌트로 처리되지만 children으로 넘겨받은 컴포넌트는 원래 선언 방식 그대로 서버/클라이언트 여부를 유지해요.

예시 코드로 이해하기

예시 코드를 통해 좀 더 쉽게 이해할 수 있도록 설명해 드릴게요.

// components/ReactQueryProvider.tsx
'use client';
 
import type { PropsWithChildren } from 'react';
 
import { isServer, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
 
let browserQueryClient: QueryClient | undefined = undefined;
 
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: 1,
        staleTime: 60 * 1000,
      },
    },
  });
}
 
function getQueryClient() {
  if (isServer) {
    return makeQueryClient();
  }
 
  if (!browserQueryClient) {
    browserQueryClient = makeQueryClient();
  }
 
  return browserQueryClient;
}
 
export function ReactQueryProvider({ children }: PropsWithChildren) {
  const queryClient = getQueryClient();
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

위 코드에서 'use client'를 선언한 ReactQueryProvider 컴포넌트는 클라이언트 컴포넌트로 동작해요. 또한 이 클라이언트 컴포넌트 내에서 import한 ReactQueryDevtools 역시 클라이언트 컴포넌트로 처리돼요.

// app/layout.tsx
import type { PropsWithChildren } from 'react';
 
import { ReactQueryProvider } from "@/components/ReactQueryProvider";
 
export default function RootLayout({ children }: PropsWithChildren) {
  return (
    <html lang="ko">
      <body>
        <ReactQueryProvider>{children}</ReactQueryProvider>
      </body>
    </html>
  );
}
// app/page.tsx
export default function HomePage() {
  // 여전히 서버 컴포넌트!
  return <h1>서버에서 렌더링되는 페이지</h1>;
}

다만 ReactQueryProvider 컴포넌트가 클라이언트 컴포넌트로 선언되었더라도, RootLayout에서 이를 사용해 children을 감싸도 HomePage는 여전히 서버 컴포넌트로 유지돼요.

요약하자면, 'use client' 선언이 된 컴포넌트에서 직접 import한 모든 컴포넌트는 클라이언트 컴포넌트로 처리되지만, props의 children으로 전달받은 컴포넌트들은 각자의 원래 선언 상태(서버 또는 클라이언트)를 그대로 유지해요.

이를 시각화해서 정리하면 아래와 같아요. 👇

RootLayout (서버 컴포넌트)
  └── ReactQueryProvider (클라이언트 컴포넌트 - use client)
        └── children (서버 또는 클라이언트 컴포넌트, 원래 선언 그대로)

마무리

지금까지 'use client'가 선언된 컴포넌트의 하위 구조에서 서버/클라이언트 컴포넌트가 어떻게 구분되는지 살펴봤어요.

세 줄로 정리하자면

  • 'use client'는 그 파일 내부에서 import한 컴포넌트까지만 클라이언트 컴포넌트로 처리돼요.
  • children으로 전달받은 컴포넌트들은 원래 선언 그대로 서버/클라이언트 여부를 유지해요.
  • ReactQueryProvider처럼 루트에서 클라이언트 Context Provider를 사용할 때도, 전체 페이지가 클라이언트 컴포넌트로 바뀌는 건 아니니 걱정하지 않으셔도 돼요.

이런 구조를 정확히 이해하고 활용하면, App Router 환경에서도 서버/클라이언트 컴포넌트를 더 유연하게 조합하고, 불필요한 클라이언트 번들링을 줄이면서 최적화된 구조를 유지할 수 있어요.

결국 필요한 부분만 딱 클라이언트 컴포넌트로 만들고, 나머지는 서버 컴포넌트로 효율적으로 운영하는 것이 Next.js App Router를 잘 활용하는 핵심 포인트라고 생각해요. 그리고 이번 글을 통해 혹시 앞서 언급된 'use client' 에 대한 오해가 있었다면, 앞으로는 걱정 없이 활용하셨으면 좋겠어요. 😄

참고 자료