HTML 은 한 번에 도착하지 않아도 된다
Article
지금까지 <Suspense>가 세 번 다른 얼굴로 나타났다.
| 강의 | 무엇을 감쌌나 | 그때 한 말 |
|---|---|---|
| 4강 | 페이지 전체 | loading.js 를 만들면 로딩 화면이 생긴다 |
| 6강 | useSearchParams 를 쓰는 컴포넌트 | 안 감싸면 빌드가 실패한다 |
| 13강 | 캐시하지 않은 데이터 | 안 감싸면 빌드가 실패한다 |
셋은 같은 장치다. 이제 정체를 밝힌다.
응답을 조각내서 보낸다
3강에서 서버가 HTML을 만들어 보낸다고 했다. 그때 암묵적으로 가정한 것이 있다. HTML이 완성돼야 보낼 수 있다는 것이다.
가정을 깬다. HTTP 응답은 조각으로 나눠 보낼 수 있다.
- 첫 조각을 즉시머리말 · 본문 · 로딩 자리
- 연결을 끊지 않는다브라우저는 계속 기다린다
- 느린 것이 준비되면그 조각을 마저 보낸다
- 브라우저가 끼워넣는다로딩 자리를 진짜 내용으로 바꾼다
이걸 스트리밍이라 부른다. 그리고 <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>은 한 줄이고, 진짜 내용은 열 줄이다. 도착하는 순간 아래 있던 것들이 우르르 밀린다.
레이아웃 시프트다. 사용자가 누르려던 버튼이 도망간다.
- 01
진짜 내용과 크기를 맞춘다
카드 세 개가 올 자리면 회색 카드 세 개를 그린다. 높이를 고정한다.
- 02
글자 대신 형태를 보여준다
"불러오는 중" 보다 스켈레톤이 낫다. 무엇이 올지 미리 알려준다.
- 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={q}
매번 초기화
- 즉시 스켈레톤
- "찾고 있다"가 분명하다
- 화면이 한 번 비워진다
정답은 없다. 검색은 key를 주고, 페이지네이션은 안 주는 편이 자연스럽다.
크롤러는 끝까지 기다린다
스트리밍하면 첫 응답에 본문이 없다. 검색봇이 못 읽는 것 아닌가.
읽는다. 조각으로 나눠 보내도 하나의 HTML 응답이다. 크롤러는 응답이 끝날 때까지 받는다. 다 받으면 완성된 HTML을 갖는다.
8강에서 "use client"가 SEO를 해치는 것과는 경우가 다르다. 그쪽은 HTML에 아예 안 들어가는 것이고, 이쪽은 나중에 들어오는 것이다.
Part 4 를 관통한 것
12강부터 여기까지 한 가지 이야기를 했다. 미리 만들 수 있는 것과 요청이 와야 아는 것을 가르는 일.
- /posts/1
- 동기 계산 · 고정 마크업
- "use cache" 붙인 것
- <Suspense> 로 감싼 것
5강에서 "await 하기 전까지는 미리 그릴 수 있다"고 했다. 그 문장이 Part 4 전체였다.
정리하면
<Suspense>는 **"여기부터 나중에 보내도 된다"**는 표시다. 그 아래를 조각으로 흘려보낸다.- 총 시간은 줄지 않는다. 첫 화면이 보이는 시각이 당겨진다.
loading.js는 페이지 전체를 감싸는<Suspense>다. 빠른 것까지 함께 기다린다.- 경계 안에서
await를 줄줄이 쓰면 소용없다.Promise.all로 묶거나 경계를 쪼갠다. <Suspense>는 컴포넌트를 동적으로 만들지 않는다. 기다릴 수 있게 할 뿐이다.fallback은 진짜 내용과 크기를 맞춘다. 스켈레톤이 스피너보다 낫다.- 너무 빨리 오는 것은 감싸지 않는다. 깜빡임이 더 나쁘다.
key를 주면 값이 바뀔 때fallback부터 다시 시작한다.- 스트리밍해도 크롤러는 다 읽는다. 하나의 응답이기 때문이다.
- 루트 레이아웃의
<body>를 감싸면 정적 껍데기를 잃는다.
Part 4가 끝났다. 렌더링이 언제 일어나고, 무엇이 저장되고, 무엇이 흘러오는지 알았다.
다음 강의부터 Part 5, 실전이다. 지금까지 만든 페이지에는 <title>이 없다. 검색 결과에 무엇이 뜰지, 카카오톡에 링크를 붙였을 때 무엇이 보일지 정해야 한다. 5강에서 generateMetadata를 잠깐 봤던 그 자리로 돌아간다.