본문 바로가기

테스팅과 성능 최적화

React Testing Library로 상호작용과 비동기 UI를 검증하고 memo·useCallback·useMemo·가상화의 성능 적용 기준을 익힙니다.

React와 타입스크립트로 견고한 애플리케이션을 만드는 것은 중요합니다.

하지만 동작한다는 사실만으로는 충분하지 않습니다.

실제 서비스에서는 예측 가능성, 안정성, 그리고 사용자 경험을 위한 성능까지 함께 고려해야 합니다.

이 절에서는 React 컴포넌트를 효과적으로 테스트하고 성능 최적화하는 방법을 다룹니다.


React 컴포넌트 테스팅

테스팅은 소프트웨어 개발에서 버그를 조기에 발견하고 코드의 안정성을 확인하는 과정입니다.

React 컴포넌트를 테스트할 때는 주로 Jest와 React Testing Library를 함께 사용합니다.

타입스크립트는 테스트 코드에도 타입 안전성을 제공하여 테스트 자체의 신뢰도를 높입니다.

Jest와 React Testing Library 설정

Vite 기반의 타입스크립트 프로젝트를 생성했다면, 테스트 도구는 직접 선택해 설정하는 경우가 많습니다. (예: Vitest 또는 Jest)

Jest + React Testing Library를 사용할 경우 다음과 같은 의존성을 설치할 수 있습니다.

npm install --save-dev @testing-library/react @testing-library/jest-dom jest ts-jest @types/jest

아래 설치와 types 설정만으로 Jest 실행 설정이 완성되지는 않습니다. DOM 테스트 환경(jest-environment-jsdom), TS/TSX 변환, @testing-library/jest-dom setup을 선택한 Jest 버전에 맞게 연결해야 합니다. types는 정적 타입 노출 설정입니다.

tsconfig.json
{
  "compilerOptions": {
    // ...
    "types": ["jest", "node"] // jest 타입 정의 추가
  },
  "include": ["src", "src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.test.ts", "src/**/*.test.tsx"]
}

설정을 실행 경로까지 연결하기

Jest는 Vite의 플러그인·변환 설정을 그대로 실행하지 않습니다. 원 설치 예는 보존하고, 다음은 Babel 변환을 선택한 별도 테스트 구성입니다. ts-jest 변환과 같은 파일에 중복 적용하지 않습니다. Jest와 babel-jest 및 DOM 환경 패키지의 버전을 함께 맞추고 잠금 파일로 기록합니다. Babel은 TS 문법을 지우므로 타입 검사는 별도로 실행합니다. 아래 구성은 CSS·이미지 import, Vite 전용 import.meta.env 등을 사용하는 앱의 모든 변환까지 대신하지 않습니다.

npm install --save-dev babel-jest @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript jest-environment-jsdom @types/node

프로젝트 루트의 .cjs 파일은 package.json의 type이 module인 경우에도 CommonJS 설정으로 읽힙니다.

babel.config.cjs
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    ['@babel/preset-react', { runtime: 'automatic' }],
    '@babel/preset-typescript',
  ],
};
jest.config.cjs
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
  transform: { '^.+\\.[jt]sx?$': 'babel-jest' },
};
jest.setup.cjs
require('@testing-library/jest-dom');

테스트를 타입 검사하는 tsconfig의 types에 jest·node와 함께 @testing-library/jest-dom을 포함합니다. 기존 프로젝트의 types·include 항목을 통째로 지우지 않고 필요한 항목을 합칩니다. 원 JSON의 // ...는 나머지 설정을 생략한 표기입니다. 실제 파일에는 프로젝트의 JSX·DOM 설정과 테스트 파일 포함 범위가 있어야 합니다.

npx tsc --noEmit -p tsconfig.app.json
npx jest --config jest.config.cjs src/components/Greeting.test.tsx --runInBand

