고급

React 강좌 13강 — TypeScript로 React 전환하기: Props, State, 이벤트 타입

🎯 학습 목표

  • tsconfig.json의 핵심 설정과 strict 모드의 의미를 이해한다
  • Props 타입을 interface와 type으로 정의하고 차이를 설명한다
  • React 이벤트 타입을 올바르게 지정할 수 있다
  • 제네릭 컴포넌트를 작성할 수 있다
  • 유틸리티 타입(Partial, Required, Pick, Omit)을 실무에서 활용한다

📖 핵심 개념 1 — tsconfig.json 설정

Vite로 TypeScript React 프로젝트를 생성하면 자동으로 tsconfig.json이 만들어집니다.

// 설치
// npm create vite@latest my-app -- --template react-ts

// tsconfig.json 핵심 설정
{
  "compilerOptions": {
    "target": "ES2020",           // 출력 JS 버전
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",           // JSX 변환 방식

    // ✅ 반드시 활성화 — strict 모드 관련
    "strict": true,               // 아래 옵션들을 한 번에 활성화
    // strict: true 는 다음을 포함:
    // "noImplicitAny": true,     // any 타입 암묵적 사용 금지
    // "strictNullChecks": true,  // null/undefined 체크 강제
    // "strictFunctionTypes": true,
    // ... 기타 strict 옵션들

    "noUnusedLocals": true,       // 사용하지 않는 변수 에러
    "noUnusedParameters": true,   // 사용하지 않는 파라미터 에러
    "noFallthroughCasesInSwitch": true,

    "paths": {
      "@/*": ["./src/*"]          // 절대 경로 임포트 설정
    }
  }
}

패널에서 “strict: true를 처음부터 켜라. 나중에 켜려고 하면 수백 개의 에러를 한꺼번에 고쳐야 한다”고 강하게 권고했습니다. strict 모드는 null 체크 누락, 암묵적 any 등 런타임 버그의 상당수를 컴파일 타임에 잡아줍니다.

📖 핵심 개념 2 — Props 타입 정의

// interface vs type 선택 기준
// interface: 객체 형태 Props, 확장(extends)이 필요한 경우
// type: 유니온, 교차 타입이 필요한 경우

// ✅ interface 방식 — Props 정의에 일반적으로 권장
interface ButtonProps {
  children: React.ReactNode;       // JSX를 포함한 모든 렌더링 가능한 값
  variant?: 'primary' | 'secondary' | 'danger'; // 선택적 + 유니온
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  className?: string;
}

