내가 만든 React Router로 동료들이 놀란 이유

R정우·2025년 3월 18일
88

React

목록 보기
3/4
post-thumbnail

타입 안전한 React Router 구현하기: 자동완성과 타입 검증의 완벽한 조화

React 애플리케이션에서 라우팅은 핵심 기능이지만, 타입 안전성을 확보하는 것은 종종 도전적인 과제입니다. 이 글에서는 TypeScript의 강력한 타입 시스템을 활용하여 경로(pathname), 파라미터(params), 검색 쿼리 searchParam) 모두에 대한 완벽한 타입 안전성을 제공하는 라우팅 시스템을 구현하는 방법을 소개합니다.

문제 정의

React Router를 사용할 때 다음과 같은 문제점들이 자주 발생합니다:

  1. 경로 문자열의 오타: 하드코딩된 문자열은 오타가 발생하기 쉽고 자동완성이 지원되지 않습니다.
  2. 파라미터 누락: 경로에 필요한 파라미터를 빠뜨리거나 잘못된 타입으로 전달할 수 있습니다.
  3. 쿼리 파라미터 불일치: 특정 경로에서 허용되는 쿼리 파라미터를 문서화하거나 검증하기 어렵습니다.
  4. 리팩토링 위험: 경로 변경 시 모든 참조를 수동으로 업데이트해야 합니다.

이러한 문제를 해결하기 위해 단일 설정 객체와 TypeScript의 타입 추론을 활용한 완전한 타입 안전 라우팅 시스템을 구축했습니다.

핵심 구성 요소

1. 중앙 집중식 라우트 정의

모든 라우트를 단일 객체에 정의하여 중앙 집중식으로 관리합니다:

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를 사용하여 객체를 읽기 전용으로 만들고, 각 값이 리터럴 타입으로 추론되도록 합니다.

2. 타입 추출 및 변환

라우트 정의에서 필요한 타입을 추출하는 유틸리티 타입들을 정의합니다:

// 전체 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]
    : []

3. URL 생성 유틸리티

경로와 옵션을 받아 최종 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
}

4. 타입 안전한 훅과 컴포넌트

이 모든 것을 조합하여 타입 안전한 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} />
}

사용 예시

이 시스템을 사용하면 다음과 같은 이점을 얻을 수 있습니다:

1. 경로 자동완성

const router = useRouter();

// 경로 문자열이 자동완성됨
router.push('/note');
router.push('/login');

2. 파라미터 타입 검증

// 오류: 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']
});

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의 옵션 완전 지원

React Router의 NavigateOptions를 그대로 지원하여 완벽한 대체가 가능합니다:

export type ExtendedOptions<T extends Pathname, S extends object = AllowedSearch<SearchOf<T>>> = Options<T, S> &
  Omit<NavigateOptions, 'replace'>

결론

이 접근 방식은 다음과 같은 이점을 제공합니다:

  1. 단일 진실 소스: 모든 라우트가 한 곳에서 관리됩니다.
  2. 타입 안전성: 컴파일 타임에 경로, 파라미터, 쿼리 오류를 잡아냅니다.
  3. 자동완성: 개발자 경험을 크게 향상시킵니다.
  4. 리팩토링 용이성: 경로 변경 시 타입 시스템이 영향받는 모든 곳을 알려줍니다.
  5. React Router와의 호환성: 기존 React Router의 모든 기능을 그대로 사용할 수 있습니다.

이 구현은 TypeScript의 강력한 타입 시스템을 활용하여 개발자 경험과 코드 품질을 모두 향상시키는 좋은 예입니다. 특히 대규모 애플리케이션에서 라우팅 관련 버그를 줄이고 유지보수성을 높이는 데 큰 도움이 됩니다.

profile
시행착오를 즐기는 프론트엔드 개발자입니다!

5개의 댓글

comment-user-thumbnail
2025년 3월 18일

므찌네여 :)

답글 달기
comment-user-thumbnail
2025년 3월 18일

우왕 굿굿

답글 달기
comment-user-thumbnail
2025년 3월 19일

멋져요

답글 달기
comment-user-thumbnail
2025년 3월 24일

타입스크립트 진짜 잘쓰시네요 ㄷㄷ

답글 달기
comment-user-thumbnail
2025년 3월 25일

혹시 추론 속도는 얼마나 나오나요? 저정도 사이즈에선 그렇게 크게 느릴거같진 않은데, path 개수가 많아지면 (현재 tsc에서) 꽤 느릴거같아보입니다.

답글 달기