문서 읽는 데 95분 · D4

D-4: 서버 vs 클라이언트 컴포넌트 ② — 서버가 기다린다

목차 63
전체 59강 중 25강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

숫자 세 개로 시작할게요.

지금 우리 앱에서 /jaehoon 프로필 화면이 뜨는 데 걸리는 시간은 6밀리초인데, 오늘 수업이 끝나면 그 화면은 1,207밀리초가 걸려서 200배 느려져요.

안녕하세요, 홍순구입니다. 네, 오늘 우리는 앱을 일부러 느리게 만듭니다.

지금 빠른 이유는 간단해요. 우리 데이터가 lib/posts.ts라는 파일에 적혀 있어서 값을 꺼내는 데 시간이 안 걸리는데, 진짜 서비스에서 게시물 열 장은 파일에 적혀 있지 않다 보니 어딘가에 물어봐야 하고 물어보면 기다려야 합니다.

그러니 오늘의 1,207밀리초는 퇴보가 아니라 정상화인데, 그 느림을 그냥 두지는 않을 거예요. 오늘 안에 810밀리초를 407밀리초로 줄이고, 첫 글자가 화면에 닿는 시간은 810밀리초에서 6밀리초로 되돌립니다.

💡 오늘 수업의 핵심 — "기다림은 없앨 수 없습니다. 어디서 기다릴지 정할 뿐이에요"

지난 시간에 우리는 선을 그었습니다. 무엇을 서버에 두고 무엇을 브라우저로 보낼 것인가.

오늘은 그 선의 서버 쪽에서 무엇을 할 수 있는지를 보는데, 답이 좀 허무해요. 서버 컴포넌트에서 데이터를 가져오는 방법은 그냥 기다렸다 받는 것입니다. 훅도 없고, 로딩 상태도 없고, 에러 상태도 없어요.

대신 새로운 질문이 세 개 생깁니다. 서버가 기다리는 동안 사용자는 뭘 보고 있어야 하나, 기다리는 것이 여러 개면 순서를 어떻게 하나, 브라우저에서 도는 부분이 서버 데이터를 써야 하면 어떻게 넘기나.

🎯 학습 목표

  • 클라이언트 컴포넌트 안에 서버 컴포넌트를 넣는 방법을 알아내고, 그것이 왜 되는지 설명합니다.
  • 서버 컴포넌트에서 await fetch로 데이터를 직접 가져오고, 지금까지 배운 세 가지 방식과 나란히 놓고 비교합니다.
  • loading.tsx 파일 하나가 무엇을 바꾸는지 밀리초로 잽니다.
  • 여러 요청을 동시에 출발시키고, 느린 것만 따로 떼어 흘려보냅니다.
  • 서버가 만든 Promise를 클라이언트 컴포넌트가 use로 읽게 합니다.
텍스트
 오늘의 여정

 1. children 이라는 구멍       클라이언트 안에 서버를 넣는다
 2. 서버에서는 그냥 기다린다    훅도 상태도 없다
 3. 흉내를 진짜로              세 번에 한 번 실패하던 것을 걷어낸다
 4. 기다리는 동안 무엇을         파일 이름 하나로 정해진다
 5. 동시에 시작한다            810ms -> 407ms
 6. 더 잘게 나눈다             떼는 것과 출발시키는 것은 다르다
 7. 껍데기가 기다리면           그 값이 첫 글자에 그대로 찍힌다
 8. 서버가 만들고 클라이언트가 읽는다

Step 1: "children이라는 구멍"

지난 시간 과제의 답부터

지난 시간 마지막 과제, 해보셨나요? app/layout.tsx 맨 위에 'use client'를 붙여보라고 했죠.

붙이면 두 번 막힙니다. 먼저 metadata를 내보낼 수 없다는 에러가 뜨는데, 그걸 걷어내고 다시 빌드하면 이번엔 통과하고 자바스크립트를 재보면 241바이트밖에 안 늘어납니다.

이상하죠. 앱에서 가장 바깥 껍데기를 브라우저로 보냈는데 겨우 241바이트라니요. 피드도, 탐색도, 프로필도 다 그 안에 들어 있는데요.

소스 보기를 열어보면 더 이상한데, 게시물 글자가 그대로 있어서 서버가 그렸다는 뜻이에요.

선은 import를 따라 내려갑니다

지난 시간 규칙을 글자 그대로 다시 읽어봅시다. 'use client'는 그 파일이 불러다 쓰는(import) 것 전부에 걸린다.

app/layout.tsx를 열어서 확인해보세요. 피드 페이지를 import 하는 줄이 있나요?

없습니다. 페이지는 children으로 들어옵니다.

텍스트
 layout.tsx 가 실제로 하는 일

   import { HeaderNav }  ── 따라간다. HeaderNav 는 클라이언트가 된다.

   { children }          ── 안 따라간다.
                             누가 들어올지 이 파일은 모른다.
                             Next 가 서버에서 미리 그려서 결과만 건네준다.

이게 오늘 첫 번째 도구입니다. import로 받으면 딸려 가고, children으로 받으면 안 딸려 갑니다.

지난 시간에 "빠져나갈 구멍이 하나 있다"고만 말하고 넘어갔던 것이 이건데, 이름을 붙이자면 우리는 이걸 그냥 children으로 넘기기라고 부르겠습니다.

이 구멍이 왜 필요한가

C-3에서 만들었던 ThemeProvider 기억하시죠. createContext로 통로를 만들고 useState로 값을 들고 있다가 아래로 흘려보내는 컴포넌트요.

Next에서 그걸 쓰려면 문제가 생깁니다. Provider는 useState를 쓰니까 반드시 클라이언트 컴포넌트여야 하는데, 앱 전체를 감싸야만 어디서든 값을 꺼내 쓸 수 있거든요.

앱 전체가 브라우저로 가는 걸까요?

직접 만들어서 확인해봅시다. 글자 크기를 키우는 작은 통로를 하나 만들게요.

tsx
// apps/web-next/app/components/Providers.tsx
'use client';

import { createContext, useContext, useState, type ReactNode } from 'react';

type TextScaleValue = {
  large: boolean;
  toggle: () => void;
};

// 통로를 하나 만든다. C-3 에서 만든 ThemeContext 와 같은 모양이다.
const TextScaleContext = createContext<TextScaleValue>({
  large: false,
  toggle: () => {},
});

// 상태를 드는 곳이라 브라우저에서 돌아야 한다.
// 그런데 안에 그릴 것은 여기서 만들지 않는다 — children 으로 받아 그 자리에 놓기만 한다.
export function Providers({ children }: { children: ReactNode }) {
  const [large, setLarge] = useState(false);

  return (
    <TextScaleContext value={{ large, toggle: () => setLarge(!large) }}>
      <div className={large ? 'text-lg' : undefined}>{children}</div>
    </TextScaleContext>
  );
}

// 꺼내 쓰는 쪽은 Context 가 무엇인지 몰라도 된다. 이 훅만 부르면 된다.
export function useTextScale() {
  return useContext(TextScaleContext);
}

<Context value> 문법이 낯설지 않으시죠. C-3에서 .Provider를 안 붙이는 이유까지 다뤘습니다.

가장 바깥 껍데기에서 이걸 씌워봅시다.

tsx
// apps/web-next/app/layout.tsx
export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="ko">
      <body className="min-h-screen bg-white text-black antialiased">
        {/* 브라우저에서 도는 껍데기다. 안에 든 것은 여기서 만들지 않고 children 으로 받는다. */}
        <Providers>
          <header className="border-b border-black/10">
            <HeaderNav />
          </header>
          {children}
        </Providers>
      </body>
    </html>
  );
}

머리말에 버튼도 하나 답시다. HeaderNav는 이미 클라이언트 컴포넌트라 통로에서 값을 바로 꺼낼 수 있어요.

tsx
// apps/web-next/app/components/HeaderNav.tsx
export function HeaderNav() {
  const pathname = usePathname();
  const { large, toggle } = useTextScale();

  return (
    <nav className="mx-auto flex max-w-3xl items-center gap-4 p-4 text-sm">
      {/* 이름·홈·탐색 링크·검색칸은 그대로 두고 아래 버튼만 더한다 */}
      <UserSearch />
      <button
        type="button"
        aria-pressed={large}
        onClick={toggle}
        className="rounded border border-black/15 px-2 py-1"
      >
        글자 크게
      </button>
    </nav>
  );
}

재봅시다

지난 시간에 쓰던 방법 그대로입니다. 빌드하고, 띄우고, 각 주소가 받아 가는 자바스크립트를 재요.

Bash
npm run build
npm run start

지난 시간 마지막 수치와 나란히 놓으면 이렇습니다.

주소 지난 시간 끝 지금 차이
/ 576,272 B 576,822 B +550 B
/explore 575,819 B 576,369 B +550 B

앱 전체를 클라이언트 컴포넌트로 감쌌는데 550바이트입니다. 그 550바이트는 Providers 자신의 코드예요. 안에 든 것은 한 글자도 안 따라왔습니다.

확인 사살을 해봅시다. 게시물 글자가 어디에 있는지 세보세요.

텍스트
 HTML 에서       '한강 노을이 미쳤다'   1건    서버가 그려서 보냈다
 JS 청크에서     '한강 노을이 미쳤다'   0건    브라우저로는 안 갔다

빌드 로그의 라우트 표도 그대로입니다. /는 여전히 표시, 그러니까 미리 그려둔 정적 화면이에요.

그럼 import로 하면 어떻게 되나

말로만 하면 안 믿기니까 직접 깨봅시다.

피드 목록을 별도 컴포넌트로 떼어두고, Providers가 그걸 import 해서 안에서 직접 그리게 바꿔보세요.

tsx
// apps/web-next/app/components/Providers.tsx
import { FeedList } from './FeedList';   // ← 이 줄을 더한다

      <div className={large ? 'text-lg' : undefined}>
        <FeedList />                     {/* ← children 이 아니라 import 로 받았다 */}
        {children}
      </div>

빌드해보면 통과합니다. 에러도, 경고도 없고 재보면 이렇게 됩니다.

주소 children import 차이
/ 576,822 B 578,442 B +1,620 B
/explore 576,369 B 578,442 B +2,073 B

/explore를 보세요. 탐색 화면은 피드 목록을 그리지도 않는데 2,073바이트를 더 받습니다. 가장 바깥 껍데기에 붙였으니 모든 주소가 값을 치르는 거예요.

게시물 글자를 다시 세보면 JS 청크에서 1건이 나오는데, 데이터가 브라우저로 새어 나간 거예요. 지난 시간 탐색 화면에서 봤던 그 유출이 이번엔 앱 전체 규모로 벌어진 셈입니다.

⚠️ 하나 더 짚고 갈 게 있습니다. import로 바꾼 뒤 스크립트 개수는 8개에서 7개로 줄었는데, 바이트는 오히려 늘었어요. 번들러가 청크를 합치면서 생긴 일이라 개수로 재면 좋아진 줄 착각하기 쉽습니다. 항상 바이트로 재세요.

확인이 끝났으면 children 판으로 되돌려주세요.

🙋 학생 질문 — "Provider는 무조건 가장 바깥에 두면 되나요?"

되긴 하는데, 그게 항상 최선은 아니에요.

Providers 자체는 브라우저로 갑니다. 550바이트든 5킬로바이트든, 그 안에 든 훅과 로직은 모든 주소가 받아요. 통로가 정말 앱 전체에 필요하면 그게 맞습니다. 글자 크기나 로그인 정보처럼요.

특정 화면에서만 쓰는 통로라면 그 화면 아래로 내려두는 게 나은데, 지난 시간에 배운 원칙이 여기서도 그대로예요. 경계는 작게, 트리 아래로.

판단 기준은 간단합니다. 이 값을 꺼내 쓰는 곳이 앱 전체에 흩어져 있는가, 아니면 한 구역에 몰려 있는가.

🙋 학생 질문 — "children으로 넘기면 서버 컴포넌트가 브라우저에서 다시 그려지나요?"

아니요. 서버에서 한 번 그려지고 끝입니다.

브라우저가 받는 것은 그 결과예요. 정확히는 React가 읽을 수 있는 형태로 옮겨 적은 결과물이고, 클라이언트 컴포넌트는 그것을 자기 children 자리에 그대로 끼워 넣습니다.