첫 명령의 설정 파일 이름은 실제 테스트를 포함하는 프로젝트 설정으로 맞춥니다. Vite 기본 설정에서 noUnusedLocals를 켰다면 원 코드의 사용하지 않는 React 기본 import는 복사본에서 정리합니다. 오류 없이 Greeting 두 문구 테스트가 통과한 다음에 UserFetcher를 별도로 추가합니다. 테스트 성공과 tsc 성공을 각각 확인하며, “invalid name type”이라는 원 테스트 이름만으로 잘못된 Props가 검사됐다고 읽지 않습니다.

환경 역할은 Jest TypeScript 안내, DOM 설정은 Testing Library 설정, 초기화 시점은 setupFilesAfterEnv에 연결됩니다.

컴포넌트 테스팅 예시

React Testing Library는 사용자 관점에서 컴포넌트를 테스트하는 데 중점을 둡니다.

즉, 컴포넌트의 내부 구현보다는 사용자가 화면에서 보는 요소들과 상호작용하는 방식을 테스트합니다.

src/components/Greeting.tsx
import React from 'react';

interface GreetingProps {
  name: string;
}

const Greeting: React.FC<GreetingProps> = ({ name }) => {
  return <h1>Hello, {name}!</h1>;
};

export default Greeting;
// src/components/Greeting.test.tsx (테스트 파일)
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

describe('Greeting Component', () => {
  // 테스트 케이스 1: 이름이 올바르게 렌더링되는지 확인
  test('renders "Hello, World!" when name is "World"', () => {
    // 컴포넌트를 렌더링합니다.
    render(<Greeting name="World" />);

    // 화면에서 "Hello, World!" 텍스트를 찾습니다.
    const greetingElement = screen.getByText(/Hello, World!/i);

    // 해당 요소가 문서에 있는지 확인합니다.
    expect(greetingElement).toBeInTheDocument();
  });

  // 테스트 케이스 2: 다른 이름으로 렌더링되는지 확인
  test('renders "Hello, Alice!" when name is "Alice"', () => {
    render(<Greeting name="Alice" />);
    const greetingElement = screen.getByText(/Hello, Alice!/i);
    expect(greetingElement).toBeInTheDocument();
  });

  // 테스트 케이스 3: Props 타입 검증 (런타임에 직접 타입 에러를 발생시키지는 않지만,
  // TypeScript는 개발 단계에서 잘못된 Props 전달을 막아줍니다.)
  test('should not allow invalid name type (checked by TypeScript)', () => {
    // TypeScript는 아래와 같은 코드에서 컴파일 에러를 발생시킵니다.
    // render(<Greeting name={123} />); // Error: Type 'number' is not assignable to type 'string'.
    // 따라서 이런 런타임 테스트는 불필요하며, 타입스크립트의 역할에 맡깁니다.
    const validRender = () => render(<Greeting name="ValidName" />);
    expect(validRender).not.toThrow();
  });
});

독립 입력으로 name을 Mina로 바꾸면 heading은 Hello, Mina!여야 합니다. getByRole('heading', { name: 'Hello, Mina!' })로 찾은 요소를 검사하면 의미 있는 표면까지 선택합니다. 원 name={123} 주석을 복사본에서 실제 코드로 바꾼 뒤 tsc를 실행하면 string 계약 위반을 발견해야 합니다. 정상 name의 not.toThrow 검사는 이 정적 오류를 대신하지 않습니다.

비동기 코드 테스팅

fetch 또는 axios를 통한 비동기 데이터 로딩이 포함된 컴포넌트는 Mocking 기법을 사용하여 테스트합니다.

msw (Mock Service Worker)나 Jest의 mock 기능을 활용할 수 있습니다.

src/components/UserFetcher.tsx
import React, { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
}

