프레임워크없는 프론트엔드개발 책을 읽고 실습을 통해 라우팅 기능 구현해보기

프로젝트 소개

7월부터 F-lab의 멘토링 과정을 들었었다. 1개월 반 동안은 자바스크립트, 네트워크, 브라우저 렌더링 등 기본적인 개념들에 집중하여 학습했다. 이 과정을 통해서 단순히 지식을 습득하는 것이 아니라, 그 지식을 내 것으로 만들어, 나만의 언어로 표현하고 소화하는 것이 중요하다는 것을 깨달았다.

학습의 이론적인 부분이 마무리되면서 실습을 시작하게 되었다. 실습의 주제는 프레임워크 없는 프론트엔드 개발1이라는 책을 참고하여 기능 구현을 해보는 것이었다. 토스 개발 블로그 사이트의 목록 페이지와 상세 페이지를 프레임워크 없이 구현하는 과제였다. 이 프로젝트를 통해 느낀 점과 경험을 공유하고자 한다.

실무에서는 직접 라우팅을 구현해본 적은 없었다. 대부분 리액트나 Next.js와 같은 도구를 활용하여 개발을 진행하면서 라우팅은 그저 당연히 제공되는 기능으로 생각했다. 라우팅을 프레임워크 없이 구현을 하려면 뭐부터 해야하지? 라는 막연한 생각만 들었다. 다행히 책에 다양한 라우팅 구현 방식이 있었고 실습을 통해 차근차근 이해해보려고 노력했다.

라우팅은 사용자의 URL 요청에 따라 적절한 코드나 함수를 연결하는 메커니즘을 말한다. 즉, 사용자가 웹사이트의 특정 경로로 접속했을 때, 해당 경로와 관련된 적절한 페이지나 컨텐츠를 보여주는 역할을 한다.

주의: 이 글에서는 프레임워크를 사용하지 않고, 순수 자바스크립트만으로 라우팅 기능을 어떻게 구현했는지 그 과정을 기록한 글입니다. 틀린 내용이 있을 수 있으니 양해 부탁드립니다!

글에서 설명하고자 하는 내용

  • 프로젝트 폴더 구조
  • 라우팅 구현 과정

폴더 구조

===================================================
├── 📄index.html
├── 📄app.js
// 서버 관련
├── 📄server.js
// 라우팅 관련
├── 📁router
|   ├── 📄router.js
|   ├── 📄pages.js
===================================================

라우팅

라우팅이란?: 라우팅의 핵심 개념은 특정 URL과 함수를 매핑하고, 접속한 URL을 통해 매핑된 함수를 호출하여 해당 페이지를 렌더링 하는 것이다. 다시 말해서 URL에 따라 페이지를 내용을 교체하는 것이다.

라우팅 기본 흐름

코드의 기본 흐름은 다음과 같다.

  • ROUTE_PARAMETER_REGEXPURL_FRAGMENT_REGEXP는 동적 URL 패턴을 처리하기 위한 정규표현식.
  • addRoute 메서드는 주어진 패턴의 경로와 콜백 함수를 routes 객체에 등록.
    • addRoute(path, callback) 함수 호출로 원하는 경로와 해당 경로에서 실행될 콜백 함수를 등록한다.
    • 이때, 경로에 동적 요소가 포함되어 있다면, 예를 들어 /article/:id와 같은 패턴으로 주어진다면, 정규표현식을 사용하여 해당 동적 요소를 적절하게 파싱한다.
  • checkRoutes 메서드는 현재 URL을 확인하고 일치하는 라우트의 콜백을 실행.
    • checkRoutes() 함수는 등록된 모든 라우트 중 현재 URL에 일치하는 라우트가 있는지 확인한다.
    • 일치하는 라우트가 있다면 해당 라우트의 콜백 함수를 호출하고, 없다면 notFound 콜백을 실행한다.
  • navigate 메서드는 주어진 경로로 이동하고 해당 라우트의 콜백을 실행.
    • 웹 페이지 내에서 라우팅을 원하는 버튼이나 링크를 클릭하면, navigate(path) 함수를 호출한다.
    • 이 함수는 브라우저의 히스토리를 업데이트하고, 다시 checkRoutes() 함수를 호출하여 적절한 라우트 콜백을 실행한다.
  • init 메서드에서는 라우터를 초기화하고 필요한 이벤트 리스너를 등록.
    • init() 함수를 호출하여 라우터를 초기화한다.
    • 현재 페이지의 URL을 확인하고 해당하는 라우트의 콜백을 호출하는 checkRoutes() 함수를 실행한다.
    • 또한, 사용자가 브라우저의 뒤로 가기 버튼 등을 클릭할 경우를 대비해 popstate 이벤트 리스너를 등록한다.