그러니 Providers가 다시 그려져도 — 예를 들어 글자 크게 버튼을 눌러서 — 안에 든 피드는 다시 안 그려지는데, 애초에 브라우저에 그 코드가 없어서 다시 그릴 수도 없어요.

💡 한 줄 정리

import로 받으면 딸려 가고 children으로 받으면 안 딸려 갑니다. 이 차이 하나로 클라이언트 껍데기 안에 서버 컴포넌트를 넣을 수 있어요.


Step 2: "서버에서는 그냥 기다립니다"

먼저 서버를 띄웁니다

C-6에서 만들어둔 연습용 API 서버를 다시 씁니다. 설치할 것은 없어요.

Bash
node apps/api-stub/server.mjs

이렇게 뜹니다.

텍스트
 [api-stub] http://localhost:8090/api — 지연 400ms · ...

지연 400ms를 기억해두세요. 오늘 나오는 모든 숫자가 이 400에서 나옵니다. 진짜 서버는 즉시 답하지 않으니까 일부러 넣어둔 값이에요.

가져오는 함수를 하나 만듭니다

tsx
// apps/web-next/lib/api.ts
import type { Post } from './posts';

// 브라우저가 아니라 서버가 부른다. 서버에는 "지금 보고 있는 주소" 같은 게 없어서
// 상대 경로(/api/posts)로는 어디로 갈지 정할 수 없다 — 주소를 통째로 적는다.
const API_BASE = 'http://localhost:8090/api';

// 백엔드는 늘 같은 봉투로 답한다. C-6 에서 만든 것과 같은 모양이다.
type ApiEnvelope<T> = {
  success: boolean;
  data: T | null;
  message: string | null;
};

async function get<T>(path: string): Promise<T> {
  const response = await fetch(`${API_BASE}${path}`);

  if (!response.ok) {
    throw new Error(`서버가 ${response.status} 로 답했습니다 (${path})`);
  }

  const envelope: ApiEnvelope<T> = await response.json();

  if (!envelope.success || envelope.data === null) {
    throw new Error(envelope.message ?? `데이터를 받지 못했습니다 (${path})`);
  }

  return envelope.data;
}

export function fetchPosts(): Promise<Post[]> {
  return get<Post[]>('/posts');
}

봉투를 벗기는 부분은 C-6에서 하던 그대로입니다. success를 확인하고 data를 꺼내죠. 달라진 것은 이 함수를 누가 부르느냐뿐이에요.

이제 피드가 서버에서 물어봅니다

tsx
// apps/web-next/app/components/FeedList.tsx
import { LikeButton } from './LikeButton';
import { fetchPosts } from '@/lib/api';

// 지시어가 없다. 그러니 서버 컴포넌트다.
// 서버에서 도니까 함수 안에서 그냥 기다렸다 받으면 된다 — 훅도, 상태도 없다.
export async function FeedList() {
  const posts = await fetchPosts();

  return (
    <ul className="space-y-4">
      {posts.map((post) => (
        <li key={post.id} className="rounded border border-black/10 p-4">
          <p className="font-semibold">@{post.username}</p>
          <p className="mt-1">{post.content}</p>
          <LikeButton likeCount={post.likeCount} liked={post.liked} />
        </li>
      ))}
    </ul>
  );
}

두 군데가 바뀌었습니다. 함수 앞에 async가 붙었고, 첫 줄이 await fetchPosts()가 됐어요.

그게 전부입니다.

지금까지 배운 것들과 나란히 놓아봅시다

우리는 데이터를 가져오는 방법을 이미 세 번 배웠습니다. 오늘 것까지 네 개를 한 표에 올려볼게요.

언제 누가 부르나 화면 코드에 필요한 것
B-4 useEffect 화면이 뜬 뒤 브라우저 effect, 의존성 배열, 로딩·에러 상태, 정리 함수
C-2 loader 화면이 뜨기 전 브라우저(라우터) 주소 표에 loader 등록, useLoaderData
C-6 useQuery 화면이 뜬 뒤 브라우저 queryKey, queryFn, isPending, error
오늘 화면을 그리는 중 서버 asyncawait

마지막 줄에 훅이 하나도 없다는 것을 보세요. 로딩 상태도 없고 에러 상태도 없습니다.

없어도 되는 이유는 함수가 아직 안 끝났기 때문이에요. useEffect는 화면을 이미 그려놓고 나중에 데이터를 채우니까 "아직 없음"이라는 상태가 필요했습니다. 서버 컴포넌트는 데이터가 올 때까지 아예 결과를 안 내놓아요. "아직 없음"이라는 상태가 존재할 수 없습니다.

🙋 학생 질문 — "왜 주소를 http://localhost:8090/api로 통째로 적나요? 그냥 /api/posts 면 안 되나요?"

브라우저에서는 됩니다. 브라우저는 지금 어느 주소에 있는지 알거든요. /api/posts라고 쓰면 지금 보고 있는 사이트의 그 경로로 알아서 붙여줍니다.

서버에는 그 기준이 없다 보니, 서버 코드가 fetch('/api/posts')를 만나면 "어느 사이트의 /api/posts인데?" 하고 막혀서 주소를 통째로 적어야 해요.

실무에서는 이 값을 환경 변수로 뺍니다. 개발할 때와 배포했을 때 서버 주소가 다르니까요. 지금은 배우는 중이라 눈에 보이게 상수로 뒀습니다.

재봅시다

텍스트
 /  자바스크립트   576,822 B   안 변했다
 /  HTML            7,573 B  12,484 B

자바스크립트가 한 바이트도 안 늘었습니다. 데이터를 파일에서 꺼내던 것을 서버에 물어보는 것으로 바꿨는데, 브라우저가 받는 코드에는 아무 변화가 없어요.

당연합니다. fetch를 부르는 코드는 서버에 있으니까요. 브라우저는 그런 게 있었다는 사실조차 모릅니다.

HTML이 늘어난 것은 게시물이 두 장에서 열 장이 됐기 때문인데, 화면에 뜨는 것도 파일에 적어둔 두 장이 아니라 서버가 준 열 장이에요.

⚠️ 여기서 함정 하나. API 서버를 안 띄운 채로 빌드하면 이렇게 죽습니다.

텍스트
 Error occurred prerendering page "/"
 TypeError: fetch failed ... code: 'ECONNREFUSED'
 Export encountered an error on /page: /, exiting the build.

Next는 빌드하는 동안 화면을 미리 그려보려고 하는데, 그때 우리가 쓴 fetch를 실제로 부릅니다. 받아줄 서버가 없으면 빌드가 끝까지 못 갑니다. 그리고 이 에러는 Compiled successfully가 지나간 뒤에 납니다. 빌드가 잘 됐는지는 화면에 지나간 글자가 아니라 종료 코드로 확인하세요.

⚠️ 함정 둘. 빌드 로그의 라우트 표를 다시 보세요.

텍스트
 ○ /            ○ /_not-found     ƒ /[username]     ○ /explore

/가 아직도 입니다. 서버에 물어보는 코드를 넣었는데도 정적 화면이에요. 빌드할 때 한 번 물어보고, 그 답이 HTML에 그대로 굳었다는 뜻입니다.

믿기지 않으면 이렇게 해보세요. 앱을 띄워둔 채로 API 서버를 완전히 꺼버리고 홈 화면을 새로고침하는 겁니다. 멀쩡하게 뜹니다. 서버에 물어보지도 않으니, 오늘 우리가 기다림을 다룰 무대는 홈이 아니라 프로필인데, 라우트 표에서 ƒ가 붙은 그 화면이에요. ƒ는 요청이 올 때마다 서버가 새로 그린다는 표시입니다.

홈이 왜 굳어 있는지, 그리고 요청마다 새로 물어보게 하려면 무엇이 필요한지는 다음 시간에 정면으로 다룹니다.

💡 한 줄 정리

서버 컴포넌트에서 데이터를 가져오는 방법은 async 함수 안에서 await 하는 것입니다. 훅도 상태도 필요 없고, 브라우저로 가는 자바스크립트도 안 늘어납니다.


Step 3: "흉내를 진짜로"

두 시간 미뤄둔 약속

D-2에서 에러 화면을 배울 때 이런 파일을 만들었습니다.

TypeScript
// apps/web-next/lib/follower-stats.ts
let attempts = 0;

export function loadFollowerCount(username: string): number {
  attempts += 1;

  if (attempts % 3 !== 0) {
    throw new Error(`팔로워 집계 서버가 응답하지 않습니다 (${username})`);
  }

  return username.length * 137;
}

세 번에 한 번만 성공하는 가짜입니다. 에러 화면을 보여주려고 일부러 실패를 만들어낸 거였죠. 그때 "진짜 서버는 나중에"라고 적어뒀는데, 그 나중이 지금이라 연습용 API 서버에 프로필 주소를 하나 열게요.

JavaScript
// apps/api-stub/server.mjs
  // 프로필 — 게시물을 올린 사람이면 로그인 계정이 없어도 프로필이 있다.
  {
    method: 'GET',
    match: (path) => /^\/api\/users\/[^/]+$/.test(path),
    handle: async (req, res, path) => {
      const username = decodeURIComponent(path.split('/').at(-1));
      if (!posts.some((it) => it.username === username)) {
        return fail(res, 404, '그런 사람이 없습니다');
      }
      ok(res, {
        username,
        profileImageUrl: seed(username, 64),
        followerCount: FOLLOWER_COUNTS[username] ?? 0,
      });
    },
  },

게시물 목록도 사람으로 걸러 받을 수 있게 username 조건을 붙였습니다. 그러면 가져오는 함수 둘이 더 생겨요.

tsx
// apps/web-next/lib/api.ts
/** 프로필 머리에 쓰는 집계값 */
export type Profile = {
  username: string;
  profileImageUrl: string;
  followerCount: number;
};

export function fetchProfile(username: string): Promise<Profile> {
  return get<Profile>(`/users/${encodeURIComponent(username)}`);
}

export function fetchPostsByUsername(username: string): Promise<Post[]> {
  return get<Post[]>(`/posts?username=${encodeURIComponent(username)}`);
}

프로필 화면은 이렇게 바뀝니다.

tsx
// apps/web-next/app/[username]/page.tsx
export default async function ProfilePage({ params }: PageProps<'/[username]'>) {
  const { username } = await params;

  // 둘 다 서버에서 직접 물어본다. 훅도, 로딩 상태도 없다.
  const profile = await fetchProfile(username);
  const posts = await fetchPostsByUsername(username);

  return (
    <>
      <p className="mb-4 text-sm text-black/60">팔로워 {profile.followerCount}</p>
      <ul className="grid grid-cols-3 gap-2">
        {posts.map((post) => (
          <li key={post.id} className="aspect-square rounded bg-black/5 p-3 text-sm">
            <p>{post.content}</p>
            <p className="mt-1 text-black/60">좋아요 {post.likeCount}</p>
          </li>
        ))}
      </ul>
    </>
  );
}

그리고 lib/follower-stats.ts는 흉내를 낼 이유가 없어졌으니 지웁니다.

화면이 달라진 것을 보세요

새로고침하면 숫자 두 개가 바뀝니다.

텍스트
 팔로워    959    1,240
 게시물      3        1

959는 'jaehoon'이 일곱 글자라서 7 × 137로 나온 값이었어요. 가짜 공식이었죠. 1,240은 서버가 들고 있는 진짜 값입니다.

게시물이 3장에서 1장으로 준 것도 같은 이유예요. 우리 파일에서는 네 사람이 열 장을 나눠 갖고 있었는데, 서버 쪽 데이터는 열 사람이 한 장씩 갖고 있습니다.

숫자가 바뀌었다는 것 자체가 출처가 바뀌었다는 증거인데, 화면의 값도 이제 우리가 적어둔 것이 아니라 서버가 정해요.

⚠️ 그런데 화면을 자세히 보면 어긋난 데가 있습니다. 위쪽 프로필 머리에는 게시물 3이라고 쓰여 있는데 아래 격자에는 한 장뿐이에요.

프로필 껍데기(app/[username]/layout.tsx)가 아직 파일을 보고 있는데 격자는 서버를 보고 있어서, 한 화면 안에서 두 출처가 서로 다른 답을 하고 있는 겁니다. 이건 Step 7에서 정리할 테니 지금은 눈에 담아두기만 하세요.

널뛰던 숫자가 멈췄습니다

지난 시간에 프로필 화면의 HTML 크기를 재보면 값이 5,977과 8,533 사이를 오갔습니다. 흉내 서버가 세 번에 한 번만 성공했으니까요. 성공하면 프로필, 실패하면 에러 화면이 그려졌던 겁니다.

