학습 자료

HTML 은 한 번에 도착하지 않아도 된다


Article

지금까지 <Suspense>가 세 번 다른 얼굴로 나타났다.

강의무엇을 감쌌나그때 한 말
4강페이지 전체loading.js 를 만들면 로딩 화면이 생긴다
6강useSearchParams 를 쓰는 컴포넌트안 감싸면 빌드가 실패한다
13강캐시하지 않은 데이터안 감싸면 빌드가 실패한다

셋은 같은 장치다. 이제 정체를 밝힌다.

응답을 조각내서 보낸다

3강에서 서버가 HTML을 만들어 보낸다고 했다. 그때 암묵적으로 가정한 것이 있다. HTML이 완성돼야 보낼 수 있다는 것이다.

가정을 깬다. HTTP 응답은 조각으로 나눠 보낼 수 있다.

하나의 응답, 여러 조각
  1. 첫 조각을 즉시머리말 · 본문 · 로딩 자리
  2. 연결을 끊지 않는다브라우저는 계속 기다린다
  3. 느린 것이 준비되면그 조각을 마저 보낸다
  4. 브라우저가 끼워넣는다로딩 자리를 진짜 내용으로 바꾼다

이걸 스트리밍이라 부른다. 그리고 <Suspense>"여기부터는 나중에 보내도 된다"고 표시하는 가위다.

import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <h1>대시보드</h1>
      <Suspense fallback={<p>불러오는 중…</p>}>
        <SlowStats />
      </Suspense>
    </>
  );
}

async function SlowStats() {
  const stats = await getStats();   // 3초 걸린다
  return <section>{stats.total}</section>;
}

<h1>은 즉시 간다. SlowStats 자리에는 <p>불러오는 중…</p>이 들어간다. 3초 뒤 진짜 내용이 도착해 그 자리를 대신한다.

경계가 있을 때와 없을 때
없을 때
아무것도 안 보낸다전부 한꺼번에
있을 때
제목 + 로딩연결 유지통계 도착
요청데이터 준비
총 시간은 같다. 사용자가 기다리는 시간이 다르다

총 소요 시간은 줄지 않는다. 첫 화면이 보이는 시각이 당겨질 뿐이다. 그게 전부라고 생각하면 안 된다. 사용자에게는 그게 거의 전부다.

loading.js 의 정체

4강에서 파일 하나 만들었을 뿐인데 로딩 화면이 생겼다. Next.js가 이렇게 감싼 것이다.

<Layout>
  <Suspense fallback={<Loading />}>
    <Page />
  </Suspense>
</Layout>

loading.js페이지 전체를 감싸는 경계다. 편하지만 거칠다. 페이지 안에 빠른 부분과 느린 부분이 섞여 있으면, 빠른 것까지 느린 것을 기다린다.

경계를 어디에 긋나

loading.js

페이지 통째로

  • 파일 하나면 끝
  • 제목도 메뉴도 로딩 화면
  • 느린 것 하나가 전부를 붙잡는다

직접 <Suspense>

조각마다

  • 느린 것만 감싼다
  • 제목은 즉시 보인다
  • 경계마다 따로 도착한다

빠른 것을 경계 밖에 두는 것이 요령이다. 경계 안에 들어간 것은 함께 기다린다.

경계 안의 워터폴을 조심한다

경계를 그어도 안쪽이 느리면 소용없다.

async function SlowStats() {
  const users = await getUsers();     // 1초
  const orders = await getOrders();   // 1초
  const revenue = await getRevenue(); // 1초
  return <section></section>;
}

3초 뒤에야 이 조각이 도착한다. 10강에서 본 워터폴이다. 안쪽도 병렬로 만든다.

const [users, orders, revenue] = await Promise.all([
  getUsers(), getOrders(), getRevenue(),
]);

아니면 경계를 셋으로 쪼갠다. 각자 준비되는 대로 도착한다.

<Suspense fallback={<Skeleton />}><Users /></Suspense>
<Suspense fallback={<Skeleton />}><Orders /></Suspense>
<Suspense fallback={<Skeleton />}><Revenue /></Suspense>

7강의 병렬 라우트(@team, @analytics)가 이것을 슬롯 단위로 해준 것이다. 슬롯마다 loading.js를 둘 수 있다고 했던 이유가 여기 있다.

fallback 이 화면을 흔든다

<p>불러오는 중…</p>은 한 줄이고, 진짜 내용은 열 줄이다. 도착하는 순간 아래 있던 것들이 우르르 밀린다.

레이아웃 시프트다. 사용자가 누르려던 버튼이 도망간다.

