본문 바로가기

React 컴포넌트와 타입스크립트 통합

React 함수·클래스 컴포넌트의 props·state·이벤트 타입을 선언해 컴포넌트 사용 계약을 컴파일 단계에서 검증합니다.

React는 선언적이고 컴포넌트 기반의 UI 개발을 위한 인기 있는 자바스크립트 라이브러리입니다.

UI를 작은 독립적인 조각, 즉 컴포넌트로 나누어 개발하고 조합하여 애플리케이션을 구축하는 방식은 복잡한 UI를 효율적으로 관리할 수 있게 해줍니다.

타입스크립트를 React 프로젝트에 통합하는 것은 여러 가지 이점을 제공합니다.

특히 컴포넌트의 Props와 State에 대한 강력한 타입 검사는 개발 과정에서 발생할 수 있는 많은 오류를 미리 방지하고, 코드의 가독성 및 유지보수성을 크게 향상시킵니다.

이 절에서는 React 컴포넌트를 타입스크립트와 함께 사용하는 기본적인 방법과 이점을 살펴보겠습니다.


이 장은 컴포넌트 개념과 props 전달 및 타입스크립트의 interface·union·함수 타입을 바탕으로 합니다. 같은 JSX 코드에 타입을 붙이는 것과, 외부 입력을 런타임에 검증하는 것은 다른 일입니다. 이번 첫 예제는 부모가 직접 작성한 값의 사용 계약을 컴파일 단계에서 확인합니다.

Vite의 TypeScript 템플릿을 사용한다면 먼저 만들어진 tsconfig와 React 타입 정의를 그대로 사용하세요. 기존 프로젝트는 의존성 설치 외에도 JSX를 처리하는 컴파일 설정과 DOM 라이브러리가 필요합니다. JSX가 들어 있는 파일은 .tsx, JSX가 없는 타입·함수 파일은 .ts로 구분합니다. 아래 기존 설치 명령의 @types/jest는 Jest를 사용할 때의 타입 정의이며, 컴포넌트 예제만 실행하려고 Jest까지 준비할 필요는 없습니다. 프로젝트의 타입 검사 명령으로 오류를 확인해야 하며, 브라우저가 코드 변환 결과를 보여준다는 사실만으로 타입 검사가 통과한 것은 아닙니다.

프로젝트가 noUnusedLocals와 noUnusedParameters를 사용하면, 원 ClassBasedCounter의 React 기본 import와 InputField의 사용하지 않는 event 인수도 검사 대상입니다. 원 파일을 보존한 실행 복사본에서 클래스의 import 줄은 다음처럼 바꾸고, InputField의 클릭 인수 이름은 _event로 바꿉니다. 이벤트 타입 표기는 그대로 둡니다. 이 차이를 피하려고 프로젝트 전체의 미사용 검사를 끌 필요는 없습니다.

import { Component } from 'react';

이 설정 조건은 현재 Vite React TypeScript 템플릿에서 확인할 수 있습니다. JSX 변환, 타입 검사, 미사용 선언 검사는 서로 다른 검사입니다.

React 프로젝트에 타입스크립트 적용하기

새로운 React 프로젝트를 타입스크립트와 함께 시작하는 가장 일반적인 방법은 Vite의 TypeScript 템플릿을 사용하는 것입니다.

npm create vite@latest my-react-ts-app -- --template react-ts
cd my-react-ts-app
npm install
npm run dev

이 명령어는 타입스크립트 설정(tsconfig.json), React와 관련된 타입 정의(@types/react, @types/react-dom 등)를 포함하여 바로 사용할 수 있는 React 프로젝트를 생성해줍니다.

기존 프로젝트에 타입스크립트를 추가하는 경우, 필요한 의존성을 수동으로 설치하고 .js 파일을 .tsx 파일로 변경해야 합니다.

npm install --save-dev typescript @types/react @types/react-dom @types/jest

함수형 컴포넌트

