경계는 import 를 타고 번진다
Article
8강에서 "use client"는 잎에 붙이라고 했다. 그런데 잎이 아닌 곳에 붙여야 할 때가 있다.
Context Provider가 그렇다. React 편 18강에서 배운 그것이다. 앱 전체를 감싸야 하니 layout.js에 둘 수밖에 없는데, createContext는 훅이라 클라이언트여야 한다.
"use client";
import { ThemeProvider } from "./theme";
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
루트 레이아웃이 클라이언트가 됐다. 그러면 그 안의 모든 페이지도 클라이언트가 되는 걸까. 서버 컴포넌트는 이제 하나도 못 쓰는 걸까.
쓸 수 있다. 위 코드가 틀렸을 뿐이다.
경계는 아래로 번진다
먼저 규칙을 정확히 안다. "use client"는 그 파일 하나에만 적용되는 게 아니다.
그 파일이 import 하는 모든 것에 전염된다.
- page.js
- Chart.jsx
- formatDate.js
- heavy-date-lib
- colors.js
- formatDate.js
- getPosts.js
- Chart.jsx
formatDate.js에는 "use client"가 없다. 그래도 클라이언트다. 경계 안쪽에서 불렸기 때문이다.
그래서 경계 안의 파일에는 "use client"를 다시 쓸 필요가 없다. 써도 무해하지만 의미는 없다. 경계는 진입점에만 선언한다.
서버 전용 코드가 브라우저로 새어나갈 때
번지는 성질이 위험한 이유가 있다. 유틸 파일 하나를 보자.
// lib/db.js
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function getUser(id) {
return pool.query("SELECT * FROM users WHERE id = $1", [id]);
}
이걸 클라이언트 컴포넌트에서 실수로 import 하면, DATABASE_URL이 브라우저 번들에 들어갈 위험이 생긴다.
Next.js가 대개는 막아준다. pg 같은 라이브러리가 fs를 쓰기 때문에 빌드가 터진다. 하지만 의존하기엔 불안한 방어다. 우연히 순수 자바스크립트로만 짜인 서버 유틸이라면 조용히 통과한다.
명시적으로 막는다.
// lib/db.js
import "server-only";
export async function getUser(id) { … }
npm install server-only
이 패키지는 아무 일도 하지 않는다. 다만 클라이언트 번들에 포함되는 순간 빌드 시점에 에러를 낸다.
- Error: You're importing a component that imports server-only.
- It only works in a Server Component but one of its parents is marked with "use client", so it's a Client Component.
반대 방향으로는 import 할 수 없다
핵심 질문으로 돌아온다. 클라이언트 컴포넌트가 서버 컴포넌트를 import 하면 어떻게 될까.
"use client";
import PostBody from "./PostBody"; // 서버 컴포넌트로 만든 것
export default function Tabs() {
const [tab, setTab] = useState("body");
return <section>{tab === "body" && <PostBody />}</section>;
}
에러가 나지 않는다. 그게 더 나쁘다.
PostBody가 조용히 클라이언트 컴포넌트가 된다. 경계 안쪽으로 끌려 들어왔기 때문이다. async로 await getPost() 를 쓰고 있었다면 그제야 터지고, 아니라면 그냥 번들에 실려 브라우저로 간다.
서버 → 클라이언트
import 해도 된다
- 8강의
LikeButton이 그랬다 - 그 자리에 구멍을 남긴다
- 정상적인 방향이다
클라이언트 → 서버
import 하면 안 된다
- 경고 없이 클라이언트로 바뀐다
async컴포넌트면 터진다- 번들만 커진다
이유는 8강에서 본 RSC Payload 구조에 있다. 서버가 렌더링을 끝낸 뒤 클라이언트 컴포넌트 자리에 표시만 남긴다. 순서가 서버 먼저, 브라우저 나중이다. 브라우저에서 도는 코드가 "지금 서버 컴포넌트를 실행해줘"라고 요청할 방법이 없다.
import 하지 말고 받아라
그런데 방법이 있다. import 하지 않으면 된다.
부모가 서버 컴포넌트라면, 부모가 서버 컴포넌트를 렌더링해서 그 결과를 클라이언트 컴포넌트에 children으로 넘긴다.
// app/page.js — 서버 컴포넌트
import Tabs from "./Tabs";
import PostBody from "./PostBody";
export default async function Page() {
return (
<Tabs>
<PostBody />
</Tabs>
);
}
// app/Tabs.jsx — 클라이언트 컴포넌트
"use client";
import { useState } from "react";
export default function Tabs({ children }) {
const [tab, setTab] = useState("body");
return (
<section>
<nav>
<button onClick={() => setTab("body")}>본문</button>
<button onClick={() => setTab("info")}>정보</button>
</nav>
{tab === "body" && children}
</section>
);
}
Tabs.jsx는 PostBody를 import 하지 않는다. children으로 받는다. PostBody는 서버에서 실행돼 이미 결과가 됐고, Tabs는 그 결과를 어디에 놓을지만 정한다.
- 서버가 page.js 실행PostBody 를 렌더링해 결과로 만든다
- Tabs 자리에 구멍props.children = PostBody 의 결과
- 브라우저가 Tabs 만 받는다PostBody 코드는 오지 않는다
- Tabs 가 구멍에 끼운다이미 그려진 것을 배치만 한다
React 편 6강에서 children을 배울 때 "태그 사이에 넣은 것"이라고 했다. 그때는 문법이었지만, 여기서는 경계를 넘는 유일한 통로가 된다.
렌더 트리를 그려보면 클라이언트 컴포넌트 안에 서버 컴포넌트가 들어앉아 있다.
- PostBody서버
- Tabs클라이언트
- app/layout.js서버
렌더 트리 — 아래가 바깥
여기서 두 가지를 구분해야 한다.
모듈 그래프
누가 누구를 import 하나
"use client"는 여기서 번진다- 클라이언트 → 서버는 불가능
- 번들 크기를 결정한다
렌더 트리
화면에서 누가 누구를 감싸나
children은 여기서 중첩된다- 클라이언트 안에 서버가 가능
- 화면 모양을 결정한다
"클라이언트 컴포넌트 안에 서버 컴포넌트를 넣을 수 있나"라는 질문이 헷갈리는 이유가 이것이다. 모듈 그래프에서는 안 되고, 렌더 트리에서는 된다.
Provider 는 이렇게 만든다
이제 처음의 문제를 푼다. 루트 레이아웃에 "use client"를 붙이면 안 된다. 대신 Provider만 클라이언트로 떼어낸다.
// app/providers.jsx
"use client";
import { ThemeProvider } from "next-themes";
export default function Providers({ children }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
// app/layout.js — 서버 컴포넌트 그대로
import Providers from "./providers";
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
layout.js에는 "use client"가 없다. 서버 컴포넌트다. Providers는 클라이언트지만 children을 받기만 하므로, 그 안의 페이지들은 전부 서버 컴포넌트로 남는다.
- 01
layout.js 는 Providers 를 import 한다
서버 → 클라이언트 방향이다. 허용된다.
- 02
Providers 는 children 을 import 하지 않는다
props 로 받을 뿐이다. 모듈 그래프에 선이 없다.
- 03
page.js 는 경계 밖에 있다
layout.js가 렌더링한다.Providers를 거치지만 import 되지는 않았다.
7강의 @modal 슬롯도 같은 원리였다. 레이아웃이 modal prop으로 받은 것은 이미 그려진 결과다.
서드파티 라이브러리에 use client 가 없을 때
npm 패키지 중에는 아직 App Router를 고려하지 않은 것들이 있다. 서버 컴포넌트에서 쓰면 터진다.
- Error: createContext only works in Client Components.
- Add the "use client" directive at the top of the file to use it.
패키지를 고칠 수는 없다. 감싸서 다시 내보낸다.
// app/_components/carousel.jsx
"use client";
export { Carousel } from "some-old-library";
이제 이 파일이 경계가 된다. 서버 컴포넌트는 some-old-library 대신 이 파일에서 가져온다. 7강의 _폴더가 이런 것을 두기 좋은 자리다.
정리하면
"use client"는import를 타고 아래로 번진다. 경계 안의 파일에는 다시 쓸 필요가 없다.- 경계는 서버 컴포넌트가 직접
import하는 지점에 선언한다. - 서버 전용 유틸에는
import "server-only"를 넣는다. 새어나가면 빌드가 실패한다. - 클라이언트 컴포넌트는 서버 컴포넌트를
import할 수 없다. 에러도 없이 클라이언트로 끌려간다. - 대신
children이나 props로 받으면 된다. 이미 그려진 결과가 건너온다. - 모듈 그래프에서는 불가능하고, 렌더 트리에서는 가능하다. 이 둘을 구분해야 혼란이 걷힌다.
- Provider는
layout.js가 아니라 별도providers.jsx에 둔다. 레이아웃은 서버 컴포넌트로 남는다. "use client"없는 라이브러리는 감싸서 다시 내보낸다.NEXT_PUBLIC_이 없는 환경변수는 브라우저에서undefined다.
Part 3의 절반이 끝났다. 어디서 실행되는지, 경계를 어디에 긋는지 알았다.
다음 강의는 데이터다. 5강에서 미뤄둔 질문이 있었다. generateMetadata와 컴포넌트가 각각 getPost(id)를 부르면 요청이 두 번 나가는가. 그리고 레이아웃과 페이지가 같은 사용자 정보를 필요로 하면, 그 값을 어떻게 내려보내야 하는가. 답은 "내려보내지 않는다"이다.