단일페이지 라우팅 동작 방식에 대하여

·

5 min read

글을 시작하며

이 글은 도서 프레임워크 없는 프론트엔드 개발의 6장. 라우팅을 참고해서 작성되었습니다.


라우팅 동작에 대한 궁금증

프론트엔드 프레임워크가 제공하는 주요 기능 중 하나는 라우팅입니다. 라우팅은 URL과 뷰를 연결하는 방식을 의미합니다. 편하게 사용하던 라우팅 기능이 내부적으로 어떻게 동작하는지 궁금해서, 단일 페이지 애플리케이션에서 라우팅을 구현하는 두 가지 방법을 살펴보았습니다.

이 과정에서 제가 궁금했던 부분은 다음과 같습니다.

  1. URL을 포함한 경로 목록을 어떻게 가져올까요?

  2. 경로가 변경되었을 때 어떻게 경로와 연결된 뷰를 렌더링할 수 있을까요?

이 두 가지 질문을 염두에 두고, 라우팅 구현 방식을 살펴보겠습니다.

SPA(Single Page Application) 라우팅 구현 방식

해시(Fragment) 식별자 이용한 방식

http://localhost:3000/#list 와 같이 해시(#) 식별자를 사용해서, 동일 페이지 내에서 섹션을 이동하는 방식을말합니다. 이 방식은 페이지 리로드 없이 섹션과 이동이 가능합니다.

이 구현 방식은 다음과 같이 동작합니다.

  1. 라우트를 추가합니다.

  2. 라우트에 진입했을 때, 해당 라우트 정보에 정의된 컴포넌트를 렌더링합니다.

아래 예제 코드를 통해 구체적으로 살펴보겠습니다.

DOM 요소에 flagment 지정하는 로직

<a href="#/">Go To Index</a>
<a href="#/list">Go To List</a>
<a href="#/dummy">Go To Dummy</a>

라우트를 매핑하고 해시 정보 변경을 감지하기 위해 이벤트 핸들러 등록하는 로직

router
  .addRoute("/", pages.home)
  .addRoute("/list", pages.list)
  .addRoute("/list/:id", pages.detail)
  .setNotFound(pages.notFound)
  .listen();

라우터 동작 방식

  1. 경로 등록 : addRoute 메서드 호출하여 URL 패턴과 컴포넌트를 등록합니다. URL 패턴에서 :id와 같은 동적 매개변수는 정규식을 사용해 변환됩니다.

  2. 경로 매칭 및 매개변수 추출 : listen 메서드를 통해 URL의 해시값 변경을 감지합니다. checkRoutes 메서드를 통해 현재 경로와 매칭되는 경로를 찾습니다. 경로가 매칭되면 extractUrlParams 를 통해 URL에서 동적으로 매개변수를 추출해서 컴포넌트에 전달합니다.

  3. 컴포넌트 렌더링 : 매칭되는 경로가 있으면 해당 경로의 컴포넌트가 URL 매개변수와 함께 실행되고, 매칭되는 경로가 없으면 setNotFount 가 실행됩니다.

코드의 흐름을 기억하면서 내부 로직을 더 자세히 살펴봅시다.

// :id와 같은 매개변수 이름 추출
const ROUTE_PARAMETER_REGEXP = /:(\w+)/g;
const URL_FRAGMENT_REGEXP = `([^\\/]+)`;

