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가 들어오면 이 질문 대부분이 에디터 자동완성과 빨간 줄로 바뀝니다.
function UserCard({ name, age }) {
return <div>{name} ({age}세)</div>;
}
// 부모 컴포넌트
<UserCard name="커티스" /> // age가 빠졌는데 그냥 렌더링
<UserCard name="커티스" age="서른" /> // 문자열인데 그냥 렌더링
<UserCard nme="커티스" age={30} /> // 오타 — undefined로 표시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에 주는 것 #
크게 네 가지로 정리할 수 있습니다.
- 컴포넌트 계약 (contract) — props가 어떤 모양인지 코드로 명시되고, 호출하는 쪽에서 바로 검증됩니다.
- 자동완성 —
event.target.value,useState반환 튜플, hook 결과 객체가 모두 추론되어 에디터가 자동완성해 줍니다. - 리팩터링 안전 — props 이름을 바꾸거나 필드를 추가 / 삭제하면, 이를 쓰는 모든 곳이 한 번에 빨간 줄로 표시됩니다.
- 문서가 필요 없는 코드 — 컴포넌트 시그니처만 봐도 어떻게 써야 하는지 알 수 있어, 별도 주석 / 문서 의존도가 줄어듭니다.
셋업 — 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를 점진적으로 도입할 수도 있습니다. 다만 새 프로젝트로 시작하는 쪽이 깔끔하므로, 본 챕터부터는 위 새 프로젝트에서 진행하는 것을 권장합니다.
프로젝트 구조 살펴보기 #
생성된 프로젝트의 핵심 파일은 다음과 같습니다.
├── 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을 한 번 열어 보면 다음과 같은 줄이 보입니다.
{
"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": true—strictNullChecks,noImplicitAny등 타입 안전성의 핵심 플래그를 모두 켭니다.
이 책의 모든 코드는 strict 모드를 가정합니다.
첫 컴포넌트에 타입 달기 #
src/App.tsx를 열고, 생성된 코드를 모두 지우고 다음으로 바꿔 봅시다.
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:
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:
import Counter from './Counter';
function App() {
return (
<>
<h1>TypeScript 카운터</h1>
<Counter />
<Counter initial={10} />
</>
);
}
export default App;저장하고 브라우저에서 동작하는지 확인합니다. 그 뒤 <Counter initial="10" />처럼 잘못된 타입을 줘 보세요. 에디터에 빨간 줄이 뜨고 pnpm build도 막힙니다.
연습문제 #
Counter의initialprop을 필수로 바꾸고 (initial: number), 부모에서 호출 시<Counter />로 빠뜨려 보세요. 빨간 줄이 어떻게 뜨는지 확인합니다.useState(0)의 추론된 타입을 에디터에서 확인해 보세요 (VS Code에서 변수에 마우스 호버).count: number,setCount: Dispatch<SetStateAction<number>>가 보입니다. 그 뒤useState('hello')로 바꿔 보면count: string으로 추론이 따라오는 것을 관찰합니다.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을 언제 쓰는지까지 한 번에 다루겠습니다.