Vanilla TS 로 중첩 라우팅 SPA 라우터 만들기

차차·2024년 1월 11일
7
post-thumbnail

그동안 route 세팅과 관련 로직 작성을 주로 담당하기도 했고, 이것저것 구현해 볼 때 꼭 필요한 기능이라는 생각이 들어서, 중첩 라우팅이 가능한 페이지 라우터를 직접 구현해봤다!
순수 자바스크립트만으로 구현하려다, 타입스크립트 친구를 데려오고 말았다. 객체를 많이 활용하는 코드를 작성할 때 타입스크립트가 상당히 많은 도움을 주기 때문이다. 타며들었다...


어떤 라우터?


단순히 라우팅 기능을 개발하는 것이 아니라, 미래의 내가 써먹을 수 있는 라우터 자체를 구현하기!

  • 재사용성
    • 이 코드만 떼어다가 다른 프로젝트에서도 써먹을 수 있어야 한다.
  • 보통의 라우터
    • 익숙한 코드 인터페이스, 기본적인 기능은 장착해야 한다. 보편적으로 !
  • 성능 고려
    • 어느 정도의 렌더링 성능 고려하기

재사용성 생각하기

이 코드만 떼어다가 다른 프로젝트에서도 써먹을 수 있어야 한다.

아래는 다시 쓸 수 없는 라우팅 기능의 예시이다.
이전에 Notion 클로닝 프로젝트를 진행했던 적이 있는데, App 함수 내에서 notion 이라는 도메인과 강력하게 엮여있는 코드를 볼 수 있었다.
다시 읽는 입장에서는 어떤 기능을 하는 라우터인지 해석하기도 어려웠다.

// App.js

this.route = () => {
  const currentUrl = router.getUrl();
  navPage.setState();

  if (currentUrl === '/') {
    editPage.setState({ id: null });
    navPage.setState({ selected: null });
  } else {
    const { id } = router.getQuery();
    editPage.setState({ id });
  }
};

this.route();
router.observe(this.route);

게다가 이것은 App 함수 안에 써져 있는 코드일 뿐 .. 어딘가로 분리하는 것도 할 수 없다.
따라서, 분리 가능 + 도메인 연관 X 인 라우터를 구현하기로 했다.


보통의 라우터

익숙한 코드 인터페이스, 기본적인 기능은 장착해야 한다. 보편적으로 !

그동안 편리한 프로젝트를 위해서 공통 커스텀 훅과 공통 컴포넌트를 구현하고 써먹으면서 깨달은 바가 있다. 그 장치를 활용할 때 드는 러닝 리소스를 최소화해야 한다는 것이다. 엄청난 기능이 들어있지 않은 이상, 인터페이스가 과하게 특이하거나 사용할 때의 제약이 많으면 결국 사용하지 않게 되는 것 같다.

또한, 기본적인 기능은 있어야 하기에 기존에 구현해보지 않았던 ‘중첩 라우팅’ 을 챌린지로 삼았다.

이런 요구사항들을 만족하는 react-router-domcreateBrowserRouter 코드 인터페이스를 가져와서 써보기로 했다.

// router 생성
const router = createBrowserRouter([
  {
    path: "/",
    element: <Home />,
    children: [
      {
        path: "about",
        element: <About />,
      },
			{
        path: "detail",
        element: <Detail />,
      },
    ],
  },
]);

// outlet
const Home = () => {
	return (
		<div>
			<h1>Home</h1>
			<Outlet />
		</div>
	)
}

createBrowserRouter 메소드로 라우터를 생성해주면, Outlet 컴포넌트에서 children 에 넣어준 친구들이 보여지는 방식이다.


성능 고려하기

어느 정도의 렌더링 성능 고려하기

/list 이면, list 페이지를 렌더링한다.
/list/item 이면, list 페이지와 그 안의 item 페이지를 렌더링한다.
/list/item1/list/item2 경로 변경이 발생하면?

