Skip to Content
WebTypeScriptTypeScript 실무08. 라우팅과 에러 경계 타이핑

이번 편의 결과물: router.tsx와 남은 pages/*.tsx 전체가 전환 완료되고, 아직 .js로 남은 파일(서비스·스토어 등) 목록을 확인합니다. · 다루는 개념: react-router Data Mode에서의 라우트 타이핑, useParams 제네릭, ErrorBoundary props/state 타입

bookshelf-ts의 라우터는 createBrowserRouter로 라우트 배열을 직접 정의하는 방식(Data Mode)입니다. 04·07편에서 Header.tsx, BookCard.tsx, BookForm.tsx, NewBookPage.tsx, ThemeContext.tsx가 전환됐고, 이 편에서 라우터 자체와 남은 페이지 컴포넌트를 마무리합니다.

이 편에서 만드는 파일

bookshelf-ts/src/ ├── router.tsx ~ .jsx → .tsx 전환 (RouteObject 배열) ├── components/ │ └── ErrorBoundary.tsx ~ .jsx → .tsx 전환 (Component 제네릭) └── pages/ ├── BookListPage.tsx ~ .jsx → .tsx 전환 ├── BookDetailPage.tsx ~ .jsx → .tsx 전환 (useParams 제네릭) ├── LoginPage.tsx ~ .jsx → .tsx 전환 (FormEvent, location.state 타입) └── NotFoundPage.tsx ~ .jsx → .tsx 전환

개념 정리

자동 타입 생성은 Framework Mode 전용이다

react-router는 라우트 구성 방식에 따라 타입 지원 수준이 다릅니다. 공식 문서(Type Safety)는 +types import와 자동 타입 생성(typegen)이 어떤 모드에서 지원되는지 표로 명시합니다.

모드자동 타입 생성(+types, typegen)
Framework Mode(파일 기반 라우트, 빌드 플러그인 필요)지원
Data Mode(createBrowserRouter로 라우트 배열 직접 작성)미지원

bookshelf-ts는 처음부터 createBrowserRouter로 라우트를 직접 정의하는 Data Mode입니다. 파일 기반 라우트로 옮기는 것은 라우팅 구조 자체를 바꾸는 큰 작업이라 이 과목의 범위 밖입니다. 대신 Data Mode에서 쓸 수 있는 수동 타이핑 방법을 씁니다: 라우트 배열을 RouteObject[]로 선언하고, 각 페이지 컴포넌트와 useParams에 개별적으로 타입을 붙입니다.

useParams 제네릭

useParams에 객체 타입을 타입 인자로 주면, 반환값은 그 객체의 Partial(모든 값이 있을 수도 없을 수도 있는 형태)이 됩니다.

const { id } = useParams<{ id: string }>() // id: string | undefined

라우트 경로가 /books/:id라 실제로는 항상 값이 들어오지만, 타입 시스템은 이를 보장하지 않습니다. idundefined일 가능성을 그대로 남겨두고, 이를 쓰는 쪽(useQueryenabled 옵션 등)에서 확인하는 것이 안전합니다.

ErrorBoundary는 클래스 컴포넌트 제네릭으로 타이핑한다

Component<Props, State>는 리액트 클래스 컴포넌트의 props·state 타입을 지정하는 제네릭입니다. 함수 컴포넌트에는 없는 개념이지만, Component를 상속하는 이상 그대로 적용됩니다.

조각타입
Component<ErrorBoundaryProps, ErrorBoundaryState>props·state 타입 지정
static getDerivedStateFromError()반환 타입이 ErrorBoundaryState
componentDidCatch(error, info)error: Error, info: ErrorInfo

실습

1. ErrorBoundary를 tsx로 전환

// src/components/ErrorBoundary.tsx import { Component, type ErrorInfo, type ReactNode } from 'react' interface ErrorBoundaryProps { children: ReactNode } interface ErrorBoundaryState { hasError: boolean } class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { state: ErrorBoundaryState = { hasError: false } static getDerivedStateFromError(): ErrorBoundaryState { return { hasError: true } } componentDidCatch(error: Error, info: ErrorInfo) { console.error('렌더링 오류:', error, info.componentStack) } render() { if (this.state.hasError) { return ( <div role="alert" className="error-fallback"> <p>이 화면을 표시하는 중 문제가 발생했습니다.</p> <button type="button" onClick={() => this.setState({ hasError: false })}> 다시 시도 </button> </div> ) } return this.props.children } } export default ErrorBoundary

react_2에서 만든 ErrorBoundary는 lazy 로딩과 함께 상세 페이지만 감쌌지만, react_3의 라우터가 createBrowserRouter 배열로 재작성되면서 그 조합은 빠졌습니다. 이 편은 router.tsx에서 각 보호 라우트를 감싸는 형태로 다시 연결합니다.

2. BookDetailPage에서 useParams 제네릭 적용

// src/pages/BookDetailPage.tsx import { useParams, Link } from 'react-router' import { useQuery } from '@tanstack/react-query' import { getBook } from '../api/books' import type { Book } from '../types/book' function BookDetailPage() { const { id } = useParams<{ id: string }>() const { data: book, isPending, isError } = useQuery({ queryKey: ['books', id], queryFn: () => getBook(id) as Promise<Book>, enabled: Boolean(id), }) if (isPending) return <p role="status">책 정보를 불러오는 중입니다...</p> if (isError || !book) return <p role="alert">책 정보를 찾을 수 없습니다.</p> return ( <article> <h1>{book.title}</h1> <p>{book.author}</p> <p>{book.pages}쪽</p> <p>상태: {book.status}</p> {book.memo && <p>{book.memo}</p>} <Link to="/">목록으로 돌아가기</Link> </article> ) } export default BookDetailPage

enabled: Boolean(id)idundefined일 때 쿼리를 아예 실행하지 않게 하는 런타임 가드입니다. getBook은 아직 .js라 06편의 useBooks.ts와 같은 방식으로 as Promise<Book> 캐스팅을 훅 호출부에 붙였습니다.

3. BookListPage 전환

// src/pages/BookListPage.tsx import { useState, type ChangeEvent } from 'react' import { useUiStore } from '../store/useUiStore' import { useDebounce } from '../hooks/useDebounce' import { useBooks } from '../hooks/useBooks' import BookTable from '../components/BookTable' function BookListPage() { const statusFilter = useUiStore((state) => state.statusFilter) const sortBy = useUiStore((state) => state.sortBy) const sortOrder = useUiStore((state) => state.sortOrder) const searchQuery = useUiStore((state) => state.searchQuery) const setSearchQuery = useUiStore((state) => state.setSearchQuery) const page = useUiStore((state) => state.page) const setPage = useUiStore((state) => state.setPage) const [searchInput, setSearchInput] = useState<string>(searchQuery) const debouncedSearch = useDebounce(searchInput, 300) if (debouncedSearch !== searchQuery) { setSearchQuery(debouncedSearch) } const { data, isPending, isError } = useBooks({ status: statusFilter, sortBy, sortOrder, search: searchQuery, page, }) if (isPending) return <p>불러오는 중...</p> if (isError) return <p role="alert">목록을 불러오지 못했습니다</p> const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize)) function handleSearchChange(event: ChangeEvent<HTMLInputElement>) { setSearchInput(event.target.value) } return ( <section> <input type="search" aria-label="책 검색" value={searchInput} onChange={handleSearchChange} placeholder="제목으로 검색" /> <BookTable books={data.items} /> <nav className="pagination" aria-label="페이지 이동"> <button type="button" disabled={page <= 1} onClick={() => setPage(page - 1)}> 이전 </button> <span> {page} / {totalPages} </span> <button type="button" disabled={page >= totalPages} onClick={() => setPage(page + 1)}> 다음 </button> </nav> </section> ) } export default BookListPage

useUiStore(11편 예정)·useDebounce·BookTable(10편 예정)은 아직 .js라 이 파일 안에서 매개변수·반환값이 느슨하게(대부분 암묵적으로) 처리됩니다. useBooks만 06편에서 타입을 얻었으므로 data.items, data.total, data.pageSize에는 자동완성이 뜹니다. 나머지 파일은 각자의 편에서 타입을 얻을 때까지 이 상태를 그대로 유지합니다. handleSearchChange는 매개변수 타입을 직접 썼지만, onChange={(event) => setSearchInput(event.target.value)}처럼 JSX 안에 인라인으로 썼다면 문맥적 타입 추론(contextual typing) 덕분에 event의 타입을 안 써도 ChangeEvent<HTMLInputElement>로 자동 추론됩니다.

4. LoginPage 전환

// src/pages/LoginPage.tsx import { useState, type FormEvent } from 'react' import { useNavigate, useLocation } from 'react-router' import { useAuth } from '../context/AuthContext' interface LoginLocationState { from?: string } function LoginPage() { const navigate = useNavigate() const location = useLocation() const { login } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const state = location.state as LoginLocationState | null const from = state?.from ?? '/' async function handleSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault() setError('') setIsSubmitting(true) try { await login(email, password) navigate(from, { replace: true }) } catch (err) { setError(err instanceof Error ? err.message : '로그인에 실패했습니다.') } finally { setIsSubmitting(false) } } return ( <form onSubmit={handleSubmit}> <h1>로그인</h1> <label htmlFor="email">이메일</label> <input id="email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required /> <label htmlFor="password">비밀번호</label> <input id="password" type="password" value={password} onChange={(event) => setPassword(event.target.value)} required /> <button type="submit" disabled={isSubmitting}> {isSubmitting ? '로그인 중...' : '로그인'} </button> {error && <p role="alert">{error}</p>} </form> ) } export default LoginPage

react-routeruseLocation().stateProtectedRouteNavigate에 실어 보낸 임의의 값이라 구체적인 모양을 알 수 없는 값으로 취급됩니다. 실제로 { from: 위치 } 모양이라는 것을 알고 있으므로 LoginLocationState로 캐스팅합니다. handleSubmit은 RHF 없이 순수 form이라 FormEvent<HTMLFormElement>를 직접 명시했습니다. 05편의 BookFormhandleSubmit(onValid)로 이 타입을 감춰줬던 것과 대조적입니다.

5. NotFoundPage 전환

// src/pages/NotFoundPage.tsx import { Link } from 'react-router' function NotFoundPage() { return ( <div className="route-error"> <h1>페이지를 찾을 수 없습니다</h1> <Link to="/">목록으로 돌아가기</Link> </div> ) } export default NotFoundPage

6. router를 tsx로 전환하고 ErrorBoundary 재적용

// src/router.tsx import { createBrowserRouter, type RouteObject } from 'react-router' import type { ReactElement } from 'react' import BookListPage from './pages/BookListPage' import BookDetailPage from './pages/BookDetailPage' import NewBookPage from './pages/NewBookPage' import LoginPage from './pages/LoginPage' import NotFoundPage from './pages/NotFoundPage' import ErrorBoundary from './components/ErrorBoundary' import ProtectedRoute from './components/ProtectedRoute' import SessionWatcher from './components/SessionWatcher' function guarded(element: ReactElement): ReactElement { return ( <ProtectedRoute> <ErrorBoundary>{element}</ErrorBoundary> </ProtectedRoute> ) } const routes: RouteObject[] = [ { element: <SessionWatcher />, children: [ { path: '/', element: guarded(<BookListPage />) }, { path: '/books/new', element: guarded(<NewBookPage />) }, { path: '/books/:id', element: guarded(<BookDetailPage />) }, { path: '/login', element: <LoginPage /> }, { path: '*', element: <NotFoundPage /> }, ], }, ] export const router = createBrowserRouter(routes)

ProtectedRoute.jsx, SessionWatcher.jsx는 아직 자바스크립트지만 컴포넌트 자체는 그대로 가져다 쓸 수 있습니다. routesRouteObject[]로 선언해 두면, 존재하지 않는 라우트 속성 이름을 쓰거나 element 자리에 컴포넌트가 아닌 값을 넣었을 때 바로 오류가 납니다. main.jsxfrom './router.jsx' import도 이번 편에서 .tsx로 바꿔야 개발 서버가 새 파일을 찾습니다.

7. 실행

npm run dev

8. 확인

  • /, /books/new, /books/:id, /login, 존재하지 않는 경로가 이전과 동일하게 동작합니다.
  • BookDetailPage.tsx에 잠깐 throw new Error('테스트 오류')를 추가해 상세 페이지에 진입하면, 화면 전체가 아니라 “이 화면을 표시하는 중 문제가 발생했습니다”만 보입니다. 확인 후 지웁니다.
  • routes 배열의 path 속성 이름을 paths처럼 잘못 써 보면 VS Code가 RouteObject에 없는 속성이라고 표시합니다. 확인 후 되돌립니다.
  • 이제 src/ 아래 .js/.jsx로 남은 파일은 api/, context/AuthContext.jsx, components/ProtectedRoute.jsx, components/SessionWatcher.jsx, components/BookTable.jsx, store/useUiStore.js, hooks/useBookMutations.js, hooks/useDebounce.js, schemas는 이미 05편에서 전환 완료. 이 목록이 9~13편에서 하나씩 줄어듭니다.

직접 해보기

useParams<{ id: string }>()가 반환하는 id의 타입을 VS Code에서 직접 확인해 보세요. id 위에 마우스를 올려 string | undefined로 표시되는지 확인하고, enabled: Boolean(id) 줄을 잠깐 지운 뒤 getBook(id) 호출부에서 id의 타입이 여전히 string | undefined로 남아 문제가 되는지 관찰해 보세요(런타임 오류는 없지만, idundefined인 요청이 나갈 수 있다는 것을 확인하는 것이 목적입니다).

정답 보기

enabled: Boolean(id)를 지워도 getBook은 아직 .js라 매개변수 타입이 없어 컴파일 오류는 나지 않습니다. 대신 idundefined인 채로 요청이 나가는 런타임 버그가 생깁니다. enabled 옵션은 타입 경고가 아니라 실제 요청을 막는 런타임 가드라는 점이 핵심입니다.

자주 하는 실수

증상원인고치는 법
react-router typegen 명령을 실행해도 아무 변화가 없음이 프로젝트는 Framework Mode가 아니라 Data Mode라 자동 타입 생성이 지원되지 않음RouteObject로 라우트 배열을 직접 타이핑하는 방식을 쓴다
idstring이라고 가정하고 바로 API를 호출해 undefined 관련 오류 발생useParams의 객체 제네릭이 Partial을 반환한다는 것을 놓침enabled: Boolean(id) 같은 가드를 두거나 값 존재를 먼저 확인한다
ErrorBoundary를 함수 컴포넌트로 작성하려다 실패getDerivedStateFromError는 클래스 컴포넌트 생명주기 메서드Component<Props, State>를 상속하는 클래스로 작성한다
router.tsx에서 모듈을 찾을 수 없다는 오류main.jsx의 import 확장자를 .tsx로 안 바꿈확장자가 바뀐 모든 import 경로를 함께 수정한다

확인 문제

문제 14지선다
bookshelf-ts처럼 createBrowserRouter로 라우트 배열을 직접 작성하는 방식에서 react-router의 자동 타입 생성(typegen)이 지원되는가?
문제 24지선다
useParams<{ id: string }>()의 반환값에서 id의 타입은?
문제 34지선다
ErrorBoundary 클래스에 Component<ErrorBoundaryProps, ErrorBoundaryState>처럼 두 개의 타입 인자를 주는 이유는?
문제 44지선다
LoginPage에서 location.state as LoginLocationState | null처럼 캐스팅이 필요한 이유는?

참고 자료

Last updated on