목차
16 장

TypeScript + React 셋업

Vite + TypeScript 셋업, tsconfig 핵심 옵션, 첫 .tsx 파일까지. 이 책의 3부 토대를 만듭니다.

15장까지 2부가 마무리됐습니다. 1~2부에서 우리는 컴포넌트, props, state, 이벤트, 폼, useEffect, lifting state up, Context, 커스텀 훅, 성능, 라우팅을 모두 손에 익혔습니다. 본 챕터부터 3부가 시작됩니다. 지금까지 본 코드를 TypeScript 위에 다시 올리겠습니다.

이 책의 모든 예제는 사실 처음부터 TypeScript가 가능했고, 이 책이 표방하는 모델은 “TypeScript 우선"입니다. 1~2부에서 JavaScript로 시작한 것은 리액트의 핵심 개념에 먼저 집중하기 위해서였습니다. 이제 그 위에 TypeScript의 안전망을 입히면, 3부 이후 (Next.js · RSC · Server Actions · 풀스택)의 모든 코드가 자연스럽게 TypeScript 위에서 흐릅니다.

왜 리액트에 TypeScript를 쓰는가 #

리액트는 결국 컴포넌트 사이에 props로 데이터를 흘려보내는 일이 전부라고 해도 과언이 아닙니다. 컴포넌트가 늘어날수록 다음 같은 질문이 끊임없이 등장합니다.

  • 이 컴포넌트는 어떤 props를 받지?
  • 이 props는 필수인가, 선택인가?
  • onClick은 어떤 인자를 받는 함수여야 하지?
  • 이 hook은 뭘 반환하지?

JavaScript로 짤 때는 코드를 거슬러 올라가 컴포넌트 본문을 직접 읽거나, 콘솔에 찍어 보거나, 잘못 넘겼는지 화면에서 확인합니다. 작은 앱이면 괜찮지만 컴포넌트가 50개를 넘어가면 비용이 빠르게 누적됩니다.

TypeScript가 들어오면 이 질문 대부분이 에디터 자동완성과 빨간 줄로 바뀝니다.

JavaScript — 잘못 넘긴 props가 런타임에야 드러남
function UserCard({ name, age }) {
  return <div>{name} ({age})</div>;
}

// 부모 컴포넌트
<UserCard name="커티스" />              // age가 빠졌는데 그냥 렌더링
<UserCard name="커티스" age="서른" />   // 문자열인데 그냥 렌더링
<UserCard nme="커티스" age={30} />      // 오타 — undefined로 표시
TypeScript — 작성하는 순간 잡힘
type UserCardProps = {
  name: string;
  age: number;
};

function UserCard({ name, age }: UserCardProps) {
  return <div>{name} ({age})</div>;
}

<UserCard name="커티스" />              // ✗ age가 없습니다
<UserCard name="커티스" age="서른" />   // ✗ age는 number여야 합니다
<UserCard nme="커티스" age={30} />      // ✗ nme라는 prop은 없습니다

세 가지 흔한 실수가 모두 에디터에서 빨간 줄로 즉시 잡힙니다. 빌드도 막혀 잘못된 코드가 사용자에게 도달하지 않습니다.

TypeScript가 React에 주는 것 #

크게 네 가지로 정리할 수 있습니다.

  1. 컴포넌트 계약 (contract) — props가 어떤 모양인지 코드로 명시되고, 호출하는 쪽에서 바로 검증됩니다.
  2. 자동완성event.target.value, useState 반환 튜플, hook 결과 객체가 모두 추론되어 에디터가 자동완성해 줍니다.
  3. 리팩터링 안전 — props 이름을 바꾸거나 필드를 추가 / 삭제하면, 이를 쓰는 모든 곳이 한 번에 빨간 줄로 표시됩니다.
  4. 문서가 필요 없는 코드 — 컴포넌트 시그니처만 봐도 어떻게 써야 하는지 알 수 있어, 별도 주석 / 문서 의존도가 줄어듭니다.

셋업 — Vite로 React + TS 시작하기 #

리액트 + TypeScript 환경을 가장 가볍게 만드는 방법은 Vite입니다. 2장에서 깐 환경 위에서 --template react-ts 옵션 하나만 추가하면 됩니다.

새 프로젝트 만들기
pnpm create vite@latest ts-react-playground --template react-ts
cd ts-react-playground
pnpm install
pnpm dev

--template react-ts가 핵심입니다. Vite가 알아서 다음을 세팅해 줍니다.

  • tsconfig.json, tsconfig.app.json, tsconfig.node.json
  • React 19 용 @types/react, @types/react-dom
  • .tsx 확장자와 strict 모드
  • ESLint + TypeScript 룰 한 벌