const UserFetcher: React.FC = () => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch('/api/user/1'); // 가상 API 엔드포인트
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        const userData: User = await response.json();
        setUser(userData);
      } catch (err: any) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    fetchUser();
  }, []);

  if (loading) return <div>Loading user...</div>;
  if (error) return <div style={{ color: 'red' }}>Error: {error}</div>;
  if (!user) return <div>No user data.</div>;

  return (
    <div>
      <h2>User Profile</h2>
      <p>Name: {user.name} (ID: {user.id})</p>
    </div>
  );
};

export default UserFetcher;
src/components/UserFetcher.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserFetcher from './UserFetcher';

// fetch API를 Mocking
const mockUser = { id: 1, name: 'Test User' };

beforeAll(() => {
  // Jest의 global.fetch를 Mocking
  jest.spyOn(global, 'fetch').mockImplementation(() =>
    Promise.resolve({
      ok: true,
      json: () => Promise.resolve(mockUser),
    } as Response) // Response 타입 단언
  );
});

afterEach(() => {
  // 각 테스트 후 Mock 초기화
  jest.clearAllMocks();
});

afterAll(() => {
  // 모든 테스트 후 Mock 복원
  jest.restoreAllMocks();
});

describe('UserFetcher Component', () => {
  test('fetches and displays user data', async () => {
    render(<UserFetcher />);

    // 로딩 상태 확인
    expect(screen.getByText(/Loading user.../i)).toBeInTheDocument();

    // 데이터 로딩 완료를 기다립니다.
    await waitFor(() => {
      expect(screen.getByText(/Name: Test User \(ID: 1\)/i)).toBeInTheDocument();
    });

    // 로딩 메시지가 사라졌는지 확인
    expect(screen.queryByText(/Loading user.../i)).not.toBeInTheDocument();
  });

  test('displays error message on fetch failure', async () => {
    // 에러 상황 Mocking
    jest.spyOn(global, 'fetch').mockImplementationOnce(() =>
      Promise.resolve({
        ok: false,
        status: 500,
        statusText: 'Internal Server Error',
      } as Response)
    );

    render(<UserFetcher />);

    await waitFor(() => {
      expect(screen.getByText(/Error: Failed to fetch user/i)).toBeInTheDocument();
    });
  });
});

타입스크립트는 jest.spyOn과 같은 Jest Mocking API 사용 시에도 타입 추론을 도와주며, Mocking된 함수의 인자나 반환값에 대한 타입 안정성을 유지할 수 있도록 합니다.

그림은 클릭·입력까지 포함한 행동 테스트 설계입니다. 원문 테스트는 getByText로 마운트 후 성공·실패를 확인하며, 사용자 입력 이벤트나 요청 취소까지 검증하지는 않습니다. “invalid name type” 테스트도 정상 Props가 예외를 던지지 않는지만 확인하고 타입 오류를 테스트하지 않습니다.

컴포넌트 테스트는 사용자가 관찰하는 행동을 따라간다 — 상태 추적

UserFetcher mount에서 Loading 표시, fetch double 정착, 성공/실패 DOM assertion 상태를 직접 추적할 수 있다. 본문에 없는 사용자 입력·취소 테스트는 만들지 않는다.

테스트, UI, API double의 세 참여자로 렌더·선택적 사용자 행동·요청·제어 응답·화면 대기·결과 검증을 나타내는 시퀀스.

BEHAVIOR TEST / ARRANGE ACT OBSERVE

테스트는 제어한 경계와 화면의 관찰 결과를 연결한다

응답을 미리 제어하고 UI를 렌더한 뒤, 필요한 사용자 행동을 보내고 비동기 화면 결과를 검증합니다. 본문 UserFetcher는 마운트 시 요청하며, 아래 클릭 단계는 행동 테스트로 확장한 경우입니다.

