티스토리 뷰

반응형

Next.js App Router로 프로젝트를 만들면서 createContext only works in Client Components라는 에러를 마주쳤습니다. ThemeProviderlayout.tsx에서 바로 사용했기 때문이었는데, 이 에러를 고치면서 RSC(React Server Components)에서 children이 어떻게 동작하는지, 그리고 서버와 클라이언트 사이에 실제로 무슨 데이터가 오가는지 정확히 이해하게 됐습니다. 기존 React 멘탈모델로는 직관적이지 않은 부분이 있어서 정리해 두려 합니다.

목차

문제 상황

layout.tsx에 Emotion의 ThemeProvider를 다음과 같이 작성했습니다.

// src/app/layout.tsx
import { ThemeProvider, Global } from '@emotion/react'
import { theme } from '@/shared/ui'

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>
        <ThemeProvider theme={theme}>
          <Global styles={globalStyles} />
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

실행하면 바로 에러가 납니다.

Error: createContext only works in Client Components.
Add the "use client" directive at the top of the file to use it.

왜 에러가 발생하는가

App Router의 모든 컴포넌트는 기본적으로 Server Component입니다. layout.tsx도 마찬가지입니다.

ThemeProvider는 내부적으로 React.createContext()를 사용합니다. Context API는 브라우저의 컴포넌트 트리를 기반으로 동작하는 클라이언트 개념이라서, 서버에서 실행되는 Server Component에서는 쓸 수 없습니다.

단순히 layout.tsx 맨 위에 'use client'를 붙이면 해결될 것 같지만, 그렇게 하면 export const metadata를 사용할 수 없게 됩니다. metadata는 Server Component에서만 export할 수 있기 때문입니다.

해결: Provider를 Client Component로 분리하기

Context를 사용하는 부분만 별도 파일로 빼고, 거기에만 'use client'를 붙입니다.

// src/app/providers.tsx
'use client'

import { Global, ThemeProvider, css } from '@emotion/react'
import type { ReactNode } from 'react'
import { theme } from '@/shared/ui'
import { EmotionRegistry } from './emotion-registry'

export const Providers = ({ children }: { children: ReactNode }) => (
  <EmotionRegistry>
    <ThemeProvider theme={theme}>
      <Global styles={globalStyles} />
      {children}
    </ThemeProvider>
  </EmotionRegistry>
)

layout.tsx는 Server Component로 유지하면서 Providers를 가져다 씁니다.

// src/app/layout.tsx
import type { ReactNode } from 'react'
import { AppShell } from './app-shell'
import { Providers } from './providers'

export const metadata = { title: 'AI Chat', description: 'GPT-5 기반 AI 채팅' }

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <Providers>
          <AppShell>{children}</AppShell>
        </Providers>
      </body>
    </html>
  )
}

그러면 page.tsx도 Client Component가 되는 걸까

여기서 의문이 생깁니다. ProvidersAppShell은 Client Component인데, 그 안에 {children}으로 들어오는 page.tsx도 Client Component가 되는 걸까요?

아닙니다. page.tsx'use client'를 선언하지 않는 한 Server Component로 유지됩니다.

import와 children은 다르다

Client 경계를 만드는 건 import이지, children prop이 아닙니다.

Client Component가 다른 컴포넌트를 직접 import하면, 그 컴포넌트는 클라이언트 번들에 포함됩니다.

// ClientComponent.tsx
'use client'
import { Page } from './page'  // Page가 클라이언트 번들에 포함됨

export const ClientComponent = () => <Page />

반면 children으로 전달하면 달라집니다.

// layout.tsx (Server Component)
export default function RootLayout({ children }) {
  return (
    <Providers>        // Client Component
      <AppShell>       // Client Component
        {children}     // page.tsx — Server Component인 채로 전달됨
      </AppShell>
    </Providers>
  )
}

layout.tsx(Server Component)가 page.tsx를 렌더링하고, 그 결과물을 Providerschildren prop으로 넘깁니다. Providers는 page를 import하거나 실행하지 않습니다. 이미 서버에서 만들어진 React 엘리먼트를 받아서 자기 자리에 끼워 넣을 뿐입니다.

RSC Payload란 무엇인가

이 동작을 이해하려면 서버가 클라이언트로 무엇을 전송하는지 알아야 합니다. Next.js App Router는 기존 SSR처럼 HTML만 보내지 않습니다. HTML과 함께 RSC Payload라는 별도의 데이터를 함께 전송합니다.