지금 여섯 번 연속으로 재보세요.

텍스트
 7,960  7,960  7,960  7,960  7,960  7,960

고정입니다. 실패를 만들어내던 것이 사라졌으니까요.

그럼 이제 에러 화면은 언제 보나

진짜로 실패시키면 됩니다. API 서버를 끄고 프로필로 들어가보세요.

머리말은 그대로 있고, 프로필 머리(@jaehoon)도 그대로 있고, 격자가 있던 부분만 이렇게 갈립니다.

텍스트
 ┌──────────────────────────────┐
 │  인스타그램 클론  홈  탐색     │   남는다
 ├──────────────────────────────┤
 │  ( )  @jaehoon               │   남는다
 │                              │
 │  ┌────────────────────────┐  │
 │  │ 이 부분을 불러오지 못했어요│  │   여기만 갈린다
 │  │ 잠시 뒤 다시 시도해주세요  │  │
 │  │ [ 다시 시도 ]            │  │
 │  └────────────────────────┘  │
 └──────────────────────────────┘

지난 시간에 배운 error.tsx의 범위 그대로입니다. 자기 구간의 page는 감싸고 같은 칸의 layout은 안 감싸죠. 달라진 것은 실패의 정체뿐이에요. 우리가 만들어낸 가짜가 아니라 진짜로 서버에 못 닿은 것입니다.

🙋 학생 질문 — "브라우저에서는 에러 화면이 보이는데 소스 보기에는 안 보여요."

맞습니다. 그리고 이건 정상이에요.

서버가 화면을 그리다 실패하면 Next는 껍데기만 담긴 HTML을 보냅니다. 그 안에 에러 화면을 그리는 데 필요한 정보를 실어 보내고, 실제로 글자를 만드는 것은 브라우저예요.

지난 시간에 없는 주소로 들어갔을 때도 같은 일이 있었죠. 화면에는 "없는 주소"가 뜨는데 소스 보기는 비어 있었으니, 이런 것을 확인할 때는 소스 보기 대신 개발자 도구의 요소 탭을 보거나 그냥 눈으로 화면을 보세요. 상태 코드는 네트워크 탭에서 확인할 수 있습니다. 이 경우 500입니다.

💡 한 줄 정리

흉내를 걷어내고 진짜 서버를 붙였습니다. 화면의 숫자가 바뀌었고, 널뛰던 값이 고정됐고, 이제 에러 화면은 진짜 실패일 때만 뜹니다.


Step 4: "기다리는 동안 무엇을 보여줄까"

810밀리초를 눈으로 봅시다

프로필 화면이 뜨는 데 얼마나 걸리는지 재봅시다.

Bash
curl -s -o /dev/null -w "첫 글자=%{time_starttransfer}s 전체=%{time_total}s\n" http://localhost:3000/jaehoon
텍스트
 첫 글자=0.810s 전체=0.811s

810밀리초. 400밀리초짜리 요청을 두 번 순서대로 했으니 당연한 값이에요.

문제는 첫 글자도 810밀리초라는 겁니다. 그때까지 사용자 화면에는 아무것도 없어요. 링크를 눌렀는데 0.8초 동안 이전 화면이 그대로 멈춰 있습니다.

C-2에서 loader를 배울 때 이 이야기가 나왔었죠. "400밀리초 도는 동안 아무 표시도 안 하면 답답하지 않나요"라는 질문에, 짧으면 오히려 낫고 길어지면 문제라고 답하면서 기다림을 어떻게 보여줄지는 그다음 이야기라고 미뤄뒀습니다.

그 다음 이야기가 지금입니다.

파일 하나면 됩니다

D-1에서 App Router의 파일 규칙 표를 볼 때 이름만 부르고 넘어간 것이 있었어요.

파일 언제
loading.tsx 기다리는 동안

그걸 만듭니다.

tsx
// apps/web-next/app/[username]/loading.tsx

// 이름이 loading 이면 Next 가 알아서 쓴다. 어디에 놓으라고 따로 적지 않는다.
// 이 파일은 같은 칸의 page 와 그 아래를 감싼다 — 같은 칸의 layout 은 안 감싼다.
export default function ProfileLoading() {
  return (
    <>
      <p className="mb-4 text-sm text-black/40">팔로워 세는 중…</p>
      <ul className="grid grid-cols-3 gap-2">
        {[0, 1, 2].map((slot) => (
          <li key={slot} className="aspect-square animate-pulse rounded bg-black/5" />
        ))}
      </ul>
    </>
  );
}

등록하는 코드가 없습니다. 어디에도 이 파일을 가져다 쓰라고 안 적었어요. app/[username]/ 안에 loading.tsx라는 이름으로 있기만 하면 Next가 알아서 씁니다.

D-1에서 배운 원칙 그대로예요. 폴더와 파일 이름이 곧 규칙입니다.

다시 재봅시다

텍스트
 loading.tsx 없음   첫 글자 = 0.810s   전체 = 0.811s
 loading.tsx 있음   첫 글자 = 0.006s   전체 = 0.810s

첫 글자가 810밀리초에서 6밀리초로 줄었습니다. 그런데 전체 시간은 810밀리초 그대로예요.

이 두 숫자를 같이 봐야 합니다. 앱이 빨라진 게 아닙니다. 데이터는 여전히 800밀리초 뒤에 도착해요. 달라진 것은 그 800밀리초 동안 사용자가 빈 화면 대신 무언가를 보고 있다는 것뿐인데, 사용자 입장에서는 그게 전부이기도 합니다. 눌렀는데 아무 반응이 없는 0.8초와, 눌렀더니 뼈대가 뜨고 0.8초 뒤 채워지는 것은 완전히 다른 경험이에요.

무엇이 남고 무엇이 갈리나

지연을 3초로 늘려놓고 화면을 보면 이렇습니다.

텍스트
 ┌──────────────────────────────┐
 │  인스타그램 클론  홈  탐색     │   남는다 (바깥 껍데기)
 ├──────────────────────────────┤
 │  ( )  @minji                 │   남는다 (프로필 껍데기)
 │       게시물 3               │
 │                              │
 │  팔로워 세는 중…              │   여기부터 갈린다
 │  ┌────┐ ┌────┐ ┌────┐        │
 │  │    │ │    │ │    │        │   격자 뼈대
 │  └────┘ └────┘ └────┘        │
 └──────────────────────────────┘

어디서 본 모양이죠. 지난 시간 error.tsx와 똑같습니다.

감싸는 것 안 감싸는 것
error.tsx 같은 칸의 page와 그 아래 같은 칸의 layout
loading.tsx 같은 칸의 page와 그 아래 같은 칸의 layout

두 파일이 같은 규칙을 씁니다. 그래서 껍데기는 살아 있고 안쪽만 갈려요. 사용자는 자기가 어느 화면에 있는지 계속 알 수 있습니다.

C-8에서 화면 코드를 나눠 받을 때 <Suspense><Outlet>만 감쌌던 것 기억나세요? 머리말은 바깥에 두고 안쪽만 감쌌죠. 완전히 같은 구조입니다. 다만 그때 기다린 것은 코드였고, 지금 기다리는 것은 데이터예요.

🙋 학생 질문 — "홈 화면에도 loading.tsx를 만들었는데 안 떠요."

정상입니다. 홈은 기다릴 게 없거든요.

Step 2에서 봤듯이 /는 빌드할 때 미리 그려진 정적 화면입니다. 요청이 오면 이미 만들어둔 HTML을 그냥 보내요. 기다림이 없으니 기다리는 동안 보여줄 것도 필요 없습니다.

실제로 재보면 API 서버 지연을 3초로 늘려놔도 홈은 2밀리초 안에 옵니다. 서버에 물어보지도 않으니까요.

loading.tsx가 일하는 화면은 라우트 표에서 ƒ가 붙은 곳입니다. 지금 우리 앱에서는 프로필뿐이에요.

🙋 학생 질문 — "그럼 뼈대 대신 그냥 '로딩 중'이라고 쓰면 안 되나요?"

되긴 합니다. 그런데 뼈대를 권하는 이유가 있어요.

화면이 두 번 크게 흔들리기 때문입니다. "로딩 중"이라는 한 줄이 떴다가, 데이터가 오면 격자 세 칸짜리 화면으로 확 바뀌죠. 사용자 눈에는 화면이 두 번 새로 그려진 것처럼 보입니다.

뼈대는 최종 화면과 비슷한 크기와 배치를 미리 잡아둡니다. 데이터가 오면 그 안이 채워질 뿐이라 흔들림이 적은데, 다만 과하게 정교한 뼈대를 만드는 것도 낭비라 크기와 개수 정도만 맞춰도 충분해요.

💡 한 줄 정리

loading.tsx 파일 하나로 첫 글자가 810밀리초에서 6밀리초가 됐습니다. 전체 시간은 그대로예요. 빨라진 게 아니라 기다림이 보이게 된 것입니다.


Step 5: "동시에 시작합니다"

810은 왜 810인가

400밀리초짜리 요청 두 개인데 전체가 810밀리초입니다. 400이 아니라 800이에요.

프로필 화면 코드를 다시 보세요.

tsx
// apps/web-next/app/[username]/page.tsx
  const profile = await fetchProfile(username);
  const posts = await fetchPostsByUsername(username);

await가 하는 일을 글자 그대로 읽으면 답이 나옵니다. 여기서 멈추고 결과가 올 때까지 기다려라.

그러니 두 번째 줄은 첫 번째 줄이 끝나기 전에는 시작조차 안 합니다.

텍스트
 지금

 0ms         400ms        800ms
 ├────────────┤            │
 │ fetchProfile           │
              ├────────────┤
              │ fetchPostsByUsername
                            여기서야 화면을 그린다


 원하는 모양

 0ms         400ms
 ├────────────┤
 │ fetchProfile
 ├────────────┤
 │ fetchPostsByUsername
               400ms 에 화면을 그린다

그렇다면 두 번째 요청이 첫 번째 결과를 필요로 할까요? 안 그렇습니다. 둘 다 username 하나만 있으면 되니, 순서대로 할 이유가 없어요.

부르는 것과 기다리는 것을 떼어놓습니다

여기서 자바스크립트의 성질 하나를 써야 합니다. fetch는 부르는 순간 이미 출발합니다. await는 그 결과를 받아 적는 행위일 뿐이에요.

그러니 먼저 둘 다 부르고, 그다음에 기다리면 됩니다.