item1, item2 가 list 페이지 내의 중첩된 라우트라고 가정할 때, 변경이 일어난 Outlet 부분만 다시 실행되어야 한다. 왜냐하면 list 페이지 자체는 변하지 않기 때문이다!
url 이 바뀔 때 전체 페이지를 다시 로드하는 것이 아니라, 바뀐 route 부분만 리렌더링하도록 구현하고 싶었다.



구현 하기


createRouter 와 Router

일단 코드 인터페이스를 적어보고 뒤에서 어떤 일들이 일어나야 하는지 생각했다.

createRouter([
  {
    path: "/",
    element: <Home />,
    children: [
      {
        path: "/about",
        element: <About />,
      },
			{
        path: "/detail",
        element: <Detail />,
      },
    ],
  },
]);

이렇게 createBrowser 를 실행하면, Router 인스턴스가 생성되는 싱글톤 패턴임을 추측할 수 있었다.
createRouter 는 constructor 에 들어가는 매개변수를 전달하여 생성된 Router 인스턴스를 반환해주는 친구인 것이다.
생성된 Router 는 url 과 매칭된 페이지를 반환해주는 등의 라우팅 기능을 실행할 것이다.

class Router {
  routes;

  constructor(routes: Route[]) {
    this.routes = routes;
  }
}

const createRouter = (routes: Route[]) => new Router(routes);
export default createRouter;

Route 객체의 타입은 아래와 같다.

export interface Route {
  path: string;
  component: (page: HTMLElement) => void;
  children?: Route[];
}
  • path : 경로
  • component : 렌더링 함수
    • 이 친구가 왜 pageElement 를 전달하게 되었는지는.. 이따가 설명하겠다!
  • children : 자식 Route 객체 배열

결국 Route 배열은 트리구조를 가진다. 재귀 함수를 많이 작성하게 될 거라는 생각이 들었다. 하하


Router 에서 해야하는 일

그렇다면 Router 클래스 안에서는 어떤 일들을 해주어야 할까?
기본적으로는 아래와 같은 기능을 수행해야 한다.

  1. 최초 url 을 참조하여 해당되는 페이지 보여주기 + history 추가
  2. url 이 수정되면, 변경된 페이지 보여주기 + history 추가

여기서 주의해야 할 점은, url 이 수정되는 케이스가 많다는 것이다.
브라우저 내에서 뒤로가기/앞으로가기 를 수행할 수도 있고, 사용자가 직접 페이지 navigate 를 할 수도 있다.

따라서 url 이 수정되는 것을 하나의 이벤트로 분리하고, 해당되는 액션이 발생할 때 마다 이벤트를 발행해야 한다!

  • navigate 커스텀 이벤트 붙이기
window.addEventListener('navigate', (event) => {
  /* 
			1. 수정된 url 가져오기
			2. 렌더링해주기
			3. history 에 추가
	*/
});
  • navigate 이벤트 발행하기
const navigateEvent = new CustomEvent('navigate', {
  detail: { path: '새로운 url' },
});
window.dispatchEvent(navigateEvent);

완성된 Router 는 아래와 같다.
class Router {
  // ...
  constructor(routes: Route[]) {
    this.routes = routes;
    this.init();

		const worker = routeWorker(this.routes);
    this.render = worker.render;
  }

  init() {
		// 최초 url 렌더링
    this.render(window.location.pathname);

		// navigate 이벤트 붙이기
    window.addEventListener('navigate', (event) => {
      const { path } = (event as CustomEvent).detail;
      this.render(path, window.location.pathname);
      window.history.pushState({}, '', path);
    });
  }
}

// navigate 이벤트를 발행하는 함수
export const navigate = (path: string) => {
  const navigateEvent = new CustomEvent('navigate', {
    detail: { path },
  });
  window.dispatchEvent(navigateEvent);
};

url 에 맞는 Route 찾기

가장 기본적이고 필수적인 기능이다!
url 에 맞는 페이지를 보여주려면, Route 배열로부터 적절한 객체를 찾아서 페이지 컴포넌트를 실행시켜야 한다.

만약 페이지 주소가 /list/item1 이라면,
찾아야 하는 Route 의 객체는 / 안의 /list 안의 /item1 객체일 것이다.

