
React 애플리케이션에서 라우팅은 핵심 기능이지만, 타입 안전성을 확보하는 것은 종종 도전적인 과제입니다. 이 글에서는 TypeScript의 강력한 타입 시스템을 활용하여 경로(pathname), 파라미터(params), 검색 쿼리 searchParam) 모두에 대한 완벽한 타입 안전성을 제공하는 라우팅 시스템을 구현하는 방법을 소개합니다.
React Router를 사용할 때 다음과 같은 문제점들이 자주 발생합니다:
이러한 문제를 해결하기 위해 단일 설정 객체와 TypeScript의 타입 추론을 활용한 완전한 타입 안전 라우팅 시스템을 구축했습니다.
모든 라우트를 단일 객체에 정의하여 중앙 집중식으로 관리합니다:
export const RoutePath = {
// 기본 경로 (파라미터 없음)
root: { pathname: '/' },
// 단일 파라미터 경로
userProfile: { pathname: '/user/:userId' },
// 다중 파라미터 경로
commentDetail: { pathname: '/note/:noteId/comment/:commentId' },
// 검색 쿼리가 있는 경로
notes: {
pathname: '/note',
search: {
sort: ['POPULARITY', 'UPDATED'],
},
},
// 파라미터와 검색 쿼리를 모두 가진 경로
userNotes: {
pathname: '/user/:userId/notes',
search: {
category: ['STUDY', 'WORK', 'PERSONAL'],
},
},
} as const
as const를 사용하여 객체를 읽기 전용으로 만들고, 각 값이 리터럴 타입으로 추론되도록 합니다.
라우트 정의에서 필요한 타입을 추출하는 유틸리티 타입들을 정의합니다:
// 전체 Pathname 타입
export type Pathname = RouteConfig['pathname']
// 특정 경로에 대한 search 객체 타입 추출
export type SearchOf<T extends Pathname> =
Extract<RouteConfig, { pathname: T }> extends { search: infer S } ? S : Record<string, never>
// 경로에서 파라미터를 튜플 형태로 추출
export type ExtractRouteParamsTuple<T extends string> = T extends `${infer _Start}:${infer _Param}/${infer Rest}`
? [string | number, ...ExtractRouteParamsTuple<`/${Rest}`>]
: T extends `${infer _Start}:${infer _Param}`
? [string | number]
: []
경로와 옵션을 받아 최종 URL을 생성하는 함수:
export const buildUrl = <T extends Pathname, S extends object>(path: T, options: ExtendedOptions<T, S>): string => {
let url: string = path
// 파라미터 치환
if (options.params && options.params.length > 0) {
let paramIndex = 0
url = url.replace(/:([^/]+)/g, (_, key) => {
if (paramIndex >= options.params.length) {
throw new Error(`Missing parameter value for "${key}"`)
}
return encodeURIComponent(String(options.params[paramIndex++]))
})
}
// search 및 hash 추가
// ...
return url
}
이 모든 것을 조합하여 타입 안전한 useRouter 훅과 Link 컴포넌트를 만듭니다:
// useRouter 훅
export const useRouter = () => {
const navigate = useNavigate()
function push<T extends Pathname>(path: T, ...rest: ParamOptions<T, AllowedSearch<SearchOf<T>>>) {
const options = (rest[0] ?? {}) as ExtendedOptions<T, AllowedSearch<SearchOf<T>>>
const url = buildUrl<T, AllowedSearch<SearchOf<T>>>(path, options)
navigate(url, {
...R.omit(options, ['search', 'hash', 'params']),
})
}
// replace, back, forward 메서드
// ...
return { push, replace, back, forward }
}
// Link 컴포넌트
export function Link<T extends Pathname>(props: CustomLinkProps<T>) {
const { to, search, hash, params, ...rest } = props
const options = { search, hash, params } as unknown as ExtendedOptions<T, AllowedSearch<SearchOf<T>>>
const url = buildUrl<T, AllowedSearch<SearchOf<T>>>(to, options)
return <RouterLink to={url} {...rest} />
}
이 시스템을 사용하면 다음과 같은 이점을 얻을 수 있습니다:
const router = useRouter();
// 경로 문자열이 자동완성됨
router.push('/note');
router.push('/login');

// 오류: noteDetail 경로는 파라미터가 필요함
router.push('/note/:noteId');
// 정상: 필요한 파라미터 제공
router.push('/note/:noteId', { params: ['123'] });
// 다중 파라미터 경로 - 오류: 파라미터가 부족함
router.push('/note/:noteId/comment/:commentId', { params: ['123'] });
// 정상: 모든 필요한 파라미터 제공
router.push('/note/:noteId/comment/:commentId', { params: ['123', '456'] });
// 복잡한 다중 파라미터 경로
router.push('/course/:courseId/lesson/:lessonId/section/:sectionId', {
params: ['course-101', 'lesson-5', 'section-3']
});
// 정상: 'sort' 파라미터는 'POPULARITY' 또는 'UPDATED'만 허용
router.push('/note', {
search: { sort: 'POPULARITY' }
});
// 오류: 'invalid'는 허용된 값이 아님
router.push('/note', {
search: { sort: 'invalid' }
});
// 파라미터와 검색 쿼리 함께 사용
router.push('/user/:userId/notes', {
params: ['user123'],
search: {
category: 'STUDY',
status: 'PUBLISHED'
}
});
// 오류: 'PRIVATE'는 status에 허용된 값이 아님
router.push('/user/:userId/notes', {
params: ['user123'],
search: {
category: 'WORK',
status: 'PRIVATE'
}
});
// 파라미터가 필요한 경로
<Link
to='/note/:noteId'
params={[123]}
>
노트 상세
</Link>
// 검색 쿼리가 있는 경로
<Link
to='/note'
search={{ sort: 'POPULARITY' }}
>
인기 노트
</Link>
// 다중 파라미터와 검색 쿼리를 함께 사용
<Link
to='/course/:courseId/lesson/:lessonId'
params={['web-dev-101', 'typescript-basics']}
search={{ view: 'compact' }}
>
타입스크립트 기초 강의
</Link>
경로에 파라미터가 있는지 여부에 따라 params 속성의 필수 여부를 결정합니다:
type CustomLinkProps<T extends Pathname> = Omit<RouterLinkProps, 'to'> &
(ExtractRouteParamsTuple<T> extends []
? { to: T; search?: string | AllowedSearch<SearchOf<T>>; hash?: string; params?: [] }
: {
to: T
search?: string | AllowedSearch<SearchOf<T>>
hash?: string
params: ExtractRouteParamsTuple<T>
})
React Router의 NavigateOptions를 그대로 지원하여 완벽한 대체가 가능합니다:
export type ExtendedOptions<T extends Pathname, S extends object = AllowedSearch<SearchOf<T>>> = Options<T, S> &
Omit<NavigateOptions, 'replace'>
이 접근 방식은 다음과 같은 이점을 제공합니다:
이 구현은 TypeScript의 강력한 타입 시스템을 활용하여 개발자 경험과 코드 품질을 모두 향상시키는 좋은 예입니다. 특히 대규모 애플리케이션에서 라우팅 관련 버그를 줄이고 유지보수성을 높이는 데 큰 도움이 됩니다.
므찌네여 :)