Rsbuild·React 커머스 교과서

Rsbuild·React 커머스 교과서

쇼핑몰 프론트엔드 하나를 처음부터 끝까지 만들며 Rsbuild(빌드 도구), Rspack(번들러), Biome(린트·포맷), Rstest(테스트)를 익힙니다. 본문의 코드는 모두 실제로 빌드하고 테스트를 통과시킨 예제 앱에서 가져왔고, 크기와 시간은 직접 잰 값입니다.

기준 버전Rsbuild 2.2.10Rspack 2.2.7Biome 2.5.14Rstest 0.12.2React 19.3.0React Router 8.4.0TanStack Query 5.104.0Playwright 1.63.0

/ 키로 전체를 검색할 수 있습니다. 점선 밑줄이 있는 용어를 누르면 뜻이 뜹니다. 상단의 실측값과 검증 필요 버튼은 본문에 흩어진 해당 항목을 한곳에 모아 보여 줍니다.

0장 시작하기

무엇을 만들고, 어떤 도구를 왜 쓰는지 먼저 봅니다. 도구 이름이 많지만 역할은 네 가지뿐입니다. 묶고, 검사하고, 시험하고, 분석합니다.

이 장의 핵심

  • 완성품은 상품 목록 → 상세 → 장바구니 → 주문서 → 모의 결제 → 주문 완료까지 이어지는 쇼핑몰입니다. 11장에서 바로 조작해 볼 수 있습니다.
  • Rspack은 코드를 묶는 엔진이고, Rsbuild는 그 엔진에 개발 서버와 기본 설정을 얹은 도구입니다. webpack과 Vite의 자리에 각각 대응합니다.
  • Biome은 린트와 포맷을, Rstest는 단위·컴포넌트 테스트를, Rsdoctor는 번들 분석을 맡습니다.

0.1 완성 결과 미리보기

예를 들어 사용자가 디지털 카테고리에서 이어폰을 골라 결제한다고 해 봅시다. 화면은 이렇게 이어집니다.

  1. 상품 목록 /?category=digital&sort=price-asc — 필터와 정렬이 주소에 남습니다. 아래로 내리면 다음 20개를 이어서 불러옵니다.
  2. 상세 /products/p-002 — 목록에서 마우스를 올린 순간 미리 받아 둔 데이터로 바로 뜹니다.
  3. 장바구니 /cart — 수량을 바꾸면 화면이 먼저 바뀌고, 재고가 모자라면 되돌아갑니다.
  4. 주문서 /checkout — 휴대폰 번호·우편번호를 검사하고, 결제 버튼을 두 번 눌러도 주문은 하나만 생깁니다.
  5. 모의 결제 /checkout/pay/ORD-000001 → /checkout/confirm — PG사의 요청 → 인증 → 승인 흐름을 흉내 냅니다.
  6. 주문 완료 /orders/ORD-000001/complete — 새로고침해도 같은 주문이 보이고, 뒤로 가기로 결제 단계에 돌아가지 않습니다.

이 흐름은 Playwright가 데스크톱과 모바일 두 화면 크기에서 매번 끝까지 눌러 보는 E2E 테스트로 지킵니다(9장). 직접 눌러 보고 싶다면 11장의 라이브 데모로 가세요.

도메인 용어는 저장소의 다른 교과서와 맞췄습니다. API 경로는 /api/products, /api/cart/items, /api/orders, /api/payments/confirm입니다. 같은 쇼핑몰을 네트워크 관점에서 본 네트워크 복원력 FE 가이드 16장, 서버 관점에서 본 백엔드 시스템 기초 지도 7장과 함께 읽으면 좋습니다.

0.2 작성일과 기준 버전

이 교과서는 2026년 9월 28일에 작성했습니다. 버전은 기억이 아니라 npm view <패키지> version과 예제 앱의 node_modules에서 확인했습니다. 빌드 도구는 버전이 빨리 바뀌니, 따라 할 때 먼저 자기 버전을 확인하세요.

분류패키지버전
빌드@rsbuild/core / @rspack/core2.2.10 / 2.2.7
빌드@rsbuild/plugin-react / @rsbuild/plugin-tailwindcss2.1.0 / 2.0.3
품질@biomejs/biome / typescript2.5.14 / 7.0.2
테스트@rstest/core / @testing-library/react / @playwright/test0.12.2 / 16.3.3 / 1.63.0
분석@rsdoctor/rspack-plugin1.6.4
화면react / react-router19.3.0 / 8.4.0
상태@tanstack/react-query / zustand5.104.0 / 5.0.15
폼react-hook-form / zod / @hookform/resolvers7.89.0 / 4.6.5 / 5.9.1
목 서버msw2.15.0
스타일tailwindcss4.3.3

실측 환경은 macOS의 Node v24.20.0입니다. 크기는 gzip으로 압축한 값(네트워크로 오가는 양)을 기준으로 씁니다.실측

0.3 도구 지도

도구 이름이 헷갈리면 "무엇을 대신하는가"로 기억하세요. Rspack은 webpack을, Rsbuild는 Vite나 Create React App을, Biome은 ESLint + Prettier를, Rstest는 Jest나 Vitest를 대신합니다. 아래 지도에서 도구를 누르면 역할과 관계가 나옵니다.

도구 지도

도구를 누르면 하는 일, 대신하는 도구, 예제 앱에서 쓰는 곳이 나옵니다. 점선 테두리는 관계가 있는 도구입니다.

0.4 예제 앱 실행하기

예제 앱은 example-app/ 폴더에 있습니다. 백엔드 없이 돌 수 있도록 MSW가 /api 요청에 가짜로 답합니다.

npm install
npm run dev          # 개발 서버 http://localhost:3000
npm run build        # dist/ 에 배포용 결과물
npx biome ci .       # 서식·린트 검사
npx rstest run       # 단위·컴포넌트 테스트
npx playwright test  # 빌드 결과로 구매 흐름 E2E

이 네 가지 검사(biome ci, Rstest, 빌드, Playwright)는 예제 앱에서 모두 통과한 상태입니다. 같은 명령이 .github/workflows/ci.yml에도 들어 있습니다(2장).

1장 프로젝트 만들기

명령 한 줄로 React + TypeScript 프로젝트를 만들고, 쇼핑몰에 맞게 폴더와 설정을 다듬습니다. 설정 파일은 짧게 유지하는 것이 목표입니다.

이 장의 핵심

  • create-rsbuild의 --tools 옵션으로 Rstest·Biome·Tailwind 설정까지 한 번에 만듭니다.
  • 폴더는 기술(components, hooks)이 아니라 기능(catalog, cart, checkout, order)으로 나눕니다.
  • 브라우저 코드에서 읽을 환경 변수는 PUBLIC_으로 시작해야 합니다. 이미 셸에 있는 값은 .env가 덮어쓰지 않습니다.
  • 경로 별칭은 tsconfig.json의 paths 한 곳에만 적습니다. Rsbuild가 그대로 읽습니다.

1.1 create-rsbuild로 시작하기

예제 앱은 이 명령으로 만들었습니다. 질문 없이 끝나도록 템플릿과 도구를 모두 인자로 넘겼습니다.

npx -y create-rsbuild@2.2.10 --dir example-app --template react-ts \
  --tools rstest,biome,tailwindcss --no-git --package-name commerce-example

이 명령이 만든 것은 rsbuild.config.ts, rstest.config.ts, biome.json, tsconfig.json, 샘플 컴포넌트와 테스트 하나입니다. 만든 직후에 rstest와 rsbuild build는 바로 통과했지만, biome ci는 오류 2개로 실패했습니다.실측 템플릿의 CSS 따옴표('tailwindcss')와 import 정렬이 Biome 규칙과 달랐기 때문입니다. npx biome check --write . 한 번으로 고쳤습니다.

템플릿 버전은 최신이 아닐 수 있습니다

템플릿이 넣은 버전은 Biome 2.5.11, TypeScript 6.0.3이었습니다. npm view로 확인한 최신은 2.5.14, 7.0.2이어서 올렸습니다. 템플릿이 만든 package.json을 그대로 믿지 말고 한 번 확인하세요.

1.2 기능 단위 폴더 구조

쇼핑몰 코드는 "상품을 보여 준다", "장바구니를 관리한다", "주문한다"처럼 기능별로 바뀝니다. 그래서 폴더도 기능으로 나눕니다. 결제 흐름을 고칠 때 features/checkout/만 열면 됩니다.

src/
├─ app/
│  ├─ App.tsx
│  ├─ queryClient.ts
│  ├─ RootLayout.tsx
│  ├─ RouteError.tsx
│  └─ routes.tsx
├─ demo/
│  ├─ index.tsx
│  └─ memoryFetch.ts
├─ features/
│  ├─ cart/
│  │  ├─ CartPage.tsx
│  │  ├─ pricing.ts
│  │  ├─ store.ts
│  │  └─ useCartMutation.ts
│  ├─ catalog/
│  │  ├─ api.ts
│  │  ├─ ProductCard.tsx
│  │  ├─ ProductDetailPage.tsx
│  │  ├─ ProductListPage.tsx
│  │  ├─ searchParams.ts
│  │  ├─ types.ts
│  │  └─ useInfiniteScroll.ts
│  ├─ checkout/
│  │  ├─ CheckoutPage.tsx
│  │  ├─ MockPgPage.tsx
│  │  ├─ PaymentConfirmPage.tsx
│  │  ├─ PaymentSteps.tsx
│  │  └─ schema.ts
│  └─ order/
│     ├─ api.ts
│     ├─ OrderCompletePage.tsx
│     └─ types.ts
├─ mocks/
│  ├─ browser.ts
│  ├─ data.ts
│  ├─ db.ts
│  ├─ handlers.ts
│  └─ node.ts
├─ shared/
│  ├─ lib/
│  │  ├─ http.ts
│  │  ├─ idempotency.ts
│  │  ├─ money.ts
│  │  └─ storage.ts
│  └─ ui/

├─ env.d.ts
├─ index.css
└─ index.tsx
  • app/ — 라우터, 레이아웃, 에러 경계, QueryClient처럼 앱 전체를 묶는 코드
  • features/ — 기능별 화면·API·상태. 기능끼리는 필요한 것만 import합니다(예: checkout이 cart의 스토어를 읽음).
  • shared/lib/ — 어느 기능에도 속하지 않는 도구(금액 표시, HTTP, 멱등 키, 안전한 저장소)
  • mocks/ — 가짜 서버. 브라우저(MSW)와 테스트(Node)가 같은 핸들러를 씁니다.
  • demo/ — 이 교과서에 넣을 데모 전용 입구(11장)

1.3 rsbuild.config.ts 핵심 옵션

설정 파일 전체입니다. 30줄 남짓이면 충분합니다. React·TypeScript·CSS·HTML·개발 서버·코드 스플리팅은 Rsbuild가 기본으로 해 주기 때문입니다.

import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginTailwindcss } from '@rsbuild/plugin-tailwindcss';
import { RsdoctorRspackPlugin } from '@rsdoctor/rspack-plugin';

// Docs: https://rsbuild.rs/config/
export default defineConfig({
  plugins: [pluginReact(), pluginTailwindcss()],
  html: {
    template: './index.html',
    // 지정하지 않으면 기본값 "Rsbuild App"이 들어간다. JS 없이 읽는 크롤러가 보는 제목이다.
    title: '예제 쇼핑몰',
  },
  output: {
    // 파일 이름에 내용 해시를 넣어 오래 캐시해도 배포 때마다 새 파일을 받게 한다.
    filenameHash: true,
  },
  performance: {
    // 빌드 결과에 gzip 크기를 함께 표시한다.
    printFileSize: { compressed: true },
  },
  server: {
    port: 3000,
  },
  tools: {
    rspack: {
      plugins: [
        // RSDOCTOR=true일 때만 번들 분석 보고서(HTML 한 파일 + JSON)를 만든다.
        process.env.RSDOCTOR === 'true' &&
          new RsdoctorRspackPlugin({
            disableClientServer: true,
            output: {
              mode: 'brief',
              reportDir: '.rsdoctor', // dist에 섞여 배포되지 않게 따로 둔다.
              options: { type: ['html', 'json'] },
            },
          }),
      ],
    },
  },
});
옵션하는 일예제 앱에서 쓴 이유
pluginsReact(JSX·Fast Refresh), Tailwind CSS 지원을 켭니다.템플릿이 넣어 준 그대로
html.template기본 HTML 대신 쓸 템플릿 파일<html lang="ko">와 기본 description이 필요해서
output.filenameHash파일 이름에 내용 해시를 넣습니다(배포 결과물의 기본값).10장 캐시 전략의 전제라서 명시
performance.printFileSize빌드가 끝나면 파일별 크기를 표로 보여 줍니다.gzip 크기 크기를 함께 보려고
server.port개발 서버 포트E2E·문서의 주소를 고정하려고
tools.rspackRsbuild가 만든 Rspack 설정에 직접 손대는 탈출구Rsdoctor 플러그인을 조건부로 넣으려고(7장)

설정 빌더

옵션을 켜고 끄면 rsbuild.config.ts가 바뀝니다. 위의 여섯 개를 켜면 주석을 뺀 모양이 예제 앱의 파일과 글자 단위로 같습니다.

1.4 환경 변수

예를 들어 API 주소와 "목 서버를 켤지"는 환경마다 다릅니다. 이런 값은 .env 파일에 두고 코드에서는 import.meta.env로 읽습니다.

# 브라우저 코드에 노출되는 변수는 PUBLIC_ 접두사가 필요하다.
PUBLIC_API_BASE=/api
PUBLIC_API_MOCKING=true

규칙은 세 가지입니다.

  1. PUBLIC_으로 시작하는 변수만 브라우저 코드에 들어갑니다. Rsbuild가 빌드할 때 값을 글자 그대로 바꿔 넣습니다. 그러니 비밀번호나 API 키를 여기에 두면 누구나 볼 수 있습니다.
  2. --env-mode로 파일을 고를 수 있습니다. rsbuild build --env-mode live는 .env를 읽은 다음 .env.live로 덮어씁니다. 예제 앱은 이 모드로 MSW를 뺀 "실서버용" 빌드를 만듭니다.
  3. 이미 process.env에 있는 값은 파일이 덮어쓰지 않습니다. 측정 스크립트에서 .env.live를 읽게 했는데도 MSW가 빠지지 않았습니다. 앞선 빌드가 PUBLIC_API_MOCKING=true를 이미 process.env에 넣어 두었기 때문입니다.실측 CI에서 셸 변수와 .env가 섞이면 같은 일이 생깁니다.
# rsbuild build --env-mode live → 실제 API 서버에 붙는 빌드(MSW 제외)
PUBLIC_API_MOCKING=false

타입스크립트가 변수 이름을 알도록 타입을 적어 둡니다. 오타가 컴파일 오류가 됩니다.

/// <reference types="@rsbuild/core/types" />

interface ImportMetaEnv {
  readonly PUBLIC_API_BASE: string;
  readonly PUBLIC_API_MOCKING: 'true' | 'false';
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

이 값은 빌드 때 상수로 바뀌기 때문에, 조건문과 합쳐 코드를 통째로 빼는 데에도 씁니다. PUBLIC_API_MOCKING이 'false'면 MSW를 불러오는 import()가 죽은 코드가 되어 번들에서 빠집니다(7장에서 크기를 잽니다).

import './index.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { App } from './app/App';
import { routes } from './app/routes';

// 목 서버(MSW)는 PUBLIC_API_MOCKING=true일 때만 불러온다. false면 번들에서도 빠진다.
async function enableMocking() {
  if (import.meta.env.PUBLIC_API_MOCKING !== 'true') return;
  const { worker } = await import(/* webpackChunkName: "msw" */ './mocks/browser');
  await worker.start({
    onUnhandledRequest: 'bypass',
    quiet: true,
  });
}

1.5 경로 별칭

../../../shared/lib/money 대신 @/shared/lib/money로 씁니다. 별칭은 tsconfig.json의 paths에만 적습니다. Rsbuild는 이 값을 그대로 읽으므로 resolve.alias를 따로 쓸 필요가 없습니다. 한 곳에만 적어야 에디터, 타입 검사, 빌드, 테스트(Rstest도 Rsbuild 설정을 물려받습니다)가 같은 규칙을 씁니다.