React 16.8부터 도입된 Hooks는 함수형 컴포넌트에서도 상태(State)와 생명주기(Lifecycle) 기능을 사용할 수 있게 하여, 이제 대부분의 React 컴포넌트는 함수형으로 작성됩니다.

타입스크립트와 함께 함수형 컴포넌트를 정의하는 방법은 다음과 같습니다.

Props 타입 정의

함수형 컴포넌트의 Props는 함수 인자의 타입으로 정의됩니다.

보통 interface나 type 별칭을 사용하여 Props의 구조를 명확하게 선언합니다.

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

// 1. Props 타입을 인터페이스로 정의
interface WelcomeMessageProps {
  name: string;
  age?: number; // 선택적 Props
  isVisible: boolean;
  onButtonClick: (message: string) => void; // 함수 Props
}

// 2. React.FC (Function Component) 타입을 사용하여 컴포넌트 정의
//    @types/react 18 이후 FC는 children을 암묵적으로 추가하지 않습니다.
//    children을 받으려면 Props에 명시하거나 PropsWithChildren을 사용합니다.
const WelcomeMessage: React.FC<WelcomeMessageProps> = ({ name, age, isVisible, onButtonClick }) => {
  if (!isVisible) {
    return null;
  }

  const handleClick = () => {
    onButtonClick(`Hello, ${name}!`);
  };

  return (
    <div>
      <h1>Hello, {name}!</h1>
      {age && <p>You are {age} years old.</p>}
      <button onClick={handleClick}>Say Hello</button>
    </div>
  );
};

export default WelcomeMessage;

코드의 과거 버전 주석과 달리 @types/react 18 이후 React.FC는 children을 암묵적으로 추가하지 않습니다. 이는 “TypeScript 18”의 변경이 아니라 React 타입 정의의 변경입니다. 또한 age && ...는 age가 0이면 숫자 0을 렌더하므로, 0을 유효한 나이로 표시하려면 부재 여부를 명시적으로 검사해야 합니다.

React.FC 사용의 장단점
  • 장점: Props를 받는 함수 컴포넌트의 타입을 표현합니다. 허용되는 반환 타입과 정적 속성은 설치한 React 타입 정의의 버전에 따라 달라집니다.
  • 단점: React 18 이전에는 children Prop이 암묵적으로 포함되어 타입 추론을 방해할 수 있었습니다. React 18부터는 React.FC가 children을 더 이상 암묵적으로 포함하지 않으므로, children을 사용하려면 Props 타입에 명시적으로 추가해야 합니다.
    • 권장되는 방식: React.FC 대신 직접 함수 인자에 Props 타입을 명시하는 것이 더 명확하고 유연합니다.
src/components/WelcomeMessageV2.tsx
import React from 'react';

interface WelcomeMessageProps {
  name: string;
  age?: number;
  isVisible: boolean;
  onButtonClick: (message: string) => void;
  children?: React.ReactNode; // children을 사용하려면 명시적으로 추가
}

// React.FC를 사용하지 않고 직접 Props 타입 명시 (권장)
const WelcomeMessageV2 = ({ name, age, isVisible, onButtonClick, children }: WelcomeMessageProps) => {
  if (!isVisible) {
    return null;
  }

  const handleClick = () => {
    onButtonClick(`Hello, ${name}!`);
  };

  return (
    <div>
      <h1>Hello, {name}!</h1>
      {age && <p>You are {age} years old.</p>}
      <button onClick={handleClick}>Say Hello</button>
      {children && <p>{children}</p>}
    </div>
  );
};

export default WelcomeMessageV2;

State 타입 정의 (useState)

useState 훅을 사용할 때 타입스크립트는 초기값을 통해 State의 타입을 추론합니다.

명시적으로 타입을 지정해줄 수도 있습니다.

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