재귀적으로 탐색을 진행 해야겠다는 아이디어를 얻을 수 있었다.

const findRoute = (
  routes: Route[],
  url: string,
): Route => {
  for (const route of routes) {
    
    if ('url에 해당되는 route객체') {
      return route;
    }
    if (route.children) {
      const childRoute = findRoute(route.children, url);
      if (childRoute) {
        return childRoute;
      }
    }
  }
  return null;
};

Route 배열로부터 적합한 Route 객체를 탐색하는 방법은 두 가지가 있다.

  1. url 을 앞에서부터 자르면서 적합한 path 찾아나가기
    = 무언가를 지우면서 재귀 호출
  2. path 를 추가하면서 url 과 똑같아질 때 까지 탐색하기
    = 무언가를 더하면서 재귀 호출

첫번째 방법의 경우, ‘어떤 기준으로 자르는가’ 의 기준이 애매하다.
아래와 같은 케이스를 처리하기 곤란하기 때문이다.

// 중첩된 페이지로 렌더링 하고 싶지는 않지만, 
// 중첩된 라우트는 가지고 싶을 때 이와 같이 쓰곤 한다.
[
	{ path : '/list' },
	{ path : '/list/item'}
]

url segment 를 나누는 / 문자를 기준으로 자르면, /list/item 을 한 덩어리로 보지 못하게 된다. 결국 /listchildren 을 찾아 떠날 것이다.


따라서 두번째 방법으로 구현할 수 있었다.

초기값인 ‘/’ 인 currentPath 가 url 과 동일해 질 때까지 route 배열 탐색

  1. route 의 path 를 currentPath 에 붙인다.
  2. currentPath 가 url 과 부합할 경우, 해당 route 객체를 반환한다.
    1. url 에 parameter 가 존재할 때, 이를 포함하여 반환한다. (RouteWithParams)
  3. 맞지 않지만 children 이 있을 시, 같은 과정을 children 에서 실행한다.
const findRoute = (
	url: string,
  routes: Route[],
  currentPath: string
): RouteWithParams | null => {

	// routes 배열을 순회하면서 검사
  for (const route of routes) {

		// currentPath 에서 route.path 더하기
    const path = (currentPath + route.path).replace('//', '/');

		// 더한 경로가 url 과 부합하는지 검사
    const { isMatched, params } = matchPathToUrl(path, url);

		// 부합한다면 Route 객체 반환
    if (isMatched) {
      return { ...route, params };
    }

		// 아니라면 children 배열에서 다시 검사
    if (route.children) {
      const childRoute = findRoute(url, route.children, path);

      if (childRoute) {
        return childRoute;
      }
    }
  }
  return null;
};
// findRoute 반환값
// url : /post/123

{
	path: '/post/:id',
	component: (page) => { /* 렌더링 */ },
	children: [],
	params: { id : '123' }
}

위에서 matchPathToUrl 은 만들어진 path 가 url 과 맞는지 검사하는 함수이다.

/post/:id 와 같이 파라미터를 포함한 path 를 고려하여 구현하였다.
/ 문자를 기준으로 각 segment 가 동일한지 검사하고, 파라미터를 포함하는 segment 일 경우 해당 파라미터를 반환한다.

const matchPathToUrl = (path: string, url: string) => {
  const params: Params = {};
	
	// 파라미터 없이 아예 똑같은 경로라면 바로 반환
  if (path === url) return { isMatched: true, params };

  const pathSegment = path.split('/');
  const urlSegment = url.split('/');

	// 같은 경로인지를 나타내는 Boolean 값
  const isMatched =
    pathSegment.length === urlSegment.length &&
    pathSegment.every((seg, i) => {
      if (seg === urlSegment[i]) return true;
      else if (seg.startsWith(':')) {
				// 파라미터 segment 일 경우      
        params[seg.slice(1)] = urlSegment[i];
        return true;
      }
      return false;
    });

  return { isMatched, params };
};

path = /post/:id, url = /post/123 일 경우 아래와 같은 객체를 반환한다.

{
	isMatched: true,
	params: { id : '123' }
}