fallback 을 만드는 법
  1. 01

    진짜 내용과 크기를 맞춘다

    카드 세 개가 올 자리면 회색 카드 세 개를 그린다. 높이를 고정한다.

  2. 02

    글자 대신 형태를 보여준다

    "불러오는 중" 보다 스켈레톤이 낫다. 무엇이 올지 미리 알려준다.

  3. 03

    스피너를 남발하지 않는다

    빙글빙글 도는 것이 여러 개 있으면 화면이 산만해진다.

검색어가 바뀌면 다시 로딩을 보여준다

6강의 검색창을 떠올린다. 주소가 ?q=react에서 ?q=next로 바뀌면 결과를 다시 받아야 한다.

그런데 화면에는 옛 결과가 그대로 남아 있다. React는 이미 그린 것을 지우지 않는다.

key를 주면 달라진다.

export default async function Page({ searchParams }) {
  const { q } = await searchParams;

  return (
    <Suspense key={q} fallback={<Skeleton />}>
      <Results query={q} />
    </Suspense>
  );
}

key가 바뀌면 React 편 11강에서 배운 대로 다른 컴포넌트로 취급한다. 옛것을 버리고 fallback부터 다시 시작한다.

key 를 줄 것인가

key 없음

옛 결과가 남는다

  • 검색 중에도 이전 목록이 보인다
  • 깜빡이지 않는다
  • 새 결과인지 헷갈린다

key={q}

매번 초기화

  • 즉시 스켈레톤
  • "찾고 있다"가 분명하다
  • 화면이 한 번 비워진다

정답은 없다. 검색은 key를 주고, 페이지네이션은 안 주는 편이 자연스럽다.

크롤러는 끝까지 기다린다

스트리밍하면 첫 응답에 본문이 없다. 검색봇이 못 읽는 것 아닌가.

읽는다. 조각으로 나눠 보내도 하나의 HTML 응답이다. 크롤러는 응답이 끝날 때까지 받는다. 다 받으면 완성된 HTML을 갖는다.

8강에서 "use client"가 SEO를 해치는 것과는 경우가 다르다. 그쪽은 HTML에 아예 안 들어가는 것이고, 이쪽은 나중에 들어오는 것이다.

Part 4 를 관통한 것

12강부터 여기까지 한 가지 이야기를 했다. 미리 만들 수 있는 것과 요청이 와야 아는 것을 가르는 일.

한 페이지가 세 종류로 나뉜다
  • /posts/1
    • 동기 계산 · 고정 마크업그냥 껍데기에 들어간다
    • "use cache" 붙인 것저장해뒀다가 껍데기에 넣는다
    • <Suspense> 로 감싼 것fallback 만 껍데기에, 내용은 흘려보낸다

5강에서 "await 하기 전까지는 미리 그릴 수 있다"고 했다. 그 문장이 Part 4 전체였다.

정리하면

  • <Suspense>는 **"여기부터 나중에 보내도 된다"**는 표시다. 그 아래를 조각으로 흘려보낸다.
  • 총 시간은 줄지 않는다. 첫 화면이 보이는 시각이 당겨진다.
  • loading.js페이지 전체를 감싸는 <Suspense>다. 빠른 것까지 함께 기다린다.
  • 경계 안에서 await를 줄줄이 쓰면 소용없다. Promise.all로 묶거나 경계를 쪼갠다.
  • <Suspense>는 컴포넌트를 동적으로 만들지 않는다. 기다릴 수 있게 할 뿐이다.
  • fallback은 진짜 내용과 크기를 맞춘다. 스켈레톤이 스피너보다 낫다.
  • 너무 빨리 오는 것은 감싸지 않는다. 깜빡임이 더 나쁘다.
  • key를 주면 값이 바뀔 때 fallback부터 다시 시작한다.
  • 스트리밍해도 크롤러는 다 읽는다. 하나의 응답이기 때문이다.
  • 루트 레이아웃의 <body>를 감싸면 정적 껍데기를 잃는다.

Part 4가 끝났다. 렌더링이 언제 일어나고, 무엇이 저장되고, 무엇이 흘러오는지 알았다.

다음 강의부터 Part 5, 실전이다. 지금까지 만든 페이지에는 <title>이 없다. 검색 결과에 무엇이 뜰지, 카카오톡에 링크를 붙였을 때 무엇이 보일지 정해야 한다. 5강에서 generateMetadata를 잠깐 봤던 그 자리로 돌아간다.

HTML 은 한 번에 도착하지 않아도 된다 — 디코드랩(DCODELAB)