const Counter: React.FC = () => {
  // 1. 타입 추론에 의존: 초기값이 number이므로 count는 number 타입으로 추론됩니다.
  const [count, setCount] = useState(0);

  // 2. 명시적으로 타입 지정: 초기값이 null이거나 복잡한 객체일 때 유용합니다.
  interface User {
    id: number;
    name: string;
  }
  const [user, setUser] = useState<User | null>(null); // user는 User 또는 null 타입

  const increment = () => {
    setCount(prevCount => prevCount + 1);
  };

  const decrement = () => {
    setCount(prevCount => prevCount - 1);
  };

  const fetchUser = () => {
    // 비동기 작업 시뮬레이션
    setTimeout(() => {
      setUser({ id: 1, name: 'Alice' });
    }, 1000);
  };

  return (
    <div>
      <h2>Counter: {count}</h2>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>

      <h3>User Info:</h3>
      <button onClick={fetchUser}>Fetch User</button>
      {user ? (
        <p>User ID: {user.id}, Name: {user.name}</p>
      ) : (
        <p>No user data.</p>
      )}
    </div>
  );
};

export default Counter;

이벤트 핸들러 타입

HTML 엘리먼트의 이벤트 핸들러는 React가 제공하는 특정 이벤트 타입(React.MouseEvent, React.ChangeEvent 등)을 사용합니다.

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

const InputField: React.FC = () => {
  const [value, setValue] = useState('');

  // ChangeEvent<HTMLInputElement> 타입 명시
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
  };

  // MouseEvent<HTMLButtonElement> 타입 명시
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    alert(`Current value: ${value}`);
  };

  return (
    <div>
      <input type="text" value={value} onChange={handleChange} placeholder="Type something..." />
      <button onClick={handleClick}>Show Value</button>
      <p>Input: {value}</p>
    </div>
  );
};

export default InputField;

클래스형 컴포넌트

클래스형 컴포넌트도 여전히 사용할 수 있으며, Props와 State 타입을 제네릭으로 명시하여 정의합니다.

src/components/ClassBasedCounter.tsx

import React, { Component } from 'react';

// Props 타입 정의
interface ClassBasedCounterProps {
  initialCount?: number;
  title: string;
}

// State 타입 정의
interface ClassBasedCounterState {
  count: number;
}

// Component<Props, State> 제네릭을 사용하여 타입 명시
class ClassBasedCounter extends Component<ClassBasedCounterProps, ClassBasedCounterState> {
  // defaultProps는 static 속성으로 정의하여 선택적 Props의 기본값을 제공
  static defaultProps = {
    initialCount: 0,
  };

  constructor(props: ClassBasedCounterProps) {
    super(props);
    this.state = {
      count: props.initialCount || 0, // defaultProps를 사용하더라도, constructor에서는 직접 접근해야 합니다.
    };
  }

  // 메서드도 타입을 명시할 수 있습니다.
  private increment = (): void => {
    this.setState(prevState => ({ count: prevState.count + 1 }));
  };

  private decrement = (): void => {
    this.setState(prevState => ({ count: prevState.count - 1 }));
  };

  render() {
    return (
      <div>
        <h2>{this.props.title}</h2>
        <h3>Count: {this.state.count}</h3>
        <button onClick={this.increment}>Increment</button>
        <button onClick={this.decrement}>Decrement</button>
      </div>
    );
  }
}

export default ClassBasedCounter;

컴포넌트 사용 예시

위에서 정의한 컴포넌트들을 메인 애플리케이션 파일에서 사용하는 방법입니다.

타입스크립트는 Props를 전달할 때 정의된 타입과 일치하는지 자동으로 검사해줍니다.

src/App.tsx
import React from 'react';
import WelcomeMessage from './components/WelcomeMessage';
import Counter from './components/Counter';
import ClassBasedCounter from './components/ClassBasedCounter';
import WelcomeMessageV2 from './components/WelcomeMessageV2'; // React.FC 대신 함수 인자에 Props 타입 직접 명시 버전