페이지 렌더링하기

url 에 맞는 route 객체를 찾았다면, 이제 그에 맞게 페이지를 보여줘야 한다.

1. 페이지 완성하기

현재 주소가 /list/item 이라면, findRoute 함수는 아래와 같은 객체를 반환할 것이다.

// url = /list/item

{
	path: '/item'
	component: (page) => { /* 렌더링 */ }
}

여기 있는 component 함수를 그대로 실행시켜주면 렌더링이 끝나는걸까?

현재 url 을 보면 / > /list > /item 이런 형태의 중첩 라우트라는 것을 알 수 있다.
//list 에 해당되는 컴포넌트도 실행되어야 한다.

따라서, 적합한 route 를 재귀적으로 탐색하는 과정에서 부모 route 들도 어딘가에 저장해야 한다.
이를 위해서 거쳐온 route 객체들도 함께 반환하도록 findMathcingRoutes 함수를 작성했다.

export const findMatchingRoutes = (routes: Route[], url: string) => {
	// 거쳐온 Route 들을 저장하는 배열
  const routeFromRoot: Route[] = [];

  const findRoute = (
    routes: Route[],
    currentPath: string
  ): RouteWithParams | null => {
    for (const route of routes) {
      // 생략

      if (route.children) {
        const childRoute = findRoute(route.children, path);
				
        if (childRoute) { // 자식 route 가 매칭될 시, 부모 route 를 배열에 추가
          routeFromRoot.push(route);
          return childRoute;
        }
      }
    }
    return null;
  };

	// findRoute 실행
  const matchedRoute = findRoute(routes, '/');

	// 최종 종착지를 routeFromRoot 에 저장
  if (matchedRoute) routeFromRoot.unshift(matchedRoute);

  return { match: matchedRoute, routes: routeFromRoot };
};

이제 findRoute 함수는 Route 배열을 탐색하면서 최종 Route 를 찾을 뿐만 아니라, 거쳐온 부모 Route 를 하나씩 저장해 나간다.
findMatchingRoutes 는 내부에서 findRoute 를 실행한 후, 최종 Route 객체와 거쳐온 Route 들의 배열을 반환한다.

// url = /list/item

{
	match: { path: '/item' ... },
	routes: [
		{ path: '/item', component: ... },
		{ path: '/list', component: ... },
		{ path: '/', component: ... }
	]
}

반환된 routes 배열을 뒤에서 부터 pop 하면서 component 를 실행하면 된다!

2. 페이지 넣어주기

url 에 맞게 page component 가 순서대로 실행된다. 그렇다면 어디에 페이지가 보여져야 하는걸까?
react-router-dom 의 Outlet 컴포넌트에서 힌트를 얻을 수 있었다.

// '/list' 에 해당하는 페이지 컴포넌트
<div>
	<h1>List</h1>
	<Outlet/> // '/item' 이 보여지는 부분
</div>

outlet 컴포넌트 내에서 children 에 해당하는 페이지들이 보여진다. outlet 컴포넌트를 넣어주지 않으면 자식 페이지들은 렌더링되지 않는다.

Route 의 depth 만큼 outlet 이 중첩되고, 그 안에 페이지가 보여져야 한다.
일단 element 의 id 값으로 outlet 을 설정하도록 하자!

/list/item 페이지의 경우, DOM 이 아래와 같이 완성되는 것이다.

<div id='app'> 
  Home Page
  <div id='outlet'>
    List Page
    <div id='outlet'>
      Item Page
    </div>
  </div>
</div>

route 배열에서 몇 번째로 pop 되었는 지를 통해 depth 를 알 수 있다.

[
	{ path: '/item' ... }, // depth : 2  => '#outlet #outlet'
	{ path: '/list' ... }, // depth : 1  => '#outlet'
	{ path: '/' ... },     // depth : 0  => '#app'
]

이 depth 를 사용하여 outlet element 를 찾는 querySelector 를 실행한다.

const getOutletElement = (depth: number) => {
  const selector = Array(depth).fill('#outlet').join(' ') || '#app';
  return document.querySelector<HTMLElement>(selector);
};

