본문 바로가기
프론트엔드 관련/기초

React Router Path AutoComplete with type-safety

by ash9river 2025. 4. 10.

리액트 라우터 경로 자동 완성 및 경로 안정성

서론

이 글을 쓰게 된 계기는 우연에서 시작되었다.


기술 블로그를 돌아다니다가 이 글을 보고 경로 자동완성에 대해 생각해보았다.

 

rjw0907 (R정우) / 작성글 - velog

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

velog.io

 
처음 글을 봤을 때에는 대단하다고 생각이 들었다.

그러나 바로 이해가 되지 않는 부분도 있었고, 좀 더 개선의 여지가 없을까 많이 고심하게 되었다.

사실 이전에 해왔던 프로젝트에는 경로 자동완성은 커녕, 타입 안정성조차 존재하지 않았다.
사용자를 네비게이트 시켜야하는 경우, 단순히 수기로 하듯이 그냥 직접 경로를 작성하였다.

이로 인해 라우트 경로를 바꾸면 전부 수정해야하는데 위치도 제대로 모르게 되는 곳이 태반이었고, 이는 일말의 여지도 없이 버그를 자주 발생시켰다.
 
그래서 나도 경로 자동 완성과 타입 안정성을 더해야겠다는 생각이 들었다.

물론, 기술블로그에서 봤던 글을 그대로 도입하기에는 마음에 안들었다.

까내리는 것이 아니라 모든 라우트를 단일 객체에 재정의한다는 것이 마음에 들지 않았다.

/*
https://velog.io/@rjw0907/a#1-%EC%A4%91%EC%95%99-%EC%A7%91%EC%A4%91%EC%8B%9D-%EB%9D%BC%EC%9A%B0%ED%8A%B8-%EC%A0%95%EC%9D%98
*/
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;

 
내가 제일 먼저 생각한 것은 어떻게 해야 제네럴하게 구성할지였다.

사실 이건 설계가 완성된 뒤에 생각해낸거지만,
먼저 루트 경로 재정의하는 것은 말도 안된다 생각하였다.
만약 루트 경로를 재정의한다고 하자. 그러면 이전에 경로를 잘못 구성한 이유가 navigate 등의 함수였다면, 루트 경로 재정의 이후에는 재정의한 루트가 원인이다.
이는 디버그가 좀 더 편하게 된 거지만, 근본적인 원인의 해결은 아니라고 생각하였다.
DRY 원칙에도 위배가 된다고도 하니...

 
퇴근을 하면서 기차에서도 고민하다가 집에서도, 자기 전에도 어떻게 할 지 고심하였다.
 
다음 날, 내가 제일 먼저 한 것은 시뮬레이션이었다.
 
먼저 예상되는 경로를 정의하였다.
 

/
/a/b/c
/a/:b/c
/a/b/:c
/a/:b/c/:d
...
/a/:b/c/:d/e/:f/g/:h/i/:j/k/:l/m/:n/o/:p/q/:r/s/:w/x/:y/z

 
...솔직히 숨이 턱 나왔다.

그러던 도중 이 글을 보았다.

 

라우팅에 TypeScript 타입 자동완성을 활용해보기 | Jeongyun.log

배우고 기록하고 성장하자.

jeongyunlog.netlify.app

 
이 글에서는 Route Path를 Constant Union으로 정의하였다.

export const ROUTE_PATH = {
  root: '/',
  Home: '/home',
  Dashboard: '/home/dashboard',
  MyPage: '/my-page',
  ///....
} as const

 
그래서 나도 처음에는 NextJs에서 app 라우팅을 하는 것처럼 routes 폴더를 따로 만들고, index.ts를 폴더별로 분기하여, 다음과 같이 작성할려 하였다.
 

// routes/index.ts
export const appRoutes = {
  root: "/",
  home: homeRoutes,
  dashBoard: dashBoardRoutes
} as const;

// routes/home/index.ts
export const homeRoutes = {
  root: "/home",
  ...
} as const;

// routes/dashBoard/index.ts
expost const dashBoardRoutes ={
  root: "/dashBoard",
  ...
} as const;

내가 경로 참조를 위해 추상적으로 다음과 같이 기획하였다.