const App: React.FC = () => {
  const handleWelcomeButtonClick = (message: string) => {
    alert(`Button clicked! Message: ${message}`);
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>
      <h1>React with TypeScript Examples</h1>

      <hr />
      <h2>Welcome Message Component (React.FC)</h2>
      {/* 필수 Props 'name'과 'isVisible'을 제공해야 합니다. */}
      <WelcomeMessage
        name="TypeScript User"
        age={25}
        isVisible={true}
        onButtonClick={handleWelcomeButtonClick}
      />
      {/*
      // 컴파일 에러 예시: 필수 Props 누락
      <WelcomeMessage name="Guest" />
      // Property 'isVisible' is missing in type '{ name: string; }'
      // but required in type 'WelcomeMessageProps'.
      */}
      {/*
      // 컴파일 에러 예시: 잘못된 Props 타입
      <WelcomeMessage name={123} isVisible={true} onButtonClick={() => {}} />
      // Type 'number' is not assignable to type 'string'.
      */}

      <hr />
      <h2>Welcome Message Component (Function Component with explicit Props)</h2>
      <WelcomeMessageV2
        name="Explicit Type User"
        isVisible={true}
        onButtonClick={handleWelcomeButtonClick}
      >
        This is a children prop passed to WelcomeMessageV2.
      </WelcomeMessageV2>

      <hr />
      <h2>Counter Component (Functional)</h2>
      <Counter />

      <hr />
      <h2>Class-based Counter Component</h2>
      <ClassBasedCounter
        title="Class Counter Example"
        initialCount={100}
      />
    </div>
  );
};

export default App;

컴포넌트를 실제로 조합할 때 TypeScript는 부모가 넘기는 Props, 컴포넌트 내부 State, 이벤트 핸들러, 렌더링 결과의 계약을 차례로 확인합니다.


예시

파일 제목에 적힌 경로대로 각 컴포넌트를 별도 파일로 저장하고 마지막 App.tsx로 조합합니다. WelcomeMessage에는 name, isVisible, onButtonClick 세 필수 prop이 필요하고 age는 선택입니다. isVisible이 false면 null을 반환하므로 버튼도 없어져 클릭할 대상이 없습니다. true인 원 App의 첫 환영 영역은 TypeScript User와 나이 25를 보여줍니다. Say Hello를 누르면 자식은 Hello, TypeScript User! 문자열만 부모에 넘기고, 부모는 Button clicked! Message: Hello, TypeScript User!를 안내합니다. DOM 이벤트 객체를 부모에 보내는 API가 아닙니다.

원 Counter는 처음 count 0과 No user data.를 보여줍니다. Increment 두 번 뒤 Decrement 한 번을 누르면 count는 1입니다. Fetch User는 실제 서버 요청이 아니라 타이머로 Alice를 넣는 모형이며, 완료 뒤 User ID: 1, Name: Alice가 보입니다. 요청 실패·취소·진행 상태를 이미 처리한 컴포넌트로 해석하지 않습니다. InputField도 따로 확인하려면 App에서 import하고 배치하세요. TS라고 입력한 뒤 Show Value를 누르면 Current value: TS가 안내되어야 합니다.

0이라는 값과 부재를 구분하기

원 코드를 보존한 복사본에서 WelcomeMessage의 나이에 0을 전달한다고 가정합니다. 타입은 number이므로 허용되지만 age && 표현식의 결과는 0입니다. 0살을 유효한 나이 문장으로 보여주려면 WelcomeMessage와 WelcomeMessageV2의 해당 JSX 줄을 복사본에서 다음처럼 부재 검사로 바꿉니다. 이름과 다른 props는 그대로 둡니다.

{age !== undefined && <p>You are {age} years old.</p>}

이 조건은 0을 포함한 number를 통과시키고, age를 생략한 undefined만 제외합니다. 숫자 타입이 나이의 허용 범위까지 검증한다는 뜻은 아니므로 외부의 음수·NaN·무한 값 등을 받는 실제 서비스에서는 도메인 검증을 별도로 설계합니다. WelcomeMessageV2의 children && 표현식도 children이 숫자 0인 경우 같은 값과 부재 구분이 필요하며, 모든 ReactNode가 참인 값이라고 가정하지 않습니다.

새 props로 독립적으로 판단하기

