학습 자료

내려보내지 말고, 필요한 곳에서 다시 부른다


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()가 두 번 실행됐다. 그런데 네트워크 요청은 한 번만 나간다.

getUserfetch를 쓴다면 그렇다.

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()가 끝날 때까지 시작조차 하지 않는다. 서로 필요로 하지도 않는데.

이걸 워터폴이라 부른다. 폭포처럼 위에서 아래로 하나씩 떨어진다.

순차와 병렬
순차 await
userpoststags
Promise.all
userpoststags
시작3초
세 요청이 서로를 기다릴 이유가 없다

자바스크립트 편 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>;
}

PostsPageawait를 마쳐야 렌더링을 시작한다. 여기서는 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에서 부른 요청이 같은 키라서, 두 번째 호출은 이미 날아간 요청의 결과를 기다린다.

미리 시동을 건다
  1. getPosts() 호출await 하지 않는다. 요청만 출발
  2. getUser() 를 await그동안 posts 도 날아가는 중
  3. Posts 에서 getPosts()같은 키 — 진행 중인 것을 받는다
  4. 둘 다 도착기다린 시간이 겹쳤다

클라이언트 컴포넌트에서 데이터가 필요하면

원칙은 서버에서 받아 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 + 옵션이고, GETHEAD에만 적용된다.
  • 이 메모는 렌더링이 끝나면 사라진다. 오래 저장하는 캐시는 13강에서 다룬다.
  • fetch가 아니면 React의 **cache()**로 감싼다. 인자는 원시값으로 넘긴다.
  • 서로 필요 없는 await가 연달아 있으면 **Promise.all**로 묶는다.
  • await 없이 함수만 불러 미리 시동을 걸 수 있다. 메모이제이션이 결과를 이어준다.
  • 클라이언트 컴포넌트에는 값을 넘기거나, Promise를 넘겨 **use()**로 푼다.

여기까지가 데이터를 읽는 이야기다. 그러면 쓰는 것은 어떻게 하나.

지금까지 우리는 API Route를 만든 적이 없다. fetch("/api/posts", { method: "POST" })를 쓴 적도 없다. 다음 강의에서 서버 함수를 브라우저에서 직접 부르는 방법을 배운다. 8강에서 "함수는 props로 넘길 수 없다"고 했을 때 에러 메시지 끝에 붙어 있던 그 "use server"다.

내려보내지 말고, 필요한 곳에서 다시 부른다 — 디코드랩(DCODELAB)