읽기 진행률:0%

TypeScript 안전한 프론트엔드 개발 가이드

왜 TypeScript를 제대로 사용해야 할까?

TypeScript는 단순히 타입 힌트를 추가하는 것이 아닙니다. 런타임 안전성을 확보하고 코드의 유지보수성을 높이려면 타입 가드, 유니온 타입, 제네릭 등을 적절히 활용해야 합니다.

// ❌ 이런 코드는 안전하지 않습니다
function getUser(data: any): User {
  return data
}

타입 가드로 런타임 안전성 확보

사용자 정의 타입 가드

interface User {
  id: number
  name: string
  email: string
}

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    typeof obj.id === 'number' &&
    'name' in obj &&
    typeof obj.name === 'string' &&
    'email' in obj &&
    typeof obj.email === 'string'
  )
}

// 사용
const data = await fetchData()
if (isUser(data)) {
  console.log(data.name) // 안전하게 접근
}

API 응답 검증

interface ApiResponse<T> {
  data: T
  status: number
}

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  const json = await response.json()

  if (!isUser(json.data)) {
    throw new Error('Invalid user data')
  }

  return json.data
}

Union 타입과 판별자

판별 유니온 (Discriminated Unions)

interface SuccessResponse {
  type: 'success'
  data: User[]
}

interface ErrorResponse {
  type: 'error'
  message: string
  code: number
}

type ApiResult = SuccessResponse | ErrorResponse

function handleResponse(result: ApiResult) {
  if (result.type === 'success') {
    console.log(result.data) // User[] 타입
  } else {
    console.error(result.message) // string 타입
  }
}

제네릭 활용

기본 제네릭

function wrapInArray<T>(value: T): T[] {
  return [value]
}

const numbers = wrapInArray(1) // number[]
const strings = wrapInArray('hello') // string[]

제약이 있는 제네릭

interface HasId {
  id: number
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id)
}

제네릭 React 컴포넌트

interface ListProps<T> {
  items: T[]
  renderItem: (item: T) => React.ReactNode
}

function List<T>({ items, renderItem }: ListProps<T>) {
  return <ul>{items.map(renderItem)}</ul>
}

// 사용
<List items={users} renderItem={user => <li>{user.name}</li>} />

React Props 타입 정의

기본 Props

interface ButtonProps {
  label: string
  onClick: () => void
  disabled?: boolean
  variant?: 'primary' | 'secondary'
}

function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled} className={variant}>
      {label}
    </button>
  )
}

Children Props

interface CardProps {
  title: string
  children: React.ReactNode
}

function Card({ title, children }: CardProps) {
  return (
    <div>
      <h2>{title}</h2>
      {children}
    </div>
  )
}

이벤트 핸들러

interface FormProps {
  onSubmit: (data: FormData) => void
}

function Form({ onSubmit }: FormProps) {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    onSubmit(formData)
  }

  return <form onSubmit={handleSubmit}>{/* ... */}</form>
}

유틸리티 타입

Partial과 Required

interface User {
  id: number
  name: string
  email: string
}

type PartialUser = Partial<User> // 모든 속성 optional
type RequiredUser = Required<User> // 모든 속성 required

Pick과 Omit

type UserPreview = Pick<User, 'id' | 'name'> // id, name만
type UserWithoutId = Omit<User, 'id'> // id 제외

Record

type UserRole = 'admin' | 'user' | 'guest'
type Permissions = Record<UserRole, string[]>

const permissions: Permissions = {
  admin: ['read', 'write', 'delete'],
  user: ['read', 'write'],
  guest: ['read'],
}

실무 패턴

API 클라이언트

class ApiClient {
  async get<T>(url: string): Promise<T> {
    const response = await fetch(url)
    if (!response.ok) {
      throw new Error(`API Error: ${response.status}`)
    }
    return response.json()
  }

  async post<T, D>(url: string, data: D): Promise<T> {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    })
    return response.json()
  }
}

// 사용
const client = new ApiClient()
const user = await client.get<User>('/api/user')

Custom Hooks

interface UseApiResult<T> {
  data: T | null
  loading: boolean
  error: Error | null
}

function useApi<T>(url: string): UseApiResult<T> {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [url])

  return { data, loading, error }
}

엄격한 설정

tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true
  }
}

자주 하는 실수

1. any 남용

// ❌ 나쁜 예
function process(data: any) {
  return data.value
}

// ✅ 좋은 예
function process(data: unknown) {
  if (typeof data === 'object' && data !== null && 'value' in data) {
    return data.value
  }
  throw new Error('Invalid data')
}

2. 타입 단언 남용

// ❌ 나쁜 예
const user = data as User

// ✅ 좋은 예
if (isUser(data)) {
  const user = data
}

3. Optional 체이닝 과용

// ❌ 나쁜 예
user?.profile?.settings?.theme?.color

// ✅ 좋은 예 - 타입을 명확히
interface User {
  profile: {
    settings: {
      theme: {
        color: string
      }
    }
  }
}

댓글