// "/"
ROUTE.APP
// "/home/diary/:diaryId/edit"
ROUTE.APP.HOME.DIARY.DIARYDETAIL.EDIT
// "/dashBoard"
ROUTE.APP.DASHBOARD

 
구상하고서 썩 나쁘지 않다는 생각이 들었다.

그런데 막상 구현을 위해 폴더를 생성하고, 수많은 index.ts를 생성하고 있으니 이건 말도 안된다는 생각이 들었다.

다음 3 가지의 이슈를 해결해야만 했다.

예상되는 문제 상황

1. 매핑되는 경로 키값의 불확정성

  • 말로만 root 속성에서 path를 꺼내지, edit 같은 페이지는 기존의 라우트 트리에서 추가적으로 라우트를 생성하여서 종속시키던가, 아니면 다음과 같이 매핑해야하는 불상사가 생겼다.
// 추가적으로 라우트 생성
export const diaryEditRoutes = {
  edit: "/edit",
} as const;
// 매핑하는 불상사
// routes/home/diary/index.ts
export const diaryRoutes = {
  root: "/diary",
  diaryDetail: "/diary/:diaryId",
  edit: "/diary/:diaryId/edit",
} as const;

2. /a/:b/c:d/의 경로에서 패러미터를 강제시키는 방법

  • 라우트 경로를 만들고, :로 시작하면 패러미터가 강제가 되나, 추가적으로 라우트를 만들고, isNeedParams의 속성을 추가하여 강제시키기...?

3. 라우트 경로를 createBrowseRouter를 통해서 만들었는데, 이 라우트 경로가 수정되는 경우

  • 이는 결국 /routes 폴더에서 변경점에 해당되는 모든 값이 수정되어야하는 결과를 초래한다.

그래서 어쩔건데?

말도 안되는 상황의 연속이다. 만병통치약은 없다는건지, 최대한 제네럴한 해답을 찾기 위해서 여정을 떠나보았다.
제네럴을 위한 여정의 첫걸음은 react-router-dom의 타입이 어떻게 되어있는지 뜯어보는 것이었다.

export type RouteObject = IndexRouteObject | NonIndexRouteObject;
export type DataRouteObject = RouteObject & {
  children?: DataRouteObject[];
  id: string;
};

 

내가 기존에 사용하던 것은 RouteObject였고, DataRouteObject는 뭘까 생각이 들었는데, 일년이었나? 그쯔음 react-router에서 data API를 사용해라라는 문서를 본 기억이 났다.

 
세간에 검색하면 나오는 리액트 라우트는 다음과 같다.
 

    <Route path="/" element={<Root />}>
      <Route path="dashboard" element={<Dashboard />} />
      {/* ... etc. */}
    </Route>

 
이는 data API를 지원안해주기도 하고 트렌드가 지나긴했는데(쓰지않는것을 추천), 보통 나는 이하의 방식으로 라우트를 구성한다.

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    loader: loadRootData,
  },
]);

 
간단하게 그렇다는거고, 내 package.json에서 버전이 몇인지도 확인하였다.

{
  // 리액트 라우터
  "react-router": "6",
  // 리액트 라우터 돔
  "react-router-dom": "6"
}

 
내 버전이 6인데, loader를 잘 쓰고 있어서 data API가 사용가능한데 뭐지? 싶어서 다시 확인해보았다.
 

npm list react-router-dom
  • 결과
└── react-router-dom@6.30.0

 
실제로는 버전 6.3이어서 loader를 활용할 수 있었고, data API 또한 사용할 수 있었다.
 
리액트 라우터의 타입을 뜯어서 분석해본 결과, RouteObjectDataRouteObjectid를 가지는거 이외에는 다른 점이 없었다.