이제 getOutletElement 에서 반환된 outlet 안을 채워주면 된다.
그렇기 때문에, route.component 에 들어가야 하는 함수는 outlet element 를 받아서 그 안에 template 을 채워넣는 친구인 것이다.

component: (page: HTMLElement) => void;

효율적으로 렌더링하기

여기까지 했다면 route 배열을 pop 하면서 전체 component 를 실행해줘도 문제 없이 동작한다.
하지만 처음에 세운 목표대로, url 이 바뀔 때 전체 페이지를 다시 로드하는 것이 아니라 바뀐 route 부분만 리렌더링하도록 구현해보았다.

/list/item1/list/item2 경로 변경이 발생하면, item1 depth 에 해당하는 outlet 만 다시 채워넣는 것이다.
route 배열이 달라지는 순간부터 component 함수가 실행되어야 한다.

url 이 달라지는 케이스는 총 3가지가 있다.

  1. 최초 로드 : 최상위 페이지 부터 차례대로 칠해 져야 한다.

  2. 상위 route 로 이동 : 하위 페이지가 지워져야 한다.

    • /list/item1/list : item1 지우기
  3. 그 외 : 변경된 route 의 페이지부터 칠해져야 한다. 이미 칠해진 부모 페이지는 건드리지 않는다.

    • /post/123/list : list 부터 새로 칠하기
    • /list/item1/list/item2 : item 부터 새로 칠하기

outlet 은 새로 칠해질 수도, 지워질 수도 있다.

getOutletElement 에서 반환된 outlet 을 수정하는 함수들을 작성했다.

// depth 에 해당하는 outlet 지우기
export const removeOutletElement = (depth: number) => {
  const $outlet = getOutletElement(depth);
  if ($outlet) {
    $outlet.innerHTML = '';
  }
};

// depth 에 해당하는 outlet 채우기
export const paintOutletElement = (
  depth: number,
  paint: (outlet: HTMLElement) => void
) => {
  const $outlet = getOutletElement(depth);
  if ($outlet) {
    paint($outlet);
  }
};

마지막으로, url 변경 방식에 따라 페이지를 렌더링하는 함수 renderRoute 를 구현했다.

  1. findMatchingRoutes 를 통해 이전 주소와 변경된 주소의 route 배열을 얻는다.

    1. prevRoutes, nextRoutes
  2. nextRoutes 배열이 비워질 때 까지, 해당되는 outlet 에 페이지를 채워 넣는다.

    1. prevRoutes 와 nextRoutes 를 뒤에서부터 지워가면서 비교한다.
    2. route.path 가 달라지는 시점부터 paintOutletElement 를 실행한다. (renderStart = true)
  3. prevRoutes 가 남아있다면,

    1. 상위 route 로 이동한 경우이다. (prev : /list/item1 ⇒ next : /list)
    2. prevRoutes 에 남아있는 친구들의 outlet 을 지워준다. removeOutletElement 실행!
export const renderRoute = (
  routes: Route[],
  nextPath: string, // 변경된 주소
  prevPath?: string // 이전의 주소
) => {
	// 이전 주소의 route 배열
  const { routes: prevRoutes } = findMatchingRoutes(
    routes,
    prevPath ?? '/'
  );

	// 변경된 주소의 route 배열
  const { routes: nextRoutes } = findMatchingRoutes(routes, nextPath);

	// 렌더링을 시작할 지 알려주는 트리거
	// prevPath 가 없다면 최초 렌더링으로 간주하여 처음부터 true
  let renderStart = !prevPath;

  let depth = 0;
	
	// nextRoutes 배열이 비워질 때 까지, 해당되는 outlet 에 페이지를 채워 넣는다.
  while (nextRoutes.length) {
    const nextRoute = nextRoutes.pop();
    const prevRoute = prevRoutes.pop();
    if (
      (renderStart || nextRoute?.path !== prevRoute?.path) &&
      nextRoute
    ) {
      renderStart = true; // 두 path 가 달라지는 시점부터 render start!
      paintOutletElement(depth, nextRoute.component);
    }
    depth += 1;
  }

	// prevRoutes 가 남아있다면, 해당되는 outlet 들은 지워준다.
  while (prevRoutes.length) {
    prevRoutes.pop();
    removeOutletElement(depth);
    depth += 1;
  }
};


