
목차
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'
에 대한 오해가 있었다면, 앞으로는 걱정 없이 활용하셨으면 좋겠어요. 😄