function Button({ children, variant = 'primary', size = 'medium', disabled = false, onClick, className }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size} ${className ?? ''}`}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

// ✅ type 방식 — 유니온이나 교차 타입 사용 시
type CardVariant = 'default' | 'outlined' | 'filled';
type CardSize = 'small' | 'medium' | 'large';

type CardProps = {
  title: string;
  description?: string;
  variant?: CardVariant;
  size?: CardSize;
} & React.HTMLAttributes<HTMLDivElement>; // HTML div 속성도 허용

// interface 확장
interface AdminButtonProps extends ButtonProps {
  requiredPermission: string;   // 추가 Props
  onPermissionDenied?: () => void;
}

📖 핵심 개념 3 — 이벤트 타입

import { useState, ChangeEvent, FormEvent, KeyboardEvent, MouseEvent } from 'react';

interface LoginFormProps {
  onSubmit: (email: string, password: string) => Promise<void>;
}

function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);

  // ChangeEvent<HTMLInputElement> — input 변경 이벤트
  function handleEmailChange(e: ChangeEvent<HTMLInputElement>) {
    setEmail(e.target.value);
  }

  // ChangeEvent<HTMLSelectElement> — select 변경 이벤트
  function handleRoleChange(e: ChangeEvent<HTMLSelectElement>) {
    console.log(e.target.value);
  }

  // FormEvent<HTMLFormElement> — 폼 제출 이벤트
  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    try {
      await onSubmit(email, password);
    } catch (err) {
      setError(err instanceof Error ? err.message : '알 수 없는 오류');
    }
  }

  // KeyboardEvent<HTMLInputElement> — 키 입력 이벤트
  function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.key === 'Enter') {
      console.log('엔터 입력');
    }
  }

  // MouseEvent<HTMLButtonElement> — 마우스 이벤트
  function handleButtonClick(e: MouseEvent<HTMLButtonElement>) {
    e.stopPropagation();
    console.log('버튼 클릭');
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <input
        type="email"
        value={email}
        onChange={handleEmailChange}
        onKeyDown={handleKeyDown}
        placeholder="이메일"
      />
      <input
        type="password"
        value={password}
        onChange={(e: ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
        placeholder="비밀번호"
      />
      <button type="submit" onClick={handleButtonClick}>로그인</button>
    </form>
  );
}

📖 핵심 개념 4 — 제네릭 컴포넌트

// 제네릭 컴포넌트 — 다양한 타입의 데이터를 처리하는 재사용 가능한 컴포넌트
interface SelectProps<T> {
  options: T[];
  value: T | null;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;  // 표시할 텍스트를 추출하는 함수
  getValue: (option: T) => string;  // 고유 키를 추출하는 함수
  placeholder?: string;
}

// TSX에서 제네릭 사용 시 <T,> 또는 <T extends object> 로 JSX 태그와 구분
function Select<T,>({ options, value, onChange, getLabel, getValue, placeholder }: SelectProps<T>) {
  return (
    <select
      value={value ? getValue(value) : ''}
      onChange={(e) => {
        const selected = options.find(opt => getValue(opt) === e.target.value);
        if (selected) onChange(selected);
      }}
    >
      {placeholder && <option value="">{placeholder}</option>}
      {options.map(opt => (
        <option key={getValue(opt)} value={getValue(opt)}>
          {getLabel(opt)}
        </option>
      ))}
    </select>
  );
}

// 사용 예시 — 다양한 타입과 함께 재사용
interface User { id: number; name: string; email: string; }
interface Category { code: string; label: string; }

function App() {
  const [selectedUser, setSelectedUser] = useState<User | null>(null);
  const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);

  const users: User[] = [
    { id: 1, name: '김개발', email: 'dev@example.com' },
    { id: 2, name: '박프론트', email: 'front@example.com' },
  ];

  return (
    <div>
      <Select
        options={users}
        value={selectedUser}
        onChange={setSelectedUser}
        getLabel={(u) => u.name}
        getValue={(u) => String(u.id)}
        placeholder="사용자 선택"
      />
    </div>
  );
}

📖 핵심 개념 5 — 유틸리티 타입

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
  role: 'admin' | 'user' | 'guest';
}

// Partial<T> — 모든 필드를 선택적으로 (업데이트 폼에 유용)
type UserUpdateInput = Partial<User>;
// = { id?: number; name?: string; email?: string; ... }

// Required<T> — 모든 필드를 필수로
type RequiredUser = Required<User>;

// Pick<T, K> — 특정 필드만 선택
type UserPreview = Pick<User, 'id' | 'name' | 'role'>;
// = { id: number; name: string; role: 'admin' | 'user' | 'guest' }

// Omit<T, K> — 특정 필드를 제외
type PublicUser = Omit<User, 'password' | 'createdAt'>;
// = { id: number; name: string; email: string; role: ... }

// 실무 사용 예시
function UserCard({ user }: { user: PublicUser }) {
  return <p>{user.name} ({user.email})</p>;
}

async function updateUser(id: number, updates: Partial<Omit<User, 'id' | 'createdAt'>>) {
  return fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(updates),
  });
}

// as const — 리터럴 타입으로 좁히기
const ROUTES = {
  HOME: '/',
  DASHBOARD: '/dashboard',
  PROFILE: '/profile',
} as const;

type Route = typeof ROUTES[keyof typeof ROUTES]; // '/' | '/dashboard' | '/profile'

// keyof — 객체 타입의 키를 유니온 타입으로
type UserKey = keyof User; // 'id' | 'name' | 'email' | 'password' | 'createdAt' | 'role'

function getUserField(user: User, field: keyof User) {
  return user[field]; // 타입 안전하게 동적 접근
}

⚠️ 흔한 실수 (よくあるミス)

  • any 남용: any는 TypeScript의 이점을 무력화합니다. 타입을 모를 때는 unknown을 사용하고 타입 가드로 좁히세요.
  • 타입 단언(as) 남용: data as User는 런타임 검증 없이 타입을 강제합니다. API 응답은 Zod 같은 라이브러리로 런타임 검증을 추가하는 것이 안전합니다.
  • React.FC 사용: const Button: React.FC<Props> 패턴은 children 타입 처리 등의 이유로 커뮤니티에서 권장하지 않습니다. 일반 함수 선언 방식을 사용하세요.
  • useRef 타입 초기값: useRef<HTMLInputElement>(null)처럼 초기값은 null이고 타입에 제네릭을 지정해야 합니다.

💡 실무 팁

  • Zod로 런타임 검증: TypeScript는 컴파일 타임에만 동작합니다. API 응답 등 외부 데이터는 Zod 스키마로 런타임에도 검증하면 타입 안전성이 완성됩니다.
  • 타입 파일 분리: src/types/ 폴더에 도메인별 타입을 모아두면 재사용과 유지보수가 쉬워집니다.
  • satisfies 연산자: TypeScript 4.9+에서 satisfies는 타입 추론은 유지하면서 타입 제약을 검증합니다. as const satisfies Record<string, string> 패턴이 유용합니다.