export interface IndexRouteObject {
  caseSensitive?: AgnosticIndexRouteObject["caseSensitive"];
  path?: AgnosticIndexRouteObject["path"];
  id?: AgnosticIndexRouteObject["id"];
  loader?: AgnosticIndexRouteObject["loader"];
  action?: AgnosticIndexRouteObject["action"];
  hasErrorBoundary?: AgnosticIndexRouteObject["hasErrorBoundary"];
  shouldRevalidate?: AgnosticIndexRouteObject["shouldRevalidate"];
  handle?: AgnosticIndexRouteObject["handle"];
  index: true;
  children?: undefined;
  element?: React.ReactNode | null;
  hydrateFallbackElement?: React.ReactNode | null;
  errorElement?: React.ReactNode | null;
  Component?: React.ComponentType | null;
  HydrateFallback?: React.ComponentType | null;
  ErrorBoundary?: React.ComponentType | null;
  lazy?: LazyRouteFunction<RouteObject>;
}
export interface NonIndexRouteObject {
  caseSensitive?: AgnosticNonIndexRouteObject["caseSensitive"];
  path?: AgnosticNonIndexRouteObject["path"];
  id?: AgnosticNonIndexRouteObject["id"];
  loader?: AgnosticNonIndexRouteObject["loader"];
  action?: AgnosticNonIndexRouteObject["action"];
  hasErrorBoundary?: AgnosticNonIndexRouteObject["hasErrorBoundary"];
  shouldRevalidate?: AgnosticNonIndexRouteObject["shouldRevalidate"];
  handle?: AgnosticNonIndexRouteObject["handle"];
  index?: false;
  children?: RouteObject[];
  element?: React.ReactNode | null;
  hydrateFallbackElement?: React.ReactNode | null;
  errorElement?: React.ReactNode | null;
  Component?: React.ComponentType | null;
  HydrateFallback?: React.ComponentType | null;
  ErrorBoundary?: React.ComponentType | null;
  lazy?: LazyRouteFunction<RouteObject>;
}
export type RouteObject = IndexRouteObject | NonIndexRouteObject;
export type DataRouteObject = RouteObject & {
  children?: DataRouteObject[];
  id: string;
};

 
그리고 indextrue일 경우, childrenundefined이고, indexfalse인 경우, childrenRouteObject[]로 옵셔널하게 되어있음을 알 수 있었다.

그래서 어떻게 할려고?

결국, 제네럴하게 경로를 추론할려면 RouteObject에서 path 꺼내오는 방식을 사용하자고 마음먹었다.
먼저, 라우트 경로를 다음과 같이 지정하고, createBrowserRouter의 인수로 할당하였다.
 

export const appRoutes = [
  {
    // ...RouteObject[] of children or more.
  },
] as const satisfies RouteObject[];

타입스크립트를 딥하게 사용한 적이 없어서 여기서부터는 지피티의 도움을 받았다.
중간중간의 오류와 디버깅을 시도하였다.
 

그런데, children으로 파고들어가는 추론이다보니까, depth가 깊어질수록 /가 더 붙어버렸다.

 

 
이를 해결하기 위해 RouteObject를 분석하고, children 속성을 가졌지만, element가 존재하지 않으면 경로를 반환하지 않는 것으로 바꾸었다.

// element는 ReactNode | null | undefined임을   있었다.
const elementType: {
  element?: React.ReactNode | null;
};

 
그래서 완성된 타입 추론은 다음과 같았다.

import { ReactNode } from "react";
import type { RouteObject } from "react-router-dom";
import { appRoutes } from "./some-where~~";

type RemoveLeadingSlash<T extends string> = T extends `/${infer R}` ? R : T;
type RemoveTrailingSlash<T extends string> = T extends `${infer R}/` ? R : T;

type JoinPath<A extends string, B extends string> = A extends ""
  ? B
  : B extends ""
  ? A
  : `${RemoveTrailingSlash<A>}/${RemoveLeadingSlash<B>}`;

type ExtractRoutePath<
  T extends RouteObject,
  ParentPath extends string = ""
> = T extends { path: infer P; element?: infer E; children?: infer C }
  ? P extends string
    ? C extends RouteObject[]
      ? // 현재 path + 하위 경로 재귀 처리
        JoinPath<ParentPath, P> | ExtractRoutePaths<C, JoinPath<ParentPath, P>>
      : // 하위 경로 없으면 현재 path만 반환
      E extends ReactNode
      ? JoinPath<ParentPath, P>
      : // element 없으면 경로 제거
        never
    : ParentPath
  : never;

// 2. RouteObject 배열 전체 처리
type ExtractRoutePaths<
  T extends RouteObject[],
  ParentPath extends string = ""
> = {
  [K in keyof T]: ExtractRoutePath<T[K], ParentPath>;
}[number];

// 전체 경로
export type AppPaths = ExtractRoutePath<typeof appRoutes>;

 
이제 위의 기술블로그에서 보던 navigate의 타입 추론을 완성시키자.

// router.module.d.ts에 작성
import { AppPaths } from "./some~where";
import { NavigateOptions } from "react-router-dom";

