테스팅과 성능 최적화
Vite 프로젝트에 Vitest와 React Testing Library를 설정해 화면에 보이는 결과와 비동기 화면을 검사하고, memo, useCallback, useMemo, 가상화를 측정한 병목에만 적용하는 순서를 정리합니다.
타입 검사를 통과한 컴포넌트도 사용자가 보기에 틀리게 동작할 수 있습니다. 문구가 잘못 나오거나, 요청이 실패했을 때 오류를 보여 주지 않거나, 화면이 느리게 반응하는 일은 타입으로 막을 수 없기 때문입니다. 그래서 컴포넌트가 화면에 무엇을 보여 주는지를 테스트로 확인하고, 느린 곳은 측정해서 찾은 뒤에 고칩니다.
이 글은 Vite 프로젝트에 Vitest와 React Testing Library를 설정하고, 화면의 문구와 비동기 화면을 검사하는 테스트를 씁니다. 이어서 memo, useCallback, useMemo, 가상화가 각각 무엇을 줄이는지 보고, 측정과 확인을 반복하는 최적화의 순서를 정리합니다.
테스트 환경 준비
Vite 프로젝트에서는 Vite의 설정과 변환을 그대로 쓰는 Vitest가 가장 간단합니다. 2026년 10월 현재 Vitest 5는 Vite 6.4 이상과 Node.js 22.12 이상을 요구하며, 앞 글들의 템플릿은 이 조건을 만족합니다. 테스트에 필요한 패키지를 개발 의존성으로 설치합니다.
npm install --save-dev vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-eventjsdom은 Node.js에서 브라우저의 DOM을 흉내 내는 라이브러리이고, @testing-library/react는 컴포넌트를 그 DOM에 그려 사용자처럼 요소를 찾게 해 줍니다. @testing-library/dom은 React Testing Library 16부터 따로 설치해야 하는 피어 의존성이며, @testing-library/jest-dom은 toBeInTheDocument()처럼 expect 뒤에 붙여 DOM의 상태를 검사하는 matcher를 더합니다. 다음으로 vite.config.ts에 테스트 설정을 더합니다.
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/setupTests.ts"],
},
});import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});맨 위의 /// <reference types="vitest/config" />는 defineConfig의 설정 객체에 test 속성의 타입을 더합니다. environment: "jsdom"은 테스트를 DOM이 있는 환경에서 실행하고, setupFiles의 파일은 각 테스트 파일보다 먼저 실행됩니다. Vitest는 기본으로 describe나 afterEach 같은 전역 함수를 두지 않으므로, React Testing Library가 테스트마다 화면을 비우는 자동 정리가 동작하지 않아 설정 파일에서 cleanup을 직접 부릅니다.
package.json의 scripts에 "test": "vitest"를 더하면 npm test로 테스트를 실행하고, npx vitest run은 감시 모드 없이 한 번만 실행합니다. Vitest는 Vite처럼 타입 표기를 지우고 실행할 뿐 타입을 검사하지 않으므로, 타입 검사는 tsc -b로 따로 합니다. Jest를 쓸 때는 Vite의 변환을 쓰지 않으므로 TSX 변환과 jest-environment-jsdom, jest-dom의 설정을 따로 연결해야 합니다.
화면에 보이는 것을 검사하기
React Testing Library는 컴포넌트의 내부 상태가 아니라, 사용자가 화면에서 보는 요소와 그 역할(role), 이름을 기준으로 검사합니다. 다음 Greeting을 예로 듭니다.
interface GreetingProps {
name: string;
}
export default function Greeting({ name }: GreetingProps) {
return <h1>Hello, {name}!</h1>;
}import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Greeting from "./Greeting";
describe("Greeting", () => {
it("shows the name in a heading", () => {
render(<Greeting name="Mina" />);
expect(screen.getByRole("heading", { name: "Hello, Mina!" })).toBeInTheDocument();
});
it("shows another name", () => {
render(<Greeting name="World" />);
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
});
});render는 컴포넌트를 jsdom의 문서에 그리고, screen은 그 문서 전체에서 요소를 찾습니다. getByRole("heading", { name: "Hello, Mina!" })는 이름이 Hello, Mina!인 제목 요소를 찾으므로, 문구뿐 아니라 그 문구가 제목으로 표시되었는지도 함께 확인합니다. getBy... 함수는 요소가 없으면 그 자리에서 실패하므로, toBeInTheDocument()는 찾은 요소가 문서에 있다는 것을 읽기 쉽게 적은 검사입니다.
<Greeting name={123} /> 같은 잘못된 props를 테스트로 검사할 필요는 없습니다. 이런 실수는 tsc -b가 Type 'number' is not assignable to type 'string'.으로 막으며, 정상 props로 렌더링해 예외가 없는지만 보는 테스트는 그 타입 오류를 검사하지 못합니다. 타입 오류가 나야 하는 코드를 남겨 두고 싶다면 그 줄 앞에 // @ts-expect-error를 적어, 오류가 사라지면 오히려 tsc가 알려 주게 합니다.
비동기 화면 검사하기
마운트할 때 서버에서 사용자를 불러오는 컴포넌트를 검사해 봅니다. 응답은 unknown으로 받아 모양을 확인하고, 13장 2절에서 본 ignore 플래그로 컴포넌트가 사라진 뒤의 결과는 반영하지 않습니다.
import { useEffect, useState } from "react";
interface User {
id: number;
name: string;
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const row = value as Record<string, unknown>;
return typeof row.id === "number" && typeof row.name === "string";
}
export default function UserFetcher() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
const load = async () => {
try {
const response = await fetch("/api/user/1");
if (!response.ok) throw new Error("Failed to fetch user");
const data: unknown = await response.json();
if (!isUser(data)) throw new Error("Invalid user response");
if (!ignore) setUser(data);
} catch (err) {
if (!ignore) setError(err instanceof Error ? err.message : "Unknown error");
} finally {
if (!ignore) setLoading(false);
}
};
load();
return () => {
ignore = true;
};
}, []);
if (loading) return <p>Loading user...</p>;
if (error !== null) return <p>Error: {error}</p>;
if (!user) return <p>No user data.</p>;
return <p>Name: {user.name} (ID: {user.id})</p>;
}테스트에서는 실제 서버 대신, 정해 둔 응답을 돌려주는 가짜 fetch를 넣습니다. 이렇게 테스트를 위해 바깥 의존성을 대신하는 객체를 대역(test double)이라고 부릅니다.
컴포넌트 테스트는 사용자가 관찰하는 행동을 따라간다 — 상태 추적
UserFetcher mount에서 Loading 표시, fetch double 정착, 성공/실패 DOM assertion 상태를 직접 추적할 수 있다. 본문에 없는 사용자 입력·취소 테스트는 만들지 않는다.
BEHAVIOR TEST / ARRANGE ACT OBSERVE
테스트는 제어한 경계와 화면의 관찰 결과를 연결한다
응답을 미리 제어하고 UI를 렌더한 뒤, 필요한 사용자 행동을 보내고 비동기 화면 결과를 검증합니다. 본문 UserFetcher는 마운트 시 요청하며, 아래 클릭 단계는 행동 테스트로 확장한 경우입니다.
ARRANGE
경계 응답과 환경을 준비
성공·HTTP 실패·필요한 경합 응답을 deterministic하게 준비합니다. 본문은 global.fetch Mock을 사용하며 설치/type 설정만으로 DOM 환경·TSX 변환·jest-dom setup이 완성되지는 않습니다.
ACT
접근 가능한 표면에서 행동
role과 accessible name 또는 적합한 label/text로 요소를 찾습니다. user-event를 쓴다면 setup 후 click/type 등의 완료를 await합니다. 그림은 선택된 시나리오의 이벤트를 표현하며 모든 브라우저 동작 재현을 보장하지 않습니다.
OBSERVE
비동기 결과를 기다리고 검증
findBy는 재시도하는 조회이고 waitFor는 callback의 assertion을 재시도합니다. 보이는 결과·로딩 제거·disabled 등의 계약을 검사하며 기다렸다는 사실만으로 결과가 검증되지는 않습니다.
ACTUAL TESTS
본문 테스트가 확인하는 것
Greeting의 문구와 UserFetcher 마운트 후 Test User 표시·HTTP 실패 문구를 검사합니다. getByText/waitFor를 사용하며 클릭·입력·취소·응답 경합까지 검증한 예는 아닙니다.
STATIC CONTRACT
타입 오류와 runtime 테스트
“invalid name type” 사례는 정상 name으로 렌더해 예외가 없는지만 검사합니다. 주석의 name={123} 타입 오류가 runtime 테스트로 검증되지는 않습니다.
Mock 응답은 경계를 제어하고 UI 구현은 그대로 실행합니다. 그림의 권장 행동 테스트와 원문의 실제 검사 범위를 구분하며 이 검토에서 fixture를 실행했다고 주장하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| mount Loading | DOM: Loading user... / fetch double: 대기 | UserFetcher를 렌더하고 응답 전 진행 문구를 확인한다 | 테스트에서 현재 화면과 대기 작업을 분리한다 |
| fake 성공 | 응답: ID 1·TestUser / DOM: 결과 반영 전 | double이 ID 1/TestUser를 반환한다 | 같은 mount의 성공 결과가 준비된다 |
| assert 결과 | 성공 DOM: Name TestUser · ID 1 / 실패: 별도 오류 assertion | 결과 DOM 또는 실패 경로의 안내를 기다려 검사한다 | 동기 getByText와 비동기 완료 대기 책임을 구분한다 |
컴포넌트 테스트는 사용자가 관찰하는 행동을 따라간다 — 상태 추적
UserFetcher mount에서 Loading 표시, fetch double 정착, 성공/실패 DOM assertion 상태를 직접 추적할 수 있다. 본문에 없는 사용자 입력·취소 테스트는 만들지 않는다.
BEHAVIOR TEST / ARRANGE ACT OBSERVE
테스트는 제어한 경계와 화면의 관찰 결과를 연결한다
응답을 미리 제어하고 UI를 렌더한 뒤, 필요한 사용자 행동을 보내고 비동기 화면 결과를 검증합니다. 본문 UserFetcher는 마운트 시 요청하며, 아래 클릭 단계는 행동 테스트로 확장한 경우입니다.
ARRANGE
경계 응답과 환경을 준비
성공·HTTP 실패·필요한 경합 응답을 deterministic하게 준비합니다. 본문은 global.fetch Mock을 사용하며 설치/type 설정만으로 DOM 환경·TSX 변환·jest-dom setup이 완성되지는 않습니다.
ACT
접근 가능한 표면에서 행동
role과 accessible name 또는 적합한 label/text로 요소를 찾습니다. user-event를 쓴다면 setup 후 click/type 등의 완료를 await합니다. 그림은 선택된 시나리오의 이벤트를 표현하며 모든 브라우저 동작 재현을 보장하지 않습니다.
OBSERVE
비동기 결과를 기다리고 검증
findBy는 재시도하는 조회이고 waitFor는 callback의 assertion을 재시도합니다. 보이는 결과·로딩 제거·disabled 등의 계약을 검사하며 기다렸다는 사실만으로 결과가 검증되지는 않습니다.
ACTUAL TESTS
본문 테스트가 확인하는 것
Greeting의 문구와 UserFetcher 마운트 후 Test User 표시·HTTP 실패 문구를 검사합니다. getByText/waitFor를 사용하며 클릭·입력·취소·응답 경합까지 검증한 예는 아닙니다.
STATIC CONTRACT
타입 오류와 runtime 테스트
“invalid name type” 사례는 정상 name으로 렌더해 예외가 없는지만 검사합니다. 주석의 name={123} 타입 오류가 runtime 테스트로 검증되지는 않습니다.
Mock 응답은 경계를 제어하고 UI 구현은 그대로 실행합니다. 그림의 권장 행동 테스트와 원문의 실제 검사 범위를 구분하며 이 검토에서 fixture를 실행했다고 주장하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| mount Loading | DOM: Loading user... / fetch double: 대기 | UserFetcher를 렌더하고 응답 전 진행 문구를 확인한다 | 테스트에서 현재 화면과 대기 작업을 분리한다 |
| fake 성공 | 응답: ID 1·TestUser / DOM: 결과 반영 전 | double이 ID 1/TestUser를 반환한다 | 같은 mount의 성공 결과가 준비된다 |
| assert 결과 | 성공 DOM: Name TestUser · ID 1 / 실패: 별도 오류 assertion | 결과 DOM 또는 실패 경로의 안내를 기다려 검사한다 | 동기 getByText와 비동기 완료 대기 책임을 구분한다 |
그림처럼 테스트는 응답을 미리 정해 두고 컴포넌트를 렌더링한 뒤, 컴포넌트가 보낸 요청에 정해 둔 응답이 돌아오면 화면이 바뀌기를 기다려 결과를 검사합니다. 그림의 클릭 단계는 버튼을 눌러야 요청하는 컴포넌트로 넓힌 경우이며, UserFetcher는 마운트할 때 요청하므로 다음 테스트에는 클릭이 없습니다.
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import UserFetcher from "./UserFetcher";
function stubFetch(ok: boolean, body?: unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok, json: async () => body }) as Response),
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("UserFetcher", () => {
it("shows the user after loading", async () => {
stubFetch(true, { id: 1, name: "Test User" });
render(<UserFetcher />);
expect(screen.getByText("Loading user...")).toBeInTheDocument();
expect(await screen.findByText("Name: Test User (ID: 1)")).toBeInTheDocument();
expect(screen.queryByText("Loading user...")).not.toBeInTheDocument();
});
it("shows an error when the server fails", async () => {
stubFetch(false);
render(<UserFetcher />);
expect(await screen.findByText("Error: Failed to fetch user")).toBeInTheDocument();
});
it("rejects a response with the wrong shape", async () => {
stubFetch(true, { id: 1, name: 17 });
render(<UserFetcher />);
expect(await screen.findByText("Error: Invalid user response")).toBeInTheDocument();
});
});vi.stubGlobal은 전역 fetch를 대역으로 바꾸고, afterEach의 vi.unstubAllGlobals()가 테스트마다 원래대로 되돌립니다. 대역은 ok와 json만 가진 객체를 as Response로 단언해 돌려주는데, 이 단언은 컴포넌트가 실제로 쓰는 두 속성만 흉내 낸다는 표시일 뿐 진짜 Response를 만든 것은 아닙니다. 첫 렌더링에서는 응답 전이므로 getByText로 Loading user...를 바로 찾을 수 있습니다.
findByText는 요소가 나타날 때까지 기본 1초 동안 다시 찾는 비동기 조회여서, 결과를 기다릴 때는 await와 함께 씁니다. 여러 조건을 한꺼번에 기다려야 하면 waitFor 안에 검사를 넣으며, waitFor는 그 콜백의 검사가 통과할 때까지 다시 실행합니다. 결과를 찾은 뒤에는 queryByText로 로딩 문구가 사라졌는지 확인하는데, queryBy...는 요소가 없을 때 실패하지 않고 null을 돌려주므로 없음을 검사할 때 씁니다.
세 테스트는 성공, HTTP 실패, 모양이 틀린 응답을 각각 확인합니다. 세 번째 응답은 name이 숫자여서, 응답에 User 타입을 붙이기만 했다면 그대로 상태에 들어갔겠지만 isUser가 거절해 오류 문구가 보입니다. 이 테스트들은 마운트할 때의 요청만 다루며, 버튼 클릭이나 입력, 요청의 경합까지 검사한 것은 아닙니다. 클릭과 입력은 @testing-library/user-event의 userEvent.setup()으로 만든 객체로 await user.click(...)처럼 보내 검사합니다.
렌더링 최적화의 도구
React는 상태가 바뀐 컴포넌트와 그 자식들을 다시 렌더링합니다. 대부분은 충분히 빠르지만, 비싼 컴포넌트가 바뀌지 않은 props로 자주 다시 렌더링된다면 memo, useCallback, useMemo로 그 일을 줄일 수 있습니다. 세 도구는 모두 렌더링을 건너뛰거나 값을 다시 쓰도록 허락하는 최적화일 뿐 실행 횟수를 보장하지 않으므로, 프로그램의 정확성이 이 도구에 기대서는 안 됩니다.
memo로 감싼 컴포넌트는 부모가 다시 렌더링되어도, 새 props가 이전 props와 같으면 다시 렌더링하지 않을 수 있습니다. 같은지는 props를 하나씩 Object.is로 비교해 판단합니다.
import { memo } from "react";
interface DisplayProps {
value: string;
count: number;
}
const MemoizedDisplay = memo(function MemoizedDisplay({ value, count }: DisplayProps) {
console.log("MemoizedDisplay rendered");
return (
<p>
{value} / {count}
</p>
);
});
export default MemoizedDisplay;import { useState } from "react";
import MemoizedDisplay from "./components/MemoizedDisplay";
export default function MemoApp() {
const [parentCount, setParentCount] = useState(0);
const [unrelated, setUnrelated] = useState(0);
return (
<div>
<button onClick={() => setParentCount((prev) => prev + 1)}>Count: {parentCount}</button>
<button onClick={() => setUnrelated((prev) => prev + 1)}>Unrelated: {unrelated}</button>
<MemoizedDisplay value="Initial Text" count={parentCount} />
</div>
);
}Unrelated 버튼을 누르면 MemoApp은 다시 렌더링되지만, MemoizedDisplay의 props인 value와 count는 그대로이므로 자식의 렌더링을 건너뛸 수 있습니다. Count 버튼을 누르면 count props가 바뀌므로 자식이 다시 렌더링되는 것이 정상이며, 이것을 막는 것이 memo의 목적은 아닙니다. memo는 자식 자신의 상태나 그 자식이 읽는 Context가 바뀌어 생기는 렌더링도 막지 않습니다.
props의 비교가 Object.is라는 점 때문에, 부모가 렌더링할 때마다 새로 만드는 함수나 객체를 넘기면 내용이 같아도 다른 값으로 보여 memo가 소용없어집니다. useCallback은 의존성 배열이 같으면 이전에 만든 함수를 그대로 돌려주어, 이런 함수 props를 같은 값으로 유지합니다.
import { memo, useCallback, useState } from "react";
const ChildComponent = memo(function ChildComponent({ onIncrement, value }: { onIncrement: () => void; value: number }) {
console.log("ChildComponent rendered");
return <button onClick={onIncrement}>Child Value: {value}</button>;
});
export default function CallbackApp() {
const [count, setCount] = useState(0);
const [otherState, setOtherState] = useState(0);
const handleIncrement = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
return (
<div>
<button onClick={() => setOtherState((prev) => prev + 1)}>Other State: {otherState}</button>
<ChildComponent onIncrement={handleIncrement} value={count} />
</div>
);
}handleIncrement는 setCount에 함수를 넘겨 이전 값으로 계산하므로 count를 의존성에 넣을 필요가 없고, 의존성 배열이 비어 있어 같은 함수가 유지됩니다. 그래서 Other State를 눌러 부모가 다시 렌더링되어도 자식의 props는 그대로입니다. 자식의 버튼을 누르면 value가 바뀌므로 자식이 다시 렌더링되며, 이것은 화면에 새 값을 보여 주기 위해 필요한 렌더링입니다.
useMemo는 계산 결과를 저장해 두고, 의존성이 같으면 다시 계산하지 않고 저장한 값을 돌려줍니다.
import { useMemo, useState } from "react";
function calculateExpensiveValue(num: number): number {
let result = 0;
for (let i = 0; i < 10000; i++) {
result += num;
}
return result;
}
export default function MemoizedValueComponent() {
const [inputNum, setInputNum] = useState(1);
const [otherState, setOtherState] = useState(0);
const expensiveValue = useMemo(() => calculateExpensiveValue(inputNum), [inputNum]);
return (
<div>
<input type="number" value={inputNum} onChange={(e) => setInputNum(Number(e.target.value))} />
<p>Expensive Value: {expensiveValue}</p>
<button onClick={() => setOtherState((prev) => prev + 1)}>Other State: {otherState}</button>
</div>
);
}입력이 1이면 결과는 10000이고, 2로 바꾸면 20000, 3으로 바꾸면 30000입니다. Other State만 바꾸면 inputNum이 그대로이므로 저장한 결과를 다시 씁니다. 다만 개발 모드의 StrictMode는 계산 함수를 두 번 부를 수 있고 React가 저장한 값을 버릴 수도 있으므로, 계산 함수는 몇 번 불려도 같은 결과를 내는 순수 함수여야 합니다.
React 공식 문서의 TypeScript 안내가 소개하듯, React Compiler를 쓰는 프로젝트에서는 컴파일러가 값과 함수를 자동으로 메모이제이션하므로 이런 코드를 직접 쓸 일이 줄어듭니다. 어느 경우든 이 도구들은 측정한 병목이 있을 때 고려하는 것이며, 모든 컴포넌트와 함수를 미리 감싸는 것은 코드만 복잡하게 만듭니다.
더 깊이: memo가 약속하지 않는 것과 비교 함수의 함정
React의 memo 문서는 memo를 성능 최적화일 뿐 보장이 아니라고 적습니다. props가 바뀌지 않으면 대개 다시 렌더링하지 않지만, React가 그래도 다시 렌더링할 수 있다는 뜻입니다. 또 memo가 다루는 것은 부모에게서 받은 props뿐이어서, 감싼 컴포넌트도 자기 상태가 바뀌거나 자기가 읽는 Context의 값이 바뀌면 다시 렌더링됩니다. 그래서 memo가 렌더링을 건너뛰어야만 화면이 맞게 나오는 코드라면, 원인은 최적화가 아닌 다른 곳에 있습니다.
memo의 두 번째 인수로 비교 함수를 넘기면 Object.is를 쓴 얕은 비교 대신 그 함수가 이전 props와 새 props를 비교하며, 새 props로 그려도 결과가 같을 때만 true를 돌려주어야 합니다. 문서는 이때 함수까지 모든 props를 비교해야 한다고 경고합니다. 함수 props는 부모의 props와 상태를 클로저로 붙잡고 있는 경우가 많아서, onClick이 바뀌었는데 true를 돌려주면 자식의 핸들러가 이전 렌더링의 값을 계속 보게 되기 때문입니다. 깊이가 정해지지 않은 자료 구조를 깊게 비교하는 것도 피하라고 하는데, 누군가 나중에 자료 구조를 바꾸면 그 비교가 크게 느려져 앱이 몇 초씩 멈출 수 있습니다.
긴 목록과 가상화
항목이 수천 개인 목록은 memo로 각 항목의 렌더링을 아껴도, 처음에 모든 항목의 DOM을 만드는 비용이 큽니다. 가상화(virtualization)는 스크롤 위치에서 화면에 보이는 항목과 그 주변만 DOM으로 만들고, 스크롤에 따라 그 범위를 바꾸는 방법입니다. react-window나 @tanstack/react-virtual 같은 라이브러리가 이를 제공합니다.
가상화는 그리는 범위를 줄이는 방법이지, 데이터를 나누어 불러오는 방법은 아닙니다. 또 화면 밖의 항목이 DOM에 없으므로 브라우저의 페이지 내 찾기나 키보드 이동, 스크린 리더의 동작이 달라질 수 있어, 적용한 뒤에는 이런 동작도 함께 확인합니다.
측정하고 고치고 다시 확인하기
최적화는 느리다는 느낌이 아니라 측정에서 시작합니다. 그림은 최적화를 한 번의 작업이 아니라 닫힌 고리로 보여 줍니다.
PERFORMANCE / MEASURE CHANGE VERIFY
같은 행동과 조건으로 측정·변경·재검증한다
대표 입력·기대 행동·환경을 고정하고 비용을 측정합니다. 실제 원인에 맞는 작은 변경 뒤 같은 조건에서 행동과 비용을 함께 확인합니다. 교재는 최적화 기법의 예이며 측정된 성능 향상을 제시하지 않습니다.
MEASUREMENT
비교 조건을 맞춘다
같은 데이터·행동·기기·빌드 조건으로 반복 측정합니다. Profiler의 commit 관련 렌더 비용과 브라우저의 layout/paint·네트워크 비용을 구분하고 렌더 횟수만으로 전체 사용자 지연을 판단하지 않습니다.
PROCESS
여섯 단계로 근거를 갱신
1 기준 고정 → 2 비용 측정 → 3 원인 선택 → 4 최소 변경 → 5 동일 조건 재검증 → 6 근거 기록입니다. 개선을 유지하거나 되돌리고, 추가 병목이 있을 때 다음 반복의 기준을 정합니다.
MEMO
같은 Props가 실제 병목인가
memo는 기본적으로 각 Prop을 Object.is로 비교하는 최적화입니다. 자신의 state·Context 변경은 별도이며 렌더 생략을 보장하지 않습니다. 본문 MemoizedDisplay는 count Prop이 바뀌면 다시 렌더하는 것이 정상입니다.
REFERENCE
함수·값 참조와 계산 비용
useCallback은 같은 의존성일 때 함수 참조를 재사용하고 useMemo는 계산 결과를 재사용합니다. 의존성·캐시 폐기·개발 Strict Mode를 고려하며 correctness나 정확한 실행 횟수 보장으로 쓰지 않습니다.
LARGE LIST
가상화는 렌더 범위를 줄인다
긴 목록의 실제 병목이면 화면 주변 항목만 렌더하도록 가상화를 고려합니다. 모든 데이터를 자동 fetch하는 도구로 혼동하지 않고 스크롤·키보드 탐색·접근성 동작을 함께 확인합니다.
REGRESSION
개선과 행동을 함께 확인
비용이 줄어도 화면·상호작용이 바뀌면 유지할 근거가 아닙니다. 결과가 충분하면 기록하고 종료하며 다음 병목이 있으면 같은 루프를 반복합니다. 이 그림은 실제 benchmark 결과가 아닙니다.
React 최적화 도구는 측정한 원인에 맞춰 사용합니다. 코드 예제의 로그나 정적 타입 선언만으로 성능 개선을 완료 판정하지 않습니다.
먼저 대표적인 입력과 사용자 동작, 기기와 빌드 같은 조건을 고정하고, React DevTools의 Profiler나 브라우저의 성능 도구로 비용을 측정합니다. 측정에서 찾은 원인 하나를 고르고, 그 원인에만 맞는 가장 작은 변경을 합니다. 그다음 같은 조건에서 다시 측정하면서 앞 절의 테스트로 화면의 동작이 그대로인지도 확인하고, 결과를 기록해 변경을 유지할지 되돌릴지 정합니다.
렌더링 횟수가 줄었다는 것만으로 사용자가 느끼는 지연이 줄었다고 볼 수는 없습니다. Profiler가 보여 주는 렌더링 비용과 브라우저의 레이아웃, 그리기, 네트워크 비용은 서로 다른 것이므로, 사용자의 작업 전체가 얼마나 걸리는지를 함께 측정합니다. 이 글의 예제는 각 도구의 동작을 보여 줄 뿐, 실제로 성능이 얼마나 나아지는지를 측정한 결과는 아닙니다.
정리
- Vite 프로젝트에서는 Vitest에
jsdom환경과 설정 파일을 지정하고, 전역 함수를 쓰지 않을 때는 설정 파일에서cleanup을 직접 부릅니다. - Vitest는 타입을 검사하지 않으므로
tsc -b로 따로 검사하고, 잘못된 props 같은 타입 오류는 런타임 테스트로 검사하지 않습니다. - React Testing Library는 역할과 이름, 문구처럼 사용자가 보는 것으로 요소를 찾고, 비동기 결과는
findBy...나waitFor로 기다립니다. - 바깥 의존성은
vi.stubGlobal로 넣은 대역으로 바꾸어 성공, 실패, 잘못된 응답을 정해 둔 대로 재현합니다. memo,useCallback,useMemo는 같은 props나 의존성에서 일을 건너뛰도록 허락하는 최적화이며 실행 횟수를 보장하지 않습니다.- 최적화는 조건을 고정해 측정하고, 원인 하나만 고친 뒤, 같은 조건에서 비용과 동작을 다시 확인하는 순서로 합니다.
연습 문제
1. 실패 응답의 검사
stubFetch(false)로 실패 응답을 넣은 테스트에서, 로딩 문구가 사라졌는지도 확인하려면 무엇을 더해야 하나요? 이 테스트가 통과하면 경합 상황에서도 컴포넌트가 올바르게 동작한다고 말할 수 있나요?
해설 보기
findByText로 오류 문구를 찾은 뒤 expect(screen.queryByText("Loading user...")).not.toBeInTheDocument()를 더합니다. 이 테스트는 마운트할 때 요청 하나가 실패하는 경우만 재현하므로, 요청이 겹치거나 컴포넌트가 사라진 뒤 응답이 오는 경우는 따로 그런 상황을 만들어 검사해야 합니다.
2. memo가 건너뛰는 렌더링
MemoApp에서 Count 버튼을 두 번, Unrelated 버튼을 세 번 눌렀습니다. 자식은 무엇을 보여 주나요? 다섯 번의 클릭 가운데 자식의 렌더링이 꼭 필요한 것은 몇 번인가요?
해설 보기
자식은 Initial Text / 2를 보여 줍니다. Count 버튼을 누를 때마다 count props가 바뀌므로 그 두 번은 자식이 다시 렌더링되어야 하고, Unrelated 버튼의 세 번은 자식의 props가 그대로이므로 memo가 건너뛸 수 있는 렌더링입니다. 다만 memo는 건너뛰도록 허락할 뿐이므로, 테스트에서 렌더링 횟수 자체를 기대값으로 삼지는 않습니다.
3. 측정 결과 읽기
같은 조건에서 반복 측정했더니 어떤 컴포넌트의 렌더링 구간이 12ms에서 4ms로 줄었지만, 사용자의 작업 전체는 여전히 200ms가 걸렸다고 가정합니다. 이 변경으로 작업이 세 배 빨라졌다고 쓸 수 있나요?
해설 보기
쓸 수 없습니다. 줄어든 것은 한 구간의 8ms이고 작업 전체는 200ms 그대로이므로, 사용자가 느끼는 지연에는 거의 변화가 없습니다. 측정한 범위와 조건을 함께 기록하고, 전체 시간의 대부분을 차지하는 다른 원인을 찾아 다음 반복을 시작합니다. 이 수치는 판단을 연습하기 위한 가정입니다.
이것으로 13장의 React와 타입스크립트를 마칩니다. 다음 장의 Node.js와 Express에서는 서버 쪽으로 넘어가, Express 프로젝트에 타입스크립트를 설정하고 요청과 응답, 라우터, 미들웨어에 타입을 붙입니다.