name은 Guest, isVisible은 true, age는 생략하고 콜백만 올바르게 전달한 경우 화면의 나이와 버튼 클릭 메시지를 예상하세요. 콜백을 빼거나 name에 숫자 123을 넘기면 어느 계약과 충돌하는지도 설명합니다. isVisible을 false로 바꾸면 화면과 클릭 가능성은 어떻게 달라지나요?

ClassBasedCounter를 initialCount 10으로 새로 마운트한 뒤 Increment 두 번, Decrement 한 번을 실행합니다. 최종 state는 얼마인가요? 그 상태에서 부모가 initialCount prop만 100으로 바꾸는 동작과, 컴포넌트를 새로 마운트하는 동작을 구분하세요. 원 클래스는 constructor에서 최초 state를 정한다는 조건을 사용합니다.

타입 계약과 실행 값을 나눈 풀이

Guest는 허용되는 문자열이고 age는 선택이므로 생략할 수 있습니다. 나이 문장은 없고, 버튼 클릭은 Hello, Guest!를 콜백에 전달합니다. onButtonClick은 필수 함수여서 누락하면 타입 계약에 맞지 않고, name의 숫자 123도 string 계약과 맞지 않습니다. isVisible이 false면 null을 반환하므로 환영 영역과 그 버튼이 없어집니다. 단순히 비활성 버튼을 렌더링한 상태와 다릅니다.

클래스 state는 10+2−1=11입니다. 이미 마운트된 컴포넌트에서 initialCount prop이 바뀌어도 constructor를 다시 실행해 state를 정하지 않으므로 원 코드가 자동으로 100으로 리셋하는 것은 아닙니다. 새로 마운트하면 새 initialCount로 초기화합니다. props를 현재 값으로 사용할지 최초 값으로 사용할지는 컴포넌트의 동작 계약이므로, 타입을 붙였다는 이유로 둘이 같아지지는 않습니다.

기본 설정·Props·Hooks·이벤트 타입의 조건은 React의 TypeScript 공식 안내를 확인할 수 있습니다. 설치한 타입 정의의 버전과 실제 검사 결과를 함께 기록하세요.

타입스크립트와 React 컴포넌트 통합의 이점

개발 단계에서의 오류 감지: Props를 잘못 전달하거나, State를 예상치 못한 타입으로 설정하는 등의 실수를 컴파일 단계에서 즉시 잡아낼 수 있습니다.

이는 런타임 오류로 이어질 수 있는 문제를 조기에 방지합니다.

강력한 자동 완성 및 리팩터링 지원: IDE는 Props와 State의 타입을 알고 있으므로, 컴포넌트 사용 시 자동 완성 기능을 제공하고, 속성 이름 변경 시 관련된 모든 코드에 대한 안전한 리팩터링을 지원합니다.

코드 가독성 및 의도 명확화: 컴포넌트가 어떤 Props를 기대하고, 어떤 타입의 값을 다루는지 명확하게 문서화됩니다.

이는 다른 개발자가 코드를 이해하고 사용하기 쉽게 만듭니다.

유지보수성 향상: 시간이 지남에 따라 컴포넌트의 요구사항이 변경될 때, 타입 정의를 업데이트함으로써 관련 코드의 모든 불일치를 빠르게 파악하고 수정할 수 있습니다.

협업 효율 증대: 팀원 간의 인터페이스 계약을 명확히 하여, 컴포넌트 간의 의존성을 줄이고 협업 시 발생할 수 있는 혼란을 최소화합니다.

React와 타입스크립트의 조합은 현대 웹 개발에서 매우 강력한 시너지를 발휘하며, 대규모 애플리케이션을 개발할 때 특히 그 가치가 빛을 발합니다.

컴포넌트 타입은 한 번 정의하고 끝나는 문서가 아니라, Props 변경, State 분리, 이벤트 흐름이 바뀔 때마다 함께 갱신해야 하는 계약입니다.

