내려보내지 말고, 필요한 곳에서 다시 부른다
Article
레이아웃에 사용자 이름을 띄우고, 페이지에도 사용자 정보가 필요하다.
// app/dashboard/layout.js
export default async function Layout({ children }) {
const user = await getUser();
return (
<section>
<header>{user.name}</header>
{children}
</section>
);
}
페이지에 user를 넘기고 싶다. 그런데 넘길 방법이 없다.
{children} // ✅ 이것만 가능
{children(user)} // ✕
<Page user={user} /> // ✕ — 어느 페이지가 들어올지 레이아웃은 모른다
4강에서 레이아웃은 children 자리에 페이지를 받는다고 했다. 레이아웃은 자기 안에 어떤 페이지가 들어올지 모른다. 주소에 따라 달라지기 때문이다. 그래서 props를 줄 수 없다.
React 편 18강에서는 이럴 때 Context로 끌어올렸다. prop drilling을 피하려고. 여기서는 그럴 필요가 없다.
페이지에서 getUser()를 그냥 다시 부른다.
두 번 불러도 한 번만 나간다
// app/dashboard/page.js
export default async function Page() {
const user = await getUser(); // 또 부른다
return <p>{user.email}</p>;
}
같은 화면을 그리는 동안 getUser()가 두 번 실행됐다. 그런데 네트워크 요청은 한 번만 나간다.
getUser가 fetch를 쓴다면 그렇다.
export async function getUser() {
const res = await fetch("https://api.example.com/me");
return res.json();
}
React가 fetch를 감싸서, 같은 렌더링 동안 같은 요청은 한 번만 보낸다. 이걸 요청 메모이제이션이라 부른다. 5강에서 generateMetadata와 컴포넌트가 각각 getPost(id)를 부를 때 미뤄뒀던 답이 이것이다.
캐시 키 = URL + 옵션
- GET /me
- 첫 호출 — 실제로 요청한다
- 결과를 적어둔다
- GET /me
- 두 번째 호출 — 적어둔 것을 준다
- 요청하지 않는다
- GET /posts
- 키가 다르다 — 요청한다
세 번째 줄이 중요하다. 메모는 한 번의 요청 처리가 끝나면 사라진다. 다음 사용자가 오면 다시 요청이 나간다.
오래 저장해두는 것은 별개의 장치이고, 13강에서 다룬다. 지금 배우는 것은 같은 화면을 그리는 동안 중복을 없애는 것뿐이다.
이 덕분에 데이터를 위로 끌어올릴 이유가 사라진다. 필요한 컴포넌트가 직접 부른다. 부모가 무엇을 가졌는지 알 필요가 없다.
끌어올려서 내려보낸다
클라이언트에서 하던 방식
- 부모가 받아 props 로 전달
- 레이아웃은 그럴 수도 없다
- 중간 컴포넌트가 다 알아야 한다
각자 부른다
서버 컴포넌트 방식
- 필요한 곳에서
await - 요청은 한 번만 나간다
- 컴포넌트가 독립적이 된다
캐시 키는 URL 과 옵션이다
같은 요청인지 무엇으로 판단할까. URL과 옵션 객체다.
fetch("/api/me"); // 키 A
fetch("/api/me"); // 키 A — 재사용
fetch("/api/me", { headers: { "X-Lang": "ko" } }); // 키 B — 다른 요청
fetch("/api/posts"); // 키 C
fetch 를 쓰지 않는다면
데이터베이스를 직접 조회하거나 ORM을 쓴다면 fetch가 없다. 그러면 메모이제이션도 없다.
React의 cache()로 직접 감싼다.
import { cache } from "react";
import { db } from "@/lib/db";
import "server-only";
export const getUser = cache(async (id) => {
return db.user.findUnique({ where: { id } });
});
이제 getUser(1)을 몇 번 부르든 한 번만 조회한다. 9강에서 배운 server-only도 같이 붙였다. 이 파일은 브라우저로 가면 안 된다.
| 무엇을 쓰나 | 메모이제이션 |
|---|---|
| fetch (GET · HEAD) | 자동 |
| fetch (POST · PUT · DELETE) | 안 된다 |
| DB · ORM · 파일 읽기 | cache() 로 감싼다 |
| 클라이언트 컴포넌트 | 해당 없음 — 서버에서 받아 props 로 |
순서대로 기다리면 느리다
메모이제이션은 중복을 없앨 뿐, 순서까지 고쳐주지는 않는다.
export default async function Page() {
const user = await getUser(); // 1초
const posts = await getPosts(); // 1초
const tags = await getTags(); // 1초
return <Dashboard user={user} posts={posts} tags={tags} />;
}
3초가 걸린다. getPosts()는 getUser()가 끝날 때까지 시작조차 하지 않는다. 서로 필요로 하지도 않는데.
이걸 워터폴이라 부른다. 폭포처럼 위에서 아래로 하나씩 떨어진다.
자바스크립트 편 20강의 Promise.all을 쓴다.
export default async function Page() {
const [user, posts, tags] = await Promise.all([
getUser(),
getPosts(),
getTags(),
]);
return <Dashboard user={user} posts={posts} tags={tags} />;
}
세 요청이 동시에 나간다. 가장 느린 것 하나만큼만 걸린다.
컴포넌트가 나뉘어 있어도 워터폴이 생긴다
한 함수 안이 아니라 부모-자식으로 나뉘어 있으면 눈에 잘 안 띈다.
export default async function Page() {
const user = await getUser(); // 이게 끝나야
return <Posts userId={user.id} />; // 자식이 시작한다
}
async function Posts({ userId }) {
const posts = await getPosts(userId);
return <ul>…</ul>;
}
Posts는 Page가 await를 마쳐야 렌더링을 시작한다. 여기서는 user.id가 필요하니 어쩔 수 없다.
필요 없다면 요청을 미리 시작해둘 수 있다. await 하지 않고 함수만 불러두는 것이다.
export default async function Page() {
getPosts(); // 시동만 건다. await 하지 않는다
const user = await getUser();
return <Posts />;
}
async function Posts() {
const posts = await getPosts(); // 이미 진행 중인 요청을 받는다
return <ul>…</ul>;
}
메모이제이션이 있어서 가능한 기법이다. Page에서 시작한 요청과 Posts에서 부른 요청이 같은 키라서, 두 번째 호출은 이미 날아간 요청의 결과를 기다린다.
- getPosts() 호출await 하지 않는다. 요청만 출발
- getUser() 를 await그동안 posts 도 날아가는 중
- Posts 에서 getPosts()같은 키 — 진행 중인 것을 받는다
- 둘 다 도착기다린 시간이 겹쳤다
클라이언트 컴포넌트에서 데이터가 필요하면
원칙은 서버에서 받아 props로 넘긴다. 8강에서 본 대로 직렬화만 되면 넘어간다.
그런데 넘길 값이 아직 안 왔다면, await 하지 않은 Promise 자체를 넘길 수도 있다.
// 서버 컴포넌트
export default function Page() {
const postsPromise = getPosts(); // await 하지 않는다
return <PostList promise={postsPromise} />;
}
"use client";
import { use } from "react";
export default function PostList({ promise }) {
const posts = use(promise); // 여기서 풀어낸다
return <ul>…</ul>;
}
React 편 21강의 use()다. 서버는 기다리지 않고 HTML을 먼저 보내고, 데이터가 도착하면 그 부분만 채워진다.
이게 스트리밍이고, 15강에서 <Suspense>와 함께 제대로 다룬다.
정리하면
- 레이아웃은 페이지에 props를 줄 수 없다. 어떤 페이지가 들어올지 모르기 때문이다.
- 그래서 데이터를 끌어올리지 않는다. 필요한 컴포넌트가 각자 부른다.
- 같은 렌더링 동안 같은
fetch는 한 번만 나간다. 요청 메모이제이션이다. - 캐시 키는 URL + 옵션이고,
GET과HEAD에만 적용된다. - 이 메모는 렌더링이 끝나면 사라진다. 오래 저장하는 캐시는 13강에서 다룬다.
fetch가 아니면 React의 **cache()**로 감싼다. 인자는 원시값으로 넘긴다.- 서로 필요 없는
await가 연달아 있으면 **Promise.all**로 묶는다. await없이 함수만 불러 미리 시동을 걸 수 있다. 메모이제이션이 결과를 이어준다.- 클라이언트 컴포넌트에는 값을 넘기거나, Promise를 넘겨 **
use()**로 푼다.
여기까지가 데이터를 읽는 이야기다. 그러면 쓰는 것은 어떻게 하나.
지금까지 우리는 API Route를 만든 적이 없다. fetch("/api/posts", { method: "POST" })를 쓴 적도 없다. 다음 강의에서 서버 함수를 브라우저에서 직접 부르는 방법을 배운다. 8강에서 "함수는 props로 넘길 수 없다"고 했을 때 에러 메시지 끝에 붙어 있던 그 "use server"다.