// 라우터 생성 함수
export default () => {
  const routes = [];
  let notFoundHandler = () => {};

  // 각 프래그먼트별로 컴포넌트를 매핑한 정보를 routes 배열에 추가
  const addRoute = (fragment, component) => {
    const params = [];
    const parsedFragment = fragment
      .replace(ROUTE_PARAMETER_REGEXP, (_, paramName) => {
        params.push(paramName);
        return URL_FRAGMENT_REGEXP;
      })
      .replace(/\//g, `\\/`);

    routes.push({
      testRegExp: new RegExp(`^${parsedFragment}$`), // 정확한 매칭을 위해 $ 추가
      fragment,
      component,
      params,
    });

    return router; // 체이닝 지원
  };

  // 매칭되는 라우트가 없는 경우 실행할 핸들러 설정
  const setNotFound = (handler) => {
    notFoundHandler = handler;
    return router; // 체이닝 지원
  };

  // 해시 정보가 변경되었을 때 라우트를 체크하는 로직 실행
  const listen = () => {
    window.addEventListener("hashchange", checkRoutes);

    if (!window.location.hash) {
      window.location.hash = "#/";
    }

    checkRoutes(); // 초기 렌더링
    return router; // 체이닝 지원
  };

  // URL 매개변수 추출
  const extractUrlParams = (route, windowHash) => {
    if (route.params.length === 0) return {};

    const params = {};
    const matches = windowHash.match(route.testRegExp);

    matches.shift(); // 전체 매칭 제거

    matches.forEach((paramValue, index) => {
      const paramName = route.params[index];
      params[paramName] = paramValue;
    });

    return params;
  };

  // 현재 프래그먼트와 일치하는 경로 찾기 및 컴포넌트 렌더링
  const checkRoutes = () => {
    const { hash } = window.location;
    const currentRoute = routes.find((route) => route.testRegExp.test(hash));
    if (!currentRoute) {
      notFoundHandler();
      return;
    }

    const urlParams = extractUrlParams(currentRoute, hash);
    currentRoute.component(urlParams);
  };

  const router = {
    addRoute,
    setNotFound,
    listen,
  };

  return router;
};

해시 방식의 한계

  • 브라우저의 기본 동작으로 페이지 리로드 없이 섹션 간 이동을 처리할 수 있지만 같은 페이지 내에서만 동작하기 때문에 페이지 전환이 불가합니다.

  • 사용자의 탐색 히스토리 제어가 어렵습니다.

그러면 어떤 대안이 있을까요? 다음에 설명할 History API는 해시 없이 브라우저 기록을 제어하며 페이지 전환처럼 동작할 수 있습니다.

History API를 이용한 방식

History API 를 활용하면 브라우저의 탐색 히스토리를 스택(stack)으로 관리하기 때문에, URL에 맞는 뷰를 동적으로 업데이트할 수 있습니다. 해시 방식과 다르게 URL 매핑하는 추가 작업을 따로 해주지 않아도 됩니다. 왜냐하면 실제 페이지 전환을 처리하는 것처럼 동작하기 때문입니다.

아래 예제 코드를 통해 구체적으로 살펴보겠습니다.

data-navigation 속성이 사용된 이유

<a>태그를 클릭하면 기본적으로 URL이 변경되고 브라우저가 새로운 페이지(index.html)를 로드합니다. 하지만 단일 페이지 애플리케이션에서는 페이지 리로드 없이 내부 탐색을 처리하기 위해, 하이퍼 링크가 아닌 라우팅 동작을 하도록 설정하기 위해서 data-navigation이 추가되었습니다. 이 속성을 사용하면, 클릭 이벤트 발생 시 모든 data-navigation 속성을 가지는 요소에서 DOM 기본 동작(링크 이동)을 비활성화하여 대신 라우팅 동작을 처리할 수 있습니다.

  <a data-navigation href="/">Go To Index</a>
  <a data-navigation href="/list">Go To List</a>
  <a data-navigation href="/list/1">Go To List Detail With Id 1</a>

라우트를 매핑하고 이벤트 핸들러 등록하는 로직

router
  .addRoute("/", pages.home)
  .addRoute("/list", pages.list)
  .addRoute("/list/:id", pages.detail)
  .addRoute("/list/:id/:anotherId", pages.anotherDetail)
  .setNotFound(pages.notFound)
  .listen();
const ROUTE_PARAMETER_REGEXP = /:(\w+)/g;
const URL_FRAGMENT_REGEXP = `([^\\/]+)`;
const INTERVAL_TIME = 250; // URL 변경을 확인할 간격

// 라우터 생성 함수
export default () => {
  const routes = [];
  let notFoundHandler = () => {};
  let lastPathname = "";

  const addRoute = (fragment, component) => {
    const params = [];
    const parsedFragment = fragment
      .replace(ROUTE_PARAMETER_REGEXP, (_, paramName) => {
        params.push(paramName);
        return URL_FRAGMENT_REGEXP;
      })
      .replace(/\//g, `\\/`);

    routes.push({
      testRegExp: new RegExp(`^${parsedFragment}$`),
      fragment,
      component,
      params,
    });

    return router;
  };

  const setNotFound = (handler) => {
    notFoundHandler = handler;
    return router;
  };


  const navigator = (path) => {
    // 브라우저 히스토리 스택에 새로운 항목 추가  
    window.history.pushState(null, null, path);
  };

  const listen = () => {
    checkRoutes();
    // URL 변경을 감지할 수 있는 DOM 이벤트가 없어서 setInterval 사용
    window.setInterval(checkRoutes, INTERVAL_TIME)
  };

  const extractUrlParams = (route, pathname) => {
    if (route.params.length === 0) return {};

    const matches = pathname.match(route.testRegExp);
    if (!matches) return {};

    matches.shift();

    matches.forEach((paramValue, index) => {
      const paramName = route.params[index];
      params[paramName] = paramValue;
    });

    return params;
  };

  const checkRoutes = () => {
    const { pathname } = window.location;
    if (lastPathname === pathname) return;
    lastPathname = pathname;

    const currentRoute = routes.find((route) =>
      route.testRegExp.test(pathname)
    );

    if (!currentRoute) {
      notFoundHandler();
      return;
    }

    const urlParams = extractUrlParams(currentRoute, pathname);
    currentRoute.component(urlParams);
  };

  const router = {
    addRoute,
    setNotFound,
    navigator,
    listen,
  };

  return router;
};

마무리

프레임워크 없는 프론트엔드 책을 통해 프레임워크를 구성하는 핵심 요소 중 하나인 라우팅에 대해 학습할 수 있었습니다. 프레임워크에서 기능을 풀어내는 방식은 다르겠지만, 해당 기능이 어떤 개념을 기반으로 동작하는지 알게 되어서 좋았습니다. 다음에는 DOM을 어떻게 업데이트하고 변경 사항을 반영하는지, 렌더링에 대해 글을 작성할 예정입니다.