테스트는 제어한 경계와 화면의 관찰 결과를 연결한다테스트, UI, API double의 세 참여자로 렌더·선택적 사용자 행동·요청·제어 응답·화면 대기·결과 검증을 나타내는 시퀀스.TEST검증 코드UI사용자 표면API DOUBLE제어한 응답render(UI)필요 시 await user.clickUI의 요청제어한 성공·실패findBy / waitFor화면 결과 assert
제어한 응답에서 화면 관찰까지의 세 참여자TEST, UI, API double의 세 lifeline에서 render, 필요한 경우 사용자 행동, UI 요청, 제어한 성공·실패, 비동기 화면 대기, 결과 assertion을 위에서 아래로 읽는다. 원 세 참여자·여섯 메시지 방향과 순서를 유지하며 원 UserFetcher는 마운트 요청이고 클릭 단계는 확장임을 지원 설명과 함께 읽는다.TEST검증UI화면APIdouble응답render(UI)필요 시 행동await user.clickUI의 요청제어한 성공 · 실패findBy / waitFor화면 결과 assert

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 LoadingDOM: 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 상태를 직접 추적할 수 있다. 본문에 없는 사용자 입력·취소 테스트는 만들지 않는다.

테스트, UI, API double의 세 참여자로 렌더·선택적 사용자 행동·요청·제어 응답·화면 대기·결과 검증을 나타내는 시퀀스.

BEHAVIOR TEST / ARRANGE ACT OBSERVE

테스트는 제어한 경계와 화면의 관찰 결과를 연결한다

응답을 미리 제어하고 UI를 렌더한 뒤, 필요한 사용자 행동을 보내고 비동기 화면 결과를 검증합니다. 본문 UserFetcher는 마운트 시 요청하며, 아래 클릭 단계는 행동 테스트로 확장한 경우입니다.

테스트는 제어한 경계와 화면의 관찰 결과를 연결한다테스트, UI, API double의 세 참여자로 렌더·선택적 사용자 행동·요청·제어 응답·화면 대기·결과 검증을 나타내는 시퀀스.TEST검증 코드UI사용자 표면API DOUBLE제어한 응답render(UI)필요 시 await user.clickUI의 요청제어한 성공·실패findBy / waitFor화면 결과 assert
제어한 응답에서 화면 관찰까지의 세 참여자TEST, UI, API double의 세 lifeline에서 render, 필요한 경우 사용자 행동, UI 요청, 제어한 성공·실패, 비동기 화면 대기, 결과 assertion을 위에서 아래로 읽는다. 원 세 참여자·여섯 메시지 방향과 순서를 유지하며 원 UserFetcher는 마운트 요청이고 클릭 단계는 확장임을 지원 설명과 함께 읽는다.TEST검증UI화면APIdouble응답render(UI)필요 시 행동await user.clickUI의 요청제어한 성공 · 실패findBy / waitFor화면 결과 assert

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 LoadingDOM: 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는 첫 렌더에서 Loading user...를 보이고, 제어한 성공 응답 뒤 Name: Test User (ID: 1)를 표시하며 로딩 문구를 지웁니다. ok가 false인 응답은 JSON 필드를 읽기 전에 Failed to fetch user 오류 경로로 갑니다. 대기 중 Promise를 직접 제어하면 로딩 단계와 완료 뒤 결과를 나누어 검사할 수 있습니다. waitFor의 callback 안에 assertion이 있어야 하며, 비동기 테스트의 Promise도 반환하거나 await해야 합니다.

Response라는 타입 단언이나 userData: User 주석은 실제 응답 객체·JSON 필드를 검증하지 않습니다. 외부 값에 대한 복사 실습에서는 다음 guard를 같은 UserFetcher 파일에 추가하고, json() 결과를 unknown으로 받은 뒤 검사합니다.

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
  const row = value as Record<string, unknown>;
  return typeof row.id === 'number' && Number.isFinite(row.id) && typeof row.name === 'string';
}

복사본의 기존 userData 선언과 setUser 호출 사이를 아래 코드로 바꿉니다. 유한한 number라는 id 조건은 이번 검사 정책이며, 양수 정수나 이름의 최소 길이까지 정한 업무 규칙은 아닙니다.

