React + TypeScript 설계 가이드
시작은 간단하게 가져간다
요즘은 Vite 템플릿으로 React + TypeScript 프로젝트를 빠르게 만들 수 있습니다.
npm create vite@latest my-app -- --template react-ts
초기 단계에서 중요한 것은 설정 자체보다 팀이 어떤 타입 규칙을 공유할지입니다. strict를 켜고, any 사용을 최소화하고, 도메인 모델과 UI 모델을 구분하는 것만으로도 품질이 크게 달라집니다.
Props 타입은 컴포넌트 계약이다
Props 타입은 재사용성을 높이는 문서 역할을 합니다. 꼭 필요한 값과 선택 가능한 값을 분명히 나누고, 의미가 드러나는 이름을 쓰는 것이 좋습니다.
interface ButtonProps {
variant?: 'primary' | 'secondary'
disabled?: boolean
onClick?: () => void
children: React.ReactNode
}
function Button({ variant = 'primary', disabled = false, onClick, children }: ButtonProps) {
return (
<button className={variant} disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
상태 모델링은 문자열보다 유니온이 강하다
로딩, 성공, 실패 같은 상태를 여러 boolean으로 관리하면 조합 오류가 생기기 쉽습니다. TypeScript에서는 분기 가능한 상태를 유니온으로 표현하는 편이 더 안전합니다.
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string }
이런 구조는 렌더링 분기와도 잘 맞고, “불가능한 상태”를 줄여줍니다.
이벤트 타입은 필요한 곳에서만 명시한다
입문 단계에서는 모든 이벤트 타입을 직접 적으려 하지만, 실무에서는 IDE 추론을 적극 활용하는 편이 좋습니다. 다만 외부로 추출되거나 재사용되는 핸들러는 명시적인 타입이 읽기 좋습니다.
제네릭은 진짜 재사용 경계에서만
TypeScript를 쓰면 제네릭 컴포넌트를 만들고 싶어지지만, 너무 이르게 일반화하면 오히려 사용성이 떨어집니다. 두세 번 이상 반복되는 패턴에서만 제네릭을 도입하는 편이 낫습니다.
자주 하는 실수
가장 흔한 실수는 백엔드 DTO 타입을 UI 컴포넌트에 그대로 퍼뜨리는 것입니다. API 응답 구조가 바뀌면 화면이 전부 흔들리기 때문입니다. 또 다른 실수는 React.FC에 과도하게 의존하거나, any로 급하게 우회해 타입 시스템 신뢰를 떨어뜨리는 경우입니다.
마무리
React + TypeScript의 핵심은 복잡한 타입 기교가 아니라 명확한 계약입니다. Props, 상태, 이벤트, 데이터 모델을 적절히 분리하면 타입은 코드를 어렵게 만드는 장치가 아니라 변경에 강한 인터페이스가 됩니다.
운영 환경에서 어려워지는 지점
- API DTO, 폼 모델, 화면 props가 하나의 인터페이스로 섞이기 시작하면 타입 경계가 빠르게 무너진다.
any를 임시 탈출구가 아니라 상시 지름길로 쓰기 시작하면 컴파일 안정성은 금방 약해진다.- 공용 유틸리티 타입이 비즈니스 의미보다 추상화 기교를 숨기는 방향으로 커지면 유지보수 비용이 커진다.
중요한 아키텍처 결정
- 초기에
strict,noUncheckedIndexedAccess, 경로 alias 규칙을 켜서 나쁜 패턴이 기본값이 되지 않게 한다. - 도메인 타입, 서버 응답 타입, UI 상태 타입을 분리하고 하나의 만능 모델로 억지 통합하지 않는다.
- 모든 화면이 가져다 쓰는 거대한
types.ts보다 명확한 모듈 경계를 우선한다.
실무 예시
안정적인 방식은 전송 계층 타입을 경계에서 UI 친화적 모델로 변환하는 것이다.
type UserResponse = {
id: string
full_name: string
last_login_at: string | null
}
type UserCardModel = {
id: string
displayName: string
isDormant: boolean
}
function toUserCardModel(user: UserResponse): UserCardModel {
return {
id: user.id,
displayName: user.full_name,
isDormant: user.last_login_at === null,
}
}
피해야 할 안티패턴
- 백엔드 응답 구조를 그대로 재사용 컴포넌트에 노출하는 것.
- 반복이 충분히 확인되기 전에 제네릭 헬퍼부터 만드는 것.
- 모델링 문제를 해결하지 않고 단언문으로 경고를 덮어버리는 것.
운영 체크리스트
tsconfig변경은 포맷팅이 아니라 아키텍처 결정으로 리뷰한다.- ESLint 규칙과 TypeScript strict 수준을 함께 맞춘다.
- 훅, context 값, 컴포넌트 props 같은 공개 경계에는 명시적 타입을 요구한다.
- 타입 그래프가 커질수록 증분 빌드 시간을 함께 본다.
최종 판단
React와 TypeScript 조합이 강해지는 지점은 타입이 실제 경계를 설명할 때다. 구현 세부사항만 장황하게 따라가면 복잡도만 늘고 안전성은 남지 않는다.
Continue Reading
다음으로 읽기 좋은 글
스트리밍 UI의 실패 복구 패턴
부분 렌더링, 서버 스트리밍, AI 응답 UI에서 중간 실패를 사용자 경험으로 흡수하는 설계 방법을 정리합니다.
🖥️ FrontendOptimistic UI의 Reconciliation 경계
낙관적 UI는 빠르게 느껴지지만, 서버 진실과 충돌하는 순간 복잡성이 드러납니다. 어디까지 낙관적으로 처리할지 경계를 정리합니다.
📈 최신 동향React Foundation 이 엔지니어링 팀에 의미하는 것
React Foundation 소식이 단순 거버넌스 뉴스가 아니라 프레임워크 생태계의 장기 예측 가능성에 어떤 의미를 갖는지 정리한 글입니다.
💬 LanguageTypeScript Utility Types 실전 가이드
TypeScript 유틸리티 타입을 DTO, 업데이트 payload, selector, 파생 타입 설계에 어떻게 써야 하는지, 어디서부터는 가독성을 해치는지 정리합니다.
다음 탐색