🎯 학습 목표
- 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>패턴이 유용합니다.