const userData: unknown = await response.json();
if (!isUser(userData)) throw new Error('Invalid user response');
setUser(userData);

이제 성공 Mock의 name을 숫자 17로 바꾸면, 원 타입 주석만 있는 코드와 달리 Error: Invalid user response로 가야 합니다. 원 fetch Effect는 요청 취소·오래된 결과 무시를 구현하지 않았습니다. 성공·HTTP 실패·구조 실패 검사가 통과해도 cleanup이나 경합까지 검증한 것으로 확대하지 않습니다.

React 컴포넌트 성능 최적화

React 애플리케이션의 성능은 사용자 경험에 직접적인 영향을 미칩니다.

타입스크립트 자체가 직접적인 성능 최적화 도구는 아니지만, 타입 안전성은 최적화 과정에서 발생할 수 있는 잠재적 버그를 줄이는 데 기여합니다.

React의 성능 최적화 기법들은 주로 불필요한 리렌더링을 방지하는 데 초점을 맞춥니다.

React.memo (함수형 컴포넌트)

React.memo는 고차 컴포넌트(Higher-Order Component)로, Props가 변경되지 않았을 때 컴포넌트의 리렌더링을 건너뛰게 합니다.

기본적으로 각 Prop을 Object.is로 비교하는 최적화이며 렌더 생략을 절대 보장하지 않습니다. 자신의 state나 Context가 바뀌면 다시 렌더할 수 있습니다. 아래 예제는 count도 전달하므로 부모 카운트 버튼으로 그 Prop이 바뀌면 다시 렌더하는 것이 정상입니다.

src/components/MemoizedDisplay.tsx
import React from 'react';

interface DisplayProps {
  value: string;
  count: number;
}

// React.memo로 동일 Props에 의한 렌더 생략을 시도
const MemoizedDisplay: React.FC<DisplayProps> = React.memo(({ value, count }) => {
  console.log('MemoizedDisplay rendered'); // 실제 렌더 시 출력됩니다.
  return (
    <div>
      <p>Value: {value}</p>
      <p>Count: {count}</p>
    </div>
  );
});

export default MemoizedDisplay;
src/App.tsx (사용예시)
import React, { useState } from 'react';
import MemoizedDisplay from './components/MemoizedDisplay';

const App: React.FC = () => {
  const [parentCount, setParentCount] = useState(0);
  const [text, setText] = useState('Initial Text');

  return (
    <div>
      <h1>Performance Optimization Example</h1>
      <button onClick={() => setParentCount(prev => prev + 1)}>
        Increment Parent Count: {parentCount}
      </button>
      <button onClick={() => setText('Updated Text ' + Math.random())}>
        Update Text
      </button>

      {/* count Prop은 계속 변하지만, value Prop은 Text가 변경될 때만 변경됩니다. */}
      {/* count도 Prop이므로 parentCount가 바뀌면 다시 렌더됩니다. */}
      <MemoizedDisplay value={text} count={parentCount} />
    </div>
  );
};

주의: React.memo는 Props를 얕게 비교(shallow comparison)합니다.

함수나 객체가 Props로 전달될 경우, 참조가 변경되면 내용이 같더라도 리렌더링될 수 있습니다.

이를 해결하기 위해 useCallback, useMemo 훅을 사용합니다.

useCallback으로 핸들러 참조 고정

useCallback은 특정 함수를 메모이제이션하여, 의존성 배열에 있는 값이 변경될 때만 함수를 재생성합니다.

이는 함수 참조 변경이 측정된 병목일 때 React.memo와 함께 고려할 수 있는 최적화입니다.

src/components/ChildComponent.tsx
import React from 'react';

interface ChildProps {
  onIncrement: () => void;
  value: number;
}

const ChildComponent: React.FC<ChildProps> = React.memo(({ onIncrement, value }) => {
  console.log('ChildComponent rendered');
  return (
    <div>
      <p>Child Value: {value}</p>
      <button onClick={onIncrement}>Increment from Child</button>
    </div>
  );
});