기존 SSR과 비교하면 이렇습니다.

기존 SSR
  서버 → 완성된 HTML 문자열 → 클라이언트

RSC (App Router)
  서버 → HTML + RSC Payload → 클라이언트

RSC Payload는 직렬화된 React 트리의 설명서입니다. 브라우저 개발자 도구의 네트워크 탭에서 실제로 확인해 보면 다음과 같은 형태입니다.

2:I["(app-pages-browser)/./src/app/providers.tsx",["app/layout","static/chunks/app/layout.js"],"Providers"]
3:I["(app-pages-browser)/./src/app/app-shell.tsx",["app/layout","static/chunks/app/layout.js"],"AppShell"]
4:["$","html",null,{"lang":"ko","children":["$","body",null,{"children":["$","$L2",null,...]}]}]

I로 시작하는 항목은 Client Component 참조입니다. 어떤 JS 파일의 어떤 export인지만 담겨 있습니다. 실제 컴포넌트 코드가 들어있는 게 아니라 "이 자리에 이 컴포넌트를 hydrate 해라"는 지시입니다.

두 가지 데이터가 각각 역할을 나눕니다.

  • HTML: 브라우저가 화면에 즉시 그릴 수 있는 마크업. 사용자가 JS 로드 전에 볼 수 있는 내용입니다.
  • RSC Payload: React가 트리를 이해하기 위한 설명서. 어디가 Client Component이고 어디가 Server Component 결과물인지 알 수 있습니다. 이후 클라이언트 내비게이션 시에는 HTML 없이 RSC Payload만 요청해서 부분 업데이트합니다.

HTML만 있으면 화면은 그릴 수 있지만 React가 트리를 파악하지 못해서 hydration이 불가능합니다. RSC Payload 덕분에 React는 어느 노드에 어떤 이벤트 핸들러와 상태를 붙여야 하는지 정확히 알 수 있습니다.

RSC Payload로 보는 실제 구조

이 구조를 우리 코드에 대입해서 보면 다음과 같습니다.

[Client Component 참조]
  Providers  → 클라이언트 번들의 providers.js
  AppShell   → 클라이언트 번들의 app-shell.js

[React 엘리먼트 트리]
  <Providers>             ← "providers.js를 여기서 hydrate"
    <AppShell>            ← "app-shell.js를 여기서 hydrate"
      <div>               ← page.tsx가 서버에서 실행된 결과물 (완성된 트리)
        <h1>AI Chat</h1>
        ...
      </div>
    </AppShell>
  </Providers>

page.tsx결과물은 payload에 이미 완성된 트리로 담겨 있습니다. ProvidersAppShell은 어떤 컴포넌트인지 참조만 담겨 있습니다. 클라이언트는 이 payload를 받아서 Client Component들을 hydrate하고, page 자리에는 서버에서 받은 결과물을 그대로 마운트합니다.

렌더링 타임라인

서버
  layout.tsx (SC) 실행
  page.tsx   (SC) 실행       ← 동시에 처리됨
  Providers, AppShell은 실행하지 않음
  → Client Component는 참조만 Payload에 기록

        HTML + RSC Payload 전송

클라이언트
  HTML로 화면을 즉시 표시
  RSC Payload를 읽고 Providers, AppShell hydrate
  page 결과물은 이미 있으므로 그대로 마운트

Providers가 렌더링된 다음에 page가 렌더링되는 게 아닙니다. 서버는 Server Component 트리를 모두 실행하고, Client Component는 실행하지 않은 채 참조만 Payload에 남깁니다. page.tsxProviders의 실행을 기다리지 않습니다. 둘은 서버와 클라이언트라는 완전히 다른 환경에서 별개로 처리됩니다.

마무리

Client Component 안에 children으로 Server Component를 넣어도 page는 Server Component로 유지됩니다. Client 경계를 만드는 건 import이지 children prop이 아니기 때문입니다. 이 동작이 가능한 이유는 Next.js가 HTML과 함께 RSC Payload를 전송하기 때문입니다. 서버는 Server Component를 실행한 결과물을 Payload에 담고, Client Component는 참조만 기록해서 클라이언트가 hydrate하도록 위임합니다.

children 슬롯 패턴 덕분에 Context Provider 같은 Client Component를 루트에 두면서도, page에서는 서버에서만 가능한 작업(DB 접근, 환경변수 사용 등)을 그대로 활용할 수 있습니다.

Next.js 공식 문서에서도 이 패턴을 Using context in Server Components 항목에서 권장하고 있습니다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함