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장 시작하기
무엇을 만들고, 어떤 도구를 왜 쓰는지 먼저 봅니다. 도구 이름이 많지만 역할은 네 가지뿐입니다. 묶고, 검사하고, 시험하고, 분석합니다.
이 장의 핵심
0.1 완성 결과 미리보기
예를 들어 사용자가 디지털 카테고리에서 이어폰을 골라 결제한다고 해 봅시다. 화면은 이렇게 이어집니다.
- 상품 목록
/?category=digital&sort=price-asc— 필터와 정렬이 주소에 남습니다. 아래로 내리면 다음 20개를 이어서 불러옵니다. - 상세
/products/p-002— 목록에서 마우스를 올린 순간 미리 받아 둔 데이터로 바로 뜹니다. - 장바구니
/cart— 수량을 바꾸면 화면이 먼저 바뀌고, 재고가 모자라면 되돌아갑니다. - 주문서
/checkout— 휴대폰 번호·우편번호를 검사하고, 결제 버튼을 두 번 눌러도 주문은 하나만 생깁니다. - 모의 결제
/checkout/pay/ORD-000001→/checkout/confirm— PG사의 요청 → 인증 → 승인 흐름을 흉내 냅니다. - 주문 완료
/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/core | 2.2.10 / 2.2.7 |
| 빌드 | @rsbuild/plugin-react / @rsbuild/plugin-tailwindcss | 2.1.0 / 2.0.3 |
| 품질 | @biomejs/biome / typescript | 2.5.14 / 7.0.2 |
| 테스트 | @rstest/core / @testing-library/react / @playwright/test | 0.12.2 / 16.3.3 / 1.63.0 |
| 분석 | @rsdoctor/rspack-plugin | 1.6.4 |
| 화면 | react / react-router | 19.3.0 / 8.4.0 |
| 상태 | @tanstack/react-query / zustand | 5.104.0 / 5.0.15 |
| 폼 | react-hook-form / zod / @hookform/resolvers | 7.89.0 / 4.6.5 / 5.9.1 |
| 목 서버 | msw | 2.15.0 |
| 스타일 | tailwindcss | 4.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'] },
},
}),
],
},
},
});
| 옵션 | 하는 일 | 예제 앱에서 쓴 이유 |
|---|---|---|
plugins | React(JSX·Fast Refresh), Tailwind CSS 지원을 켭니다. | 템플릿이 넣어 준 그대로 |
html.template | 기본 HTML 대신 쓸 템플릿 파일 | <html lang="ko">와 기본 description이 필요해서 |
output.filenameHash | 파일 이름에 내용 해시를 넣습니다(배포 결과물의 기본값). | 10장 캐시 전략의 전제라서 명시 |
performance.printFileSize | 빌드가 끝나면 파일별 크기를 표로 보여 줍니다. | gzip 크기 크기를 함께 보려고 |
server.port | 개발 서버 포트 | E2E·문서의 주소를 고정하려고 |
tools.rspack | Rsbuild가 만든 Rspack 설정에 직접 손대는 탈출구 | Rsdoctor 플러그인을 조건부로 넣으려고(7장) |
1.4 환경 변수
예를 들어 API 주소와 "목 서버를 켤지"는 환경마다 다릅니다. 이런 값은 .env 파일에 두고 코드에서는 import.meta.env로 읽습니다.
# 브라우저 코드에 노출되는 변수는 PUBLIC_ 접두사가 필요하다.
PUBLIC_API_BASE=/api
PUBLIC_API_MOCKING=true
규칙은 세 가지입니다.
PUBLIC_으로 시작하는 변수만 브라우저 코드에 들어갑니다. Rsbuild가 빌드할 때 값을 글자 그대로 바꿔 넣습니다. 그러니 비밀번호나 API 키를 여기에 두면 누구나 볼 수 있습니다.--env-mode로 파일을 고를 수 있습니다.rsbuild build --env-mode live는.env를 읽은 다음.env.live로 덮어씁니다. 예제 앱은 이 모드로 MSW를 뺀 "실서버용" 빌드를 만듭니다.- 이미
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/useExhaustiveDependencies | useEffect 의존성 빠뜨림 | 상품을 바꿔도 이전 상품 가격이 남음 |
correctness/noUnusedImports | 쓰지 않는 import | 번들에 필요 없는 모듈이 딸려 올 수 있음 |
suspicious/noConsole | 남겨 둔 console.log | 결제 정보가 브라우저 콘솔에 찍힘 |
style/noNonNullAssertion | value!로 null 검사 건너뛰기 | 빈 장바구니에서 화면이 깨짐 |
예제 앱을 만드는 중에도 Biome이 두 번 멈춰 세웠습니다.실측 데모용 가짜 fetch에서 if ((m = path.match(...)))처럼 조건문 안에서 대입한 코드가 suspicious/noAssignInExpressions에 걸렸고, 측정 스크립트의 console.log가 noConsole에 걸렸습니다. 앞의 것은 코드를 고쳤고, 뒤의 것은 overrides로 그 폴더만 허용했습니다.
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장 라우팅
주소마다 어떤 화면을 보여 줄지 정합니다. 레이아웃은 한 번만 그리고, 페이지 코드는 그 주소에 처음 갈 때 내려받게 나눕니다. 오류가 나도 헤더는 살아 있게 합니다.
이 장의 핵심
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을 읽어 남긴 것이고, 테스트는 "첫 화면에 결제 코드가 없다"를 확인합니다.
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가 서버 역할을 합니다.
이 장의 핵심
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,
});
}
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());
}
- 타임아웃 —
AbortSignal.timeout과 TanStack Query가 넘긴 취소 신호를AbortSignal.any로 합쳐, 먼저 오는 쪽에서 끊습니다. 사용자가 페이지를 떠나도, 8초가 지나도 요청이 멈춥니다. - 재시도 대상 — 400·404·409·422는 다시 보내도 같은 답이 오므로 재시도하지 않습니다. 408·429·5xx와 네트워크 오류만 재시도합니다.
- 간격 — 1초, 2초, 4초로 늘리되 8초에서 멈추고, 그 절반~전체 사이에서 무작위로 고릅니다. 서버가 잠깐 멈췄다 살아났을 때 모든 사용자의 재시도가 같은 순간에 몰리지 않게 합니다.
이 규칙은 tests/http.test.ts가 지킵니다. 백엔드 입장에서 타임아웃·재시도·입장 제어를 보려면 백엔드 시스템 기초 지도 5.4절, "결과를 모름" 상태를 모델에 넣는 법은 7.2절을 보세요.
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장의 데모는sandboxiframe 안에서 도는데, 그곳에서는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 };
}
- onMutate — 이 상품의 "서버가 마지막으로 받아들인 줄"(
confirmed)을 기억해 두고, 진행 중인 요청 수(pending)를 하나 올린 뒤 화면을 새 수량으로 바꿉니다. - mutationFn —
PUT /api/cart/items/:productId로 재고를 확인합니다. - onSuccess — 서버가 받아들였으니
confirmed를 새 줄로 바꿉니다. - onError — 더 새로운 변경이 남아 있으면(
pending > 1) 아무것도 하지 않습니다. 마지막 요청이 실패했을 때만confirmed로 되돌리고(없던 상품이면 지웁니다), 서버가 보낸 이유를 보여 줍니다. - 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장에 있습니다.
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사 도메인에 띄우지만, 예제는 앱 안의 모의 결제창으로 대신합니다.
- 요청 — 주문서가
POST /api/orders로 주문을 만듭니다. 서버는 클라이언트가 보낸 금액을 믿지 않고 상품 가격으로 다시 계산해PENDING_PAYMENT주문을 돌려줍니다. - 인증 — 결제창(
/checkout/pay/:orderId)에서 사용자가 카드 인증을 합니다. 끝나면 PG가paymentKey,orderId,amount를 붙여 우리 페이지로 돌려보냅니다. - 승인 — 돌아온 페이지(
/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);
});
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로 캐시에 넣어 고쳤습니다. 서버의 상태를 바꾼 뒤에는 관련 캐시를 갱신하거나 무효화해야 한다는 규칙을 몸으로 배운 사례입니다.
출처
- IETF draft: The Idempotency-Key HTTP Header Field검증 필요 (초안 단계이므로 최신 판의 상태 코드 권고를 확인하세요)
- React Hook Form: useForm · Zod
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 ms | JS API로 3번 빌드한 중앙값. 캐시가 데워진 상태실측 |
| JS 청크 수 | 16개 (비동기 12개) | 페이지 7개 + 공유 청크 + MSW |
| 첫 화면 초기 JS | 113.1 kB (원본 351.6 kB) | index.html이 직접 부르는 청크실측 |
| 첫 방문에 받은 JS 전체 | 238.9 kB (9개 파일) | 초기 청크 + 목록 페이지 + Zod + MSW. 브라우저 기록 기준실측 |
| 전체 JS | 264.5 kB | 모든 페이지를 다 돌았을 때 |
| MSW를 뺀 실서버 빌드 전체 JS | 169.5 kB (14개) | --env-mode live. MSW가 95.0 kB를 차지했음실측 |
| CSS | 3.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 (예제 앱) | 16 | 113.1 kB | React·Router를 따로 떼어 오래 캐시. 대부분의 앱 |
single-vendor | 10 | 243.0 kB | 의존성이 적고 거의 안 바뀌는 앱. 대신 첫 화면이 무거워짐 |
per-package | 41 | 118.4 kB (초기 8개) | 의존성 하나만 올려도 그 청크만 다시 받게 하고 싶을 때. HTTP/2에서도 요청이 많아짐 |
분할 끔 + asyncChunks: false | 1 | 252.8 kB | 한 파일이어야 할 때(11장 데모). 결제 코드까지 첫 화면에 실림 |
숫자를 보면 single-vendor는 첫 화면이 두 배 넘게 무거워집니다. 모든 페이지의 라이브러리(React Hook Form, Zod, MSW…)가 한 vendor 파일에 모여 첫 화면에 실리기 때문입니다.실측
실측으로 찾은 개선 거리: 첫 화면의 Zod
Rsdoctor에서 Zod 청크(원본 86.8 kB)가 목록 페이지의 의존성으로 잡혔습니다. 목록 페이지가 URL 검사에 z.enum, z.coerce.number()를 쓰기 때문입니다. 쓸 수 있는 선택지는 세 가지입니다.
- URL 검사를 손으로 짠 작은 함수로 바꾼다 — 가장 가볍지만 규칙이 두 곳(폼은 Zod)으로 나뉩니다.
- Zod의 가벼운 배포판(
zod/mini)을 목록 페이지에만 쓴다검증 필요 — 크기를 다시 재 봐야 합니다. - 그대로 둔다 — 주문서도 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로 둡니다.
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 Pages | Cloudflare 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/ 파일을 한동안 남겨 두세요.
11장 종합 과제
지금까지 만든 쇼핑몰을 직접 조작해 보고, 완성 체크리스트로 스스로 점검합니다. 마지막에는 실측으로 찾은 개선 거리를 과제로 풉니다.
이 장의 핵심
- 아래 데모는 예제 앱과 같은
routes와 화면 코드로 만든 데모 전용 빌드입니다. MSW 대신 메모리 목 데이터를, 브라우저 라우터 대신 메모리 라우터를 씁니다. - 데모는 스크립트·스타일을 모두 넣은 HTML 한 파일(518.2 kB)이고,
sandboxiframe의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 과제
- 첫 화면에서 Zod 빼기 — 7.3절에서 찾은 문제입니다.
searchParams.ts를 Zod 없이 다시 쓰고,npm run measure로 첫 방문 JS가 얼마나 줄었는지 재 보세요.tests/searchParams.test.ts가 그대로 통과해야 합니다. - 취소는 롤백하지 않기 — 5.3절의 함정을 고칩니다. 페이지를 떠나 요청이 취소됐을 때(
AbortError)는 장바구니를 되돌리지 않도록useCartMutation을 바꾸고, E2E에서 "담기 직후 이동"을 테스트로 추가하세요. - GitHub Pages에 올리기 — 하위 경로(
/저장소/) 배포에 맞게 설정하고, SPA 새로고침을 해결하세요. React Router의basename도 필요합니다. - 실서버 붙이기 — 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 구현으로 바꿉니다.
| webpack | Rspack |
|---|---|
new webpack.DefinePlugin() | new rspack.DefinePlugin() |
html-webpack-plugin | html-rspack-plugin 또는 내장 rspack.HtmlRspackPlugin |
copy-webpack-plugin | rspack.CopyRspackPlugin |
mini-css-extract-plugin | rspack.CssExtractRspackPlugin |
tsconfig-paths-webpack-plugin | resolve.tsConfig 옵션 |
fork-ts-checker-webpack-plugin | ts-checker-rspack-plugin의 TsCheckerRspackPlugin |
terser-webpack-plugin | rspack.SwcJsMinimizerRspackPlugin |
css-minimizer-webpack-plugin | rspack.LightningCssMinimizerRspackPlugin |
babel-loader | builtin:swc-loader (Babel 플러그인이 꼭 필요하면 일부 파일에만 babel-loader 유지) |
swc-loader | builtin:swc-loader (옵션은 그대로) |
file-loader / url-loader / raw-loader | Asset Modules: asset/resource / asset/inline / asset/source |
cache: { type: 'filesystem' } | cache: { type: 'persistent' } (buildDependencies는 경로 배열로 평평하게) |
/* webpackChunkName: "x" */ | 그대로 동작(예제 앱 3장에서 사용) |
A.2 Vite → Rsbuild
| Vite | Rsbuild |
|---|---|
@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-html | html.template |
@vitejs/plugin-legacy | 필요 없음(Browserslist로 대상 지정) |
base | server.base |
define | source.define |
resolve.alias | resolve.alias (또는 tsconfig paths) |
publicDir, build.copyPublicDir | server.publicDir |
appType | server.historyApiFallback |
server.port, server.proxy, server.headers | 같은 이름의 server.* |
server.hmr | dev.hmr, dev.client |
build.outDir, build.assetsDir | output.distPath |
build.sourcemap | output.sourceMap |
build.target | Browserslist |
build.reportCompressedSize | performance.printFileSize |
build.manifest | output.manifest |
build.lib | Rslib 사용 |
VITE_ 접두사 환경 변수 | PUBLIC_ 접두사 |
tsconfig의 "types": ["vite/client"] | "types": ["@rsbuild/core/types"] |
| 기본 포트 5173 | 기본 포트 3000 |
부록 B 용어집
본문에서 점선 밑줄로 표시한 용어를 가나다순으로 모았습니다.