export default ChildComponent;
src/App.tsx (사용예시)
import React, { useState, useCallback } from 'react';
import ChildComponent from './components/ChildComponent';

const App: React.FC = () => {
  const [count, setCount] = useState(0);
  const [otherState, setOtherState] = useState(0);

  // 함수형 state 갱신을 사용해 count를 의존성에서 제외합니다.
  const handleIncrement = useCallback(() => {
    setCount(prevCount => prevCount + 1);
  }, []); // 의존성이 같으면 저장한 함수 참조를 재사용하는 최적화

  // otherState가 변경될 때마다 App 컴포넌트가 리렌더링되지만,
  // handleIncrement는 재생성되지 않으므로 ChildComponent는 불필요하게 리렌더링되지 않습니다.
  return (
    <div>
      <h1>useCallback Example</h1>
      <button onClick={() => setOtherState(prev => prev + 1)}>
        Update Other State: {otherState}
      </button>
      <ChildComponent onIncrement={handleIncrement} value={count} />
    </div>
  );
};

useCallback은 인라인 함수를 자식 컴포넌트에 Props로 전달할 때 유용합니다.

useMemo로 계산 결과 캐시

useMemo는 계산 비용이 비싼 값을 메모이제이션하여, 의존성 배열에 있는 값이 변경될 때만 재계산하도록 합니다.

src/components/MemoizedValueComponent.tsx
import React, { useState, useMemo } from 'react';

// 계산 비용이 비싼 함수 (예시)
const calculateExpensiveValue = (num: number): number => {
  console.log('Calculating expensive value...');
  let result = 0;
  for (let i = 0; i < 100000000; i++) {
    result += num;
  }
  return result;
};

const MemoizedValueComponent: React.FC = () => {
  const [inputNum, setInputNum] = useState(1);
  const [otherState, setOtherState] = useState(0);

  // inputNum이 변경될 때만 expensiveValue를 재계산합니다.
  const expensiveValue = useMemo(() => calculateExpensiveValue(inputNum), [inputNum]);

  return (
    <div>
      <h2>useMemo Example</h2>
      <input
        type="number"
        value={inputNum}
        onChange={(e) => setInputNum(Number(e.target.value))}
      />
      <p>Expensive Value: {expensiveValue}</p>

      <button onClick={() => setOtherState(prev => prev + 1)}>
        Update Other State: {otherState}
      </button>
      <p>Other State: {otherState}</p>
    </div>
  );
};

export default MemoizedValueComponent;

useMemo는 의존성이 같으면 저장된 결과를 재사용하는 최적화입니다. 개발 Strict Mode의 추가 계산이나 캐시 폐기 가능성이 있으므로 실행 횟수 보장으로 쓰지 않습니다. 이 절은 성능 측정 결과를 제시하지 않으며 아래 루프에 따라 실제 병목을 확인한 뒤 적용합니다.

먼저 안전한 계산량으로 따라 하기

원 calculateExpensiveValue는 1억 번 반복하므로 작은 기기에서 화면을 오래 막을 수 있습니다. 원 코드는 유지하고, 복사본의 반복 상한을 10000으로 줄여 상태·의존성 관계부터 관찰합니다. 입력은 유한한 0~10 정수로 제한하고, 이 비교가 원 1억 회 작업의 성능 개선 측정이라고 주장하지 않습니다.

function calculatePracticeValue(num: number): number {
  if (!Number.isInteger(num) || num < 0 || num > 10) throw new Error('0~10 정수를 입력하세요');
  let result = 0;
  for (let i = 0; i < 10000; i++) result += num;
  return result;
}