router.js

/* 
 URL 경로의 동적 세그먼트를 식별
예) /article/:id 에서 :id 등의 패턴을 검색하는 정규 표현식
\w+는 하나 이상의 알파벳, 숫자, 밑줄을 의미
*/
const ROUTE_PARAMETER_REGEXP = /:(\w+)/g;

/* 
URL의 일부분을 대응시키기 위한 정규 표현식
URL의 세그먼트를 캡쳐
[^\\/]는 슬래시를 제외한 모든 문자를 의미
*/
const URL_FRAGMENT_REGEXP = "([^\\/]+)";

/* 
위 두 정규표현식으로
예)  /article/:id라는 패턴을 가진 라우트를 등록할 경우
ROUTE_PARAMETER_REGEXP는 :id를 감지하고 이를 URL_FRAGMENT_REGEXP로 교체
이는 /article/([^\\/]+) 정규표현식이 되며, 'article/123' 같은 실제 URL과 매치됨
*/

/**
 * 현재 URL에서 동적 라우트 매개변수의 값을 추출
 * @param {*} route - 주어진 라우트
 * @param {*} pathname - 경로명을 기반으로
 * @returns  params 파라미터 추출
 */
const extractUrlParams = (route, pathname) => {
  const params = {};
  if (route.params.length === 0) {
    return params;
  }
  const matches = pathname.match(route.testRegExp);
  matches.shift();
  matches.forEach((paramValue, index) => {
    const paramName = route.params[index];
    params[paramName] = paramValue;
  });
  return params;
};

class Router {
  #routes;
  #notFound;
  #lastPathname;

  constructor() {
    this.routes = [];
    this.notFound = () => {};
    this.lastPathname = "";
  }