declare module "react-router-dom" {
  interface NavigateFunction {
    (to: AppPaths, options?: NavigateOptions): void;
  }
}

 
이제, 다음과 같이 자동완성이 된다.
 

 

그런데 /*이 뜨고, 동적 세그먼트에 대해 파라미터 강제도 되지 않는다.


이제 이를 해결해보자.
 
일단 경로가 성공적으로 추론되었으니, 이를 파싱만 하면 된다.

/* eslint-disable @typescript-eslint/no-empty-object-type */
// 경로에서 파라미터 타입 추출
export type ExtractParamsFromPath<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param]: string | number } & ExtractParamsFromPath<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
    ? { [K in Param]: string | number }
    : {};

// 파라미터 필요성 재고
export type IsEmptyObject<T> = keyof T extends never ? true : false;

// 추출 잘되는지 hover로 확인
type Example =
  ExtractParamsFromPath<"/a/:b/c/:d/e/:f/g/:h/i/:j/k/:l/m/:n/o/:p/q/:r/s/:w/x/:y/z">;

 
이제 성공적으로 파라미터만 추출되었으니 이제 파라미터를 강제해보자.
 
파라미터를 가장 강제하기 쉬우면서도 확장성이 좋은 것은 역시 기존에 사용하던 것들을 extends하고, 새 컴포넌트로 만드는 것이다.
 
기존에 사용하는 것은 Link, useNavigate, redirect가 있으니 시작해보자.

TypedLink

Link 컴포넌트에서 사용하는 타입을 extends하여, 동적 세그먼트의 파라미터를 강제시킨다.
 
먼저, 파라미터를 정규식을 통해 강제시킨다.

// /utils/interpolatePath.ts
export function interpolatePath<Path extends AppPaths>(
  path: Path,
  params: ExtractParamsFromPath<Path>
): string {
  return path.replace(/:([^\s/]+)/, (_, key) => {
    const typedKey = key as keyof typeof params;
    const value = params[typedKey];
    if (!(key in params)) {
      throw new Error(`Missing param: ${key}`);
    }
    return value;
  });
}
type SearchParams = Record<string, string | number | boolean | undefined>;

type TypedLinkProps<Path extends string> = Omit<LinkProps, "to"> & {
  to: Path;
} & (IsEmptyObject<ExtractParamsFromPath<Path>> extends true
    ? { search?: SearchParams } // 없음 말고
    : { params: ExtractParamsFromPath<Path>; search?: SearchParams }); // 파라미터 있으면 필수

function TypedLink<Path extends AppPaths>(props: TypedLinkProps<Path>) {
  const { to, ...rest } = props;
  const finalTo = "params" in props ? interpolatePath(to, props.params) : to;
  return <Link to={finalTo} {...rest} />;
}
export default TypedLink;

 

useTypedNavigate

type TypedNavigateArg<Path extends AppPaths> = IsEmptyObject<
  ExtractParamsFromPath<Path>
> extends true
  ? [options?: NavigateOptions]
  : [params: ExtractParamsFromPath<Path>, options?: NavigateOptions];

import { NavigateOptions } from 'react-router';

export function isNavigateOptions(arg: unknown): arg is NavigateOptions {
  if (typeof arg !== 'object' || arg === null) return false;

  const allowedKeys = [
    'replace',
    'state',
    'preventScrollReset',
    'relative',
    'flushSync',
    'viewTransition',
  ];

  return Object.keys(arg).every((key) => allowedKeys.includes(key));
}


export function useTypedNavigate() {
  const navigate = useNavigate();

  return function typedNavigate<Path extends AppPaths>(
    to: Path,
    ...args: TypedNavigateArg<Path>
  ) {
    let finalTo: string;
    let options: NavigateOptions | undefined;

    if (args.length === 0) {
      finalTo = to;
    } else if (args.length === 1) {
      if (!isNavigateOptions(args[0])) {
        const params = args[0] as ExtractParamsFromPath<Path>;
        finalTo = interpolatePath(to, params);
      } else finalTo = to;
    } else {
      const [params, opt] = args as [
        ExtractParamsFromPath<Path>,
        NavigateOptions?
      ];
      finalTo = interpolatePath(to, params);
      options = opt;
    }

    navigate(finalTo, options);
  };
}

 
성공적으로 경로 안정성 및 경로 자동 완성이 마무리되었다.
 

 
경로 자동 완성은 부산물이고 경로 안정성이 주이다.