복사 컴포넌트에서 useMemo의 계산 호출을 이 함수로 바꾸고 입력값을 검증한 뒤 state에 넣습니다. inputNum 2는 20000, 3은 30000입니다. 다른 상태 버튼을 누르면 표시할 입력·계산 결과는 그대로여야 합니다. 캐시가 없어도 결과의 정확성이 유지되는 함수를 먼저 작성하며, 로그 횟수 자체를 테스트의 계약으로 고정하지 않습니다.

가상화

수백, 수천 개의 아이템이 있는 긴 리스트를 렌더링할 때는 React.memo, useCallback, useMemo만으로 충분하지 않을 수 있습니다.

이때는 가상화(Virtualization) 라이브러리(예: react-window, react-virtualized)를 사용해 화면에 보이는 아이템만 렌더링하고, 스크롤에 따라 동적으로 로드하는 기법을 적용합니다.

이 방식은 대규모 데이터 리스트 성능을 극적으로 향상시킵니다.


타입스크립트의 역할

타입스크립트 자체는 런타임 성능에 직접적인 영향을 주지 않습니다.

하지만 다음과 같은 방식으로 간접적으로 성능 최적화에 기여합니다.

  • 버그 감소: 타입 오류를 컴파일 시점에 잡아내어, 런타임에 발생할 수 있는 예상치 못한 버그를 줄입니다. 이는 디버깅 시간을 절약하고 안정적인 코드를 만듭니다.
  • 코드 품질 향상: 명확한 타입 정의는 코드를 더 쉽게 이해하고 유지보수할 수 있게 합니다. 이는 최적화 작업을 수행할 때 코드를 안전하게 수정할 수 있는 기반을 제공합니다.
  • 개발자 생산성: 강력한 자동 완성 기능과 리팩터링 지원은 개발자가 더 빠르게 코드를 작성하고, 최적화 패턴을 안전하게 적용할 수 있도록 돕습니다.

행동 계약과 측정을 독립적으로 판단하기

① Greeting의 name은 Mina이며, UserFetcher는 처음 로딩 뒤 ok=false인 응답을 받습니다. 각각 어떤 문구와 로딩 제거를 검사해야 하나요? 별도 검증 복사본에 ok=true이지만 name이 숫자 17인 JSON을 주면 어느 오류가 되어야 하나요? ② MemoizedDisplay의 부모 count를 두 번 올리면 자식 count와 value는 어떻게 되나요? ③ useCallback 예제에서 Other State만 세 번 올린 뒤 자식의 Increment를 한 번 누르면 otherState·count는 각각 얼마이고, 무엇이 자식의 새 표시를 요구하나요?

④ 위 작은 계산 입력을 2에서 3으로 바꾼 뒤 otherState만 갱신하면 계산 결과는 어떻게 되나요? 대표 입력·기기·빌드가 같은 반복 측정에서 한 구간이 12ms에서 4ms가 됐지만 전체 사용자 작업은 여전히 200ms라면, 전체가 세 배 빨라졌다고 쓸 수 있을까요? 이 수치는 판단 연습용 가정이며 실제 측정 기록이 아닙니다.

정적 검사·화면 결과·성능 근거를 분리한 풀이

Greeting은 Hello, Mina! heading을 검사합니다. HTTP 실패는 Error: Failed to fetch user와 로딩 문구 제거를 검사하며 성공 사용자가 표시된다고 예상하지 않습니다. 구조를 검사하는 복사본은 Error: Invalid user response로 갑니다. 원 타입 주석만 있는 코드에는 이 구조 검사가 없습니다.

MemoizedDisplay는 count 2와 value Initial Text를 표시해야 합니다. count Prop이 바뀌었으므로 memo가 이를 숨기는 것이 목표가 아닙니다. 다른 예제의 otherState는 3, count는 1입니다. 안정적인 함수 참조가 있어도 value가 0에서 1로 바뀌어 자식의 새 결과를 표시해야 합니다. 같은 Props일 때 렌더를 줄이는 최적화와 사용자에게 필요한 갱신을 구별합니다.