라우터 완성


페이지 라우팅 과정

페이지 라우팅이 이루어지는 과정은 다음과 같다.

  1. createRouter 를 호출하여 페이지 라우터 설정

  2. Router 클래스에서 navigation 이벤트 연결

  3. navigate 함수에서 navigation 이벤트 발행

  4. 이전 url 과 변경된 url 를 비교하여 바뀐 부분만 렌더링

    1. findMatchingRoutes 에서 findRoute 를 재귀적으로 호출하여 거쳐온 route 배열 반환

    2. 위에서 반환된 배열을 비교하면서,

      1. getOutletElement 함수가 depth 를 받아 outlet element 를 반환

      2. 여기에 paintOutletElement 또는 removeOutletElement 실행


파일 구조

각종 함수들은 관심사에 따라 다른 파일에 분류해 두었다.

  • Router.ts

    • Router
  • routeUtils.ts

    • matchPathToUrl
    • findMatchingRoutes
  • domUtils.ts

    • getOutletElement
    • removeOutletElement
    • paintOutletElement
  • renderRoute.ts

    • renderRoute
  • routeWorker.ts

    • route 배열을 받아서 해당 데이터를 넣어준 renderRoute, findMatchingRoute 함수를 반환하는 친구이다.
      Router constructor 에서 route 배열을 넣고 worker 에서 반환한 함수를 실행한다.
    • 따라서 renderRoute 에 동일한 route 배열을 반복해서 넣어주지 않아도 된다.
// Router constructor

const worker = routeWorker(this.routes);
this.render = worker.render;

결과

의도한대로 잘 작동한다 🙂

// main.ts

createRouter([
  {
    path: '/',
    component: homePage.render,
    children: [
      {
        path: '/about',
        component: aboutPage.render,
      },
      {
        path: '/list',
        component: listPage.render,
        children: [
          {
            path: '/item1',
            component: (page) => {
              page.innerHTML = `<p>item1 페이지 입니다</p>`;
            },
          },
          {
            path: '/item2',
            component: (page) => {
              page.innerHTML = `<p>item2 페이지 입니다</p>`;
            },
          },
        ],
      },
    ],
  },
]);

남은 할 일

navigation 이 가능한 기본적인 페이지 라우터를 구현했지만, 아직 남은 과제가 몇 가지 있다. 차근차근 개선할 예정이다!

  • 라우팅/렌더링 분리 라우팅과 렌더링 로직이 함께 있다.
    렌더링 로직을 Renderer 로 따로 빼거나 하는 분리 과정이 필요할 것 같다.
  • 브라우저 history 이벤트 처리 뒤로가기, 앞으로가기 같은 이벤트를 처리해야 한다.
  • Outlet 컴포넌트 추가 outlet 을 쓰려면 무조건 id=’outlet’ 인 element 를 알아서 작성해야 한다.
    id 가 고정으로 박혀있는 컴포넌트로 만들 가치가 있다.
  • route.component 이름 수정 component 는 적합한 이름이 아닌 것 같다. painter, page, element, view 등등 다른 이름을 고민해봐야 겠다.
  • route.path 앞에 / 안붙여도 작동 되게 하기
  • NotFound(/*) 라우팅 구현, 등등..!

2개의 댓글

comment-user-thumbnail
2024년 1월 16일

재귀적인 경로 탐색 알고리즘에 렌더링 효율성까지 많은 것을 고민하셨군요..
라이브러리를 사용하지 않는 상황에서 라우팅을 어떻게 처리 해야 할지 고민이었는데, 좋은 글 잘 보고가요!! 👍👍

답글 달기
comment-user-thumbnail
2024년 2월 22일

헉,,안그래도 중첩 라우터를 어떻게 구현하면 좋을지 고민이었는데..! 덕분에 배우고갑니다bb

답글 달기