타입스크립트 + React 실전 #1 시작과 셋업

14 분 소요

타입스크립트 기초 강좌(#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개를 넘어가면 비용이 빠르게 누적돼요.

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

자바스크립트 — 잘못 넘긴 props가 런타임에야 드러남
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에 주는 것

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

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

셋업 — 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 을 열면 기본 카운터 페이지가 보일 겁니다.

프로젝트 구조 살펴보기

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

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를 포함하는 타입스크립트 파일은 .ts가 아니라 .tsx 입니다. JSX 문법을 인식하려면 컴파일러가 알아야 하기 때문에 확장자로 구분해요.

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

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

첫 컴포넌트에 타입 달기

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은 없음

세 가지 모두 에디터에서 즉시 빨간 줄이 그어지고, 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 패턴, 그리고 합성 컴포넌트에서 타입을 어떻게 흘려보내는지까지 다룰게요.