그림의 흐름은 WelcomeMessage가 Props로 화면을 만들고 클릭 메시지를 부모 callback에 전달하는 실제 예제입니다. 보조 카드의 판별 상태와 도메인 ID는 설계 확장입니다. 원문은 User | null과 event.target.value를 사용하며 loading/error/ready 상태나 UserId 검증까지 구현한 것은 아닙니다. 타입 주석만으로 외부 데이터가 런타임에 검증되지는 않습니다.

WelcomeMessage의 부모 Props에서 렌더 결과까지, 클릭에서 메시지 callback까지의 흐름과 상태·이벤트 타입 경계의 확장.

REACT COMPONENT / TYPED BOUNDARY

Props는 입력 계약, callback은 컴포넌트가 알리는 값이다

교재 WelcomeMessage는 부모의 name·isVisible·callback을 받아 화면을 만들고, 클릭하면 메시지 문자열을 부모에 알립니다. State union과 UserId 변환은 이 경계를 확장하는 별도 설계입니다.

Props는 입력 계약, callback은 컴포넌트가 알리는 값이다WelcomeMessage의 부모 Props에서 렌더 결과까지, 클릭에서 메시지 callback까지의 흐름과 상태·이벤트 타입 경계의 확장.부모 Propsname · isVisibleWelcomeMessageProps 계약JSX 또는 null화면 결과버튼 클릭MouseEventhandleClick메시지 생성부모 callback문자열 전달아래 행: 오른쪽 → 왼쪽으로 알림 전달
Props 입력과 클릭 메시지 전달의 실제 경계원 여섯 노드·다섯 간선을 세로로 재배치한다. 부모 Props→WelcomeMessage→JSX 또는 null, 표시된 버튼을 클릭한 경우 MouseEvent→handleClick→부모 callback 문자열 전달이다. null을 반환한 화면에서 버튼을 클릭할 수 있다는 뜻이 아니다. State union/UserId는 원 지원 설명의 별도 확장이다.
부모 Propsname · isVisible
연결 관계부모 Props · name · isVisible → WelcomeMessage · Props 계약
WelcomeMessageProps 계약
연결 관계WelcomeMessage · Props 계약 → JSX 또는 null · 화면 결과
JSX 또는 null화면 결과
연결 관계JSX 또는 null · 화면 결과 → 보이는 버튼 클릭 · MouseEvent
보이는 버튼 클릭MouseEvent
연결 관계보이는 버튼 클릭 · MouseEvent → handleClick · 메시지 생성
handleClick메시지 생성
연결 관계handleClick · 메시지 생성 → 부모 callback · 문자열 전달
부모 callback문자열 전달

INPUT

필수·선택·children을 선언

name·isVisible·onButtonClick은 필수이며 age는 선택입니다. isVisible이 false면 null을 반환하고 true면 JSX를 만듭니다. children을 사용하면 Props에 ReactNode 등을 명시합니다. 설치한 React 타입 정의의 계약을 확인합니다.

LOCAL STATE

렌더링과 State의 관계

본문 Counter는 number와 User | null을 사용합니다. loading·error·ready를 tag가 있는 union으로 묶고 모든 분기를 처리하는 방식은 다음 절로 이어지는 설계 확장입니다.

DOM TO DOMAIN

이벤트 값에 의미를 부여

본문 InputField는 event.target.value 문자열을 지역 상태에 저장합니다. 확장 설계에서는 currentTarget.value를 읽고 도메인 규칙을 검증한 뒤 UserId 등으로 변환해 callback에 전달할 수 있습니다.

RUNTIME

타입 선언과 실제 동작

타입 주석은 외부 데이터를 검증하지 않습니다. 부모 callback 예제는 alert를 실행하며 상태 갱신을 구현하지 않습니다. age && ...는 age가 0일 때 숫자 0을 렌더합니다.

그림의 위·아래 행은 실제 WelcomeMessage 예입니다. 판별 상태와 UserId는 설계 확장이며, DOM 이벤트 객체를 부모에 넘기는 방식도 API 목적에 따라 선택합니다.