파라미터를 잘못 주입하면 오류가 나는 것을 확인하였다.

그런데 100개 이상의 경로면?

라우트 경로가 100개 이상이면 vscode에서 추론 속도가 느려져서 버벅이다가 멈출 수도 있을 것이다.

문제 해결을 위해 추상적으로 생각해보자.
라우트 경로를 RouteObject[]에서 children에서 분기를 할 것이다.
 

예를 들어, /a, /b, /c, /d, /e의 하위에 각각 100개의 라우트 경로가 존재한다고 생각하자.

그럼 /를 쳤을때 먼저 /, /a, /b, /c, /d, /e, 이 다섯 개가 뜨게 만들고, 만약 내가 /a를 완성시켰을때, /a의 하위 라우팅만 추론하는 방식이다.

 
완성도있게 생각을 하였지만, 구현을 하지는 않았다.
현 프로젝트에서 라우팅 경로를 100개 이상 만들지 않을 것이기에 필요성을 못느꼈다. 단순히 생각해보면 개발에 편자이다.

끝?

버그도 있을거고 차근차근 고쳐나가야하지만, 그건 내일의 내가 이뤄나갈 것이다.
 
그런데, 지피티의 도움을 받은 코드가 원본이 어디인지 찾아버렸다.
 

 

GitHub - 0916dhkim/typed-react-router: Proof of concept implementation of typescript-friendly react-router hooks.

Proof of concept implementation of typescript-friendly react-router hooks. - 0916dhkim/typed-react-router

github.com

 
읽어보니 대단한 사람이라는 생각이 들었다.
2020년의 코드가 지피티에서 추천을 해주었다니 나도 내 코드가 지피티에서 나왔으면 좋겠다.
나는 사실 개발을 2022년 겨울에 시작하였다. 전역을 2022년 4월에 하고, 여름에는 전기 회로를 공부하다가 어찌저치 개발이 재밌어서 시작하게 되었다.
올해 처음 신입 프론트엔드 개발자가 되었지만, 회사에 다니면서 일하는데도 아직 너무나도 재밌다.

참고

/*를 없앨까 말까 고민했는데 그냥 없애기 않기로 마음먹었다. 내 프로젝트에서 다음 기능 추가로 인한 하위 경로를 어떻게 구성할지 아직 정하지 않아서 남겨두었다.

 

솔직히 다음과 같이 추론하면 /*를 없앨 수 있지만 아직 구성 계획이 덜되어서 방식은 다음과 같다.

리액트 라우터에서의 타입 추론 방식을 글을 쓰면서 다시 정리하다가 발견해버려서 이를 남긴다.

// react-router에서의 타입 추론 방식
export type RouteManifest = Record<string, AgnosticDataRouteObject | undefined>;
type _PathParam<Path extends string> = Path extends `${infer L}/${infer R}`
  ? _PathParam<L> | _PathParam<R>
  : Path extends `:${infer Param}`
  ? Param extends `${infer Optional}?`
    ? Optional
    : Param
  : never;
/**
 * Examples:
 * "/a/b/*" -> "*"
 * ":a" -> "a"
 * "/a/:b" -> "b"
 * "/a/blahblahblah:b" -> "b"
 * "/:a/:b" -> "a" | "b"
 * "/:a/b/:c/*" -> "a" | "c" | "*"
 */
export type PathParam<Path extends string> = Path extends "*" | "/*"
  ? "*"
  : Path extends `${infer Rest}/*`
  ? "*" | _PathParam<Rest>
  : _PathParam<Path>;
export type ParamParseKey<Segment extends string> = [
  PathParam<Segment>
] extends [never]
  ? string
  : PathParam<Segment>;
/**
 * The parameters that were parsed from the URL path.
 */
export type Params<Key extends string = string> = {
  readonly [key in Key]: string | undefined;
};

 
이를 통해 소프트웨어 아키텍처 개선을 이끌어내었다.
 
사실 이와 같이 깊게 파고든 경험은 처음이다. 너무 어려워서 하루동안 하다가 커피를 1.5L나 마셔버렸지만…그래도 나는 이러한 과정이 있어서 매일매일 개발자로서 성장해나가고 있다고 생각한다.