타입스크립트 기초 강좌(#1~#7)와 리액트 기초 강좌를 마치셨다면, 이제 그 둘이 만나야 할 때입니다. 이 시리즈는 자바스크립트로 짠 리액트 코드를 타입스크립트로 옮기면서 어떤 결정을 어떻게 내려야 하는지, 실전에서 자주 만나는 패턴을 6편에 걸쳐 정리합니다.
총 6편으로 구성됩니다.
- #1 시작과 셋업 ← 이번 글
- #2 props와 children 타이핑
- #3 hooks 타이핑 (useState/useReducer/useRef)
- #4 이벤트와 폼 타이핑
- #5 Context와 제네릭 컴포넌트
- #6 fetch와 API 응답 타이핑
이번 글에서는 왜 리액트에 타입스크립트를 쓰는지, 그리고 Vite로 React + TS 프로젝트를 만들어 첫 컴포넌트에 타입을 다는 곳까지 갑니다.
왜 리액트에 타입스크립트인가요?
리액트는 결국 컴포넌트 사이에 props로 데이터를 흘려보내는 일이 전부라고 해도 과언이 아닙니다. 컴포넌트가 늘어날수록 다음과 같은 질문이 끊임없이 등장합니다.
- 이 컴포넌트는 어떤 props를 받지?
- 이 props는 필수인가, 선택인가?
onClick은 어떤 인자를 받는 함수여야 하지?- 이 hook은 뭘 반환하지?
자바스크립트로 짤 때는 코드를 거슬러 올라가서 컴포넌트 본문을 직접 읽거나, 콘솔에 찍어보거나, 잘못 넘겼는지 화면에서 확인합니다. 작은 앱이면 괜찮지만 컴포넌트가 50개를 넘어가면 비용이 빠르게 누적돼요.
타입스크립트가 들어오면 이 질문 대부분이 에디터 자동완성과 빨간 줄로 바뀝니다.
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은 없습니다세 가지 흔한 실수가 모두 에디터에서 빨간 줄로 즉시 잡힙니다. 빌드도 막혀서 잘못된 코드가 사용자에게 도달하지 않아요.
타입스크립트가 React에 주는 것
크게 네 가지로 정리할 수 있습니다.
- 컴포넌트 계약(contract) — props가 어떤 모양인지 코드로 명시되고, 호출하는 쪽에서 바로 검증됩니다.
- 자동완성 —
event.target.value,useState반환 튜플, hook 결과 객체가 모두 추론되어 에디터가 자동완성해 줍니다. - 리팩터링 안전 — props 이름을 바꾸거나 필드를 추가/삭제하면, 이를 쓰는 모든 곳이 한 번에 빨간 줄로 표시됩니다.
- 문서가 필요 없는 코드 — 컴포넌트 시그니처만 봐도 어떻게 써야 하는지 알 수 있어, 별도 주석/문서 의존도가 줄어듭니다.
셋업 — Vite로 React + TS 시작하기
리액트 + 타입스크립트 환경을 가장 가볍게 만드는 방법은 Vite입니다. 다음 명령으로 5초 안에 프로젝트가 만들어집니다.
npm create vite@latest ts-react-playground -- --template react-ts
cd ts-react-playground
npm install
npm run dev--template react-ts가 핵심입니다. Vite가 알아서 다음을 세팅해 줍니다.
tsconfig.json,tsconfig.app.json,tsconfig.node.json- 리액트 19용
@types/react,@types/react-dom .tsx확장자와 strict 모드- ESLint + 타입스크립트 룰 한 벌
브라우저로 http://localhost:5173 을 열면 기본 카운터 페이지가 보일 겁니다.
프로젝트 구조 살펴보기
생성된 프로젝트의 핵심 파일은 다음과 같습니다.
├── 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를 포함하는 타입스크립트 파일은 .ts가 아니라 .tsx 입니다. JSX 문법을 인식하려면 컴파일러가 알아야 하기 때문에 확장자로 구분해요.
2) strict 모드 — Vite의 react-ts 템플릿은 기본으로 "strict": true가 켜져 있습니다. 이게 켜져 있어야 타입스크립트의 보호막이 의미 있게 작동해요. 처음엔 빨간 줄이 많아 답답할 수 있지만 절대 끄지 마세요.
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"— 리액트 17+ 새 JSX 변환을 사용합니다. 컴포넌트 파일 상단에import React from 'react'를 매번 쓰지 않아도 됩니다."strict": true—strictNullChecks,noImplicitAny등 타입 안전성의 핵심 플래그를 모두 켭니다.
첫 컴포넌트에 타입 달기
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은 없음세 가지 모두 에디터에서 즉시 빨간 줄이 그어지고, npm run build 도 막힙니다. 이게 우리가 시리즈 끝까지 누리게 될 가장 기본적인 이득입니다.
컴포넌트 반환 타입은 보통 추론에 맡깁니다
기초 강좌에서 함수에 반환 타입을 명시하라고 배웠지만, 리액트 함수 컴포넌트는 보통 명시하지 않습니다. 추론이 충분히 잘 되고, 명시하면 오히려 표현력이 떨어지는 경우가 많아요.
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
}리액트 19용 @types/react는 컴포넌트가 반환할 수 있는 모든 형태(엘리먼트, 문자열, null, fragment 등)를 알아서 추론합니다.
옛날 자료에서는
React.FC<Props>를 쓰라고 자주 권하지만, 요즘 커뮤니티는 그냥({ ... }: Props) => ...패턴을 쓰는 쪽으로 정착했습니다.FC는 children을 강제로 받게 만드는 등 잔불편이 있어서, 이 시리즈에서도 쓰지 않습니다.
자주 만나는 첫 인상 — 빨간 줄이 너무 많아요
자바스크립트에서 타입스크립트로 넘어오면 처음에는 빨간 줄과의 싸움처럼 느껴질 수 있습니다. 익숙해지기 전까지 기억하면 좋은 두 가지가 있어요.
1) 빨간 줄은 적이 아니라 동료입니다. 그것 하나하나가 "지금 이 코드대로면 런타임에 잡혔을 버그"를 미리 보여 주는 거예요. 처음 한두 주는 답답해도 시간이 지나면 "어, 빨간 줄이 안 떠? 잘못된 거 아닌가?" 쪽으로 감각이 바뀝니다.
2) any로 입막음은 마지막 수단입니다. 막히면 일단 any로 넘기고 싶은 유혹이 생기지만, 그 자리에는 보통 unknown 또는 더 좁은 타입이 더 어울립니다. any를 쓰면 그 자리부터 모든 자동완성과 리팩터 안전이 사라져요.
마무리
이번 글에서는 다음을 정리했습니다.
- 리액트에 타입스크립트를 쓰는 이유 — 컴포넌트 계약, 자동완성, 리팩터 안전
- Vite + react-ts 템플릿으로 환경 셋업
.tsx확장자와 strict 모드의 의미- 첫 컴포넌트에 props 타입 달기
- 반환 타입은 추론에 맡기는 게 자연스럽다
React.FC보다({ ... }: Props) => ...패턴
다음 글(#2 props와 children 타이핑)에서는 props 타이핑을 더 깊이 들어갑니다. 선택 prop, union prop, children 패턴, 그리고 합성 컴포넌트에서 타입을 어떻게 흘려보내는지까지 다룰게요.