브라우저로 http://localhost:5173을 열면 기본 카운터 페이지가 보입니다.

이 책의 1~2부 예제를 이미 만든 프로젝트가 있다면 그 위에 TypeScript를 점진적으로 도입할 수도 있습니다. 다만 새 프로젝트로 시작하는 쪽이 깔끔하므로, 본 챕터부터는 위 새 프로젝트에서 진행하는 것을 권장합니다.

프로젝트 구조 살펴보기 #

생성된 프로젝트의 핵심 파일은 다음과 같습니다.

ts-react-playground/
├── src/
│   ├── App.tsx          # 메인 컴포넌트 (.tsx 확장자에 주목)
│   ├── main.tsx         # 진입점
│   ├── App.css
│   └── vite-env.d.ts    # Vite 환경 변수 타입 선언
├── index.html
├── tsconfig.json
├── tsconfig.app.json    # 앱 코드용 컴파일 설정
├── tsconfig.node.json   # vite.config.ts용 설정
├── vite.config.ts
└── package.json

핵심은 두 가지입니다.

1) .tsx 확장자 — JSX를 포함하는 TypeScript 파일은 .ts가 아니라 **.tsx**입니다. JSX 문법을 인식하려면 컴파일러가 알아야 하기 때문에 확장자로 구분합니다.

2) strict 모드 — Vite의 react-ts 템플릿은 기본으로 "strict": true가 켜져 있습니다. 이게 켜져 있어야 TypeScript의 보호막이 의미 있게 작동합니다. 처음엔 빨간 줄이 많아 답답할 수 있지만 절대 끄지 마세요.

tsconfig.app.json을 한 번 열어 보면 다음과 같은 줄이 보입니다.

tsconfig.app.json (발췌)
{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "bundler"
  },
  "include": ["src"]
}

이 중 리액트 작업에서 가장 먼저 의미 있는 옵션은 두 가지입니다.

  • "jsx": "react-jsx" — React 17+ 새 JSX 변환을 씁니다. 컴포넌트 파일 상단에 import React from 'react'를 매번 쓰지 않아도 됩니다.
  • "strict": truestrictNullChecks, noImplicitAny 등 타입 안전성의 핵심 플래그를 모두 켭니다.

이 책의 모든 코드는 strict 모드를 가정합니다.

첫 컴포넌트에 타입 달기 #

src/App.tsx를 열고, 생성된 코드를 모두 지우고 다음으로 바꿔 봅시다.

src/App.tsx — Hello 컴포넌트
type HelloProps = {
  name: string;
};

function Hello({ name }: HelloProps) {
  return <h1>안녕하세요, {name}!</h1>;
}

function App() {
  return (
    <div>
      <Hello name="커티스" />
    </div>
  );
}

export default App;

저장하면 브라우저에 “안녕하세요, 커티스님!“이 뜹니다. 이번엔 일부러 잘못 호출해 봅시다.

잘못 쓰기 — 빨간 줄을 직접 보세요
<Hello />                          // ✗ name이 빠짐
<Hello name={42} />                // ✗ string이어야 하는데 number
<Hello name="커티스" age={30} />   // ✗ age라는 prop은 없음

세 가지 모두 에디터에서 즉시 빨간 줄이 그어지고, pnpm build도 막힙니다. 이게 이 책의 3부에서 누리게 될 가장 기본적인 이득입니다.

컴포넌트 반환 타입은 보통 추론에 맡깁니다 #

함수에 반환 타입을 명시하라고 자주 배우지만, 리액트 함수 컴포넌트는 보통 명시하지 않습니다. 추론이 충분히 잘 되고, 명시하면 오히려 표현력이 떨어지는 경우가 많습니다.

과한 명시 — 권장하지 않음
function Hello({ name }: HelloProps): React.ReactElement {
  return <h1>안녕하세요, {name}!</h1>;
}

React.ReactElement로 못 박으면 나중에 조건부로 null을 반환하거나 fragment를 돌려주려 할 때 타입이 안 맞아 또 손을 봐야 합니다. 추론에 맡기면 다음 모두를 자유롭게 반환할 수 있습니다.

추론을 신뢰 — 자연스럽게
function Hello({ name, hidden }: { name: string; hidden?: boolean }) {
  if (hidden) return null;              // OK
  return <h1>안녕하세요, {name}!</h1>; // OK
}