  /* 
  현재 URL을 확인하고 일치하는 라우트의 콜백을 실행
  일치하는 라우트 없을 경우 notFound 콜백 실행
  */
  checkRoutes() {
    const { pathname } = window.location;
    if (this.lastPathname === pathname) {
      return;
    }
    this.lastPathname = pathname;
    const currentRoute = this.routes.find((route) => {
      const { testRegExp } = route;
      return testRegExp.test(pathname);
    });

    if (!currentRoute) {
      this.notFound();
      return;
    }

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

  /**
   * routes 객체에 path, 콜백함수 인자로 받아
   *
   * @param {*} path - url, url/:경로매개변수
   * @param {*} callback - 렌더함수
   * @returns router 객체에 경로, 해당경로에 대한 콜백 등록
   */
  addRoute(path, callback) {
    const params = [];
    const parsedPath = path
      .replace(ROUTE_PARAMETER_REGEXP, (match, paramName) => {
        params.push(paramName);
        return URL_FRAGMENT_REGEXP;
      })
      .replace(/\//g, "\\/");
    this.routes.push({
      testRegExp: new RegExp(`^${parsedPath}$`),
      callback,
      params,
    });
    return this;
  }

  /* 일치하는 라우트 없을 떄 실행할 콜백 설정 */
  setNotFound(cb) {
    this.notFound = cb;
    return this;
  }

  /* 주어진 경로로 이동하고 해당 라우트의 콜백을 실행 */
  navigate(path) {
    window.history.pushState(null, null, path);
    this.checkRoutes();
  }

  /** 초기화 - navigation 버튼 이벤트 리스너 등록*/
  init() {
    this.checkRoutes();
    window.addEventListener("popstate", this.checkRoutes.bind(this));

    /* 
    내베기에션 버튼 이벤트 리스너 설정
    data-navigate 속성 값을 가져와 router.navigate() 메서드 호출하여 해당 경로로 이동
    */
    const NAV_BTN_SELECTOR = "[data-navigate]";
    document.body.addEventListener("click", (e) => {
      // 클릭 대상의 가까운 부모 중 data-navigate 속성을 갖는 요소 탐색
      const target = e.target.closest(NAV_BTN_SELECTOR);
      if (target !== null && target.matches(NAV_BTN_SELECTOR)) {
        const { navigate } = target.dataset;
        this.navigate(navigate);
      }
    });
  }
}

export default Router;

pages.js

여러 페이지 함수들을 관리하고, 사용자가 특정 경로에 접근할 때 적절한 페이지 뷰를 렌더링하는 역할을 한다.

  • home (메인 페이지)
  • tech (목록페이지 - 개발)
  • design (목록페이지 - 디자인)
  • article (상세페이지)
export default (container) => {
  const home = () => {
    container.textContent = "메인 페이지입니다.";
  };

  const tech = () => {
    container.textContent = "목록(개발) 페이지입니다!";
  };

  const design = () => {
    container.textContent = "목록(디자인) 페이지입니다!";
  };

  const article = (params) => {
    container.textContent = "상세(아티클) 페이지입니다!";
  };

  const notFound = () => {
    container.textContent = "페이지 없음!";
  };

  return {
    home,
    tech,
    design,
    article,
    notFound
  };
};

app.js

해당 실습 프로젝트의 메인 진입점 역할을 한다. 이곳에서 라우터의 설정, 페이지 렌더링을 초기화하며, 사용자의 URL 경로 요청에 따라 적절한 페이지를 렌더링한다.

import createPages from "./router/pages.js";
import Router from "./router/router.js";

/* 컨테이너 설정 */
const container = document.querySelector("#app");

/* 컨테이너 기반으로 페이지 렌더링 함수들을 생성 */
const pages = createPages(container);

/* 
라우트 설정
경로 - 경로에 맞는 함수 호출되도록 설정
일치하는 경로 없을 경우 page.notFound 호출
init() 메서드로 라우터 시작
- 초기 경로 확인, 페이지 내에서 라우트 변경을 감지하기 위한 리스너 설정
*/
const router = new Router();
router
  .addRoute("/", pages.tech)
  .addRoute("/tech", pages.tech)
  .addRoute("/design", pages.design)
  .addRoute("/article/:id", pages.article)
  .setNotFound(pages.notFound)
  .init();

index.html

각 버튼을 눌렀을 때 해당 페이지의 내용으로 교체하는 방식으로 기능을 구현했다.

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>프레임워크없는 프론트엔드개발 실습</title>
  </head>
  <body class="caret-transparent">
    <div id="app"></div>
    <button data-navigate="/">메인</button>
    <button data-navigate="/tech">개발 목록 페이지</button>
    <button data-navigate="/design">디자인 목록 페이지</button>
    <button data-navigate="/article/1">아티클 상세 페이지</button>
    <button data-navigate="/qwertt">페이지 없음!</button>
    <script type="module" src="/app.js"></script>
  </body>
</html>

프로젝트 링크: 위 내용을 적용한 예제 코드 링크 : https://codesandbox.io/s/frameworkless-router-358qkk?file=/router/pages.js:0-490

회고

이번 포스팅을 통해서 프레임워크 없는 애플리케이션 개발은 생각해본 적 없던 나에게 라우팅 기능이 어떻게 작동되는지를 파악할 수 있도록 해준 실습이었다. 해당 페이지로 내용을 교체하는 것은 구현해봤으니, 다음엔 페이지에 렌더링 하는 작업을 진행할 예정이다.

중요한 것은 기능을 작게 구현해보는 것, 작은 단위로 생각해보는 것이다. 추가적인 개선을 통해 더 풍부한 기능이 될 수 있도록 노력해보는 것은 추후에도 할 수 있으니 핵심 기능을 작게 잘라보는 연습을 해보자.

다음 기록은 렌더링 함수 편으로 정리해보자.