tsx
// apps/web-next/app/[username]/page.tsx
export default async function ProfilePage({ params }: PageProps<'/[username]'>) {
  const { username } = await params;

  // 둘은 서로의 결과가 필요 없다. 그러니 먼저 둘 다 출발시켜 두고,
  // 그다음에 한꺼번에 기다린다. await 를 두 줄 쓰면 두 번째가 첫 번째를 기다린다.
  const profileRequest = fetchProfile(username);
  const postsRequest = fetchPostsByUsername(username);
  const [profile, posts] = await Promise.all([profileRequest, postsRequest]);

await를 안 붙이고 부르면 결과가 아니라 Promise가 돌아옵니다. "이거 지금 하고 있으니까 나중에 결과 줄게"라는 약속표 같은 것이죠. 그 약속표를 두 개 손에 쥔 다음, Promise.all로 "둘 다 되면 알려줘"라고 한 번에 기다립니다.

Promise.all은 배열을 받아 배열을 돌려줍니다. 순서는 넣은 순서 그대로라 구조 분해로 받으면 돼요. 구조 분해는 A 카테고리에서 이미 손에 익은 문법입니다.

재봅시다

텍스트
 await 두 줄       810ms
 Promise.all       407ms

정확히 절반입니다. 첫 글자는 여전히 6밀리초예요.

코드 세 줄을 옮겼을 뿐인데 사용자가 기다리는 시간이 반으로 줄었고, 요청이 세 개, 네 개로 늘어나면 차이는 더 벌어져요.

⚠️ 다만 Promise.all에는 성질 하나가 딸려 옵니다. 하나라도 실패하면 전체가 실패합니다.

프로필은 잘 왔는데 게시물 목록만 못 받았다면, Promise.all은 그냥 던집니다. 프로필이라도 보여줄 수는 없어요.

우리 화면에서는 그게 맞는데, 게시물 없는 프로필은 반쪽이기 때문이에요. "이건 없어도 화면은 그릴 수 있다"는 것이 섞여 있으면 다른 도구가 필요한데, Promise.allSettled는 실패한 것도 결과에 담아 돌려줘서 온 것만 골라 쓸 수 있어요.

지금은 이름만 알아두고, 실제로 어떤 상황에서 갈리는지는 과제로 남겨둘게요.

🙋 학생 질문 — "그럼 await는 항상 나쁜 건가요? 다 Promise.all로 바꿔야 하나요?"

아니요. 뒤엣것이 앞엣것의 결과를 써야 하면 순서대로 하는 게 맞습니다.

예를 들어 사용자 정보를 먼저 받아서 그 안의 번호로 다른 것을 조회해야 한다면, 두 번째 요청은 첫 번째가 끝나기 전에는 만들 수조차 없어요. 이럴 때는 await를 줄줄이 쓰는 게 정직한 코드입니다.

판단 기준은 하나예요. 아래 요청이 위 요청의 결과를 쓰는가. 안 쓰는데 순서대로 기다리고 있다면 그건 그냥 낭비입니다.

우리 코드에서는 둘 다 username만 쓰고 있었죠. username은 주소에서 온 값이라 둘 다 처음부터 알고 있었습니다.

💡 한 줄 정리

fetch는 부르는 순간 출발하고 await는 받아 적는 행위입니다. 서로 필요 없는 요청은 먼저 다 부르고 Promise.all로 한꺼번에 기다리세요.


Step 6: "더 잘게 나눕니다"

느린 것이 하나 끼면

프로필에 정보를 하나 더 붙여봅시다. 이 사람이 자주 쓰는 해시태그요.

이건 게시물을 전부 훑어서 세야 하는 집계라 다른 조회보다 느립니다. 연습용 서버에도 그렇게 만들어뒀어요.

JavaScript
// apps/api-stub/server.mjs
  // 이 사람이 자주 쓴 해시태그 — 전부 훑어서 세는 집계라 다른 것보다 오래 걸린다.
  {
    method: 'GET',
    match: (path) => /^\/api\/users\/[^/]+\/tags$/.test(path),
    extraDelayMs: 800,
    handle: async (req, res, path) => {
      const username = decodeURIComponent(path.split('/').at(-2));
      const mine = posts.filter((it) => it.username === username);
      if (mine.length === 0) {
        return fail(res, 404, '그런 사람이 없습니다');
      }
      ok(res, [...new Set(mine.flatMap((it) => it.hashtagNames))]);
    },
  },

기본 400밀리초에 800밀리초가 더 붙어서 1,200밀리초입니다.

배운 대로 셋을 한꺼번에 출발시켜 봅시다.

tsx
// apps/web-next/app/[username]/page.tsx
  const [profile, posts, tags] = await Promise.all([
    profileRequest,
    postsRequest,
    tagsRequest,
  ]);

재보면 1,207밀리초입니다.

병렬로 잘 돌렸는데도 이렇게 되는 이유는 명백해요. Promise.all은 셋 다 와야 넘어갑니다. 팔로워와 게시물은 400밀리초에 이미 준비됐는데, 태그 하나 때문에 800밀리초를 더 기다리고 있는 겁니다.

준비된 것부터 먼저 보여주면 안 될까요?

<Suspense>를 다시 만납니다

C-8에서 이미 쓴 적이 있습니다. 화면 코드를 나눠 받으면서, 아직 도착 안 한 화면 자리에 뭘 그릴지 정할 때요.

tsx
// apps/web-spa/src/routes/Layout.tsx (C-8 에서 만든 것)
      <Suspense fallback={<p className="text-sm text-faint">화면을 불러오는 중이에요…</p>}>
        <Outlet />
      </Suspense>

문법은 그때와 똑같습니다. 달라지는 것은 무엇을 기다리느냐뿐이에요.

기다리는 것 준비되면
C-8 아직 안 내려받은 코드 그 화면을 그린다
오늘 아직 안 도착한 데이터 그 데이터로 그린다

느린 조각을 떼어냅니다

태그 부분만 별도 컴포넌트로 뺍니다.

tsx
// apps/web-next/app/components/TopTags.tsx

// 느린 조각을 별도 컴포넌트로 떼어냈다.
// 요청은 부모가 미리 걸어두고, 여기는 그 결과만 기다린다.
export async function TopTags({ tags }: { tags: Promise<string[]> }) {
  const list = await tags;

  return (
    <p className="mb-4 text-sm text-black/60">
      자주 쓰는 태그 {list.map((tag) => `#${tag}`).join(' ')}
    </p>
  );
}

props 타입을 보세요. string[]이 아니라 Promise<string[]>입니다. 결과가 아니라 약속표를 받는데, 페이지는 이렇게 씁니다.

tsx
// apps/web-next/app/[username]/page.tsx
  // 셋 다 여기서 출발시킨다. 셋은 서로의 결과가 필요 없다.
  const profileRequest = fetchProfile(username);
  const postsRequest = fetchPostsByUsername(username);
  const tagsRequest = fetchTopTags(username);

  // 화면을 그리는 데 꼭 필요한 둘만 여기서 기다린다.
  const [profile, posts] = await Promise.all([profileRequest, postsRequest]);

  return (
    <>
      <p className="mb-4 text-sm text-black/60">팔로워 {profile.followerCount}</p>
      {/* 느린 조각은 여기서 안 기다린다. 준비되면 그때 이 자리에 끼워 넣는다. */}
      <Suspense fallback={<p className="mb-4 text-sm text-black/40">태그 세는 중…</p>}>
        <TopTags tags={tagsRequest} />
      </Suspense>

tagsRequest는 만들어만 놓고 Promise.all에는 안 넣은 채, 그 약속표를 TopTags에게 그대로 넘겨요.

여기서 함정에 한 번 빠져봅시다

"떼어내면 되는 거 아냐?" 하고 이렇게 쓰기 쉽습니다. TopTags가 자기 안에서 요청을 시작하는 판이요.

tsx
// 이렇게 쓰기 쉽다 — 그런데 느려진다
export async function TopTags({ username }: { username: string }) {
  const list = await fetchTopTags(username);
  // ...
}

page.tsx가 훨씬 깔끔해지고 태그 요청을 아예 몰라도 되는데, 막상 재보면 이렇습니다.

첫 글자 팔로워·격자 전체 완료
셋 다 같이 기다림 7ms 1,207ms 1,207ms
떼되 안에서 요청 시작 7ms 407ms 1,612ms
떼고 요청은 미리 출발 7ms 407ms 1,207ms

가운데 줄을 보세요. 팔로워와 격자는 400밀리초에 잘 나옵니다. 그런데 전체는 오히려 400밀리초 느려졌어요.

이유는 이렇습니다. TopTags는 부모가 그려진 다음에 그려지고, 요청은 그때 시작돼요. 부모가 400밀리초를 쓰고, 그 뒤에 1,200밀리초짜리가 출발하니 합이 1,600밀리초입니다.

텍스트
 안에서 요청 시작

 0ms         400ms                    1,600ms
 ├────────────┤                        │
 │ 부모(팔로워·격자)                    │
              ├────────────────────────┤
              │ 태그 (여기서야 출발)     


 미리 출발시켜 넘기기

 0ms         400ms        1,200ms
 ├────────────┤            │
 │ 부모                    │
 ├─────────────────────────┤
 │ 태그 (처음부터 출발)      

떼어내는 것과 언제 출발시키느냐는 별개의 결정입니다. 떼어내기만 하면 그 조각이 늦게 출발해요. 부모에서 미리 부르고 약속표를 넘겨야 둘 다 얻습니다.

준비된 것부터 나갑니다

고친 판으로 재보면, 요청 0.9초에서 응답을 끊어도 이미 이런 것들이 들어 있습니다.

텍스트
 게시물 1 · 팔로워 1240       왔다
 [격자]                      왔다
 태그 세는 중…                아직, 자리만 잡고 있다

서버가 화면을 조각내서 순서대로 보내고 있는 것입니다. 준비된 부분을 먼저 보내고, 늦게 오는 것은 준비되는 대로 이어 보내요. 브라우저는 받는 대로 그 부분을 채웁니다.

🙋 학생 질문 — "그럼 loading.tsx는 이제 필요 없나요? <Suspense>로 다 하면 되잖아요."

둘은 겹치는 게 아니라 범위가 다릅니다.

loading.tsx는 그 화면 전체를 감쌉니다. 페이지가 통째로 준비될 때까지 보여줄 것을 정하는 거예요. 파일 하나만 만들면 되니 손이 거의 안 갑니다.

<Suspense>는 우리가 감싼 그 부분만 감쌉니다. 어디를 감쌀지 직접 고를 수 있어요.

지금 우리 프로필 화면에는 둘 다 있습니다. 처음 들어오면 loading.tsx가 화면 전체 뼈대를 보여주고, 그게 걷히고 나서 태그 부분만 <Suspense>의 안내 문구로 남아 있죠.

사실 loading.tsx도 속을 들여다보면 <Suspense>입니다. Next가 파일 이름을 보고 대신 감싸주는 것뿐이에요.

💡 한 줄 정리

느린 조각은 <Suspense>로 떼어내되, 요청은 부모에서 미리 출발시켜 약속표만 넘기세요. 떼기만 하면 오히려 느려집니다.


Step 7: "껍데기가 기다리면"

어긋난 숫자를 고칩시다

Step 3에서 눈에 담아두라고 했던 것 기억하시죠. 프로필 머리에는 게시물 3, 아래 격자에는 한 장.

껍데기가 아직 파일을 보고 있어서 생긴 일입니다. 고쳐봅시다.

먼저 "이 사람이 있는가"를 서버에 물어보는 함수가 필요한데, 지금 쓰던 get<T>()은 실패하면 무조건 던져서 없는 사람과 서버가 고장 난 것을 구분할 수가 없어요.

tsx
// apps/web-next/lib/api.ts
/** 없는 사람이면 예외 대신 null 을 준다 — 없는 것과 못 가져온 것은 다르게 다뤄야 한다. */
export async function findProfile(username: string): Promise<Profile | null> {
  const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`);

  if (response.status === 404) {
    return null;
  }
  if (!response.ok) {
    throw new Error(`서버가 ${response.status} 로 답했습니다 (프로필)`);
  }

  const envelope: ApiEnvelope<Profile> = await response.json();

  if (!envelope.success || envelope.data === null) {
    throw new Error(envelope.message ?? '프로필을 받지 못했습니다');
  }

  return envelope.data;
}

404는 null로, 나머지 실패는 예외로 갈랐습니다. 없는 것과 못 가져온 것은 사용자에게 다르게 보여줘야 하니까요. 앞의 것은 "그런 사람 없어요", 뒤의 것은 "잠시 뒤 다시 시도해주세요"인데, 이제 프로필 껍데기가 서버에 물어봐요.

tsx
// apps/web-next/app/[username]/layout.tsx
export default async function ProfileLayout({ children, params }: LayoutProps<'/[username]'>) {
  const { username } = await params;

  // 모르는 사람이면 여기서 멈춘다 — 게시물 0장으로 그리면 안 된다.
  if ((await findProfile(username)) === null) {
    notFound();
  }

  return (
    <main className="mx-auto max-w-3xl p-6">
      <div className="mb-6 flex items-center gap-4">
        <div className="size-16 rounded-full bg-black/10" />
        <h1 className="text-xl font-bold">@{username}</h1>
      </div>
      {children}
    </main>
  );
}

게시물 수는 껍데기에서 뺐습니다. 페이지가 이미 목록을 갖고 있으니 거기서 세면 돼요.

tsx
// apps/web-next/app/[username]/page.tsx
      <p className="mb-4 text-sm text-black/60">
        게시물 {posts.length} · 팔로워 {profile.followerCount}
      </p>

화면을 보면 게시물 1 · 팔로워 1240입니다. 어긋난 숫자가 사라졌어요.

덤으로 하나 더 고쳐졌습니다. 서버 데이터에만 있던 사람들 — /jiwon 같은 주소 — 이 이제 404가 아니라 정상으로 뜹니다. 우리 앱이 아는 사람 목록을 서버가 정하게 됐으니까요. 없는 사람(/zzzzz)은 여전히 404입니다.

그런데 무언가 이상합니다

재보세요.

텍스트
 껍데기가 파일을 볼 때    첫 글자 = 0.006s
 껍데기가 서버에 물을 때   첫 글자 = 0.408s

첫 글자가 6밀리초에서 408밀리초로 늘었습니다. loading.tsx를 만들기 전으로 반쯤 되돌아간 셈이에요.

Step 4에서 그린 그림을 다시 떠올려봅시다. loading.tsx는 같은 칸의 layout안 감쌉니다.

텍스트
 ┌──────────────────────────────┐
 │  바깥 껍데기 (app/layout.tsx)  │   loading 밖
 │  ┌────────────────────────┐  │
 │  │ 프로필 껍데기            │  │   loading 밖  ★ 여기가 기다린다
 │  │  ┌──────────────────┐  │  │
 │  │  │ loading.tsx      │  │  │
 │  │  │   page.tsx       │  │  │   loading 안
 │  │  └──────────────────┘  │  │
 │  └────────────────────────┘  │
 └──────────────────────────────┘

껍데기는 loading.tsx 바깥에 있습니다. 그러니 껍데기가 아직 안 끝났으면 그 안쪽에 있는 loading.tsx도 보낼 수가 없어요. 껍데기를 못 그렸는데 그 안에 든 것을 먼저 보낼 수는 없으니까요.

공식 문서도 이 상황을 따로 경고합니다. 껍데기가 서버 데이터를 만지면 그 화면의 loading.tsx는 대신 나서주지 못하고, 껍데기가 끝날 때까지 이동 자체가 멈춘다고요.

얼마를 치르고 무엇을 얻었나

껍데기에서 게시물 수를 뺐으니 조금은 나아졌을까요? 재보면 이렇습니다.

껍데기가 하는 일 첫 글자 없는 사람
존재 확인 + 게시물 수 408ms 404
존재 확인만 408ms 404
아무것도 안 기다림 7ms 200

가운데 줄을 보세요. 안 줄었습니다. 게시물 수를 빼도 존재 확인 하나가 남아 있으니 어차피 400밀리초를 기다려요.

마지막 줄은 존재 확인마저 뺀 판입니다. 첫 글자가 7밀리초로 돌아왔죠. 그런데 없는 사람 주소가 200이 됐어요. 화면에는 "없는 주소"라고 뜨지만, 검색 엔진과 모니터링 도구에게는 "정상 응답"이라고 말하는 겁니다.

이유는 이렇습니다. 상태 코드는 응답 맨 앞에 붙어서 나가고 한 번 보내면 못 바꾸는데, 조각내서 보내기 시작하는 순간 맨 앞은 이미 나가버려요. 그러니 404를 제대로 내려면 보내기 시작하기 전에 없는 사람인지 알아야 하고, 그래서 이건 고칠 수 있는 문제가 아니라 골라야 하는 문제예요.

  • 404를 정확히 내겠다 → 껍데기가 400밀리초를 기다린다
  • 첫 글자를 빨리 보내겠다 → 없는 주소에 200을 주게 된다

우리는 앞쪽을 고릅니다. 401밀리초는 아깝지만, 없는 프로필에 200을 주면 검색 엔진이 그 주소를 정상 화면으로 알고 색인하려 들거든요.

⚠️ 그래서 실무 원칙이 하나 나옵니다. 껍데기에는 꼭 있어야 하는 것만 두세요. 껍데기가 무언가를 기다리는 시간은 그 아래 모든 화면이 함께 치릅니다.

🙋 학생 질문 — "껍데기랑 페이지가 게시물 목록을 각각 부르면 서버에 요청이 두 번 가나요?"

안 갑니다. 프로필 화면 한 번에 서버가 실제로 받는 요청은 세 건이에요.

텍스트
 GET /api/posts?username=jaehoon
 GET /api/users/jaehoon
 GET /api/users/jaehoon/tags

주소 세 개, 요청 세 건입니다. 껍데기와 페이지가 같은 주소를 각각 불러도 실제로 나가는 것은 한 번뿐이에요.

한 화면을 그리는 동안 똑같은 주소로 가는 fetch는 한 번만 나가고 결과를 나눠 씁니다. 그래서 데이터가 필요한 컴포넌트가 각자 부르게 두어도 되고, 굳이 위에서 받아 props로 줄줄이 내려보낼 필요가 없는데, 다만 이건 그 요청 한 번 안에서만 그렇습니다. 다음 사람이 같은 화면을 열면 서버에 다시 물어봐요. 요청과 요청 사이에 결과를 들고 있는 이야기는 다음 시간에 다룹니다.

💡 한 줄 정리

껍데기가 서버를 기다리면 그 화면의 loading.tsx도 못 나갑니다. 껍데기에는 꼭 있어야 하는 것만 두세요.


Step 8: "서버가 만들고 클라이언트가 읽습니다"

태그를 누를 수 있게 만들어봅시다

지금 태그는 그냥 글자입니다. 눌러서 고를 수 있게 만들어보죠. 고른 태그는 테두리가 진해지고, 다시 누르면 풀립니다.

누른다는 것은 상태가 있다는 뜻이고, 상태가 있으면 브라우저에서 돌아야 합니다. 지난 시간에 배운 그대로예요. TopTags는 클라이언트 컴포넌트가 되어야 하는데, 여기서 문제가 생깁니다. 클라이언트 컴포넌트는 async 함수가 될 수 없어서 await로 데이터를 기다릴 수가 없어요.

그렇다면 태그 목록을 어떻게 받을까요?

약속표는 경계를 건널 수 있습니다

지난 시간에 경계를 건널 수 있는 값과 없는 값을 갈랐습니다. 함수는 못 건넜고, 배열과 날짜는 건넜죠.

Promise는 건너는데, 그냥 건너기만 하는 게 아니라 서버에서 결과가 나오면 그것이 클라이언트 쪽 약속표에도 흘러 들어옵니다.

그러니 이렇게 됩니다. 서버가 요청을 걸어두고 약속표를 내려보내면, 클라이언트가 그 약속표를 읽어요. 읽는 도구가 use입니다.

tsx
// apps/web-next/app/components/TagFilter.tsx
'use client';

import { use, useState } from 'react';

// 서버가 만들어 내려보낸 Promise 를 여기서 읽는다.
// 이 컴포넌트가 직접 만들면 안 된다 — 다시 그릴 때마다 새것이 되어 영영 안 끝난다.
export function TagFilter({ tags }: { tags: Promise<string[]> }) {
  const list = use(tags);
  const [picked, setPicked] = useState<string | null>(null);

  return (
    <div className="mb-4 flex gap-2 text-sm">
      {list.map((tag) => (
        <button
          key={tag}
          type="button"
          aria-pressed={picked === tag}
          onClick={() => setPicked(picked === tag ? null : tag)}
          className={
            picked === tag
              ? 'rounded border border-black px-2 py-1'
              : 'rounded border border-black/15 px-2 py-1 text-black/60'
          }
        >
          #{tag}
        </button>
      ))}
    </div>
  );
}

페이지 쪽은 TopTagsTagFilter로 바꾸기만 하면 됩니다. 넘기는 것은 그대로 tagsRequest예요.

tsx
// apps/web-next/app/[username]/page.tsx
      <Suspense fallback={<p className="mb-4 text-sm text-black/40">태그 세는 중…</p>}>
        <TagFilter tags={tagsRequest} />
      </Suspense>

use(tags)는 약속표가 풀릴 때까지 이 컴포넌트를 멈춰 세웁니다. 그동안 위에 있는 <Suspense>가 안내 문구를 대신 보여주죠. 풀리면 그 결과가 list에 들어오고, 그때부터는 평범한 클라이언트 컴포넌트입니다.

use는 C-3에서 이미 만났습니다

그때는 Context를 읽는 데 썼죠. 조건문 안에서도 부를 수 있는 것이 useContext와의 차이였습니다. 그리고 그때 공식 문서의 제약 한 줄을 같이 봤습니다.

Reading context with use is not supported in Server Components.

"서버 컴포넌트가 무엇인지는 D 카테고리에서 제대로 본다" 고 미뤄뒀던 그 문장인데, 이제는 정확히 읽을 수 있어요.

그 제약은 Context 를 읽을 때만 걸립니다. use로 Promise를 읽는 것은 서버든 클라이언트든 됩니다. 오히려 오늘 배운 자리에서는 그게 유일한 방법이에요.

use가 읽는 것 서버 컴포넌트 클라이언트 컴포넌트
Context 안 됨
Promise 됨 (보통 await를 쓴다) 됨 — 오늘 배운 것

⚠️ 여기서 반드시 짚고 갈 함정

TagFilter 주석에 적어둔 경고를 이제 확인해봅시다. 이 컴포넌트가 약속표를 직접 만들면 어떻게 될까요.

tsx
// 절대 이렇게 쓰지 마세요
export function TagFilter({ username }: { username: string }) {
  const list = use(
    fetch(`http://localhost:8090/api/users/${username}/tags`)
      .then((response) => response.json())
      .then((envelope) => envelope.data as string[]),
  );

훨씬 간단해 보이고 부모가 요청을 알 필요도 없지만, 먼저 이것부터 확인해야 합니다. 타입 검사도 통과하고, 린트도 통과하고, 빌드도 통과합니다. 종료 코드 전부 0이에요. 아무도 안 막아줍니다.

띄워서 프로필에 들어가보면 화면도 멀쩡합니다. 태그가 정상으로 뜨죠. 여기서 멈추면 잘 짠 줄 아는데, 이제 태그를 눌러보세요.

  • 선택 상태가 안 바뀝니다. 테두리가 그대로예요.
  • 안내 문구조차 안 뜹니다. 이전 화면이 그냥 굳어 있어요.
  • 개발자 도구 네트워크 탭을 보면 같은 주소로 요청이 계속 나가는데, 두 번 눌렀을 뿐인데 열 건이 넘게 쌓입니다.

무슨 일이 벌어지는지 따라가봅시다.

텍스트
 버튼을 누른다
    상태가 바뀌니 컴포넌트를 다시 그린다
    다시 그리면서 fetch(...) 가 또 불린다  새 약속표
    새 약속표는 아직 안 풀렸으니 멈춰 선다
    1.2초 뒤 풀린다  다시 그린다
    또 새 약속표 …

끝이 없습니다. use에 넘기는 약속표는 다시 그릴 때마다 같은 것이어야 하는데, 렌더 안에서 만들면 매번 새것이 되거든요.

공식 문서도 같은 이야기를 합니다. 렌더 중에 만든 Promise는 매 렌더마다 새로 만들어져서, React가 안내 화면을 반복해서 보여주고 내용이 영영 안 나타난다고요.

그러니 규칙은 하나입니다. 약속표는 서버가 만들어 내려보내고, 클라이언트는 받아서 읽기만 합니다.

⚠️ 그리고 이 함정의 진짜 무서운 점은 첫 화면에서는 안 드러난다는 겁니다. 서버가 한 번 그려서 보내주니까 처음엔 멀쩡해요. 상호작용이 시작되는 순간에야 터집니다. 확인할 때 화면만 보지 말고 눌러보세요.

💡 한 줄 정리

서버가 await 없이 요청을 걸어 약속표를 내려보내고, 클라이언트가 use로 읽습니다. 클라이언트가 약속표를 직접 만들면 세 도구 다 통과한 채로 런타임에서 터집니다.


마무리

오늘 시작할 때 숫자 세 개를 꺼냈습니다. 이제 다 채워졌네요.

텍스트
 프로필 화면 /jaehoon

 오늘 시작    데이터가 파일에 있었다        6ms      (물어볼 데가 없으니 빠르다)
 Step 3       진짜 서버에 물어보기 시작    810ms     (400ms 짜리 둘을 순서대로)
 Step 5       동시에 출발                 407ms
 Step 6       태그 집계를 더함           1,207ms
 지금         첫 글자가 닿는 시간          408ms

              첫 글자만 보면
              loading.tsx 전   810ms
              loading.tsx 후     6ms
              껍데기가 서버를 볼 때  408ms

숫자가 오르내리는 것을 보세요. 우리는 오늘 앱을 빠르게 만든 게 아닙니다. 어디서 기다릴지를 계속 옮겨 다녔어요.

오늘 배운 핵심 세 가지

💡 하나 — 서버 컴포넌트에서 데이터를 가져오는 방법은 그냥 기다리는 것입니다. async를 붙이고 await 하면 끝이에요. 훅도, 로딩 상태도, 에러 상태도 없습니다. 그것들이 필요했던 이유는 화면을 먼저 그려놓고 나중에 채웠기 때문인데, 서버는 다 되고 나서 내놓거든요.

💡 둘 — 기다림은 없앨 수 없고, 어디서 기다릴지만 정할 수 있습니다. Promise.all로 나란히 기다릴지, <Suspense>로 준비된 것부터 내보낼지, 껍데기에서 기다릴지 페이지에서 기다릴지. 오늘 우리가 한 일은 전부 이 선택이었습니다.

💡 셋 — 클라이언트가 서버 데이터를 써야 하면 결과가 아니라 약속표를 넘깁니다. 서버가 만들고 클라이언트가 use로 읽어요. 반대로 하면 세 가지 검사를 다 통과한 채로 런타임에서 무너집니다.

다음 시간 예고

오늘 내내 걸리는 게 하나 있었을 겁니다. 홈 화면은 왜 계속 빠른가.

/에도 똑같이 await fetch를 넣었는데 라우트 표에서는 로 남았고, API 서버를 완전히 꺼버려도 게시물이 그대로 떴죠. 빌드할 때 한 번 물어보고 그 답이 굳었기 때문입니다. 그런데 진짜 서비스에서 그러면 큰일입니다. 새 게시물이 올라와도 홈에는 영영 안 보일 테니까요. 반대로 프로필처럼 요청마다 매번 물어보는 것도 답은 아니에요. 하루에 한 번 바뀌는 값을 초당 천 번씩 물어볼 이유가 없죠.

그 사이 어딘가에 답이 있고, Next는 그걸 우리가 고르게 해둡니다. 무엇을 굳혀두고 무엇을 매번 새로 물어볼지를 정하는 데까지가 다음 시간이고, 굳혀둔 것을 언제 갈아 끼울지는 그다음 시간에 이어서 다룹니다.

한 가지 더 예고하자면, 오늘 답을 미룬 질문이 하나 있었습니다. "같은 주소로 가는 fetch는 한 번만 나간다" 고 했는데, 그건 한 요청 안에서만 그렇다고 했죠. 요청과 요청 사이에는 어떻게 되는지가 다음 시간의 본론입니다.


과제

[구현] 탐색 화면도 서버에서 가져오기

탐색 화면(/explore)은 아직 lib/posts.ts 파일을 보고 있습니다. 홈에서 한 것처럼 서버에서 가져오도록 바꿔보세요.

  • 바꾸기 전에 예상해보세요. 빌드 로그의 라우트 표에서 /explore 표시가 에서 ƒ로 바뀔까요, 그대로일까요?
  • 바꾼 뒤 라우트 표를 확인하세요. 예상과 같았나요?
  • /explore의 자바스크립트를 재서, 데이터 출처를 바꾼 것이 브라우저가 받는 코드에 영향을 주는지 확인하세요.
  • ⚠️ API 서버를 안 띄운 채 빌드하면 어떻게 되는지도 한 번 겪어보고, 그 에러가 어느 단계에서 났는지 로그에서 찾아보세요.

[탐구] 탐색 화면에는 왜 기다림이 안 보이는가

과제 1을 마쳤으면 탐색 화면도 서버에 물어봅니다. 그러니 기다림이 생겼을 텐데, 정말 그런지 확인해보세요.

  • app/explore/loading.tsx를 만들어보세요. 격자 열 칸짜리 화면이니 뼈대도 그에 맞춰서요.
  • 만든 뒤 첫 글자가 닿는 시간을 재보세요. 뼈대가 뜨긴 하나요?
  • 결과가 예상과 다르다면 이유를 설명해보세요. 빌드 로그의 라우트 표에서 /explore/[username]에 붙은 표시가 다릅니다.
  • ⚠️ 여기서 한 걸음 더 가봅시다. 프로필 주소는 왜 ƒ 일까요? 우리가 그렇게 하라고 설정한 적이 없는데요. 폴더 이름을 보면 실마리가 있습니다.
  • 만든 loading.tsx를 남길지 지울지 정하고, 그 이유를 적어두세요.

[탐구] loading.tsx를 가장 바깥에도 두면

app/loading.tsx를 만들어보세요. 프로필에는 이미 app/[username]/loading.tsx가 있습니다.

  • 프로필로 들어갈 때 둘 중 어느 것이 뜰까요? 하나만 뜰까요, 아니면 둘 다일까요? 먼저 예상하고 확인하세요.
  • 지난 시간에 없는 주소를 다룰 때는 가까운 것과 먼 것 중 한쪽만 이겼습니다. 이번에도 같은 규칙인가요?
  • ⚠️ 그리고 반드시 이 둘을 재세요. 프로필의 첫 글자가 닿는 시간없는 사람 주소의 상태 코드입니다. Step 7의 표와 나란히 놓아보면 놀라운 것이 나옵니다.
  • 확인이 끝나면 이 파일을 남길지 지울지 정하고 이유를 적어두세요. 취향 문제가 아닙니다.

[탐구] 하나가 실패하면

프로필 화면은 Promise.all로 두 요청을 기다립니다. 하나만 실패하면 어떻게 될까요.

  • 연습용 서버에서 게시물 목록만 실패하도록 잠깐 고쳐보세요. 프로필 정보는 정상으로 두고요.
  • 화면이 어떻게 되나요? 팔로워 수라도 뜨나요?
  • Promise.allPromise.allSettled로 바꾸고, 온 것만 골라 그리도록 고쳐보세요. 화면이 어떻게 달라지나요?
  • 그리고 판단해보세요. 우리 프로필 화면에는 둘 중 어느 쪽이 맞을까요? 정답이 하나가 아닐 수 있습니다. 어떤 화면이라면 반대쪽이 맞을지도 함께 적어주세요.
  • ⚠️ 확인이 끝나면 연습용 서버를 원래대로 되돌리는 것을 잊지 마세요.

생각해볼 주제

1. 느려진 것을 어떻게 설명할 것인가

오늘 우리 프로필 화면은 6밀리초에서 1,207밀리초가 됐습니다. 200배 느려졌어요.

기술적으로는 정상화인데, 데이터를 진짜 서버에서 가져오게 됐기 때문이에요. 여러분이 팀에서 이 변경을 올렸는데 성능 대시보드에 빨간 줄이 뜨고 누군가 "이거 왜 이렇게 느려졌어요?"라고 묻는다면 어떻게 답하시겠습니까?

그리고 더 어려운 질문. 여러분이 그 질문을 하는 쪽이라면, 어떤 숫자를 봐야 "정상화"와 "성능 악화"를 구분할 수 있을까요? 오늘 우리가 잰 숫자들 중 어떤 것이 그 판단에 쓸모 있고 어떤 것이 오해를 부를까요?

2. 껍데기에 무엇을 둘 것인가

Step 7에서 우리는 401밀리초를 내고 404 상태 코드를 샀습니다. 껍데기가 "이 사람이 있는가"를 서버에 묻는 동안 모든 프로필 화면이 그만큼 늦게 시작해요.

이 거래가 항상 남는 장사일까요? 만약 이 서비스가 검색 엔진에 노출될 일이 전혀 없는 사내 도구라면요? 반대로 프로필 화면이 서비스 트래픽의 절반을 차지한다면요?

그리고 한 걸음 더 나아가서, 이 판단을 누가 내려야 할까요? 화면을 만드는 프론트엔드 개발자가 혼자 정할 수 있는 문제인지, 아니면 다른 누군가와 이야기해야 하는 문제인지 생각해보세요.

3. 아무도 안 막아주는 코드

Step 8의 잘못 쓴 판은 타입 검사, 린트, 빌드를 전부 통과했습니다. 첫 화면도 멀쩡했어요. 눌러봐야 드러났습니다.

지난 시간에는 반대 경우를 봤죠. 함수를 경계 너머로 넘겼을 때 타입 검사는 통과했지만 빌드가 막았습니다.

이 둘의 차이는 어디서 올까요? 어떤 실수는 도구가 잡아주고 어떤 실수는 못 잡는 걸까요? 실무에서 이런 종류의 실수 — 통과하는데 틀린 것 — 를 줄이려면 무엇이 필요할지도 함께 생각해보세요. 사람이 더 조심하는 것으로 될 일인지, 아니면 다른 장치가 필요한지요.

✅ 예시 답안정답 보기

먼저 직접 풀어보신 뒤에 펼쳐보세요. 오늘 과제 넷 중 셋은 "안 되는 것이 정답"입니다. 안 된다고 잘못한 게 아니에요. 왜 안 되는지가 오늘의 배움입니다.


🎯 [과제 1 예시답안] 탐색 화면도 서버에서 가져오기 (25점)

채점 포인트

항목 배점 기준
예상을 먼저 적었는가 5 라우트 표가 바뀔지 재기 전에 적어둔 기록이 있다
fetchPosts를 재사용 5 새 함수를 만들지 않고 lib/api.ts 것을 가져다 썼다
라우트 표 확인 5 그대로임을 확인하고 이유를 설명했다
자바스크립트 측정 5 데이터 출처가 바뀌어도 안 변한다는 것을 확인했다
빌드 실패를 겪고 단계를 짚었다 5 프리렌더 단계에서 났다는 것을 로그에서 찾았다

풀이 예시

tsx
// apps/web-next/app/explore/page.tsx
import { fetchPosts } from '@/lib/api';

export default async function ExplorePage() {
  const posts = await fetchPosts();

  return (
    <main className="mx-auto max-w-3xl p-6">
      <h1 className="mb-4 text-xl font-bold">탐색</h1>
      <ul className="grid grid-cols-3 gap-2">
        {posts.map((post) => (
          <li key={post.id} className="aspect-square rounded bg-black/5 p-3 text-sm">
            <p className="font-semibold">@{post.username}</p>
            <p className="mt-1 text-black/60">좋아요 {post.likeCount}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

두 줄입니다. async를 붙이고, allPosts 대신 await fetchPosts()를 쓰면 끝이에요.

라우트 표는 안 바뀝니다

텍스트
 ┌ ○ /
 ├ ○ /_not-found
 ├ ƒ /[username]
 └ ○ /explore         그대로 ○

홈에서 봤던 것과 같습니다. 서버에 물어보는 코드를 넣었는데도 정적 화면이에요. 빌드할 때 한 번 물어보고 그 답이 HTML에 굳었습니다.

자바스크립트도 안 변합니다.

텍스트
 /explore   576,369 B      576,369 B

fetch를 부르는 코드는 서버에 남으니까요.

빌드 실패는 어느 단계에서 나는가

API 서버를 안 띄우고 빌드하면 이렇게 됩니다.

텍스트
 ✓ Compiled successfully in 323ms
 Running TypeScript ...
 Collecting page data ...
 Generating static pages ...
 Error occurred prerendering page "/explore"
 TypeError: fetch failed ... code: 'ECONNREFUSED'

Compiled successfully가 먼저 지나갑니다. 코드를 자바스크립트로 옮기는 일은 잘 끝났어요. 막힌 것은 그다음, 화면을 미리 그려보는 단계입니다.

빌드는 한 덩어리가 아니라 여러 단계라서, 화면에 지나간 글자로 판단하면 안 됩니다.

Bash
npm run build; echo "종료 코드=$?"

자주 나오는 실수

allPosts를 지우지 않고 fetchPosts를 나란히 두는 것.

동작은 하는데, lib/posts.ts의 상수가 살아 있으면 나중에 누군가 그걸 다시 가져다 써서 한 화면 안에서 두 출처가 갈리는 문제가 또 생겨요. Step 3에서 겪은 그것입니다.

지금 lib/posts.ts에서 아직 쓰이는 것은 Post 타입뿐입니다. 나머지는 정리해도 됩니다.

"라우트 표가 ƒ로 바뀔 것"이라고 예상하고, 안 바뀌자 자기가 뭘 잘못했나 찾는 것.

잘못한 게 없습니다. 예상이 틀린 거예요. 그리고 그 틀림이 다음 시간의 출발점입니다.

💡 튜터의 한마디

이 과제의 진짜 값은 "바뀔 줄 알았는데 안 바뀐다"를 손으로 겪는 것입니다.

머리로는 "서버에서 가져오니까 요청마다 새로 가져오겠지"가 자연스러운데, Next는 그렇게 안 합니다. 가져올 수 있으면 빌드할 때 가져와서 굳혀둡니다.

이 어긋남이 실무에서 진짜 사고를 냅니다. 개발할 때는 새 데이터가 잘 보이는데(개발 서버는 매번 새로 그리니까) 배포하면 안 바뀌는 거예요. 다음 시간에 이 이야기만 합니다.


🎯 [과제 2 예시답안] 탐색 화면에는 왜 기다림이 안 보이는가 (25점)

채점 포인트

항목 배점 기준
loading.tsx를 만들어 실제로 확인 5 만들고 재봤다
안 뜬다는 것을 확인 5 시간을 재서 기다림 자체가 없음을 확인했다
이유를 정확히 설명 5 정적 화면이라 기다릴 게 없다는 것을 말할 수 있다
프로필이 ƒ인 이유 5 대괄호 폴더 때문이라는 것을 알아냈다
남길지 지울지 판단 5 결정과 이유가 함께 있다

풀이 예시

만드는 것 자체는 쉽습니다.

tsx
// apps/web-next/app/explore/loading.tsx
export default function ExploreLoading() {
  return (
    <main className="mx-auto max-w-3xl p-6">
      <h1 className="mb-4 text-xl font-bold">탐색</h1>
      <ul className="grid grid-cols-3 gap-2">
        {Array.from({ length: 10 }, (_, slot) => (
          <li key={slot} className="aspect-square animate-pulse rounded bg-black/5" />
        ))}
      </ul>
    </main>
  );
}

그런데 재보면 이렇습니다.

텍스트
 /explore   첫 글자 = 0.002s   전체 = 0.002s

2밀리초에 완성된 화면이 옵니다. 뼈대가 뜰 틈이 없어요.

왜 안 뜨나

기다림이 없기 때문입니다.

라우트 표를 보세요.

텍스트
 ○ /explore         미리 그려둔 화면
 ƒ /[username]      요청이 올 때마다 그리는 화면

/explore는 빌드할 때 이미 다 그려져서 파일로 저장돼 있습니다. 요청이 오면 그 파일을 그대로 보내요. 서버에 물어보지도 않고, 그리지도 않습니다. 기다리는 시간이 0 이니 기다리는 동안 보여줄 것도 필요 없어요.

loading.tsx가 일하려면 그 화면이 요청받고 나서 그려져야 합니다.

프로필은 왜 ƒ인가

폴더 이름이 [username] 이기 때문입니다.

Next는 빌드할 때 화면을 미리 그려두려고 하는데, 그러려면 어떤 주소들이 있는지 알아야 합니다. /explore는 주소가 하나로 정해져 있는데, [username]은 몇 개인지 알 수가 없어요. jaehoon도 되고 minji도 되고, 앞으로 가입할 사람도 되니까요.

그러니 미리 그릴 수가 없고, 요청이 와서 username이 정해져야 그립니다. 그래서 ƒ예요.

D-1에서 대괄호 폴더를 만들 때 "이 칸에는 아무 값이나 들어온다" 고 했던 그 성질이 여기까지 이어집니다.

남길까 지울까

지우는 쪽을 권합니다.

지금은 아무 일도 안 하는 파일인데, 파일이 있으면 나중에 읽는 사람이 "여긴 기다림이 있구나"라고 오해해서 안 도는 코드를 남겨두면 거짓말이 돼요.

다만 남길 만한 이유도 하나 있습니다. 나중에 이 화면이 요청마다 그려지도록 바뀌면 그때 바로 일하기 시작하거든요. 팀에서 그 변경이 곧 예정돼 있다면 미리 둬도 괜찮습니다.

어느 쪽을 골라도 됩니다. 왜 그렇게 정했는지 말할 수 있으면 정답이에요.

자주 나오는 실수

loading.tsx가 안 떠서 파일 위치를 계속 옮겨보는 것.

app/explore/loading.tsx가 맞습니다. 위치 문제가 아니에요. 위치를 의심하기 전에 그 화면에 기다림이 있는지부터 확인하세요.

이건 지난 시간 과제에서 배운 것과 같은 결입니다. 그때도 경계를 아래로 내렸는데 바이트가 안 줄었죠. 원인을 엉뚱한 데서 찾으면 시간만 씁니다. 먼저 재고, 그다음에 고치세요.

💡 튜터의 한마디

loading.tsx는 "만들면 뜨는 것"이 아니라 "기다림이 있을 때 뜨는 것"인데, 어떤 화면에 기다림이 있는지는 우리가 정하지 않았어요. 폴더 이름이 정했습니다. [username]이라고 쓴 순간 그 화면은 요청마다 그려지는 화면이 됐고, /explore라고 쓴 순간 미리 그려두는 화면이 됐어요.

D-1에서 "폴더가 곧 주소"라고 배웠죠. 오늘 하나 더 붙습니다. 폴더가 언제 그릴지도 정합니다.


🎯 [과제 3 예시답안] loading.tsx를 가장 바깥에도 두면 (25점)

채점 포인트

항목 배점 기준
예상을 먼저 적었는가 5 어느 것이 뜰지 재기 전에 적어뒀다
둘 다 뜬다는 것을 확인 5 하나만 뜬다고 결론 내지 않았다
지난 시간 규칙과 비교 5 없는 주소 때와 규칙이 다르다는 것을 짚었다
첫 글자 시간과 상태 코드를 함께 쟀다 10 상태 코드가 바뀐 것을 발견했다

풀이 예시

tsx
// apps/web-next/app/loading.tsx
export default function RootLoading() {
  return <p className="mx-auto max-w-3xl p-6 text-sm text-black/40">불러오는 중…</p>;
}

프로필로 들어가서 응답을 이른 시점에 끊어보면 이렇게 나옵니다.

텍스트
 불러오는 중…           바깥 것
 게시물 세는 중…         프로필 것

둘 다 뜹니다. 하나가 다른 하나를 이기는 게 아니에요.

순서를 생각하면 당연합니다. 바깥 것이 먼저 화면 전체를 채우고, 프로필 껍데기가 준비되면 그 안쪽이 프로필 것으로 바뀝니다. 껍데기가 한 겹씩 벗겨지는 셈이에요.

지난 시간 규칙과는 다릅니다

지난 시간 없는 주소를 다룰 때는 한쪽만 이겼습니다. 껍데기에서 막으면 바깥 것이 뜨고, 페이지에서 막으면 구간 것이 떴죠. 둘이 같이 뜨는 일은 없었어요.

여러 개가 있으면
없는 주소 (not-found.tsx) 한쪽만 이긴다
터진 화면 (error.tsx) 한쪽만 이긴다
기다림 (loading.tsx) 둘 다 뜬다 (바깥부터 차례로)

이유는 하는 일이 다르기 때문입니다. 앞의 둘은 대신 그려주는 것이라 하나만 골라야 해요. 기다림은 잠깐 채워두는 것이라 겹쳐도 됩니다. 바깥이 먼저 채우고 안쪽이 이어받으면 되니까요.

⚠️ 그런데 진짜 발견은 이겁니다

첫 글자가 닿는 시간과 없는 사람 주소의 상태 코드를 함께 재보세요.

첫 글자 /zzzzz
바깥 loading.tsx 없음 408ms 404
바깥 loading.tsx 있음 8ms 200

첫 글자가 408밀리초에서 8밀리초로 돌아왔습니다. Step 7에서 401밀리초를 치르고 산 그 문제가 해결된 것처럼 보이는데, 같은 줄 오른쪽을 보세요. 없는 사람 주소가 200이 됐습니다.

Step 7에서 봤던 그 교환이 그대로 일어난 겁니다. 바깥 loading.tsx가 프로필 껍데기까지 감싸니까, 껍데기가 아직 기다리는 중이어도 화면을 보내기 시작하는데, 보내기 시작하면 상태 코드는 이미 나가버려서 나중에 "사실 없는 사람이었어요"라고 바꿀 수가 없어요.

화면에는 여전히 "없는 주소"라고 뜹니다. 사람 눈에는 멀쩡해요. 검색 엔진에게만 거짓말을 합니다. (noindex 표시가 함께 나가서 색인은 막지만, 상태 코드 자체는 200입니다.)

남길까 지울까

이건 취향 문제가 아닙니다. 파일 하나를 만들었을 뿐인데 앱의 HTTP 동작이 바뀌었어요.

판단 기준은 Step 7과 똑같습니다.

  • 검색 노출이 중요하거나 상태 코드로 모니터링을 한다 → 지웁니다. 401밀리초를 치르고 404를 지킵니다.
  • 로그인해야 들어오는 사내 도구라 검색 엔진이 올 일이 없다 → 남깁니다. 400밀리초를 아끼는 쪽이 낫습니다.

어느 쪽이든 알고 고르는 것이 핵심입니다.

자주 나오는 실수

"바깥에도 두면 더 촘촘해지겠지" 하고 두고 끝내는 것.

화면만 보면 좋아 보입니다. 더 빨리 뭔가 뜨니까요. 상태 코드를 재보지 않으면 영영 모릅니다.

이건 오늘 마지막 Step에서 본 것과 같은 종류의 사고예요. 통과하고, 화면도 멀쩡하고, 아무도 안 알려줍니다.

💡 튜터의 한마디

loading.tsx를 그냥 "예쁘게 보여주는 파일"로 알면 이 발견을 못 합니다.

이 파일이 실제로 하는 일은 "여기서부터 조각내서 보내라" 고 표시하는 것인데, 조각내서 보내기 시작하는 순간 응답의 맨 앞은 확정되니, 어디에 두느냐가 상태 코드를 정하는 결정이 돼요.

파일 하나가 화면을 바꾸는 게 아니라 HTTP 응답의 모양을 바꿉니다.


🎯 [과제 4 예시답안] 하나가 실패하면 (25점)

채점 포인트

항목 배점 기준
한쪽만 실패시키는 데 성공 5 프로필은 살리고 게시물만 실패시켰다
Promise.all 결과 관찰 5 팔로워 수도 못 본다는 것을 확인했다
allSettled로 고쳐서 비교 10 온 것만 골라 그리도록 만들었다
어느 쪽이 맞는지 판단 5 결론과 반대 사례를 함께 적었다

실패시키는 법

여러 방법이 있는데, 가장 간단한 것은 가져오는 주소를 잠깐 없는 것으로 바꾸는 겁니다.

tsx
// apps/web-next/lib/api.ts — 확인용으로만 잠깐
export function fetchPostsByUsername(username: string): Promise<Post[]> {
  return get<Post[]>(`/posts-broken?username=${encodeURIComponent(username)}`);
}

연습용 서버는 모르는 주소에 404를 주고, get()은 그걸 받아 예외를 던집니다.

Promise.all 일 때

화면이 이렇게 됩니다.

텍스트
 @jaehoon
 이 부분을 불러오지 못했어요
 잠시 뒤 다시 시도해주세요. (기록 번호 2700314715)
 [ 다시 시도 ]

팔로워 수가 안 보입니다. 프로필 조회는 성공했는데도요.

Promise.all이 던졌으니 그 아래 줄은 아예 실행되지 않아서 화면을 그리는 코드까지 못 갔고, 잘 받아온 프로필 정보도 함께 버려집니다.

Promise.allSettled로 고치면

tsx
// apps/web-next/app/[username]/page.tsx
  // 온 것만 골라 쓴다. 실패한 것도 결과에 담겨 온다.
  const [profileResult, postsResult] = await Promise.allSettled([profileRequest, postsRequest]);

  // 프로필이 없으면 화면 자체가 성립하지 않는다 — 이건 그대로 던진다.
  if (profileResult.status === 'rejected') {
    throw profileResult.reason;
  }
  const profile = profileResult.value;
  const posts = postsResult.status === 'fulfilled' ? postsResult.value : null;

게시물이 없을 때는 이렇게 화면에서 다룹니다.

tsx
      <p className="mb-4 text-sm text-black/60">
        게시물 {posts === null ? '?' : posts.length} · 팔로워 {profile.followerCount}
      </p>
      {posts === null && <p className="text-sm text-black/40">게시물을 불러오지 못했어요</p>}

결과입니다.

텍스트
 @jaehoon
 게시물 ? · 팔로워 1240
 게시물을 불러오지 못했어요

팔로워 수는 보입니다. 못 받은 부분만 그 사실을 알려요.

두 판을 나란히

게시물만 실패했을 때
Promise.all 화면 전체가 에러로 갈린다. 받아온 프로필도 못 쓴다
Promise.allSettled 프로필은 그대로 보이고, 게시물 부분만 안내 문구

어느 쪽이 맞을까

우리 프로필 화면에서는 Promise.all이 맞습니다.

프로필의 본체는 게시물 격자예요. 그게 없으면 사람들은 "이 사람 게시물이 하나도 없구나"로 오해하거나, 반쪽짜리 화면을 보고 혼란스러워합니다. 차라리 "못 불러왔어요, 다시 시도"를 명확히 보여주고 다시 눌러보게 하는 쪽이 정직해요.

반대 경우도 어렵지 않게 떠올릴 수 있습니다.

화면의 곁가지 정보가 실패했을 때예요. 예를 들어 오늘 만든 태그 요약이 그렇습니다. 태그를 못 세어 왔다고 프로필 전체를 에러로 덮으면 과잉이죠. 사실 우리는 그걸 이미 <Suspense>로 떼어놨습니다. 실패해도 그 부분만 갈립니다.

판단 기준을 한 줄로 정리하면 이렇습니다. 이것 없이도 사용자가 하려던 일을 할 수 있는가. 있으면 따로 떼고, 없으면 함께 실패시킵니다.

자주 나오는 실수

allSettled로 바꿔놓고 실패한 것을 조용히 넘기는 것.

tsx
  const posts = postsResult.status === 'fulfilled' ? postsResult.value : [];

빈 배열로 두면 화면에는 "게시물 0"이 뜨는데, 못 가져온 것과 진짜로 0장인 것은 완전히 다릅니다. 사용자는 이 사람이 게시물을 안 올렸다고 믿게 돼요.

Step 7에서 findProfile을 만들 때 404를 null로, 나머지 실패를 예외로 갈랐던 것과 같은 이야기입니다. 없는 것과 못 가져온 것은 다르게 보여줘야 합니다.

확인이 끝나고 연습용 서버나 주소를 안 되돌리는 것.

다음 수업 시작할 때 앱이 깨져 있으면 원인을 찾느라 시간을 씁니다. 실험한 것은 반드시 되돌리세요.

💡 튜터의 한마디

Promise.all이 "하나 실패하면 전부 실패"인 것은 결함이 아니라 설계인데, 그 성질이 우리에게 질문을 강제해요. "이 요청들은 운명을 같이해야 하는가?"라는 질문이요. 그 답이 갈리면 도구도 갈립니다. 같이 가야 하면 Promise.all, 따로 가도 되면 <Suspense>로 떼거나 allSettled로 골라 씁니다.

오늘 배운 도구들이 사실 전부 이 질문 하나로 이어져 있어요. 무엇과 무엇이 함께 가야 하는가.


생각해볼 주제

🤔 [생각해볼 주제 1] 느려진 것을 어떻게 설명할 것인가

문제 상황 요약

프로필 화면이 6밀리초에서 1,207밀리초가 됐습니다. 200배입니다. 성능 대시보드에는 빨간 줄이 그어졌고, 누군가 묻습니다. "이거 왜 이렇게 느려졌어요?"

튜터의 가이드 및 해설

먼저 "정상화"라는 말로 방어하지 마세요. 그 말은 맞지만 아무것도 설명하지 않습니다. 상대는 숫자를 봤고, 여러분도 숫자로 답해야 합니다.

이렇게 갈라서 보여주는 것이 좋습니다.

텍스트
 6ms      데이터가 코드에 적혀 있던 시절. 서버에 물어보지 않았다.
 1,207ms  이제 진짜로 물어본다. 400ms 짜리 두 개와 1,200ms 짜리 하나.
 407ms    같이 물어보게 고친 뒤 (태그 집계를 붙이기 전)
 8ms      사용자가 첫 화면을 보기까지 걸리는 시간

이 표가 말하는 것은 "느려졌다"는 한 문장이 사실 네 개의 다른 이야기라는 겁니다.

이제 두 번째 질문이 더 중요합니다. 어떤 숫자를 봐야 정상화와 성능 악화를 구분할 수 있을까요.

전체 완료 시간만 보면 구분이 안 됩니다. 오늘 우리 수치가 그 증거예요. Step 6에서 <Suspense>를 잘못 쓴 판(1,612ms)과 제대로 쓴 판(1,207ms)은 사용자가 첫 화면을 보는 시점이 똑같이 407밀리초입니다. 반대로 Step 7에서는 전체 시간이 1,207밀리초로 같은데 첫 글자가 8밀리초와 408밀리초로 갈렸어요.

그러니 최소한 두 개를 봐야 합니다.

  • 뭐라도 보이기까지 — 사용자가 "눌렸구나"를 아는 시점
  • 쓸 수 있게 되기까지 — 하려던 일을 할 수 있는 시점

이 둘 사이에 무엇이 보이고 있는가도 세 번째 축입니다. 뼈대만 있는 800밀리초와 절반이 채워진 800밀리초는 같은 숫자지만 다른 경험이에요.

⚠️ 오해를 부르기 쉬운 숫자도 짚어둡시다. 오늘 우리가 잰 것 중 서버가 자기 일을 끝내는 데 걸린 시간은 사용자 경험과 직접 이어지지 않습니다. 조각내서 보내기 시작하면 서버는 마지막 조각까지 붙들고 있지만 사용자는 이미 화면을 쓰고 있거든요.

🎯 면접관을 홀리는 핵심 멘트

"전체 응답 시간 하나로 성능을 이야기하면 오해가 생깁니다. 저희는 첫 화면이 보이는 시점상호작용이 가능한 시점을 따로 봤고, 데이터 출처를 서버로 옮기면서 전체는 늘었지만 첫 화면은 810밀리초에서 6밀리초로 줄었습니다. 사용자가 체감하는 것은 뒤쪽 숫자입니다."

🤔 [생각해볼 주제 2] 껍데기에 무엇을 둘 것인가

문제 상황 요약

프로필 껍데기가 "이 사람이 있는가"를 서버에 묻습니다. 그 400밀리초 동안 모든 프로필 화면이 아무것도 못 보냅니다. 이 값을 치르고 우리가 산 것은 없는 사람 주소에 404를 내보내는 것 하나입니다.

튜터의 가이드 및 해설

먼저 이 거래의 정체를 정확히 봅시다. 우리가 산 것은 404라는 숫자 자체가 아니라 그 숫자를 읽는 쪽이 하는 일입니다.

  • 검색 엔진은 404를 보면 그 주소를 목록에서 뺍니다. 200을 보면 계속 찾아옵니다.
  • 모니터링 도구는 404를 "정상적인 없음"으로, 200을 "정상 응답"으로 셉니다. 없는 주소에 200을 주면 오류율 그래프에 아무 흔적도 안 남아요.
  • 앞단의 캐시나 방화벽도 상태 코드로 판단합니다.

그러니 읽는 쪽이 없으면 살 이유도 없습니다.

사내 도구라면요? 로그인해야 들어오는 화면에는 검색 엔진이 아예 못 옵니다. 모니터링을 상태 코드로 안 하고 애플리케이션 로그로 한다면 그쪽도 해당 없고요. 이 경우 400밀리초는 그냥 낭비입니다.

프로필이 트래픽의 절반이라면요? 이번엔 반대로 값이 커집니다. 절반의 사용자가 매번 400밀리초를 더 기다려요. 그런데 동시에 그 화면이 검색 유입의 핵심일 가능성도 높아집니다. 인스타그램 프로필 주소를 떠올려보세요. 이럴 때는 다른 해법을 찾아야 합니다. 존재 여부만 훨씬 빨리 답하는 경로를 따로 두거나, 그 판단을 요청이 앱에 닿기 전 단계로 올리거나요.

이제 마지막 질문입니다. 누가 정하나.

프론트엔드 개발자 혼자 정할 수 없습니다. 이 결정은 최소한 셋과 맞물려 있어요.

  • 검색 유입이 이 서비스에 얼마나 중요한가 — 마케팅이나 기획이 압니다
  • 없는 프로필 요청이 실제로 얼마나 오는가 — 데이터를 봐야 압니다
  • 상태 코드로 무엇을 감시하고 있는가 — 운영하는 쪽이 압니다

그런 만큼 이런 종류의 결정은 혼자 고르고 넘어가지 말고 드러내야 합니다. "저는 이쪽을 골랐고 이유는 이겁니다"라고 적어두면, 나중에 전제가 바뀌었을 때 누군가 되짚을 수 있어요.

🎯 면접관을 홀리는 핵심 멘트

"레이아웃에서 데이터를 조회하면 그 아래 모든 화면이 그 시간을 함께 치릅니다. 저희는 프로필 레이아웃에 존재 확인만 남기고 나머지는 페이지로 내렸는데, 그래도 첫 응답 시간은 안 줄었습니다. 남은 한 건이 그 시간을 다 쓰고 있었거든요. 그래서 그건 최적화 문제가 아니라 404 상태 코드와 응답 시작 시점 중 무엇을 지킬지 고르는 문제로 다뤘습니다."

🤔 [생각해볼 주제 3] 아무도 안 막아주는 코드

문제 상황 요약

클라이언트 컴포넌트가 Promise를 직접 만든 코드는 타입 검사, 린트, 빌드를 전부 통과했습니다. 첫 화면도 멀쩡했어요. 버튼을 눌러야 드러났습니다. 반면 지난 시간에 함수를 경계 너머로 넘긴 코드는 타입 검사는 통과했지만 빌드가 막았습니다.

튜터의 가이드 및 해설

두 실수의 차이는 언제 판정할 수 있느냐에 있습니다.

함수를 경계 너머로 넘기는 것은 코드만 보고 알 수 있습니다. 이 파일에 'use client'가 있고, 저기서 함수를 prop으로 넘기고 있다는 것만 보면 되니 실행해볼 필요가 없어서, 빌드가 잡습니다.

Promise를 렌더 안에서 만드는 것은 코드만 봐서는 알 수 없습니다. 문제는 "이 값이 매번 새것인가" 인데, 그건 이 컴포넌트가 몇 번 다시 그려지는지에 달려 있어요. 한 번만 그려지면 아무 문제 없습니다. 실제로 첫 화면에서는 멀쩡했죠.

그러니 이건 도구가 게을러서가 아닙니다. 판정에 필요한 정보가 코드 안에 없어서예요.

그럼 어떻게 줄일까요. "조심하자"는 답이 아닌데, 사람의 주의력은 재현되지 않기 때문이에요.

첫째, 통과 기준을 화면 로드에서 상호작용까지 넓힙니다. 오늘 사고의 핵심은 첫 화면이 멀쩡했다는 겁니다. 확인할 때 눌러보는 것을 습관으로 두면 이 종류가 크게 줄어요. 이걸 사람이 매번 하지 말고 자동으로 눌러보게 만드는 이야기는 F 카테고리에서 본격적으로 다룹니다.

둘째, 실수할 수 없는 모양으로 코드를 짭니다. 오늘 우리가 만든 TagFilter는 props 타입이 Promise<string[]>입니다. 이 컴포넌트는 username을 아예 안 받아요. 그러니 자기가 요청을 만들려야 만들 수가 없습니다. 잘못 쓸 수 있는 문을 애초에 안 열어두는 것이 주의를 요구하는 것보다 훨씬 강합니다.

셋째, 눈에 보이는 신호를 심어둡니다. 무한 반복은 개발자 도구 네트워크 탭에서 즉시 드러났어요. 같은 주소로 열 건이 쌓이는 것은 못 보고 지나칠 수가 없습니다. 확인할 때 화면만 보지 말고 네트워크 탭을 함께 여는 것이 그래서 중요합니다.

⚠️ 마지막으로 균형을 잡고 갑시다. 도구가 못 잡는다고 도구를 탓할 일은 아닙니다. 오늘 세 도구는 각자 자기 일을 정확히 했어요. 타입 검사는 타입을 봤고, 린트는 코드 모양을 봤고, 빌드는 경계를 봤습니다. 셋 다 통과했다는 것은 "맞다"가 아니라 "이 셋이 보는 범위에서는 문제가 없다"는 뜻입니다. 그 범위 밖을 누가 볼 것인지가 우리 몫이에요.

🎯 면접관을 홀리는 핵심 멘트

"타입 검사와 빌드를 통과했다는 것은 정확성의 증명이 아니라 그 도구들이 보는 범위 안에서 문제가 없다는 뜻입니다. 실제로 저희가 겪은 사고는 셋 다 통과하고 첫 화면도 정상이었는데 버튼을 누르는 순간 무한 요청이 나갔습니다. 그래서 저는 잘못 쓸 수 있는 인자를 아예 안 받는 형태로 컴포넌트 입구를 좁히는 쪽을 먼저 봅니다."

전체 목록 리액트