React 19 용 @types/react는 컴포넌트가 반환할 수 있는 모든 형태 (엘리먼트, 문자열, null, fragment 등)를 알아서 추론합니다.

노트
옛 자료에서는 React.FC<Props>를 쓰라고 자주 권합니다. 요즘 커뮤니티는 그냥 ({ ... }: Props) => ... 패턴을 쓰는 쪽으로 정착했습니다. FC는 children을 강제로 받게 만드는 등 잔불편이 있어, 이 책에서도 쓰지 않습니다. 부록 A (옛 리액트 마이그레이션)에서 FC → 함수 + props destructuring으로 옮기는 절차를 다룹니다.

자주 만나는 첫 인상 — 빨간 줄이 너무 많습니다 #

JavaScript에서 TypeScript로 넘어오면 처음에는 빨간 줄과의 싸움처럼 느껴질 수 있습니다. 익숙해지기 전까지 기억하면 좋은 두 가지가 있습니다.

1) 빨간 줄은 적이 아니라 동료입니다. 그것 하나하나가 “지금 이 코드대로면 런타임에 잡혔을 버그"를 미리 보여 주는 것입니다. 처음 한두 주는 답답해도 시간이 지나면 “어, 빨간 줄이 안 떠? 잘못된 거 아닌가?” 쪽으로 감각이 바뀝니다.

2) any로 입막음은 마지막 수단입니다. 막히면 일단 any로 넘기고 싶은 유혹이 생기지만, 그 부분에는 보통 unknown 또는 더 좁은 타입이 더 적합합니다. any를 쓰면 그 부분부터 모든 자동완성과 리팩터 안전이 사라집니다. 이 책은 예제 코드에서 any를 쓰지 않습니다.

직접 해보기 #

1~2부에서 만든 컴포넌트 몇 개를 TypeScript로 다시 짜 봅니다.

src/Counter.tsx:

src/Counter.tsx
import { useState } from 'react';

type CounterProps = {
  initial?: number;
};

function Counter({ initial = 0 }: CounterProps) {
  const [count, setCount] = useState(initial);

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>카운트: {count}</h2>
      <button onClick={() => setCount(prev => prev + 1)}>+1</button>
      <button onClick={() => setCount(prev => prev - 1)}>-1</button>
      <button onClick={() => setCount(initial)}>리셋</button>
    </div>
  );
}

export default Counter;

src/App.tsx:

src/App.tsx
import Counter from './Counter';

function App() {
  return (
    <>
      <h1>TypeScript 카운터</h1>
      <Counter />
      <Counter initial={10} />
    </>
  );
}

export default App;

저장하고 브라우저에서 동작하는지 확인합니다. 그 뒤 <Counter initial="10" />처럼 잘못된 타입을 줘 보세요. 에디터에 빨간 줄이 뜨고 pnpm build도 막힙니다.

연습문제 #

  1. Counterinitial prop을 필수로 바꾸고 (initial: number), 부모에서 호출 시 <Counter />로 빠뜨려 보세요. 빨간 줄이 어떻게 뜨는지 확인합니다.
  2. useState(0)의 추론된 타입을 에디터에서 확인해 보세요 (VS Code에서 변수에 마우스 호버). count: number, setCount: Dispatch<SetStateAction<number>>가 보입니다. 그 뒤 useState('hello')로 바꿔 보면 count: string으로 추론이 따라오는 것을 관찰합니다.
  3. Counter 컴포넌트 안에 일부러 (initial as any).toFixed(2) 같은 any 사용을 넣어 보세요. 자동완성과 리팩터 안전이 그 부분부터 어떻게 사라지는지 확인합니다. 그 뒤 unknown으로 바꿔 보면 컴파일러가 좁히기 (typeof initial === 'number')를 강제하는 것을 관찰합니다.

한 줄 요약: 이 책의 3부는 TypeScript 우선. Vite + react-ts 템플릿으로 시작하고, .tsx 확장자와 strict 모드를 기본으로 둔다. 첫 컴포넌트의 props는 type으로 정의하고, 반환 타입은 추론에 맡긴다. React.FC 대신 ({ ... }: Props) => ... 패턴을 쓴다. any는 마지막 수단이고, 빨간 줄은 적이 아니라 동료다.

다음 챕터 #

다음 17장 props와 children 타이핑에서는 props 타이핑을 더 깊이 들어가겠습니다. 선택 prop, union prop, ComponentProps<'button'>로 HTML 속성 받기, ReactNode vs ReactElement 차이, PropsWithChildren을 언제 쓰는지까지 한 번에 다루겠습니다.

X