작은 계산은 20000에서 30000으로 바뀌며 otherState만 바꾸어도 30000을 유지합니다. 측정 가정의 한 구간은 8ms 감소했지만 전체 200ms가 같으므로 전체 작업의 세 배 개선이라고 쓸 수 없습니다. 입력·반복 수·빌드·측정 범위를 함께 적고, 최적화 전후의 행동 테스트가 같은 결과를 확인하는지도 판단합니다.

테스트/최적화 핵심 정리

React와 타입스크립트 프로젝트에서 테스팅은 코드의 신뢰성과 안정성을 보장하고, 성능 최적화는 사용자 경험을 좌우하는 핵심 요소입니다.

최적화 작업은 사용자 행동 테스트로 기준을 고정한 뒤, 측정된 병목만 좁게 바꾸고 다시 회귀 여부를 확인하는 반복 과정으로 다루는 편이 좋습니다.

기준 고정, 측정, 원인 선택, 최소 변경, 동일 조건 재검증, 근거 기록의 여섯 단계 루프와 memo/useCallback/useMemo 및 가상화의 적용 조건.

PERFORMANCE / MEASURE CHANGE VERIFY

같은 행동과 조건으로 측정·변경·재검증한다

대표 입력·기대 행동·환경을 고정하고 비용을 측정합니다. 실제 원인에 맞는 작은 변경 뒤 같은 조건에서 행동과 비용을 함께 확인합니다. 교재는 최적화 기법의 예이며 측정된 성능 향상을 제시하지 않습니다.

같은 행동과 조건으로 측정·변경·재검증한다기준 고정, 측정, 원인 선택, 최소 변경, 동일 조건 재검증, 근거 기록의 여섯 단계 루프와 memo/useCallback/useMemo 및 가상화의 적용 조건.1 기준 고정입력·행동·환경2 비용 측정Profiler·대표 시나리오3 원인 하나 선택실제 병목4 최소 변경원인에 대응5 동일 조건 재검증행동 + 비용6 근거 기록유지·되돌림·다음 반복
측정과 최소 변경 뒤 같은 기준으로 돌아간다기준 고정→비용 측정→실제 원인 하나→최소 변경→동일 조건의 행동과 비용 재검증→근거 기록 뒤 기준으로 돌아가는 원 여섯 노드·여섯 간선 순환이다. 렌더 횟수만으로 사용자 지연을 판단하지 않으며 실제성능 개선 측정치가 아니라 방법을 설명한다.1. 기준 고정입력 · 행동 · 환경2. 비용 측정Profiler · 대표 시나리오3. 원인 하나 선택실제 병목4. 최소 변경원인에 대응5. 동일 조건 재검증행동 + 비용6. 근거 기록유지 · 되돌림 · 반복

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 최적화 도구는 측정한 원인에 맞춰 사용합니다. 코드 예제의 로그나 정적 타입 선언만으로 성능 개선을 완료 판정하지 않습니다.

Jest와 React Testing Library를 사용해 사용자 관점에서 컴포넌트를 테스트하고, React.memo, useCallback, useMemo로 불필요한 리렌더링을 줄이는 것이 중요합니다.

대규모 리스트처럼 비용이 큰 렌더링 상황에서는 가상화 라이브러리 적용을 함께 고려해야 합니다.

타입스크립트는 이러한 테스팅 및 최적화 과정에서 발생하는 잠재적인 오류를 미리 방지하고, 코드의 품질과 개발자 생산성을 높여 결과적으로 더 견고하고 효율적인 React 애플리케이션을 구축하는 데 크게 기여합니다.

테스팅과 성능 최적화를 마무리할 때는 props와 state의 타입 계약, 렌더링 경계, 회귀 확인 지점을 함께 봅니다.

테스트가 사용자 행동을 설명하고 성능 최적화가 실제 병목을 겨냥하는지 확인해야 불필요한 memoization을 줄일 수 있습니다.