    /* type checking */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "strict": true,

    /* path alias: @/features/... → src/features/... */
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"]

TypeScript 7

예제 앱은 TypeScript 7.0.2로 tsc --noEmit을 돌립니다. baseUrl 없이 paths만 써도 동작했습니다.실측 Rsbuild는 타입 검사를 하지 않고 변환만 하므로, 타입 검사는 npm run typecheck로 따로 돌립니다.

2장 코드 품질

Biome 하나로 서식(포맷), 규칙 검사(린트), import 정렬을 합니다. 에디터에서 저장할 때, 커밋할 때, CI에서 같은 설정으로 세 번 검사합니다.

이 장의 핵심

  • 설정은 biome.json 하나입니다. recommended 규칙에서 시작해 팀에 필요한 규칙만 더합니다.
  • biome check --write는 고치고, biome ci는 고치지 않고 검사만 합니다. CI에는 ci를 씁니다.
  • Biome은 모든 실수를 잡지 않습니다. 예제에서 배열 인덱스를 key로 쓴 코드는 진단 없이 통과했습니다.

2.1 biome.json

예제 앱의 설정 전체입니다.

{
  "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  },
  "files": {
    "includes": ["**", "!**/skills-lock.json", "!public/mockServiceWorker.js"]
  },
  "assist": {
    "actions": {
      "source": {
        "organizeImports": "on"
      }
    }
  },
  "formatter": {
    "indentStyle": "space",
    "lineWidth": 100
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single"
    }
  },
  "css": {
    "parser": {
      "cssModules": true,
      "tailwindDirectives": true
    }
  },
  "linter": {
    "enabled": true,
    "rules": {
      "preset": "recommended",
      "correctness": {
        "noUnusedImports": "error",
        "useExhaustiveDependencies": "error"
      },
      "suspicious": {
        "noConsole": {
          "level": "error",
          "options": {
            "allow": ["error", "warn"]
          }
        }
      },
      "style": {
        "noNonNullAssertion": "error"
      }
    }
  },
  "overrides": [
    {
      "includes": ["scripts/**"],
      "linter": {
        "rules": {
          "suspicious": {
            "noConsole": "off"
          }
        }
      }
    }
  ]
}
  • vcs.useIgnoreFile — .gitignore에 적힌 dist/, node_modules를 검사에서 뺍니다. git 저장소여야 동작하므로 예제 앱 폴더에서 git init을 했습니다.
  • files.includes — !로 시작하면 제외입니다. MSW가 만든 public/mockServiceWorker.js는 우리가 고칠 파일이 아니니 뺐습니다.
  • assist.actions.source.organizeImports — import 정렬. 린트 규칙이 아니라 "어시스트"로 분류됩니다.
  • css.parser.tailwindDirectives — Tailwind의 @import "tailwindcss", @apply 같은 문법을 이해하게 합니다.
  • overrides — 특정 폴더에만 다른 규칙을 씁니다. 측정 스크립트(scripts/)는 결과를 출력해야 하므로 noConsole을 껐습니다.

2.2 주요 규칙

recommended에 더해 네 규칙을 오류(error)로 올렸습니다. 쇼핑몰에서 실제 사고로 이어지기 쉬운 것들입니다.

규칙막는 실수쇼핑몰에서 생기는 일
correctness/useExhaustiveDependenciesuseEffect 의존성 빠뜨림상품을 바꿔도 이전 상품 가격이 남음
correctness/noUnusedImports쓰지 않는 import번들에 필요 없는 모듈이 딸려 올 수 있음
suspicious/noConsole남겨 둔 console.log결제 정보가 브라우저 콘솔에 찍힘
style/noNonNullAssertionvalue!로 null 검사 건너뛰기빈 장바구니에서 화면이 깨짐

예제 앱을 만드는 중에도 Biome이 두 번 멈춰 세웠습니다.실측 데모용 가짜 fetch에서 if ((m = path.match(...)))처럼 조건문 안에서 대입한 코드가 suspicious/noAssignInExpressions에 걸렸고, 측정 스크립트의 console.log가 noConsole에 걸렸습니다. 앞의 것은 코드를 고쳤고, 뒤의 것은 overrides로 그 폴더만 허용했습니다.

Biome 진단 맞히기

코드를 보고 Biome이 어떤 규칙으로 멈춰 세울지 고르세요. 정답과 메시지는 예제 앱과 같은 설정으로 biome lint --reporter=json을 실제로 돌린 결과입니다.

2.3 에디터 연동

VS Code

Biome 확장(biomejs.biome)을 설치하고, 저장할 때 서식·안전한 수정·import 정렬을 하도록 설정합니다. 예제 앱은 이 설정을 저장소에 넣어 팀원이 같은 동작을 얻게 했습니다.

{
  "editor.defaultFormatter": "biomejs.biome",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.biome": "explicit",
    "source.organizeImports.biome": "explicit"
  }
}
{
  "recommendations": ["biomejs.biome"]
}

"explicit"은 직접 저장할 때만 실행한다는 뜻입니다(자동 저장 때는 실행하지 않음).

IntelliJ IDEA·WebStorm

Settings → Plugins → Marketplace에서 "Biome"을 검색해 설치하고 IDE를 다시 시작합니다. 플러그인은 프로젝트의 biome.json과 node_modules의 Biome을 씁니다. 저장 시 서식·수정 옵션의 정확한 메뉴 이름은 플러그인 버전에 따라 다를 수 있으니 설치 후 Settings에서 "Biome"을 검색해 확인하세요.검증 필요 기존 Prettier·ESLint 연동이 켜져 있으면 저장할 때 두 도구가 서로 다른 서식을 만들 수 있으니 꺼 두세요.

2.4 커밋 전 검사

에디터 설정을 안 한 사람도 있으니 커밋 직전에 한 번 더 검사합니다. Biome 문서의 Lefthook 레시피를 그대로 썼습니다. 스테이징한 파일만 검사하고, 안전한 수정은 자동으로 다시 스테이징합니다.

# 커밋 전에 스테이징된 파일만 Biome으로 검사하고 안전한 수정은 자동 반영한다.
pre-commit:
  commands:
    check:
      glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,css}"
      run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files}
      stage_fixed: true
npm i -D lefthook
npx lefthook install   # .git/hooks/pre-commit 생성

설치 스크립트가 막힐 수 있습니다

최근 npm은 허용하지 않은 패키지의 postinstall을 막습니다. 예제 앱에서도 lefthook과 msw의 설치 스크립트가 막혔다는 경고가 나왔습니다.실측 그래서 npx lefthook install을 직접 실행했습니다. msw init도 마찬가지로 직접 실행합니다(4장).

2.5 CI의 biome ci

biome check --write는 파일을 고칩니다. CI는 고치면 안 되고 "틀렸다"고 알려야 합니다. 그래서 CI에서는 biome ci를 씁니다. 서식·린트·import 정렬을 모두 검사하고, 하나라도 어긋나면 실패 코드로 끝납니다.

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      # biome ci: 파일을 고치지 않고 서식·린트·import 정렬을 검사만 한다.
      - run: npx biome ci .
      - run: npm run typecheck
      - run: npx rstest run
      - run: npm run build
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - uses: actions/upload-artifact@v7
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

순서에도 이유가 있습니다. 가장 빠른 Biome(64개 파일에 약 10ms)을 먼저 돌려 사소한 실수로 느린 E2E까지 기다리지 않게 했습니다.실측

3장 라우팅

주소마다 어떤 화면을 보여 줄지 정합니다. 레이아웃은 한 번만 그리고, 페이지 코드는 그 주소에 처음 갈 때 내려받게 나눕니다. 오류가 나도 헤더는 살아 있게 합니다.

이 장의 핵심

  • React Router 8.4.0의 Data Mode(createBrowserRouter)로 라우트를 객체 배열 하나에 모읍니다. v8부터 react-router-dom은 없고, RouterProvider는 react-router/dom에서 가져옵니다.
  • 라우트에 lazy를 주면 페이지마다 청크가 나뉩니다. 첫 화면은 결제 코드를 한 줄도 받지 않습니다.
  • 에러 경계는 레이아웃 바로 아래(경로 없는 라우트)에 둡니다. 그래야 오류 화면에서도 헤더와 장바구니 버튼이 남습니다.

3.1 페이지와 레이아웃

라우트 전체입니다. 맨 바깥 라우트가 레이아웃(RootLayout)이고, 그 아래 경로 없는 라우트가 에러 경계만 맡습니다. 실제 페이지는 그 아래에 있습니다.

import type { RouteObject } from 'react-router';
import { RootLayout } from './RootLayout';
import { NotFound, RouteError } from './RouteError';

// 페이지마다 import()로 나누면 첫 화면에는 목록 페이지 코드만 내려받는다.
export const routes: RouteObject[] = [
  {
    path: '/',
    Component: RootLayout,
    // 첫 화면의 lazy 라우트를 불러오는 동안 보여 줄 화면
    HydrateFallback: () => <p className="py-10 text-center">불러오는 중…</p>,
    children: [
      {
        ErrorBoundary: RouteError,
        children: [
          {
            index: true,
            lazy: () =>
              import(/* webpackChunkName: "page-list" */ '@/features/catalog/ProductListPage').then(
                (m) => ({ Component: m.default }),
              ),
          },
          {
            path: 'products/:productId',
            lazy: () =>
              import(
                /* webpackChunkName: "page-detail" */ '@/features/catalog/ProductDetailPage'
              ).then((m) => ({ Component: m.default })),
          },
          {
            path: 'cart',
            lazy: () =>
              import(/* webpackChunkName: "page-cart" */ '@/features/cart/CartPage').then((m) => ({
                Component: m.default,
              })),
          },
          {
            path: 'checkout',
            lazy: () =>
              import(
                /* webpackChunkName: "page-checkout" */ '@/features/checkout/CheckoutPage'
              ).then((m) => ({ Component: m.default })),
          },
          {
            path: 'checkout/pay/:orderId',
            lazy: () =>
              import(/* webpackChunkName: "page-pg" */ '@/features/checkout/MockPgPage').then(
                (m) => ({
                  Component: m.default,
                }),
              ),
          },
          {
            path: 'checkout/confirm',
            lazy: () =>
              import(
                /* webpackChunkName: "page-confirm" */ '@/features/checkout/PaymentConfirmPage'
              ).then((m) => ({ Component: m.default })),
          },
          {
            path: 'orders/:orderId/complete',
            lazy: () =>
              import(
                /* webpackChunkName: "page-complete" */ '@/features/order/OrderCompletePage'
              ).then((m) => ({ Component: m.default })),
          },
          { path: '*', Component: NotFound },
        ],
      },
    ],
  },
];

레이아웃은 헤더와 <Outlet />(자식 페이지가 들어갈 자리)을 그립니다. 페이지를 옮기는 동안 useNavigation().state가 'loading'이 되므로 얇은 진행 막대를 보여 줍니다.

import { Suspense, useEffect } from 'react';
import { Link, Outlet, ScrollRestoration, useNavigation } from 'react-router';
import { selectCount, syncCartAcrossTabs, useCartStore } from '@/features/cart/store';

export function RootLayout() {
  const count = useCartStore(selectCount);
  const navigation = useNavigation();
  useEffect(() => syncCartAcrossTabs(), []);

  return (
    <div className="min-h-screen bg-white text-slate-900">
      <header className="sticky top-0 z-10 border-b border-slate-200 bg-white/90 backdrop-blur">
        <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
          <Link to="/" className="font-bold">
            예제 쇼핑몰
          </Link>
          <Link to="/cart" className="text-sm">
            장바구니 <span data-testid="cart-count">{count}</span>
          </Link>
        </div>
        {navigation.state === 'loading' && (
          <div
            role="progressbar"
            aria-label="페이지 불러오는 중"
            className="h-0.5 animate-pulse bg-blue-600"
          />
        )}
      </header>
      <main className="mx-auto max-w-5xl px-4 py-6">
        <Suspense fallback={<p className="py-10 text-center">불러오는 중…</p>}>
          <Outlet />
        </Suspense>
      </main>
      <ScrollRestoration />
    </div>
  );
}

아래 App은 라우터를 직접 만들지 않고 인자(router)로 받기만 합니다. routes는 "어떤 주소에 어떤 화면을 보여 줄지"를 적은 배열일 뿐이고, 그것을 실제로 돌리는 라우터는 바깥에서 만들어 넘깁니다.

import { QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import type { DataRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import { createQueryClient } from './queryClient';

export function App({ router }: { router: DataRouter }) {
  const [queryClient] = useState(createQueryClient);
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}

그래서 routes 하나를 두고, 라우터만 상황에 맞게 바꿔 끼웁니다.

  • 실제 브라우저: createBrowserRouter(routes). 주소창의 URL을 읽고, 이동하면 주소창을 바꿉니다.
  • 테스트·데모: createMemoryRouter(routes, { initialEntries: ['/cart'] }). 주소를 브라우저가 아니라 메모리에 두므로 주소창이 없는 환경(테스트 러너, 페이지 안의 데모)에서도 같은 화면 코드가 돕니다.
// 브라우저 입구
<App router={createBrowserRouter(routes)} />

// 테스트·데모 입구
<App router={createMemoryRouter(routes, { initialEntries: ['/'] })} />

라우트를 두 번 정의하지 않아도 되는 이유가 이것입니다. 실제 사용은 9장(테스트)과 11장(데모)에서 봅니다. MSW는 서버 응답을 흉내 낼 뿐 라우팅과는 관계없고, HashRouter는 URL을 # 뒤에 두는 브라우저용 방식이라 이 문제의 답이 아닙니다.

3.2 lazy 코드 스플리팅

lazy: () => import('...')를 만나면 Rspack이 그 파일과 거기서만 쓰는 코드를 따로 떼어 비동기 청크로 만듭니다. webpackChunkName 주석은 청크 이름을 정합니다. Rspack은 webpack의 이 주석 문법을 그대로 이해합니다.

빌드 결과에서 페이지 청크는 이렇게 나왔습니다.실측 목록 페이지 청크는 gzip 2.8 kB, 주문서 페이지 청크는 2.5 kB입니다. 실제 브라우저로 열어 받은 파일을 기록해 보니:

  • 첫 방문(목록)에는 JS 파일 9개를 받았고 page-checkout.js는 받지 않았습니다.
  • 상세로 가자 page-detail.js 하나만 더 받았습니다.
  • 장바구니를 거쳐 주문서로 가자 page-cart.js, f.js, page-checkout.js를 더 받았습니다. f.js는 React Hook Form이 든 공유 청크입니다.

이 기록은 e2e/measure.spec.ts가 Playwright로 브라우저의 Resource Timing을 읽어 남긴 것이고, 테스트는 "첫 화면에 결제 코드가 없다"를 확인합니다.

lazy 경계 시각화

페이지를 lazy에서 빼면(= 첫 화면 번들에 넣으면) 첫 화면에 받는 JS가 얼마나 늘어나는지 봅니다. 막대 길이는 예제 앱 빌드에서 잰 파일별 gzip 크기입니다.

    항상 받는 초기 청크페이지 청크(목록 + 첫 화면에 넣은 페이지)

    "첫 화면에 넣기"는 각 페이지와 그 페이지만 쓰는 공유 청크 크기를 더한 추정입니다. 실제로 eager import로 바꾸면 공유 모듈의 배치가 달라져 수 kB 차이가 날 수 있습니다.

    HydrateFallback

    처음 열 때 첫 페이지도 lazy라서, 그 코드를 받는 동안 보여 줄 화면이 필요합니다. 없으면 React Router가 "No HydrateFallback element provided" 경고를 냅니다. 테스트 로그에서 이 경고를 보고 루트 라우트에 HydrateFallback을 넣었습니다.실측

    3.3 에러 경계

    상품 상세를 불러오다 404가 나면 어떻게 될까요? 상세 페이지는 useSuspenseQuery를 쓰므로 실패하면 렌더링 중에 오류를 던집니다. 그 오류는 가장 가까운 라우트의 ErrorBoundary가 받습니다.

    import { isRouteErrorResponse, Link, useRouteError } from 'react-router';
    import { HttpError } from '@/shared/lib/http';
    
    export function NotFound() {
      return (
        <section className="py-16 text-center">
          <title>페이지를 찾을 수 없어요</title>
          <h1 className="text-xl font-bold">페이지를 찾을 수 없어요</h1>
          <Link to="/" className="mt-4 block underline">
            홈으로
          </Link>
        </section>
      );
    }
    
    // 라우트 아래에서 던져진 오류를 받아 헤더는 그대로 두고 본문만 바꾼다.
    export function RouteError() {
      const error = useRouteError();
      if (
        (isRouteErrorResponse(error) && error.status === 404) ||
        (error instanceof HttpError && error.status === 404)
      ) {
        return <NotFound />;
      }
    
      return (
        <section role="alert" className="py-16 text-center">
          <title>문제가 생겼어요</title>
          <h1 className="text-xl font-bold">문제가 생겼어요</h1>
          <button type="button" onClick={() => window.location.reload()} className="mt-4 underline">
            다시 시도
          </button>
          <Link to="/" className="mt-4 block underline">
            홈으로
          </Link>
        </section>
      );
    }
    • HTTP 404(없는 상품)와 라우터의 404(없는 주소)는 같은 "페이지를 찾을 수 없어요" 화면으로 모읍니다.
    • 그 밖의 오류는 "다시 시도"를 보여 줍니다. 네트워크 오류는 TanStack Query가 이미 재시도한 뒤라서(4장), 여기서는 새로고침으로 처음부터 다시 시작합니다.
    • 경계를 레이아웃 아래에 두었기 때문에 오류가 나도 헤더의 장바구니 링크는 그대로 동작합니다. 쇼핑몰에서는 "어디로든 빠져나갈 길"이 중요합니다.

    없는 주소(*)는 처음에 로더에서 404를 던지는 방식으로 만들었는데, 컴포넌트가 없는 라우트라고 경고가 나서 NotFound 컴포넌트를 바로 연결했습니다. 이 동작은 tests/ProductListPage.test.tsx의 "없는 경로는 404 화면을 보여 준다"가 지킵니다.

    출처

    • react-router 패키지의 CHANGELOG(v8.0.0: react-router-dom 제거, RouterProvider는 react-router/dom)와 docs/start/data/route-object.md(lazy)
    • Rsbuild: Code splitting

    4장 서버 상태

    상품과 주문은 서버가 원본이고, 화면에 있는 것은 복사본입니다. 복사본이 언제 낡는지, 언제 다시 받을지, 실패하면 몇 번 다시 시도할지를 TanStack Query에 맡깁니다. 백엔드 없이도 개발할 수 있게 MSW가 서버 역할을 합니다.

    이 장의 핵심

    • staleTime은 "다시 요청하지 않는 시간", gcTime은 "아무도 안 쓸 때 캐시에 남기는 시간"입니다. 둘은 다른 시계입니다.
    • 필터·정렬·불러온 페이지 수는 URL 상태로 둡니다. 새로고침·공유·뒤로 가기가 공짜로 됩니다.
    • 재시도는 "다시 보내도 결과가 같은 요청"에만 합니다. 4xx는 재시도하지 않고, 간격은 지수 백오프 + 지터로 벌립니다.
    • 모든 요청에 타임아웃을 겁니다. 타임아웃은 실패가 아니라 "결과를 모름"입니다.

    4.1 TanStack Query 기본값

    예를 들어 사용자가 상품 목록을 보다가 상세에 갔다가 10초 만에 돌아온다고 해 봅시다. 목록을 다시 받아야 할까요? 가격이 10초 사이에 바뀔 일은 드뭅니다. 그래서 30초 동안은 fresh로 보고 다시 요청하지 않게 했습니다.

    import { QueryClient } from '@tanstack/react-query';
    import { isRetryable, retryDelay } from '@/shared/lib/http';
    
    export function createQueryClient() {
      return new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 30_000, // 30초 동안은 fresh: 같은 데이터를 다시 요청하지 않는다.
            gcTime: 5 * 60_000, // 쓰는 화면이 없어진 뒤 5분 동안 캐시에 남긴다.
            retry: (failureCount, error) => failureCount < 3 && isRetryable(error),
            retryDelay: (attempt) => retryDelay(attempt),
          },
          mutations: {
            retry: false, // 쓰기 요청은 멱등성이 보장될 때만 개별로 재시도한다.
          },
        },
      });
    }

    상품 상세는 더 드물게 바뀌므로 쿼리에서 staleTime을 5분으로 늘렸습니다. 쿼리 키는 한 파일에 모아 두어 무효화할 때 오타가 나지 않게 합니다.

    import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
    import { request } from '@/shared/lib/http';
    import type { ListParams } from './searchParams';
    import type { Product, ProductPage } from './types';
    
    export const PAGE_SIZE = 20;
    
    export const productKeys = {
      all: ['products'] as const,
      list: (p: Omit<ListParams, 'page'>) => [...productKeys.all, 'list', p] as const,
      detail: (productId: string) => [...productKeys.all, 'detail', productId] as const,
    };
    
    export function productListQuery(params: Omit<ListParams, 'page'>) {
      return infiniteQueryOptions({
        queryKey: productKeys.list(params),
        queryFn: ({ pageParam, signal }) =>
          request<ProductPage>(
            `/products?${new URLSearchParams({
              category: params.category,
              sort: params.sort,
              page: String(pageParam),
              size: String(PAGE_SIZE),
            })}`,
            { signal },
          ),
        initialPageParam: 1,
        getNextPageParam: (last) => last.nextPage,
      });
    }
    
    export function productQuery(productId: string) {
      return queryOptions({
        queryKey: productKeys.detail(productId),
        queryFn: ({ signal }) => request<Product>(`/products/${productId}`, { signal }),
        staleTime: 5 * 60_000,
      });
    }

    staleTime·gcTime 캐시 타임라인

    목록을 0초에 열고, 떠났다가, 다시 돌아오는 상황입니다. 두 값을 바꾸면 돌아왔을 때 캐시가 fresh인지, stale인지, 지워졌는지가 바뀝니다.

    4.2 URL 상태: 필터·정렬·페이지

    필터를 컴포넌트 state에 두면 새로고침할 때 사라지고, 친구에게 링크를 보내도 같은 화면이 안 나옵니다. 주소에 두면 해결됩니다. 다만 주소는 사용자가 마음대로 고칠 수 있는 입력이므로 Zod로 검사하고, 틀린 값은 기본값으로 되돌립니다.

    import { z } from 'zod';
    import { CATEGORY_IDS, SORT_IDS } from './types';
    
    // URL은 사용자가 고칠 수 있는 입력이다. 잘못된 값은 버리고 기본값으로 되돌린다.
    const schema = z.object({
      category: z.enum(CATEGORY_IDS).catch('all'),
      sort: z.enum(SORT_IDS).catch('popular'),
      page: z.coerce.number().int().min(1).max(50).catch(1),
    });
    
    export type ListParams = z.infer<typeof schema>;
    
    export const DEFAULT_LIST_PARAMS: ListParams = {
      category: 'all',
      sort: 'popular',
      page: 1,
    };
    
    export function parseListParams(sp: URLSearchParams): ListParams {
      return schema.parse({
        category: sp.get('category') ?? undefined,
        sort: sp.get('sort') ?? undefined,
        page: sp.get('page') ?? undefined,
      });
    }
    
    // 기본값은 URL에 쓰지 않아 공유 링크를 짧게 유지한다.
    export function toSearchParams(params: ListParams): URLSearchParams {
      const sp = new URLSearchParams();
      if (params.category !== DEFAULT_LIST_PARAMS.category) sp.set('category', params.category);
      if (params.sort !== DEFAULT_LIST_PARAMS.sort) sp.set('sort', params.sort);
      if (params.page !== DEFAULT_LIST_PARAMS.page) sp.set('page', String(params.page));
      return sp;
    }

    페이지 번호도 주소에 남깁니다. /?sort=price-desc&page=2로 들어오면 1쪽을 받은 뒤 2쪽까지 이어서 받아 40개를 보여 줍니다. 스크롤로 한 쪽 더 불러오면 replace로 주소의 page만 바꿔, 뒤로 가기 기록이 쪽마다 쌓이지 않게 했습니다.

    import { useInfiniteQuery } from '@tanstack/react-query';
    import { useEffect } from 'react';
    import { useSearchParams } from 'react-router';
    import { productListQuery } from './api';
    import { ProductCard } from './ProductCard';
    import { type ListParams, parseListParams, toSearchParams } from './searchParams';
    import { CATEGORY_IDS, CATEGORY_LABEL, SORT_IDS, SORT_LABEL, type SortId } from './types';
    import { useInfiniteScroll } from './useInfiniteScroll';
    
    export default function ProductListPage() {
      const [searchParams, setSearchParams] = useSearchParams();
      const params = parseListParams(searchParams);
      const { category, sort } = params;
      const query = useInfiniteQuery(productListQuery({ category, sort }));
      const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = query;
      const loadedPages = data?.pages.length ?? 0;
    
      const update = (next: Partial<ListParams>, replace = false) =>
        setSearchParams(toSearchParams({ ...params, ...next }), {
          replace,
          preventScrollReset: replace,
        });
    
      // 새로고침하거나 링크로 들어왔을 때 URL의 page까지 이어서 불러온다.
      useEffect(() => {
        if (loadedPages > 0 && loadedPages < params.page && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      }, [loadedPages, params.page, hasNextPage, isFetchingNextPage, fetchNextPage]);
    
      const sentinel = useInfiniteScroll(() => {
        if (!hasNextPage || isFetchingNextPage || loadedPages < params.page) return;
        fetchNextPage().then((r) => {
          const pages = r.data?.pages.length;
          if (pages) update({ page: pages }, true);
        });
      }, query.isSuccess);

    4.3 무한 스크롤

    목록 끝에 보이지 않는 감시 요소를 두고, 그것이 화면 아래 400px 안으로 들어오면 다음 쪽을 부릅니다. 스크롤 이벤트를 직접 세지 않고 IntersectionObserver에 맡기므로 가볍습니다.

    import { useEffect, useRef } from 'react';
    
    // 목록 끝의 감시 요소가 화면에 들어오면 다음 페이지를 부른다.
    export function useInfiniteScroll(onReachEnd: () => void, enabled: boolean) {
      const ref = useRef<HTMLDivElement>(null);
      const callback = useRef(onReachEnd);
      callback.current = onReachEnd;
    
      useEffect(() => {
        const el = ref.current;
        if (!el || !enabled) return;
        const observer = new IntersectionObserver(
          (entries) => {
            if (entries.some((e) => e.isIntersecting)) callback.current();
          },
          { rootMargin: '400px 0px' },
        );
        observer.observe(el);
        return () => observer.disconnect();
      }, [enabled]);
    
      return ref;
    }

    E2E는 "마지막 상품까지 스크롤하면 40개가 되고 주소에 page=2가 붙는다", "?sort=price-desc&page=2로 새로고침하면 40개와 정렬이 유지된다"를 확인합니다. 페이지네이션을 서버 관점에서 비교하려면 네트워크 복원력 FE 가이드 8장을 보세요.

    4.4 prefetch

    사용자가 상품 카드에 마우스를 올리면 곧 누를 가능성이 큽니다. 그 순간 상세 데이터를 미리 받아 두면, 누른 뒤에는 로딩 없이 바로 뜹니다. 키보드 사용자를 위해 onFocus에도 겁니다.

    import { useQueryClient } from '@tanstack/react-query';
    import { Link } from 'react-router';
    import { formatKRW } from '@/shared/lib/money';
    import { productQuery } from './api';
    import type { Product } from './types';
    
    // …
    
    export function ProductCard({ product, priority = false }: Props) {
      const queryClient = useQueryClient();
      // 마우스를 올리거나 포커스하면 상세 데이터를 미리 받아 둔다.
      const prefetch = () => queryClient.prefetchQuery(productQuery(product.productId));
    
      return (
        <li>
          <Link
            to={`/products/${product.productId}`}
            onMouseEnter={prefetch}
            onFocus={prefetch}

    이미 fresh한 캐시가 있으면 prefetchQuery는 요청하지 않으므로 여러 번 올려도 괜찮습니다.

    4.5 재시도와 타임아웃

    모든 요청은 request() 한 함수를 지나갑니다. 여기서 세 가지를 정합니다.

    const API_BASE = import.meta.env.PUBLIC_API_BASE;
    const DEFAULT_TIMEOUT_MS = 8000;
    
    export class HttpError extends Error {
      constructor(
        readonly status: number,
        readonly code: string,
        message: string,
        readonly body?: unknown,
      ) {
        super(message);
        this.name = 'HttpError';
      }
    }
    
    type RequestOptions = {
      method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
      body?: unknown;
      headers?: Record<string, string>;
      signal?: AbortSignal;
      timeoutMs?: number;
    };
    
    export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
      const { method = 'GET', body, headers, signal, timeoutMs } = options;
      // TanStack Query가 넘겨준 취소 신호와 타임아웃 신호 중 먼저 오는 쪽으로 요청을 끊는다.
      const timeout = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS);
      const res = await fetch(`${API_BASE}${path}`, {
        method,
        headers: {
          ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
          ...headers,
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
      });
      const data: unknown = res.status === 204 ? null : await res.json();
      if (!res.ok) {
        const problem = (data ?? {}) as { code?: string; message?: string };
        throw new HttpError(
          res.status,
          problem.code ?? 'UNKNOWN',
          problem.message ?? `요청이 실패했습니다 (${res.status})`,
          data,
        );
      }
      return data as T;
    }
    
    // 4xx는 다시 보내도 결과가 같으므로 재시도하지 않는다. 408·429와 5xx, 네트워크 오류만 재시도한다.
    export function isRetryable(error: unknown): boolean {
      if (error instanceof HttpError) {
        return error.status === 408 || error.status === 429 || error.status >= 500;
      }
      return true;
    }
    
    // 지수 백오프(1초, 2초, 4초… 최대 8초)에 지터를 섞어 여러 사용자의 재시도가 한꺼번에 몰리지 않게 한다.
    export function retryDelay(attempt: number, random = Math.random): number {
      const base = Math.min(1000 * 2 ** attempt, 8000);
      return Math.round(base / 2 + (base / 2) * random());
    }
    1. 타임아웃 — AbortSignal.timeout과 TanStack Query가 넘긴 취소 신호를 AbortSignal.any로 합쳐, 먼저 오는 쪽에서 끊습니다. 사용자가 페이지를 떠나도, 8초가 지나도 요청이 멈춥니다.
    2. 재시도 대상 — 400·404·409·422는 다시 보내도 같은 답이 오므로 재시도하지 않습니다. 408·429·5xx와 네트워크 오류만 재시도합니다.
    3. 간격 — 1초, 2초, 4초로 늘리되 8초에서 멈추고, 그 절반~전체 사이에서 무작위로 고릅니다. 서버가 잠깐 멈췄다 살아났을 때 모든 사용자의 재시도가 같은 순간에 몰리지 않게 합니다.

    이 규칙은 tests/http.test.ts가 지킵니다. 백엔드 입장에서 타임아웃·재시도·입장 제어를 보려면 백엔드 시스템 기초 지도 5.4절, "결과를 모름" 상태를 모델에 넣는 법은 7.2절을 보세요.

    느린 네트워크 시뮬레이터

    지연·실패율·타임아웃을 바꿔 한 요청이 어떻게 끝나는지 봅니다. 재시도 간격은 예제 앱의 retryDelay와 같은 식입니다.

    4.6 MSW: 백엔드 없이 개발하기

    MSW는 브라우저에서 서비스 워커로 fetch를 가로채 가짜 응답을 돌려줍니다. 네트워크 탭에 실제 요청이 보이므로 앱 코드는 진짜 서버와 똑같이 동작합니다. 먼저 워커 파일을 public/에 만듭니다.

    npx msw init public --save   # public/mockServiceWorker.js 생성

    핸들러는 경로마다 응답을 정합니다. delay()로 실제 네트워크처럼 조금 기다리게 했습니다. 아래 코드의 db는 import * as db from './db'로 가져온 src/mocks/db.ts 모듈입니다. 그 파일이 만든 가짜 데이터베이스를 다루는 함수(db.listProducts(), db.getProduct() 등)를 모아 쓰는 것입니다. 실제 서버 대신 상품 목록과 주문을 브라우저 메모리에 두는데, db.ts의 일부는 이 절 끝에 있습니다.

    import { delay, HttpResponse, http } from 'msw';
    import type { SortId } from '@/features/catalog/types';
    import type { ConfirmPaymentRequest, CreateOrderRequest } from '@/features/order/types';
    import * as db from './db';
    
    const api = (path: string) => `${import.meta.env.PUBLIC_API_BASE}${path}`;
    const json = (r: db.MockResult) => HttpResponse.json(r.body as object, { status: r.status });
    
    export const handlers = [
      http.get(api('/products'), async ({ request }) => {
        const url = new URL(request.url);
        await delay();
        return HttpResponse.json(
          db.listProducts({
            category: url.searchParams.get('category') ?? 'all',
            sort: (url.searchParams.get('sort') ?? 'popular') as SortId,
            page: Number(url.searchParams.get('page') ?? 1),
            size: Number(url.searchParams.get('size') ?? 20),
          }),
        );
      }),
    
      http.get(api('/products/:productId'), async ({ params }) => {
        await delay();
        const product = db.getProduct(String(params.productId));
        if (!product) {
          return HttpResponse.json(
            { code: 'PRODUCT_NOT_FOUND', message: '상품이 없습니다.' },
            { status: 404 },
          );
        }
        return HttpResponse.json(product);
      }),
    
      http.put(api('/cart/items/:productId'), async ({ params, request }) => {

    맨 위 1~4행이 이 파일이 쓰는 이름의 출처입니다. http·HttpResponse·delay는 msw에서, SortId는 카탈로그 타입 파일에서, db는 같은 폴더의 ./db에서 옵니다. 6~7행의 api(환경 변수의 API 주소를 앞에 붙이는 도우미)와 json(db가 돌려준 결과를 응답으로 바꾸는 도우미)은 이 파일 안에서 직접 정의합니다.

    import { setupWorker } from 'msw/browser';
    import { handlers } from './handlers';
    
    export const worker = setupWorker(...handlers);

    앱은 워커가 준비된 다음에 그립니다(1.4절의 enableMocking). 그렇지 않으면 첫 요청이 가로채지기 전에 나가 버립니다.

    목 서버의 상태는 새로고침하면 사라집니다

    MSW 핸들러는 페이지의 메모리에서 돕니다. 그대로 두면 주문 완료 페이지를 새로고침할 때 주문이 사라져, 실제 서버와 다르게 동작합니다. 그래서 목 DB를 sessionStorage에 저장해 "서버는 새로고침과 상관없이 주문을 기억한다"를 흉내 냈습니다.

    import { calcTotals } from '@/features/cart/pricing';
    import type { Product, ProductPage, SortId } from '@/features/catalog/types';
    import type { ConfirmPaymentRequest, CreateOrderRequest, Order } from '@/features/order/types';
    import { safeSessionStorage } from '@/shared/lib/storage';
    import { createProducts } from './data';
    
    // …
    
    const STORAGE_KEY = 'mock-db';
    const products = createProducts();
    
    // 목 서버는 브라우저 메모리에서 돈다. 새로고침 뒤에도 주문이 남도록 sessionStorage에 저장한다.
    function load(): State {
      const raw = safeSessionStorage.getItem(STORAGE_KEY);
      if (raw) return JSON.parse(raw) as State;
      return { orders: {}, idem: {}, seq: 0 };
    }

    5장 장바구니

    장바구니는 브라우저가 들고 있는 상태입니다. 새로고침해도, 다른 탭에서 바꿔도 같아야 합니다. 수량을 바꾸면 화면은 바로 바뀌고, 서버가 재고 부족으로 거절하면 조용히 되돌아가야 합니다.

    이 장의 핵심

    • 클라이언트 상태는 Zustand에, 서버 상태는 TanStack Query에 둡니다. 장바구니 목록은 앞쪽, 재고 확인은 뒤쪽입니다.
    • Zustand의 persist로 localStorage에 저장하고, storage 이벤트로 다른 탭과 맞춥니다.
    • 낙관적 업데이트는 "서버가 받아들인 값 기억 → 화면 먼저 변경 → 마지막 요청이 실패하면 그 값으로 복원" 세 단계입니다. 빠른 연속 변경에서는 더 새로운 변경이 남아 있는 동안 롤백하지 말아야 하고, 같은 대상의 요청은 scope로 순서대로 보냅니다.
    • 금액은 원 단위 정수로만 계산하고, 화면에 보일 때만 Intl.NumberFormat('ko-KR')으로 바꿉니다.

    5.1 Zustand persist

    import { create } from 'zustand';
    import { createJSONStorage, persist } from 'zustand/middleware';
    import { safeLocalStorage } from '@/shared/lib/storage';
    
    export type CartLine = {
      productId: string;
      name: string;
      price: number;
      imageUrl: string;
      quantity: number;
    };
    
    type CartState = {
      lines: CartLine[];
      setLine: (line: CartLine) => void;
      remove: (productId: string) => void;
      clear: () => void;
    };
    
    export const CART_STORAGE_KEY = 'commerce-cart';
    
    export const useCartStore = create<CartState>()(
      persist(
        (set) => ({
          lines: [],
          setLine: (line) =>
            set((s) => {
              const exists = s.lines.some((l) => l.productId === line.productId);
              return {
                lines: exists
                  ? s.lines.map((l) => (l.productId === line.productId ? line : l))
                  : [...s.lines, line],
              };
            }),
          remove: (productId) =>
            set((s) => ({ lines: s.lines.filter((l) => l.productId !== productId) })),
          clear: () => set({ lines: [] }),
        }),
        {
          name: CART_STORAGE_KEY,
          version: 1,
          storage: createJSONStorage(() => safeLocalStorage),
          partialize: (s) => ({ lines: s.lines }),
        },
      ),
    );
    
    export const selectCount = (s: CartState) => s.lines.reduce((n, l) => n + l.quantity, 0);
    • partialize로 저장할 필드만 고릅니다. 함수는 저장할 수 없고, 저장할 필요도 없습니다.
    • version은 나중에 저장 모양이 바뀔 때 옛 데이터를 옮기는(migrate) 기준입니다. 처음부터 붙여 둡니다.
    • storage는 안전한 래퍼를 씁니다. 11장의 데모는 sandbox iframe 안에서 도는데, 그곳에서는 localStorage에 접근만 해도 예외가 납니다. 래퍼는 그럴 때 메모리 저장소로 바꿔 줍니다.
    function pick(name: 'localStorage' | 'sessionStorage'): Storage {
      try {
        const storage = globalThis[name];
        const probe = '__probe__';
        storage.setItem(probe, probe);
        storage.removeItem(probe);
        return storage;
      } catch {
        return memoryStorage();
      }
    }
    
    export const safeLocalStorage = pick('localStorage');
    export const safeSessionStorage = pick('sessionStorage');

    5.2 탭 간 동기화

    예를 들어 사용자가 탭 A에서 상품을 담고, 이미 열려 있던 탭 B에서 장바구니를 봅니다. 탭 B의 스토어는 메모리에 옛 값을 들고 있습니다. 브라우저는 다른 탭이 localStorage를 바꾸면 storage 이벤트를 보내 주므로, 그때 저장소에서 다시 읽습니다.

    // 다른 탭에서 장바구니를 바꾸면 storage 이벤트가 온다. 그때 저장소에서 다시 읽는다.
    export function syncCartAcrossTabs(): () => void {
      const onStorage = (e: StorageEvent) => {
        if (e.key === CART_STORAGE_KEY) useCartStore.persist.rehydrate();
      };
      window.addEventListener('storage', onStorage);
      return () => window.removeEventListener('storage', onStorage);
    }

    레이아웃이 마운트될 때 이 함수를 부르고, 반환한 정리 함수로 리스너를 뗍니다(src/app/RootLayout.tsx의 useEffect). 같은 탭에서 바꾼 경우에는 이벤트가 오지 않으니 걱정하지 않아도 됩니다.

    5.3 낙관적 업데이트와 롤백

    수량을 5로 바꾸었는데 서버 응답을 기다리느라 0.3초 동안 숫자가 안 바뀌면 사용자는 다시 누릅니다. 그래서 화면을 먼저 바꿉니다. 대신 서버가 "재고가 2개 남았습니다"라고 거절하면 원래 값으로 돌려놓아야 합니다.

    import { useMutation } from '@tanstack/react-query';
    import { useState } from 'react';
    import { HttpError, request } from '@/shared/lib/http';
    import { type CartLine, useCartStore } from './store';
    
    type Vars = { line: Omit<CartLine, 'quantity'>; quantity: number };
    
    // 상품별로 "서버가 마지막으로 받아들인 줄"과 아직 끝나지 않은 요청 수를 기억한다.
    const inflight = new Map<string, { confirmed: CartLine | undefined; pending: number }>();
    
    // 화면을 먼저 바꾸고(낙관적 업데이트) 서버가 거절하면 서버가 받아들인 상태로 되돌린다.
    export function useCartMutation() {
      const [error, setError] = useState<string | null>(null);
      const mutation = useMutation({
        // 같은 scope의 요청은 순서대로 하나씩 서버로 간다. (화면은 바로 바뀐다)
        scope: { id: 'cart' },
        mutationFn: ({ line, quantity }: Vars) =>
          request(`/cart/items/${line.productId}`, {
            method: 'PUT',
            body: { quantity },
          }),
        onMutate: ({ line, quantity }) => {
          setError(null);
          const { lines, setLine } = useCartStore.getState();
          const entry = inflight.get(line.productId) ?? {
            confirmed: lines.find((l) => l.productId === line.productId),
            pending: 0,
          };
          entry.pending += 1;
          inflight.set(line.productId, entry);
          setLine({ ...line, quantity });
        },
        onSuccess: (_data, { line, quantity }) => {
          const entry = inflight.get(line.productId);
          if (entry) entry.confirmed = { ...line, quantity };
        },
        onError: (err, { line }) => {
          const entry = inflight.get(line.productId);
          // 더 새로운 변경이 남아 있으면 건드리지 않는다. 마지막 요청이 실패했을 때만 되돌린다.
          if (!entry || entry.pending > 1) return;
          const { setLine, remove } = useCartStore.getState();
          if (entry.confirmed) setLine(entry.confirmed);
          else remove(line.productId);
          setError(err instanceof HttpError ? err.message : '장바구니를 바꾸지 못했습니다.');
        },
        onSettled: (_data, _err, { line }) => {
          const entry = inflight.get(line.productId);
          if (!entry) return;
          entry.pending -= 1;
          if (entry.pending === 0) inflight.delete(line.productId);
        },
      });
      return { ...mutation, error };
    }
    1. onMutate — 이 상품의 "서버가 마지막으로 받아들인 줄"(confirmed)을 기억해 두고, 진행 중인 요청 수(pending)를 하나 올린 뒤 화면을 새 수량으로 바꿉니다.
    2. mutationFn — PUT /api/cart/items/:productId로 재고를 확인합니다.
    3. onSuccess — 서버가 받아들였으니 confirmed를 새 줄로 바꿉니다.
    4. onError — 더 새로운 변경이 남아 있으면(pending > 1) 아무것도 하지 않습니다. 마지막 요청이 실패했을 때만 confirmed로 되돌리고(없던 상품이면 지웁니다), 서버가 보낸 이유를 보여 줍니다.
    5. onSettled — 성공이든 실패든 pending을 하나 내립니다.

    사용자가 수량을 3 → 4 → 5로 빠르게 바꾸는 경우를 봅시다. onMutate는 요청 순서를 기다리지 않고 바로 실행되므로 화면은 3 → 4 → 5로 즉시 바뀝니다. 이때 4가 실패하고 5가 성공한다면 화면은 5로 남아야 합니다. 그런데 "요청 전 값"(3)으로 무조건 되돌리면 화면이 3이 되어, 서버(5)와 어긋납니다.

    • 4 실패 → 5 성공: 4의 onError 시점에 5가 아직 진행 중(pending > 1)이므로 되돌리지 않습니다. 화면 5, 서버 5.
    • 4 성공 → 5 실패: 5가 마지막이므로 confirmed(4)로 되돌립니다. 화면 4, 서버 4.
    • 4 실패 → 5 실패: 5의 onError에서 confirmed(3)로 되돌립니다. 화면 3, 서버 3. ("요청 전 값"을 쓰면 실패한 4로 되돌아가 어긋납니다.)

    scope: { id: 'cart' }는 다른 문제를 막습니다. scope가 없으면 요청 4와 5가 동시에 나가서, 5가 먼저 서버에 도착하고 4가 뒤늦게 도착해 서버가 4로 끝날 수 있습니다. 같은 scope의 요청은 하나씩 순서대로 서버로 가므로 서버는 마지막 변경(5)으로 끝납니다. 화면을 바꾸는 onMutate는 대기하지 않으니 사용자는 지연을 느끼지 못합니다.

    이 동작은 컴포넌트 테스트로 지킵니다. 재고가 2개인 p-010을 5개로 바꾸면 응답 전에 5가 보이고, 409 뒤에 1로 돌아옵니다.

    import { expect, test } from '@rstest/core';
    import { fireEvent, screen, waitFor } from '@testing-library/react';
    import { useCartStore } from '@/features/cart/store';
    import { renderRoute } from './render';
    
    const line = {
      productId: 'p-010',
      name: '재고 적은 상품',
      price: 10_000,
      imageUrl: '',
      quantity: 1,
    };
    
    test('재고보다 많이 담으면 화면을 먼저 바꾼 뒤 서버 거절에 맞춰 되돌린다', async () => {
      useCartStore.setState({ lines: [line] });
      renderRoute('/cart');
      const input = await screen.findByRole('spinbutton', { name: '재고 적은 상품 수량' });
    
      fireEvent.change(input, { target: { value: '5' } });
      // 낙관적 업데이트: 응답 전에 이미 5로 보인다.
      expect(input).toHaveValue(5);
    
      // p-010의 재고는 2개 → 409 → 1로 롤백
      expect(await screen.findByRole('alert')).toHaveTextContent('재고가 2개 남았습니다.');
      await waitFor(() => expect(input).toHaveValue(1));
      expect(useCartStore.getState().lines[0].quantity).toBe(1);
    });

    E2E가 찾아낸 함정: 응답 전에 떠나면 롤백된다

    E2E에서 "장바구니 담기"를 누르고 바로 /checkout으로 이동했더니 장바구니가 비어 있었습니다.실측 페이지를 떠나면서 진행 중이던 PUT 요청이 취소됐고, 취소도 실패이므로 onError가 담은 상품을 지웠기 때문입니다. 테스트는 "장바구니에 담았어요" 메시지를 기다린 뒤 이동하도록 고쳤습니다. 실제 서비스라면 취소(AbortError)는 롤백하지 않거나, 담기 요청이 끝날 때까지 이동 버튼을 잠그는 방법을 검토하세요.

    동시 수정과 낙관적 업데이트를 네트워크 쪽에서 더 깊게 다룬 글은 네트워크 복원력 FE 가이드 6장에 있습니다.

    낙관적 업데이트 실패 시뮬레이터

    재고 2개인 상품의 수량을 바꿔 봅니다. 낙관적 업데이트를 끄고 켜며 사용자가 보는 숫자가 시간에 따라 어떻게 바뀌는지 비교하세요.

    1화면의 수량
    2서버 재고
    0 ms경과

      5.4 금액 계산

      자바스크립트에서 0.1 + 0.2는 0.30000000000000004입니다. 원화는 소수점이 없으니 처음부터 정수만 씁니다. 배송비 규칙(5만 원 이상 무료, 아니면 3,000원)도 같은 함수에 둡니다. 서버도 이 함수로 주문 금액을 다시 계산합니다(6장).

      export const FREE_SHIPPING_THRESHOLD = 50_000;
      export const SHIPPING_FEE = 3_000;
      
      export type PricedLine = { price: number; quantity: number };
      
      export type Totals = { subtotal: number; shippingFee: number; total: number };
      
      // 부동소수점 오차가 없도록 원 단위 정수끼리만 더하고 곱한다.
      export function calcTotals(lines: PricedLine[]): Totals {
        const subtotal = lines.reduce((sum, l) => sum + l.price * l.quantity, 0);
        const shippingFee = subtotal === 0 || subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_FEE;
        return { subtotal, shippingFee, total: subtotal + shippingFee };
      }

      표시는 한 함수에서만 합니다. 정수가 아닌 값이 들어오면 조용히 반올림하지 않고 오류를 던져 버그를 빨리 드러냅니다.

      // 금액은 항상 "원" 단위 정수로 다루고, 화면에 보일 때만 문자열로 바꾼다.
      const krw = new Intl.NumberFormat('ko-KR', {
        style: 'currency',
        currency: 'KRW',
      });
      
      export function formatKRW(amount: number): string {
        if (!Number.isSafeInteger(amount)) {
          throw new RangeError(`금액은 원 단위 정수여야 합니다: ${amount}`);
        }
        return krw.format(amount);
      }

      formatKRW(1234500)은 ₩1,234,500이 됩니다. 테스트가 이 모양과 배송비 경계값을 확인합니다(tests/money.test.ts).

      6장 주문서와 결제

      돈이 오가는 화면입니다. 틀린 입력은 요청 전에 막고, 버튼을 두 번 눌러도, 네트워크가 끊겨 다시 보내도, 새로고침해도 주문과 결제는 딱 한 번만 일어나야 합니다.

      이 장의 핵심

      • 폼 검증은 Zod 스키마 하나로 정의하고 React Hook Form에 zodResolver로 붙입니다. 같은 스키마를 테스트에서도 씁니다.
      • PG 결제는 요청(주문 생성) → 인증(결제창) → 승인(서버 확정) 세 단계입니다. 금액은 서버가 다시 계산하고, 승인 때 한 번 더 대조합니다.
      • 중복 결제는 두 겹으로 막습니다. 화면에서는 버튼 잠금, 서버와의 약속으로는 Idempotency-Key.
      • 결제 단계 사이의 이동은 replace로 합니다. 뒤로 가기가 결제창으로 돌아가지 않습니다.

      6.1 폼 검증

      휴대폰 번호 칸에 1234를 넣고 결제를 누르면, 서버까지 가지 않고 그 칸 아래에 "휴대폰 번호 형식이 아닙니다"가 떠야 합니다. 규칙은 스키마 한 곳에 모읍니다.

      import { z } from 'zod';
      
      export const checkoutSchema = z.object({
        recipient: z.string().trim().min(2, '받는 분 이름을 2자 이상 입력하세요.').max(20),
        phone: z
          .string()
          .trim()
          .regex(/^01[016789]-?\d{3,4}-?\d{4}$/, '휴대폰 번호 형식이 아닙니다. 예: 010-1234-5678'),
        zipCode: z.string().regex(/^\d{5}$/, '우편번호는 숫자 5자리입니다.'),
        address1: z.string().trim().min(1, '주소를 입력하세요.'),
        address2: z.string().trim().max(50, '상세 주소는 50자 이하로 입력하세요.'),
        memo: z.string().max(50, '배송 메모는 50자 이하로 입력하세요.').optional(),
        paymentMethod: z.enum(['card', 'transfer', 'easy-pay']),
        agree: z.literal(true, { error: '주문 내용 확인에 동의해야 결제할 수 있습니다.' }),
      });
      
      export type CheckoutInput = z.input<typeof checkoutSchema>;
      export type CheckoutForm = z.output<typeof checkoutSchema>;
      • z.literal(true) — 동의 체크박스는 true만 통과합니다.
      • z.input과 z.output을 따로 내보냅니다. trim()처럼 값을 바꾸는 규칙이 있으면 폼에 넣는 모양과 검증 뒤의 모양이 다르기 때문입니다. useForm<입력, 컨텍스트, 출력>에 둘 다 넘깁니다.

      스키마는 순수 함수라서 화면 없이 테스트할 수 있습니다. 틀린 세 칸이 순서대로 보고되는지 확인합니다.

      import { expect, test } from '@rstest/core';
      import { checkoutSchema } from '@/features/checkout/schema';
      
      const valid = {
        recipient: '김하나',
        phone: '010-1234-5678',
        zipCode: '06236',
        address1: '서울시 강남구 테헤란로 1',
        address2: '101호',
        paymentMethod: 'card',
        agree: true,
      };
      
      test('올바른 주문서는 통과한다', () => {
        expect(checkoutSchema.safeParse(valid).success).toBe(true);
      });
      
      test('휴대폰 번호·우편번호·동의를 검사한다', () => {
        const r = checkoutSchema.safeParse({ ...valid, phone: '02-123', zipCode: '1234', agree: false });
        expect(r.success).toBe(false);
        const paths = r.error?.issues.map((i) => i.path.join('.'));
        expect(paths).toEqual(['phone', 'zipCode', 'agree']);
      });

      6.2 PG 결제 흐름: 요청 → 인증 → 승인

      국내 PG의 일반적인 흐름을 흉내 냈습니다. 실제 PG SDK는 결제창을 PG사 도메인에 띄우지만, 예제는 앱 안의 모의 결제창으로 대신합니다.

      1. 요청 — 주문서가 POST /api/orders로 주문을 만듭니다. 서버는 클라이언트가 보낸 금액을 믿지 않고 상품 가격으로 다시 계산해 PENDING_PAYMENT 주문을 돌려줍니다.
      2. 인증 — 결제창(/checkout/pay/:orderId)에서 사용자가 카드 인증을 합니다. 끝나면 PG가 paymentKey, orderId, amount를 붙여 우리 페이지로 돌려보냅니다.
      3. 승인 — 돌아온 페이지(/checkout/confirm)가 서버에 POST /api/payments/confirm을 보냅니다. 서버는 주문 금액과 amount가 같은지 확인한 뒤에야 결제를 확정합니다. 주소의 금액은 사용자가 고칠 수 있기 때문입니다.

      결제 흐름 단계 보기

      단계를 넘기며 주소, 요청, 서버 상태, 브라우저 기록(뒤로 가기 스택)이 어떻게 바뀌는지 봅니다.

        모의 결제창입니다. 인증하면 paymentKey를 만들어 승인 페이지로 replace 이동합니다. 이미 결제된 주문이면 결제창 대신 완료 화면으로 보냅니다.

        import { useSuspenseQuery } from '@tanstack/react-query';
        import { Navigate, useNavigate, useParams } from 'react-router';
        import { orderQuery } from '@/features/order/api';
        import { createIdempotencyKey } from '@/shared/lib/idempotency';
        import { formatKRW } from '@/shared/lib/money';
        import { PaymentSteps } from './PaymentSteps';
        
        // …
        
        export default function MockPgPage() {
          const { orderId = '' } = useParams();
          const { data: order } = useSuspenseQuery(orderQuery(orderId));
          const navigate = useNavigate();
        
          if (order.status === 'PAID') return <Navigate to={`/orders/${orderId}/complete`} replace />;
        
          const authorize = () => {

        6.3 중복 결제 방지

        버튼 잠금

        disabled={order.isPending}만으로는 부족합니다. 두 번째 클릭이 React가 버튼을 비활성화해 다시 그리기 전에 들어올 수 있기 때문입니다. 그래서 렌더링을 기다리지 않는 useRef 잠금을 함께 씁니다.

        import { zodResolver } from '@hookform/resolvers/zod';
        import { useMutation } from '@tanstack/react-query';
        import { useRef } from 'react';
        import { useForm } from 'react-hook-form';
        import { Navigate, useNavigate } from 'react-router';
        import { calcTotals } from '@/features/cart/pricing';
        import { useCartStore } from '@/features/cart/store';
        import { createOrder } from '@/features/order/api';
        import { HttpError, isRetryable } from '@/shared/lib/http';
        import { clearIdempotencyKey, getOrCreateIdempotencyKey } from '@/shared/lib/idempotency';
        import { formatKRW } from '@/shared/lib/money';
        import { type CheckoutForm, type CheckoutInput, checkoutSchema } from './schema';
        
        // …
        
          const order = useMutation({
            mutationFn: (form: CheckoutForm) =>
              createOrder(
                {
                  lines: lines.map(({ productId, quantity }) => ({ productId, quantity })),
                  shipping: {
                    recipient: form.recipient,
                    phone: form.phone,
                    zipCode: form.zipCode,
                    address1: form.address1,
                    address2: form.address2,
                    memo: form.memo,
                  },
                  paymentMethod: form.paymentMethod,
                },
                // 한 번의 "주문하기" 의도에는 같은 키를 쓴다. 재시도해도 주문이 두 개 생기지 않는다.
                getOrCreateIdempotencyKey('checkout'),
              ),
            // 키가 같으니 네트워크 오류나 처리 중(409)일 때 다시 보내도 안전하다.
            retry: (count, error) =>
              count < 2 && (isRetryable(error) || (error instanceof HttpError && error.status === 409)),
            onSuccess: (created) => {
              clearIdempotencyKey('checkout');
              // 뒤로 가기로 주문서에 돌아와 다시 제출하지 않도록 기록을 바꿔 끼운다.
              navigate(`/checkout/pay/${created.orderId}`, { replace: true });
            },
            onError: (error) => {
              locked.current = false;
              if (!isRetryable(error)) clearIdempotencyKey('checkout');
            },
          });
        
          if (lines.length === 0) return <Navigate to="/cart" replace />;
          const totals = calcTotals(lines);
        
          const onSubmit = handleSubmit((form) => {
            // 버튼 비활성화가 반영되기 전의 빠른 두 번째 클릭까지 막는다.
            if (locked.current) return;
            locked.current = true;
            order.mutate(form);

        Idempotency-Key

        버튼 잠금은 한 화면 안의 이야기입니다. 네트워크가 끊겨 재시도하거나, 새로고침해서 다시 보내는 경우는 서버만 막을 수 있습니다. 그래서 "이번 주문하기"마다 키를 하나 만들어 헤더에 붙이고, 서버는 같은 키를 한 번만 처리합니다. 키는 sessionStorage에 두어 새로고침해도 바뀌지 않게 합니다.

        import { safeSessionStorage } from './storage';
        
        // …
        
        export function getOrCreateIdempotencyKey(scope: string): string {
          const storageKey = `idem:${scope}`;
          const saved = safeSessionStorage.getItem(storageKey);
          if (saved) return saved;
          const key = createIdempotencyKey();
          safeSessionStorage.setItem(storageKey, key);
          return key;
        }

        서버(목 DB)의 약속은 IETF httpapi 작업 그룹의 Idempotency-Key 초안이 권하는 응답을 따랐습니다. 백엔드 시스템 기초 지도 7장에서 같은 규칙을 서버 쪽에서 설명합니다.

        import { calcTotals } from '@/features/cart/pricing';
        import type { Product, ProductPage, SortId } from '@/features/catalog/types';
        import type { ConfirmPaymentRequest, CreateOrderRequest, Order } from '@/features/order/types';
        import { safeSessionStorage } from '@/shared/lib/storage';
        import { createProducts } from './data';
        
        // …
        
        // 같은 Idempotency-Key로 다시 오면 처음 결과를 그대로 돌려준다.
        export async function withIdempotency(
          key: string | null,
          payload: unknown,
          run: () => Promise<MockResult>,
        ): Promise<MockResult> {
          if (!key) return problem(400, 'IDEMPOTENCY_KEY_REQUIRED', 'Idempotency-Key 헤더가 필요합니다.');
          const fingerprint = JSON.stringify(payload);
          const found = state.idem[key];
          if (found) {
            if (found.fingerprint !== fingerprint) {
              return problem(422, 'IDEMPOTENCY_KEY_REUSED', '같은 키를 다른 요청에 쓸 수 없습니다.');
            }
            if (found.state === 'processing') {
              return problem(409, 'IDEMPOTENCY_IN_PROGRESS', '같은 요청을 처리하고 있습니다.');
            }
            return found.result;
          }
          state.idem[key] = { state: 'processing', fingerprint };
          save();
          const result = await run();
          if (result.status >= 500) {
            delete state.idem[key];
          } else {
            state.idem[key] = { state: 'done', fingerprint, result };
          }
          save();
          return result;
        상황서버 응답클라이언트 동작
        처음 보는 키처리하고 결과를 키와 함께 저장다음 단계로
        같은 키, 처리 끝남저장해 둔 첫 결과를 그대로같은 주문번호로 진행
        같은 키, 아직 처리 중409 Conflict잠시 뒤 재시도(키가 같으니 안전)
        같은 키, 다른 내용422 Unprocessable Content키를 버리고 사용자에게 다시 시도하게 함

        E2E는 결제 버튼을 dblclick으로 두 번 눌러 POST /api/orders가 한 번만 나가는지, 헤더에 UUID 모양의 키가 붙는지 확인합니다. 단위 테스트는 같은 키로 동시에 두 번 보내 주문이 하나만 생기는지 봅니다.

        import { expect, test } from '@rstest/core';
        import type { Order } from '@/features/order/types';
        import { request } from '@/shared/lib/http';
        
        const body = {
          lines: [{ productId: 'p-001', quantity: 1 }],
          shipping: {
            recipient: '김하나',
            phone: '010-1234-5678',
            zipCode: '06236',
            address1: '서울',
            address2: '',
          },
          paymentMethod: 'card',
        };
        const post = (key: string, b: unknown = body) =>
          request<Order>('/orders', { method: 'POST', body: b, headers: { 'Idempotency-Key': key } });
        
        test('같은 Idempotency-Key로 두 번 보내면 주문은 하나만 생긴다', async () => {
          const [a, b] = await Promise.allSettled([post('k-1'), post('k-1')]);
          // 동시에 오면 두 번째는 409(처리 중), 끝난 뒤 다시 보내면 첫 결과를 그대로 받는다.
          expect(a.status).toBe('fulfilled');
          expect(b.status).toBe('rejected');
          const again = await post('k-1');
          expect(again.orderId).toBe((a as PromiseFulfilledResult<Order>).value.orderId);
        });

        중복 클릭 시뮬레이터

        결제 버튼을 빠르게 여러 번 누릅니다. 버튼 잠금과 Idempotency-Key를 켜고 끄며 서버에 주문이 몇 개 생기는지 보세요.

        0보낸 요청
        0생긴 주문

          서버 처리 시간은 600ms로 가정했습니다.

          6.4 결과 페이지: 새로고침과 뒤로 가기

          승인 페이지는 마운트되면 한 번 승인을 요청합니다. 개발 모드의 StrictMode는 effect를 두 번 실행하므로 useRef로 한 번만 보내고, 그래도 두 번 나가면 같은 멱등 키 덕분에 서버가 한 번만 처리합니다.

          import { useMutation, useQueryClient } from '@tanstack/react-query';
          import { useEffect, useRef } from 'react';
          import { Link, useNavigate, useSearchParams } from 'react-router';
          import { z } from 'zod';
          import { useCartStore } from '@/features/cart/store';
          import { confirmPayment, orderQuery } from '@/features/order/api';
          import { HttpError } from '@/shared/lib/http';
          import { clearIdempotencyKey, getOrCreateIdempotencyKey } from '@/shared/lib/idempotency';
          import { PaymentSteps } from './PaymentSteps';
          
          // …
          
            const confirm = useMutation({
              mutationFn: (body: z.infer<typeof paramsSchema>) =>
                // 새로고침해도 같은 키를 다시 쓰므로 승인은 한 번만 일어난다.
                confirmPayment(body, getOrCreateIdempotencyKey(`confirm:${body.orderId}`)),
              retry: (count, error) => count < 3 && error instanceof HttpError && error.status === 409,
              retryDelay: 500,
              onSuccess: (order) => {
                // PG 화면에서 캐시해 둔 "결제 대기" 주문을 승인 결과로 바꿔 둔다.
                queryClient.setQueryData(orderQuery(order.orderId).queryKey, order);
                clearCart();
                clearIdempotencyKey(`confirm:${order.orderId}`);
                navigate(`/orders/${order.orderId}/complete`, { replace: true });
              },
              onError: (error, body) => {
                if (error instanceof HttpError && error.code === 'ALREADY_PAID') {
                  queryClient.invalidateQueries({ queryKey: orderQuery(body.orderId).queryKey });
                  navigate(`/orders/${body.orderId}/complete`, { replace: true });
                }
              },
            });
          
            const { mutate } = confirm;
            const data = parsed.success ? parsed.data : null;
            useEffect(() => {
              // StrictMode에서 effect가 두 번 실행돼도 요청은 한 번만 보낸다.
              if (!data || started.current) return;
              started.current = true;
              mutate(data);
            }, [data, mutate]);
          • 새로고침 — 완료 페이지는 주소의 주문번호로 서버에서 주문을 다시 읽습니다. 화면 상태에 기대지 않으므로 새로고침해도 같은 내용이 나옵니다.
          • 뒤로 가기 — 주문서 → 결제창 → 승인 → 완료를 모두 replace로 이동했습니다. 기록에는 장바구니 다음에 완료 페이지 하나만 남아, 뒤로 가기가 결제창이나 승인 페이지로 돌아가지 않습니다.
          import { useSuspenseQuery } from '@tanstack/react-query';
          import { Link, useParams } from 'react-router';
          import { formatKRW } from '@/shared/lib/money';
          import { orderQuery } from './api';
          
          // 새로고침해도 URL의 주문번호로 서버에서 다시 읽기 때문에 같은 화면이 나온다.
          export default function OrderCompletePage() {
            const { orderId = '' } = useParams();
            const { data: order } = useSuspenseQuery(orderQuery(orderId));
            const paid = order.status === 'PAID';
          
            return (
              <section className="mx-auto max-w-lg py-10 text-center">
                <title>주문 완료 | 예제 쇼핑몰</title>
                <h1 className="text-2xl font-bold">
                  {paid ? '주문이 완료되었어요' : '결제가 아직 끝나지 않았어요'}
                </h1>
                <dl className="mt-6 grid grid-cols-2 gap-y-2 text-left">
                  <dt>주문번호</dt>
                  <dd data-testid="order-id">{order.orderId}</dd>
                  <dt>결제 금액</dt>
                  <dd data-testid="order-amount">{formatKRW(order.amount)}</dd>
                  <dt>상태</dt>
                  <dd>{paid ? '결제 완료' : '결제 대기'}</dd>
                </dl>
                <Link to="/" className="mt-8 inline-block underline">
                  쇼핑 계속하기
                </Link>
              </section>
            );
          }

          E2E가 찾아낸 버그: 완료 화면에 "결제 대기"

          처음 E2E를 돌렸을 때 완료 페이지가 "결제가 아직 끝나지 않았어요"를 보여 줬습니다.실측 결제창이 캐시해 둔 주문(PENDING_PAYMENT)이 아직 fresh(30초)여서 완료 페이지가 서버에 다시 묻지 않았기 때문입니다. 승인 응답으로 받은 주문을 setQueryData로 캐시에 넣어 고쳤습니다. 서버의 상태를 바꾼 뒤에는 관련 캐시를 갱신하거나 무효화해야 한다는 규칙을 몸으로 배운 사례입니다.

          출처

          7장 성능

          느낌이 아니라 숫자로 봅니다. Rsdoctor로 어느 청크에 무엇이 들었는지 보고, 청크 나누는 방식을 바꿔 가며 첫 화면에 받는 양을 잽니다. 이미지와 Core Web Vitals, 가상 스크롤이 필요한 시점도 다룹니다.

          이 장의 핵심

          • 예제 앱의 첫 화면은 초기 청크 4개, JS gzip 113.1 kB입니다. 이 중 React가 68.7 kB, React Router가 31.7 kB로 가장 큽니다.실측
          • Rsdoctor 보고서로 보니 목록 페이지가 Zod 청크(gzip 25.2 kB)를 첫 방문에 함께 받고 있었습니다. URL 검사에 Zod를 썼기 때문입니다.
          • 청크 분할 방식은 "요청 수 ↔ 캐시 효율 ↔ 첫 화면 크기"의 교환입니다. 기본값이 대체로 무난하다는 것을 실측으로 확인합니다.
          • 이미지는 width·height로 자리를 잡아 CLS를 막고, 첫 줄만 fetchPriority="high"로 LCP를 당깁니다.

          7.1 Rsdoctor 번들 분석

          Rsdoctor는 빌드할 때 플러그인으로 붙어 모듈·청크·패키지 정보를 모읍니다. 예제 앱은 RSDOCTOR=true일 때만 플러그인을 켭니다(1.3절의 tools.rspack). 보고서는 dist 밖(.rsdoctor/)에 둡니다. 처음에는 기본값대로 dist 안에 3MB짜리 보고서가 생겨, 그대로 배포될 뻔했습니다.실측

          npm run build:rsdoctor   # RSDOCTOR=true rsbuild build
          open .rsdoctor/rsdoctor-report.html

          mode: 'brief'는 보고서 HTML 한 파일과 JSON을 만듭니다. 서버를 띄우지 않아 CI에서도 쓸 수 있고, JSON은 스크립트로 읽을 수 있습니다. 예제 앱의 scripts/measure.mjs가 이 JSON에서 "청크마다 든 패키지"를 뽑아 아래 위젯에 넘깁니다.

          // 빌드 결과를 실측해 metrics/build.json에 남긴다. 교과서의 숫자는 모두 이 파일에서 가져온다.
          // 사용법: npm run measure
          import { readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
          import { join, relative } from 'node:path';
          import { gzipSync } from 'node:zlib';
          import { createRsbuild, loadConfig, mergeRsbuildConfig } from '@rsbuild/core';
          
          // …
          
          // Rsdoctor JSON에서 청크마다 어떤 패키지가 들어 있는지 뽑는다.
          function composition() {
            const { data } = JSON.parse(readFileSync('.rsdoctor/rsdoctor-data.json', 'utf8'));
            const byId = new Map(data.moduleGraph.modules.map((m) => [m.id, m]));
            const pkgOf = (path) => {
              const m = path.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/);
              return m ? m[1] : path.includes('/src/') ? 'src' : 'other';
            };
            return data.chunkGraph.chunks.map((c) => {
              const pk = {};
              for (const id of c.modules) {
                const m = byId.get(id);
                if (!m) continue;
                const k = pkgOf(m.path);
                pk[k] = (pk[k] ?? 0) + (m.size?.parsedSize ?? 0);
              }
              const packages = Object.entries(pk)
                .sort((a, b) => b[1] - a[1])
                .map(([name, parsed]) => ({ name, parsed }));
              return { name: c.name || c.id, initial: c.initial, assets: c.assets, packages };
            });
          }

          7.2 실측값

          항목값설명
          빌드 시간(중앙값)102 msJS API로 3번 빌드한 중앙값. 캐시가 데워진 상태실측
          JS 청크 수16개 (비동기 12개)페이지 7개 + 공유 청크 + MSW
          첫 화면 초기 JS113.1 kB (원본 351.6 kB)index.html이 직접 부르는 청크실측
          첫 방문에 받은 JS 전체238.9 kB (9개 파일)초기 청크 + 목록 페이지 + Zod + MSW. 브라우저 기록 기준실측
          전체 JS264.5 kB모든 페이지를 다 돌았을 때
          MSW를 뺀 실서버 빌드 전체 JS169.5 kB (14개)--env-mode live. MSW가 95.0 kB를 차지했음실측
          CSS3.7 kB (원본 13.0 kB)Tailwind가 실제로 쓴 클래스만 남김

          MSW는 개발·데모용입니다. 실서비스 빌드에서는 1.4절의 조건부 import()가 죽은 코드가 되어 통째로 빠집니다. 숫자로 확인하니 청크 2개, gzip 95.0 kB가 사라졌습니다.

          실측 번들 크기

          예제 앱을 청크 분할 방식별로 실제 빌드해 잰 결과입니다. 막대에 마우스를 올리거나 누르면 그 청크에 든 패키지가 나옵니다(기본 빌드만).

            초기 청크(첫 화면)비동기 청크(필요할 때)

            7.3 청크 분할

            Rsbuild 2의 splitChunks에는 네 가지 프리셋이 있습니다. 설정 파일에는 한 줄이면 됩니다(아래는 공식 문서 예시이고, 예제 앱은 기본값을 씁니다).

            export default {
              splitChunks: {
                preset: 'per-package', // 'default' | 'per-package' | 'single-vendor' | 'none'
              },
            };
            방식JS 청크 수첫 화면 JS(gzip)언제 좋은가
            default (예제 앱)16113.1 kBReact·Router를 따로 떼어 오래 캐시. 대부분의 앱
            single-vendor10243.0 kB의존성이 적고 거의 안 바뀌는 앱. 대신 첫 화면이 무거워짐
            per-package41118.4 kB (초기 8개)의존성 하나만 올려도 그 청크만 다시 받게 하고 싶을 때. HTTP/2에서도 요청이 많아짐
            분할 끔 + asyncChunks: false1252.8 kB한 파일이어야 할 때(11장 데모). 결제 코드까지 첫 화면에 실림

            숫자를 보면 single-vendor는 첫 화면이 두 배 넘게 무거워집니다. 모든 페이지의 라이브러리(React Hook Form, Zod, MSW…)가 한 vendor 파일에 모여 첫 화면에 실리기 때문입니다.실측

            실측으로 찾은 개선 거리: 첫 화면의 Zod

            Rsdoctor에서 Zod 청크(원본 86.8 kB)가 목록 페이지의 의존성으로 잡혔습니다. 목록 페이지가 URL 검사에 z.enum, z.coerce.number()를 쓰기 때문입니다. 쓸 수 있는 선택지는 세 가지입니다.

            1. URL 검사를 손으로 짠 작은 함수로 바꾼다 — 가장 가볍지만 규칙이 두 곳(폼은 Zod)으로 나뉩니다.
            2. Zod의 가벼운 배포판(zod/mini)을 목록 페이지에만 쓴다검증 필요 — 크기를 다시 재 봐야 합니다.
            3. 그대로 둔다 — 주문서도 Zod를 쓰므로 어차피 한 번은 받습니다. 첫 화면 속도가 중요한 쇼핑몰이라면 1이나 2를 고르세요.

            이 교과서는 "측정하고 나서 고친다"를 보여 주려고 그대로 두었습니다. 11장의 과제로 남깁니다.

            7.4 이미지 최적화

            상품 목록의 이미지는 화면 크기를 미리 알려 주지 않으면, 이미지가 도착할 때 아래 내용이 밀립니다(CLS). 그래서 모든 이미지에 원본 비율의 width·height를 적고 CSS로 폭을 맞춥니다.

            import { useQueryClient } from '@tanstack/react-query';
            import { Link } from 'react-router';
            import { formatKRW } from '@/shared/lib/money';
            import { productQuery } from './api';
            import type { Product } from './types';
            
            // …
            
                    <img
                      src={product.imageUrl}
                      alt=""
                      width={400}
                      height={400}
                      loading={priority ? 'eager' : 'lazy'}
                      fetchPriority={priority ? 'high' : 'auto'}
                      decoding="async"
                      className="aspect-square w-full rounded-lg"
                    />
            • 첫 줄 4개만 loading="eager" + fetchPriority="high". 첫 화면에 보이는 가장 큰 요소(LCP 후보)를 먼저 받게 합니다.
            • 나머지는 loading="lazy". 화면 근처에 올 때까지 받지 않습니다.
            • 예제 이미지는 SVG 데이터 URI라서 가볍습니다. 실제 사진이라면 srcset·sizes로 화면 폭에 맞는 크기를 고르게 하고, AVIF·WebP로 변환하는 이미지 CDN을 앞에 두세요.

            7.5 Core Web Vitals

            Core Web Vitals는 LCP(2.5초 이하), INP(200ms 이하), CLS(0.1 이하) 세 가지입니다. 기준은 실제 사용자 데이터의 75번째 백분위입니다. 로컬에서 Playwright로 잰 값은 참고용입니다. 예제 앱의 목록 첫 화면은 LCP 380 ms, CLS 0였습니다.실측 이 값에는 MSW 워커를 등록하는 시간이 포함되어 있습니다. E2E는 CLS가 0.1 미만인지 확인합니다.

            INP는 사용자의 실제 상호작용이 있어야 잴 수 있어 로컬 측정에서 뺐습니다. 운영에서는 web-vitals 라이브러리로 실제 사용자 값을 모으세요.

            7.6 가상 스크롤

            무한 스크롤로 10쪽을 내리면 상품 카드 200개가 DOM에 남습니다. 카드가 수천 개가 되면 스크롤과 입력이 느려지는데, 이때 화면에 보이는 것만 그리는 가상 스크롤(예: TanStack Virtual)을 씁니다.

            예제 앱은 가상 스크롤을 넣지 않았습니다. 상품이 120개뿐이고 한 쪽이 20개라서 측정할 만한 문제가 생기지 않았기 때문입니다. 넣을지 판단하는 기준은 이렇습니다.

            • 한 화면에 쌓이는 항목이 수백~수천 개인가?
            • Performance 패널에서 스크롤 중 긴 작업(50ms 넘는 작업)이 보이는가?
            • 항목 높이가 제각각이어도 되는가? (가상 스크롤은 높이 측정이 필요해 복잡해집니다)
            • 브라우저의 찾기(Ctrl+F)와 접근성(스크린 리더의 목록 개수)을 포기해도 되는가?

            8장 렌더링과 SEO

            예제 앱은 CSR입니다. 브라우저가 JS를 받아 실행해야 화면이 생깁니다. 쇼핑몰에서 이것이 언제 문제가 되는지, 메타 태그와 구조화 데이터로 무엇을 할 수 있는지, 언제 SSR·SSG로 가야 하는지 봅니다.

            이 장의 핵심

            • CSR 앱의 HTML에는 본문이 없습니다. JS를 실행하지 않는 크롤러와 링크 미리보기는 빈 페이지를 봅니다.
            • React 19는 컴포넌트 안의 <title>·<meta>를 <head>로 옮겨 줍니다. 라이브러리 없이 페이지마다 제목을 바꿀 수 있습니다.
            • 상품 상세에는 JSON-LD(schema.org Product)를 넣어 가격·재고를 검색 엔진에 알립니다.
            • 검색 유입이 중요한 목록·상세는 SSR·SSG를, 로그인 뒤의 장바구니·주문은 CSR을 고르는 식으로 화면마다 나눠 생각합니다.

            8.1 CSR의 한계

            빌드한 dist/index.html 전체입니다. 738바이트이고, 상품 이름도 가격도 없습니다.실측

            <!doctype html>
            <html lang="ko">
              <head>
                <meta name="description" content="Rsbuild·React로 만든 예제 쇼핑몰" />
              <link rel="icon" href="/favicon.png"><title>예제 쇼핑몰</title><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><script defer src="/static/js/lib-react.7aa43924ab.js"></script><script defer src="/static/js/lib-router.31b5c4506b.js"></script><script defer src="/static/js/o.8649481aad.js"></script><script defer src="/static/js/index.dcd9387b90.js"></script><link href="/static/css/index.6facdd8645.css" rel="stylesheet"></head>
              <body>
                <noscript>이 쇼핑몰은 자바스크립트가 필요합니다.</noscript>
                <div id="root"></div>
              </body>
            </html>

            검색 엔진 중에는 JS를 실행해 보는 곳도 있지만, 실행이 늦거나 제한될 수 있습니다. 메신저·SNS의 링크 미리보기는 대부분 HTML만 읽습니다. 그러니 CSR 앱에서 상품 링크를 공유하면 미리보기에는 이 HTML의 제목과 설명만 뜹니다.

            빌드 결과를 열어 보고 찾은 것

            처음 빌드한 HTML의 제목은 <title>Rsbuild App</title>이었습니다.실측 Rsbuild는 html.title을 지정하지 않으면 기본 제목을 넣습니다. 브라우저에서는 React가 페이지 제목으로 바꿔 줘서 E2E도 통과했기 때문에, 결과물 HTML을 직접 열어 보기 전까지 몰랐습니다. html.title: '예제 쇼핑몰'을 넣어 고쳤습니다.

            템플릿(index.html)에는 lang="ko", 기본 설명, JS가 꺼져 있을 때의 안내만 둡니다.

            <!doctype html>
            <html lang="ko">
              <head>
                <meta name="description" content="Rsbuild·React로 만든 예제 쇼핑몰" />
              </head>
              <body>
                <noscript>이 쇼핑몰은 자바스크립트가 필요합니다.</noscript>
                <div id="<%= mountId %>"></div>
              </body>
            </html>

            8.2 메타·OG 태그

            React 19부터는 컴포넌트 어디서든 <title>과 <meta>를 쓰면 React가 문서의 <head>로 옮깁니다. 예제 앱은 페이지마다 제목을 다르게 두고, 상세 페이지에는 설명과 OG 태그도 넣었습니다.

            import { useSuspenseQuery } from '@tanstack/react-query';
            import { useState } from 'react';
            import { Link, useNavigate, useParams } from 'react-router';
            import { useCartStore } from '@/features/cart/store';
            import { useCartMutation } from '@/features/cart/useCartMutation';
            import { formatKRW } from '@/shared/lib/money';
            import { productQuery } from './api';
            import type { Product } from './types';
            
            // …
            
                <article className="grid gap-6 md:grid-cols-2">
                  {/* React 19는 컴포넌트 안의 title·meta를 <head>로 옮겨 준다 */}
                  <title>{`${product.name} | 예제 쇼핑몰`}</title>
                  <meta name="description" content={product.description} />
                  <meta property="og:title" content={product.name} />
                  <meta property="og:type" content="product" />
                  <script type="application/ld+json">{productJsonLd(product)}</script>

            E2E가 상세 페이지의 제목이 상품명 | 예제 쇼핑몰인지 확인합니다. 다만 이것은 JS가 실행된 뒤의 이야기입니다. 링크 미리보기에 상품마다 다른 이미지와 제목을 보여 주려면 HTML 자체에 그 태그가 있어야 하므로 SSR이나 SSG가 필요합니다.

            8.3 구조화 데이터 (Product)

            검색 결과에 가격과 재고가 함께 뜨게 하려면 페이지에 schema.org 형식의 데이터를 넣습니다. 금액은 원 단위 정수 그대로, 통화는 KRW로 적습니다.

            import { useSuspenseQuery } from '@tanstack/react-query';
            import { useState } from 'react';
            import { Link, useNavigate, useParams } from 'react-router';
            import { useCartStore } from '@/features/cart/store';
            import { useCartMutation } from '@/features/cart/useCartMutation';
            import { formatKRW } from '@/shared/lib/money';
            import { productQuery } from './api';
            import type { Product } from './types';
            
            // 검색 엔진이 읽는 구조화 데이터(schema.org Product)
            function productJsonLd(p: Product) {
              return JSON.stringify({
                '@context': 'https://schema.org',
                '@type': 'Product',
                sku: p.productId,
                name: p.name,
                description: p.description,
                offers: {
                  '@type': 'Offer',
                  priceCurrency: 'KRW',
                  price: p.price,
                  availability: p.stock > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
                },
              });
            }

            이 데이터도 CSR에서는 JS를 실행해야 생깁니다. 검색 엔진이 제대로 읽는지는 Google의 리치 결과 테스트 같은 도구로 배포 후에 확인하세요.검증 필요

            8.4 SSR·SSG 선택지

            렌더링 방식은 앱 전체가 아니라 화면마다 고르는 것입니다.

            화면검색 노출개인화추천
            카테고리·상품 목록중요없음SSG(주기적 재빌드) 또는 SSR
            상품 상세매우 중요거의 없음(가격·재고는 자주 바뀜)SSG/SSR로 뼈대, 가격·재고는 화면에서 다시 조회
            장바구니·주문서·결제·주문 내역불필요(로그인 뒤)강함CSR

            Rsbuild 쪽 선택지는 이렇습니다.

            • Rsbuild만으로 SSR — environments로 웹용·Node용 빌드를 함께 만들고 직접 서버를 붙입니다. 가장 손이 많이 갑니다.
            • React Server Components — Rsbuild 2.0에서 rsbuild-plugin-rsc가 나왔습니다. 아직 새 기능이니 채택 전에 생태계 지원을 확인하세요.검증 필요
            • 프레임워크 — Rsbuild 문서에는 TanStack Start를 Vite에서 Rsbuild로 옮기는 가이드가 있습니다. 파일 기반 라우팅·SSR·데이터 로딩을 한 번에 얻습니다.
            • SSG만 — 공개 페이지가 적다면, 빌드 때 상품 상세 HTML을 미리 만들고 나머지는 지금처럼 CSR로 둡니다.

            렌더링 전략 선택기

            화면의 성격을 고르면 어울리는 렌더링 방식과 이유를 보여 줍니다.

            검색 엔진·링크 미리보기에 노출돼야 하나요?
            사용자마다 내용이 다른가요?
            내용이 얼마나 자주 바뀌나요?
            Node 서버(또는 엣지 런타임)를 운영할 수 있나요?

            추천

            9장 테스트

            테스트를 세 층으로 나눕니다. 금액 계산 같은 순수 함수는 단위 테스트로, 화면과 서버가 만나는 곳은 컴포넌트 테스트로, 구매 흐름 전체는 E2E로 지킵니다. 세 층 모두 같은 MSW 핸들러를 씁니다.

            이 장의 핵심

            • Rstest는 withRsbuildConfig()로 앱의 빌드 설정(별칭, 환경 변수, 플러그인)을 그대로 물려받습니다. 테스트용 설정을 따로 맞출 필요가 없습니다.
            • Testing Library로 역할·이름·텍스트로 요소를 찾습니다. 사용자가 보는 방식이라 구현이 바뀌어도 테스트가 덜 깨집니다.
            • 컴포넌트 테스트는 실제 routes를 메모리 라우터로 띄웁니다. URL부터 화면까지 한 번에 검사합니다.
            • E2E는 개발 서버가 아니라 배포할 결과물(dist)을 띄워 검사합니다.

            9.1 Rstest 설정

            import { withRsbuildConfig } from '@rstest/adapter-rsbuild';
            import { defineConfig } from '@rstest/core';
            
            // Docs: https://rstest.rs/config/
            export default defineConfig({
              // rsbuild.config.ts의 플러그인·별칭·define을 그대로 물려받는다.
              extends: withRsbuildConfig(),
              testEnvironment: 'happy-dom',
              include: ['tests/**/*.test.{ts,tsx}'],
              setupFiles: ['./tests/rstest.setup.ts'],
            });

            브라우저 API가 필요하므로 happy-dom 환경을 씁니다. 설정 파일은 테스트마다 MSW를 켜고, 끝나면 목 DB·장바구니·저장소를 비웁니다.

            import { afterAll, afterEach, beforeAll, expect } from '@rstest/core';
            import * as jestDomMatchers from '@testing-library/jest-dom/matchers';
            import { cleanup } from '@testing-library/react';
            import { useCartStore } from '@/features/cart/store';
            import { resetDb } from '@/mocks/db';
            import { server } from '@/mocks/node';
            
            expect.extend(jestDomMatchers);
            
            // 브라우저와 같은 MSW 핸들러를 Node에서도 쓴다.
            beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
            afterEach(() => {
              cleanup();
              server.resetHandlers();
              resetDb();
              useCartStore.setState({ lines: [] });
              localStorage.clear();
              sessionStorage.clear();
            });
            afterAll(() => server.close());

            스토어는 모듈 안에 살아 있습니다

            처음에는 localStorage.clear()만 했더니 "장바구니가 비어 있으면 장바구니로 돌려보낸다" 테스트가 실패했습니다.실측 Zustand 스토어는 모듈 변수라서 앞 테스트가 담은 상품이 메모리에 남아 있었기 때문입니다. useCartStore.setState({ lines: [] })를 추가했습니다.

            Rstest는 globals가 기본으로 꺼져 있어 test, expect를 @rstest/core에서 import합니다. Testing Library의 자동 정리도 전역 afterEach에 기대므로, 설정 파일에서 cleanup()을 직접 부릅니다.

            9.2 Testing Library와 MSW

            도우미는 실제 routes를 createMemoryRouter로 띄우고, 재시도를 끈 QueryClient를 넣습니다. 재시도가 켜져 있으면 실패 테스트가 몇 초씩 기다립니다.

            import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
            import { render } from '@testing-library/react';
            import { createMemoryRouter } from 'react-router';
            import { RouterProvider } from 'react-router/dom';
            import { routes } from '@/app/routes';
            
            // 실제 routes를 메모리 라우터로 띄워 URL부터 화면까지 한 번에 검사한다.
            export function renderRoute(path: string) {
              const queryClient = new QueryClient({
                defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
              });
              const router = createMemoryRouter(routes, { initialEntries: [path] });
              render(
                <QueryClientProvider client={queryClient}>
                  <RouterProvider router={router} />
                </QueryClientProvider>,
              );
              return { router, queryClient };
            }

            목록 페이지 테스트입니다. 주소로 카테고리를 넘기고, 정렬을 바꾼 뒤 주소와 가격 순서를 확인합니다. 요청은 MSW가 받으므로 네트워크가 없어도 됩니다.

            import { expect, test } from '@rstest/core';
            import { screen, within } from '@testing-library/react';
            import userEvent from '@testing-library/user-event';
            import { renderRoute } from './render';
            
            test('URL의 카테고리로 목록을 불러오고, 정렬을 바꾸면 URL도 바뀐다', async () => {
              const { router } = renderRoute('/?category=digital');
              expect(await screen.findByText('총 24개')).toBeInTheDocument();
              expect(screen.getByRole('button', { name: '디지털' })).toHaveAttribute('aria-pressed', 'true');
            
              await userEvent.selectOptions(screen.getByRole('combobox', { name: '정렬' }), 'price-asc');
              expect(router.state.location.search).toBe('?category=digital&sort=price-asc');
            
              expect(await screen.findByText('총 24개')).toBeInTheDocument();
              const prices = within(await screen.findByRole('list', { name: '상품' }))
                .getAllByText(/₩/)
                .map((el) => Number(el.textContent?.replace(/[^\d]/g, '')));
              expect(prices).toEqual([...prices].sort((a, b) => a - b));
            });
            
            test('없는 경로는 404 화면을 보여 준다', async () => {
              renderRoute('/nope');
              expect(
                await screen.findByRole('heading', { name: '페이지를 찾을 수 없어요' }),
              ).toBeInTheDocument();
            });

            이 테스트도 처음에는 실패했습니다. 정렬을 바꾸면 새 쿼리 키로 다시 불러오는 동안 목록이 사라지는데, getAllByRole('list')가 그 순간을 잡았기 때문입니다. findBy…로 다시 나타날 때까지 기다리게 하고, 목록에 aria-label="상품"을 붙여 찾기 쉽게 했습니다.실측

            9.3 Playwright E2E

            import { defineConfig, devices } from '@playwright/test';
            
            // Docs: https://playwright.dev/docs/test-configuration
            export default defineConfig({
              testDir: './e2e',
              fullyParallel: true,
              forbidOnly: !!process.env.CI,
              retries: process.env.CI ? 2 : 0,
              reporter: process.env.CI ? 'github' : 'list',
              use: {
                baseURL: 'http://localhost:4173',
                trace: 'on-first-retry',
              },
              projects: [
                { name: 'desktop', use: { ...devices['Desktop Chrome'] } },
                { name: 'mobile', use: { ...devices['Pixel 7'] } },
              ],
              // 배포할 결과물(dist)을 그대로 띄워 검사한다.
              webServer: {
                command: 'npm run build && npx rsbuild preview --port 4173',
                url: 'http://localhost:4173',
                reuseExistingServer: !process.env.CI,
              },
            });
            • webServer가 npm run build 후 rsbuild preview로 결과물을 띄웁니다. 개발 서버에서만 통과하는 테스트를 막습니다.
            • 데스크톱 Chrome과 모바일(Pixel 7) 두 프로젝트로 같은 테스트를 돌립니다.

            구매 흐름 테스트는 목록에서 완료까지 누르고, 새로고침·뒤로 가기·콘솔 오류까지 확인합니다.

            import { expect, type Page, test } from '@playwright/test';
            
            async function fillCheckout(page: Page) {
              await page.getByLabel('받는 분').fill('김하나');
              await page.getByLabel('휴대폰 번호').fill('010-1234-5678');
              await page.getByLabel('우편번호').fill('06236');
              await page.getByLabel('주소', { exact: true }).fill('서울시 강남구 테헤란로 1');
              await page.getByLabel('주문 내용을 확인했으며 결제에 동의합니다.').check();
            }
            
            test('목록 → 상세 → 장바구니 → 주문서 → 모의 결제 → 주문 완료', async ({ page }) => {
              const errors: string[] = [];
              page.on('pageerror', (e) => errors.push(e.message));
              page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
            
              await page.goto('/?category=digital');
              await expect(page.getByText('총 24개')).toBeVisible();
              await page.getByRole('list', { name: '상품' }).getByRole('link').first().click();
            
              await expect(page).toHaveURL(/\/products\/p-\d{3}$/);
              const name = await page.getByRole('heading', { level: 1 }).textContent();
              await expect(page).toHaveTitle(`${name} | 예제 쇼핑몰`);
              await page.getByRole('button', { name: '바로 구매' }).click();
            
              await expect(page).toHaveURL(/\/cart$/);
              await expect(page.getByTestId('cart-count')).toHaveText('1');
              const total = await page.getByTestId('cart-total').textContent();
              await page.getByRole('link', { name: '주문하기' }).click();
            
              await fillCheckout(page);
              await page.getByRole('button', { name: /결제하기/ }).click();
            
              await expect(page.getByRole('heading', { name: '모의 PG 결제창' })).toBeVisible();
              await page.getByRole('button', { name: '인증하고 결제하기' }).click();
            
              await expect(page.getByRole('heading', { name: '주문이 완료되었어요' })).toBeVisible();
              await expect(page.getByTestId('order-amount')).toHaveText(total ?? '');
              await expect(page.getByTestId('cart-count')).toHaveText('0');
            
              // 결과 페이지는 새로고침해도 같은 주문을 보여 준다.
              const orderId = await page.getByTestId('order-id').textContent();
              await page.reload();
              await expect(page.getByTestId('order-id')).toHaveText(orderId ?? '');
            
              // 뒤로 가기로 결제 단계에 돌아가지 않는다(replace 이동).
              await page.goBack();
              await expect(page).not.toHaveURL(/\/checkout/);
            
              expect(errors).toEqual([]);
            });

            모든 검사를 돌린 결과입니다.실측

            검사결과
            npx biome ci .통과
            npx rstest run테스트 파일 8개, 21개 통과
            npm run build통과
            npx playwright test구매 흐름 4개 × 데스크톱·모바일 = 8개 + 측정 1개 통과

            9.4 무엇을 어느 층에서 테스트할까

            • 단위 — 입력과 출력만 있는 함수. 금액 계산, URL 파싱, 재시도 판단, 스키마. 빠르고 많이 씁니다.
            • 컴포넌트(통합) — 화면 + 상태 + 가짜 서버. 낙관적 업데이트 롤백, 폼 오류 표시, 404 화면.
            • E2E — 진짜 브라우저로 흐름 전체. 결제 완료, 새로고침, 뒤로 가기, 중복 클릭. 느리니 돈과 직결되는 핵심 흐름만.

            테스트 계층 분류 연습

            각 검사를 어느 층에서 하는 것이 가장 알맞은지 고르세요. 예제 앱이 실제로 어디서 검사하는지와 비교합니다.

            10장 배포

            dist/ 폴더는 정적 파일뿐이라 어느 정적 호스팅에나 올릴 수 있습니다. 문제는 두 가지입니다. SPA 주소로 새로고침했을 때 404가 나는 것, 그리고 새 버전을 배포했는데 사용자가 옛 파일을 계속 쓰는 것입니다.

            이 장의 핵심

            • SPA는 /cart 같은 주소에 실제 파일이 없습니다. 서버가 없는 주소에 index.html을 돌려주도록(SPA 폴백) 해야 새로고침이 됩니다.
            • Cloudflare Pages는 최상위 404.html이 없으면 SPA로 보고 모든 경로를 /로 연결합니다. GitHub Pages는 없는 경로에 404를 돌려줍니다.
            • 해시가 붙은 파일은 max-age=31536000, immutable로 1년, index.html은 no-cache로 매번 확인합니다.

            10.1 정적 배포

            빌드 결과는 이렇게 생겼습니다. 파일 이름의 해시는 내용이 바뀔 때만 바뀝니다.

            dist/
            ├─ index.html              ← 해시 없음. 항상 같은 이름
            ├─ _headers                ← Cloudflare Pages용 헤더 설정(public/에서 복사)
            ├─ mockServiceWorker.js    ← 해시 없음(MSW가 정한 경로)
            ├─ favicon.png
            └─ static/
               ├─ css/index.[hash].css
               └─ js/
                  ├─ index.[hash].js, lib-react.[hash].js, lib-router.[hash].js …
                  └─ async/page-list.[hash].js, page-checkout.[hash].js …
            GitHub PagesCloudflare Pages
            없는 경로404 상태로 응답(404.html이 있으면 그 내용)실측최상위 404.html이 없으면 SPA로 보고 /의 내용을 응답
            캐시 헤더직접 못 바꿈. 측정해 보니 max-age=600실측_headers 파일로 경로별 지정
            하위 경로 배포사용자.github.io/저장소/라서 server.base(또는 output.assetPrefix) 설정 필요검증 필요보통 도메인 루트

            이 교과서가 올라가 있는 GitHub Pages에 직접 요청해 본 결과입니다. 기존 교과서 파일은 200, cache-control: max-age=600이었고, 없는 경로(/ljk0071/no-such-page)는 404였습니다.

            10.2 SPA 새로고침 404

            사용자가 /orders/ORD-000001/complete에서 새로고침하면 브라우저는 서버에 그 경로의 파일을 달라고 합니다. 그런 파일은 없습니다. 앱 안에서 링크로 이동할 때는 React Router가 주소만 바꾸므로 괜찮았던 것입니다.

            • Cloudflare Pages — 아무것도 하지 않아도 됩니다. 다만 404.html을 만들면 SPA 동작이 꺼지니 주의하세요.
            • GitHub Pages — 흔히 빌드 뒤 index.html을 404.html로 복사합니다. 화면은 뜨지만 응답 상태는 404이므로 검색 엔진에는 "없는 페이지"로 보입니다.검증 필요 공개 상품 페이지가 중요하다면 GitHub Pages보다 SPA 폴백을 지원하는 호스팅을 고르세요.
            • 개발·미리보기 서버 — Rsbuild의 개발 서버와 rsbuild preview는 기본으로 폴백합니다. 그래서 E2E의 "완료 페이지에서 새로고침"이 통과합니다. 로컬에서 통과했다고 호스팅에서도 된다는 뜻은 아닙니다.

            10.3 캐시 헤더

            규칙은 "이름이 곧 버전인 파일은 영원히, 이름이 안 바뀌는 파일은 매번 확인"입니다. 예제 앱은 Cloudflare Pages용 public/_headers를 두었습니다. public/의 파일은 빌드할 때 dist/로 그대로 복사됩니다.

            # Cloudflare Pages가 읽는 헤더 설정(GitHub Pages는 이 파일을 무시한다).
            # 내용 해시가 붙은 파일은 이름이 곧 버전이므로 1년 동안 다시 묻지 않는다.
            /static/*
              Cache-Control: public, max-age=31536000, immutable
            
            # HTML은 늘 서버에 새 버전이 있는지 확인한다.
            /
              Cache-Control: no-cache
            /index.html
              Cache-Control: no-cache
            
            # 서비스 워커 스크립트도 해시가 없으므로 매번 확인한다.
            /mockServiceWorker.js
              Cache-Control: no-cache
            • /static/* — 내용 해시가 붙어 있으므로 내용이 바뀌면 이름도 바뀝니다. 1년 동안 다시 묻지 않아도 안전합니다. immutable은 새로고침할 때도 확인 요청을 보내지 말라는 뜻입니다.
            • index.html — 이 파일이 새 해시 이름들을 가리키는 "목차"입니다. 이것이 캐시되면 새 배포가 보이지 않습니다. no-cache는 "저장은 하되 쓰기 전에 서버에 확인"입니다(no-store와 다릅니다).
            • mockServiceWorker.js — 해시가 없으니 HTML과 같은 취급입니다.

            배포 중 옛 청크가 사라지는 문제

            사용자가 옛 index.html을 연 채로 있는데 새 배포가 옛 청크 파일을 지우면, 그 사용자가 주문서로 갈 때 page-checkout.[옛 해시].js를 못 받아 오류가 납니다. 3장의 에러 경계가 "다시 시도(새로고침)"를 보여 주는 이유 중 하나입니다. 가능하면 배포할 때 이전 버전의 static/ 파일을 한동안 남겨 두세요.

            캐시 헤더 시뮬레이터

            어제 방문한 사용자가 오늘 새 배포 뒤에 다시 옵니다. 파일과 헤더를 바꿔 가며 사용자가 무엇을 받는지 보세요.

            파일
            Cache-Control

            11장 종합 과제

            지금까지 만든 쇼핑몰을 직접 조작해 보고, 완성 체크리스트로 스스로 점검합니다. 마지막에는 실측으로 찾은 개선 거리를 과제로 풉니다.

            이 장의 핵심

            • 아래 데모는 예제 앱과 같은 routes와 화면 코드로 만든 데모 전용 빌드입니다. MSW 대신 메모리 목 데이터를, 브라우저 라우터 대신 메모리 라우터를 씁니다.
            • 데모는 스크립트·스타일을 모두 넣은 HTML 한 파일(518.2 kB)이고, sandbox iframe의 srcdoc으로 격리해 이 페이지 스타일과 섞이지 않습니다.

            11.1 라이브 데모

            재고가 2개인 상품(10번째 상품마다, 예: p-010)을 3개 이상 담아 롤백을 보고, 결제 버튼을 빠르게 두 번 눌러 보세요.

            외부 네트워크 없이 이 페이지 안에서만 동작합니다.

            데모 입구는 두 가지만 바꿉니다. fetch를 메모리 목으로 바꿔 끼우고, createMemoryRouter를 씁니다. 화면 코드는 한 줄도 다르지 않습니다.

            import '../index.css';
            import { StrictMode } from 'react';
            import { createRoot } from 'react-dom/client';
            import { createMemoryRouter } from 'react-router';
            import { App } from '@/app/App';
            import { routes } from '@/app/routes';
            import { memoryFetch } from './memoryFetch';
            
            // 교과서 안 iframe에서 도는 데모. 주소창이 없으므로 메모리 라우터를 쓴다.
            window.fetch = memoryFetch;
            
            const rootEl = document.getElementById('root');
            if (rootEl) {
              createRoot(rootEl).render(
                <StrictMode>
                  <App router={createMemoryRouter(routes, { initialEntries: ['/'] })} />
                </StrictMode>,
              );
            }
            import { defineConfig, mergeRsbuildConfig } from '@rsbuild/core';
            import baseConfig from './rsbuild.config';
            
            // 교과서에 넣을 데모: 스크립트와 스타일을 모두 HTML 한 파일에 넣는다.
            export default mergeRsbuildConfig(
              baseConfig,
              defineConfig({
                source: { entry: { index: './src/demo/index.tsx' } },
                output: {
                  distPath: { root: 'dist-demo' },
                  inlineScripts: true,
                  inlineStyles: true,
                  filenameHash: false,
                },
                // 인라인 스크립트에는 defer가 듣지 않는다. #root 뒤(body 끝)에 넣어야 한다.
                html: { inject: 'body' },
                server: { publicDir: false },
                // 청크를 나누지 않아야 한 파일로 끝난다.
                splitChunks: false,
                tools: { rspack: { output: { asyncChunks: false } } },
              }),
            );

            데모를 만들며 부딪힌 것

            • 처음 빌드한 데모는 iframe 안에서 빈 화면이었습니다.실측 인라인 스크립트에는 defer가 듣지 않아 <head>에서 바로 실행됐고, 그때는 아직 #root가 없었습니다. html.inject: 'body'로 고쳤습니다.
            • sandbox="allow-scripts"만 주면 iframe은 고유한 출처를 갖지 못해 localStorage 접근이 예외를 던집니다. 5.1절의 안전한 저장소 래퍼 덕분에 콘솔 오류 없이 동작합니다.실측

            11.2 완성 체크리스트

            자기 프로젝트에 대해 체크해 보세요. 체크 상태는 이 브라우저에만 저장됩니다.

            11.3 과제

            1. 첫 화면에서 Zod 빼기 — 7.3절에서 찾은 문제입니다. searchParams.ts를 Zod 없이 다시 쓰고, npm run measure로 첫 방문 JS가 얼마나 줄었는지 재 보세요. tests/searchParams.test.ts가 그대로 통과해야 합니다.
            2. 취소는 롤백하지 않기 — 5.3절의 함정을 고칩니다. 페이지를 떠나 요청이 취소됐을 때(AbortError)는 장바구니를 되돌리지 않도록 useCartMutation을 바꾸고, E2E에서 "담기 직후 이동"을 테스트로 추가하세요.
            3. GitHub Pages에 올리기 — 하위 경로(/저장소/) 배포에 맞게 설정하고, SPA 새로고침을 해결하세요. React Router의 basename도 필요합니다.
            4. 실서버 붙이기 — Spring Boot 4 + Kotlin 실무 교과서로 같은 API를 만들고 .env.live의 PUBLIC_API_BASE를 바꿔 연결하세요. 개발 중에는 server.proxy로 /api를 넘기면 CORS 없이 붙습니다(1.3절 설정 빌더의 프록시 옵션).

            부록 A webpack·Vite에서 옮기기

            webpack 설정은 Rspack으로, Vite 설정은 Rsbuild로 옮기는 것이 자연스럽습니다. 자주 쓰는 항목만 추렸습니다. 전체 목록은 공식 마이그레이션 문서를 보세요.

            A.1 webpack → Rspack

            Rspack은 webpack의 설정 모양을 따르므로 대부분 이름만 바꿉니다. 플러그인은 webpack.X를 rspack.X로 바꾸고, 느린 JS 도구는 내장 Rust 구현으로 바꿉니다.

            webpackRspack
            new webpack.DefinePlugin()new rspack.DefinePlugin()
            html-webpack-pluginhtml-rspack-plugin 또는 내장 rspack.HtmlRspackPlugin
            copy-webpack-pluginrspack.CopyRspackPlugin
            mini-css-extract-pluginrspack.CssExtractRspackPlugin
            tsconfig-paths-webpack-pluginresolve.tsConfig 옵션
            fork-ts-checker-webpack-plugints-checker-rspack-plugin의 TsCheckerRspackPlugin
            terser-webpack-pluginrspack.SwcJsMinimizerRspackPlugin
            css-minimizer-webpack-pluginrspack.LightningCssMinimizerRspackPlugin
            babel-loaderbuiltin:swc-loader (Babel 플러그인이 꼭 필요하면 일부 파일에만 babel-loader 유지)
            swc-loaderbuiltin:swc-loader (옵션은 그대로)
            file-loader / url-loader / raw-loaderAsset Modules: asset/resource / asset/inline / asset/source
            cache: { type: 'filesystem' }cache: { type: 'persistent' } (buildDependencies는 경로 배열로 평평하게)
            /* webpackChunkName: "x" */그대로 동작(예제 앱 3장에서 사용)

            A.2 Vite → Rsbuild

            ViteRsbuild
            @vitejs/plugin-react, @vitejs/plugin-react-swc@rsbuild/plugin-react
            @tailwindcss/vite@rsbuild/plugin-tailwindcss
            vite-plugin-svgr@rsbuild/plugin-svgr
            vite-plugin-checker@rsbuild/plugin-type-check
            vite-plugin-htmlhtml.template
            @vitejs/plugin-legacy필요 없음(Browserslist로 대상 지정)
            baseserver.base
            definesource.define
            resolve.aliasresolve.alias (또는 tsconfig paths)
            publicDir, build.copyPublicDirserver.publicDir
            appTypeserver.historyApiFallback
            server.port, server.proxy, server.headers같은 이름의 server.*
            server.hmrdev.hmr, dev.client
            build.outDir, build.assetsDiroutput.distPath
            build.sourcemapoutput.sourceMap
            build.targetBrowserslist
            build.reportCompressedSizeperformance.printFileSize
            build.manifestoutput.manifest
            build.libRslib 사용
            VITE_ 접두사 환경 변수PUBLIC_ 접두사
            tsconfig의 "types": ["vite/client"]"types": ["@rsbuild/core/types"]
            기본 포트 5173기본 포트 3000

            부록 B 용어집

            본문에서 점선 밑줄로 표시한 용어를 가나다순으로 모았습니다.