항해플러스 프론트엔드 5기 후기(1주차) - 프레임워크 없이 SPA 만들기(Router)

유한별·2025년 3월 29일
7
post-thumbnail

리액트를 쓰다 보면 라우팅이 당연하게 느껴진다.
Route, useNavigate 같은 걸 사용해 컴포넌트만 연결해주면 일단 다 되니까.
깊게 생각할 일 없이도 페이지 전환은 잘 되고, 주소도 알아서 바뀐다.

이번 과제를 시작할 때도 마찬가지였다.
‘라우터 하나 만들면 되는 거 아냐?’ 싶은 마음으로 가볍게 시작했다.
하지만 막상 하나하나 직접 구현해보니, 생각보다 손이 많이 갔다.

라우터의 동작부터 이벤트 처리 방식, 히스토리 API, 그리고 정적 서버 위에 SPA를 올릴 때 마주하게 되는 문제들까지.

프레임워크가 다 해주던 걸 바닐라 JS로 구현해보며 내가 얼마나 많은 걸 ‘당연하다’고 여기고 있었는지를 실감하게 됐다.

이번 글에서는 과제를 진행하며 직접 부딪히고 고민했던 것들, 그리고 그 과정에서 배운 점들을 정리해보려 한다.

라우터 구현

처음엔 단순히 주어진 URL에 따라 location.pathname을 기준으로 해당하는 페이지 컴포넌트를 리턴하면 되겠다고 생각했다.
실제로 각 URL로 직접 접근했을 때 페이지가 정상적으로 보였고, 네비게이션 버튼을 눌러도 <a href="/어쩌구">가 잘 동작하는 것처럼 보였다.

하지만 이건 큰 착각이었다.
SPA는 화면 전환 시 깜빡임 없이(reload 없이) 콘텐츠가 바뀌어야 하며, 단순히 페이지 전체를 새로 불러오는 방식으로는 SPA의 핵심인 사용자 경험을 해칠 수밖에 없다.
게다가 a href 방식만으로는 브라우저의 뒤로 가기나 앞으로 가기 같은 기능도 제대로 동작하지 않는다.

이런 문제들을 인식하고 나서야, History API를 활용한 라우터의 필요성을 느꼈고, 직접 구현해보기로 했다.

그럼 어떤 방식으로 구현해야 할까?

많은 동료들이 클래스형과 함수형 사이에서 고민하는 것 같지만, 나는 딱히 깊이 고민하진 않았다.
'라우터라는 인스턴스를 만들어서 사용하는 구조니까, 자연스럽게 class로 만들면 되겠다'라는 생각이 먼저 들었다.

결과적으로는 이 선택이 싱글톤 패턴을 적용하기에도 좋았고, 인스턴스 내부에서 상태를 관리하기도 쉬워졌다.
무엇보다 Router라는 클래스를 통해 라우팅이라는 책임을 명확하게 분리할 수 있었다.

Router 코드

class Router {
  static instance = null;

  static getInstance(routes, options) {
    if (!Router.instance) {
      Router.instance = new Router(routes, options);
    }
    return Router.instance;
  }

  constructor(routes, options = { mode: "history", base: "/" }) {
    if (Router.instance) return Router.instance;

    this.routes = routes;
    this.mode = options.mode;
    this.base = options.base;

    // history 모드일 경우 popstate 이벤트로 경로 변경 감지
    if (this.mode === "history") {
      window.addEventListener("popstate", () => this.render());
    }

    Router.instance = this;
    this.render(); // 초기 렌더링
  }

  getCurrentPath() {
    return window.location.pathname;
  }

  navigate(to) {
    const fullPath = this.base + to.replace(/^\//, "");
    window.history.pushState({}, "", fullPath);
    this.render();
  }

  render() {
    const path = this.getCurrentPath();
    const page = this.routes[path]
      ? this.routes[path]()
      : "<h1>404 Not Found</h1>";
    document.getElementById("root").innerHTML = page;
  }
}
  • 라우터 클래스화
    Router라는 이름으로 라우팅의 책임을 명확히 분리

  • 싱글톤 패턴
    static instance를 통해 인스턴스를 한 번만 생성
    Router.getInstance()로 접근하도록 제한

  • 라우팅 모드 설정
    기본값은 "history"
    popstate 이벤트로 경로 변경 감지

  • 경로 이동 처리
    navigate(to)에서 pushState로 URL만 변경 후 렌더링

  • 현재 경로 추출
    window.location.pathname 사용

  • 렌더링 처리
    해당 경로에 매핑된 컴포넌트를 실행하고, 없으면 404 출력
    결과는 #root에 삽입

Router 초기화 방식

라우터는 앱 어디서든 사용할 수 있어야 하기에, 전역에서 접근 가능한 형태로 초기화 구조를 따로 만들었다.
직접 클래스를 생성하기보다는 initRouter()로 한 번만 초기화하고, 다른 곳에서는 getRouter()를 통해 안전하게 접근하도록 했다.

let routerInstance = null;

export const initRouter = (options = { mode: "history" }) => {
  routerInstance = Router.getInstance(routes, {
    ...options,
    base: BASE_PATH,
  });
  return routerInstance;
};

export const getRouter = () => {
  if (!routerInstance) {
    throw new Error("Router has not been initialized");
  }
  return routerInstance;
};

이런 구조라면 싱글톤 유지도 쉬워지고, 외부에서 라우터 인스턴스를 안전하게 활용할 수 있다.

로컬 스토리지를 활용한 상태 관리

과제에서는 간단한 폼을 이용해 사용자 정보를 입력하고 저장하는 기능이 포함되어 있었다.
입력된 정보는 localStorage에 저장해 관리해야 했는데, 로직 자체는 어렵지 않았지만 다른 분들의 코드를 참고하면서 관심사 분리의 필요성을 느끼게 되었다.

그래서 단순히 localStorage를 비즈니스 로직에서 직접 사용하는 대신,

  • 스토리지 접근 로직
  • 비즈니스 로직 (로그인/로그아웃/유저 정보 수정 등)
  • 인터페이스 레벨의 API

로 역할을 나누어 모듈화했다.

이를 통해 코드의 재사용성과 테스트 용이성을 높이고 유지보수가 수월하도록 구성할 수 있었다.

Storage 유틸 함수

export const storage = {
  get(key) {
    const item = localStorage.getItem(key);
    return item ? JSON.parse(item) : null;
  },

  set(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  },

  remove(key) {
    localStorage.removeItem(key);
  },
};

로컬 스토리지의 get/set/remove 동작을 하나의 유틸로 모아 사용

Auth 서비스 (비즈니스 로직)

import { storage } from "../utils/storage";

const USER_KEY = "user";

export const authService = {
  getUser() {
    return storage.get(USER_KEY);
  },

  login(user) {
    storage.set(USER_KEY, user);
  },

  logout() {
    storage.remove(USER_KEY);
  },

  isLoggedIn() {
    return this.getUser() !== null;
  },

  updateUser(userData) {
    const currentUser = this.getUser();
    storage.set(USER_KEY, { ...currentUser, ...userData });
  },
};

사용자 로그인/로그아웃, 정보 수정 등 사용자 관련된 비즈니스 로직을 분리

Auth 인터페이스

import { authService } from "../services/auth.service";

export const AuthAPI = {
  getUser: () => authService.getUser(),
  login: (username) =>
    authService.login({
      username,
      email: "",
      bio: "",
    }),
  logout: () => authService.logout(),
  isLoggedIn: () => authService.isLoggedIn(),
  updateUser: (user) => authService.updateUser(user),
};

최종적으로 외부에서는 이 인터페이스를 통해만 사용자 정보를 다루도록 구성

Hash Router

브라우저 라우터 구현 이후, 해시 라우터 구현도 필요하다는 요구사항이 있었다.
당시에는 컴포넌트에서 직접 router.navigate()를 호출하는 방식으로 구현되어 있어서, hash 버전이든 history 버전이든 최대한 변경 없이 호환되도록 만들고 싶었다.

그래서 새로운 클래스를 따로 만들기보다는, 하나의 클래스에서 두 라우터 모드를 모두 지원하는 방식으로 구성했다.
이때 싱글톤 패턴을 적용해, 앱 전체에서 하나의 라우터 인스턴스만 존재하도록 했다.

라우터 초기화 시 넘겨받는 mode 값에 따라 hash 모드 또는 history 모드로 동작하도록 구성했고,
이벤트 리스너 등록부터 경로 추출, URL 변경 방식까지 내부 메서드에서 모드에 따라 유연하게 분기 처리되도록 만들었다.

Router 코드(Hash 모드 추가)

class Router {
  static instance = null;

  static getInstance(routes, options = { mode: "history", base: "/" }) {
    if (!Router.instance) {
      Router.instance = new Router(routes, options);
    }
    return Router.instance;
  }

  constructor(routes, options) {
    if (Router.instance) return Router.instance;

    this.routes = routes;
    this.mode = options.mode;
    this.base = options.base;

    const handleRender = () => this.render();

    if (this.mode === "hash") {
      window.addEventListener("hashchange", handleRender);
    } else {
      window.addEventListener("popstate", handleRender);
    }

    Router.instance = this;
    this.render();
  }

  getCurrentPath() {
    if (this.mode === "hash") {
      return window.location.hash.slice(1) || "/";
    }

    const params = new URLSearchParams(window.location.search);
    const redirectPath = params.get("p");
    if (redirectPath?.startsWith("/")) return redirectPath;

    return window.location.pathname;
  }

  navigate(to) {
    const fullPath = to.startsWith(this.base)
      ? to
      : this.base + to.replace(/^\//, "");

    if (this.mode === "hash") {
      window.location.hash = to;
    } else {
      window.history.pushState({}, "", fullPath);
    }

    this.render();
  }

  render() {
    const path = this.getCurrentPath();
    const page = this.routes[path]
      ? this.routes[path]()
      : "<h1>404 Not Found</h1>";

    const root = document.getElementById("root");
    if (root) root.innerHTML = page;
  }
}
  • navigate(), getCurrentPath(), render() 등 주요 메서드에서 this.mode 값을 기준으로 분기 처리

  • hash 모드에서는 window.location.hashhashchange 이벤트 사용

  • history 모드에서는 pushStatepopstate 이벤트 활용

구현 과정에서 겪은 시행착오 덕분에, 해시 라우터와 히스토리 라우터의 차이뿐만 아니라 라우터 설계 자체에 대해 고민할 수 있었다.

라우트 가드

지금까지 구현한 라우터는 모든 경로에 대해 무제한 접근이 가능했다.

하지만 이는 보안상 문제가 될 수 있기 때문에, 특정 경로에 대한 접근을 제어하기 위해 라우트 가드(Route Guard)를 구현했다.

어떻게 구현할까?

라우터 내부가 아닌, routes 설정에서 각 경로별로 접근 조건을 정의하도록 했다.
각 컴포넌트 함수 안에서 먼저 가드 함수를 실행한 뒤, 그 결과에 따라 컴포넌트를 렌더링하거나 리다이렉트하도록 구성했다.

routes 코드

// routes 예시
const routes = {
  "/profile": () => {
    const guard = routeGuardService.checkProfileAccess();
    if (guard) return guard; // 예: { redirect: "/login" }
    return ProfilePage();
  },
  "/login": LoginPage,
};
import { AuthAPI } from "../interfaces/auth.interface";
import { BASE_PATH } from "../consts/path";

export const routeGuardService = {
  checkProfileAccess() {
    if (!AuthAPI.isLoggedIn()) {
      return { redirect: BASE_PATH + "login" };
    }
    return null;
  },

  checkLoginAccess() {
    if (AuthAPI.isLoggedIn()) {
      return { redirect: BASE_PATH };
    }
    return null;
  },
};

즉, routes에서 접근 조건을 판단하고, 라우터는 해당 결과가 리다이렉트인지 컴포넌트인지에 따라 처리만 해주면 된다.

React Router에서는 보통 useEffect 안에서 조건을 검사하고, 필요하면 navigate로 리다이렉트를 걸어준다.
근데 이 방식은 렌더링이 한 번 되고 나서야 조건이 실행되기 때문에, 구조가 살짝 돌아가는 느낌이 있다.

이번에는 라우터랑 컴포넌트가 그냥 함수처럼 이어져 있어서, 렌더링 전에 조건부터 체크하고 바로 리다이렉트할 수 있었다.
그래서 더 단순하고 직관적으로 흐름을 짤 수 있었던 것 같다.

이벤트 위임

라우터를 직접 구현하다 보니, 클릭이나 폼 전송 같은 사용자 이벤트도 직접 처리해야 했다.
처음엔 React처럼 각 컴포넌트 내부에 이벤트 리스너를 붙이면 될 줄 알았다.

그런데 막상 해보니 예상치 못한 문제가 생겼다.
라우터는 HTML을 동적으로 렌더링하는 구조다 보니, JavaScript가 실행되는 시점에 DOM이 아직 존재하지 않는 경우가 많았다.
결국 document.querySelector()로 원하는 요소를 찾지 못해 null 에러가 발생하거나,
렌더링이 바뀔 때마다 이벤트를 다시 등록해줘야 하는 번거로운 상황이 생겼다.

이 문제를 해결하려면 이벤트 위임(Event Delegation) 이 필요했다.
나는 이 처리를 Router 클래스의 constructor 내부에 직접 추가해, 라우터가 초기화될 때 이벤트 위임도 함께 세팅되도록 구성했다.

Router 내 이벤트 위임 코드

class Router {
  constructor(routes, options = { mode: "history", base: "/" }) {
    // ...
    
    // 클릭 이벤트 위임
    document.addEventListener("click", (e) => {
      eventService.handleNavigation(e);
      eventService.handleAuth(e);
    });

    // 폼 제출 이벤트 위임
    document.addEventListener("submit", (e) => {
      eventService.handleFormSubmit(e);
    });
  }
}

렌더링 시점마다 매번 이벤트를 등록해주는 건 번거롭고, 누락될 가능성도 있다.
프레임워크 없이 바닐라 JS로 구성된 SPA에서는 이런 구조를 안정적으로 유지하기 위해 이벤트 위임을 사용하는 게 훨씬 효과적이라는 걸 이번에 직접 체감했다.

GitHub Pages 배포

마지막으로, 이번 과제에서 가장 많은 시간을 쏟았던 배포 이야기.

처음에는 그냥 Vite로 빌드해서 GitHub Pages에 올리면 끝이겠지 싶었다.
하지만 웬걸, 메인 페이지는 잘 뜨는데 다른 경로로 직접 접근하면 404 페이지만 나왔다.

SPA 구조에서 왜 이런 문제가 생기는지, GitHub Pages가 어떤 방식으로 파일을 서빙하는지부터 다시 공부해야 했다.
그 과정에서 hash router, 404.html 리다이렉트 방식 등 다양한 해결책을 시도해봤고, 정적 서버에서 SPA를 어떻게 배포해야 하는지에 대해 많은 시행착오를 겪었다.

이 과정에서 정리한 내용을 아래 글에 자세히 담아두었다:

👉 -Vercel에선 잘 되던 SPA, GitHub Pages에선 왜 404일까?

회고

과제를 시작할 때만 해도 ‘라우터 하나 만들면 되는 거 아냐?’ 싶었다.
리액트에서 늘 써오던 것처럼, a 태그 눌렀을 때 페이지가 전환되면 끝이라고 생각했으니까.

하지만 막상 바닐라 JS로 하나하나 구현해보니, 내가 그동안 얼마나 많은 걸 프레임워크에 맡기고 있었는지 체감하게 됐다.
SPA 라우팅이라는 건 단순한 화면 전환이 아니라, 브라우저의 동작과 사용자 흐름을 섬세하게 설계하는 일이었다.

이번 과제는 단순히 기능을 구현하는 데서 끝나지 않았다.
‘왜 이렇게 동작하지?’, ‘이건 진짜 이렇게 해야 맞는 걸까?’ 같은 질문을 스스로에게 던지며, 기술적인 이해를 조금씩 쌓아나간 시간이었다.

🧠 기술적으로 얻은 것들

SPA 라우터의 본질

React Router에 익숙해져 있다 보니, 평소엔 라우팅이 그냥 알아서 잘 된다고만 생각했었다.
하지만 직접 History API 기반 라우터를 직접 구현해보니, 라우팅은 단순히 URL을 바꾸는 일이 아니라
브라우저가 어떻게 동작하는지를 이해하고, 그 흐름 위에서 사용자 경험을 설계하는 일이라는 걸 느꼈다.

Hash Router와 History Router의 차이

이전에도 HashRouter를 써본 적은 있었지만, 왜 필요한지에 대한 맥락은 몰랐다.
이번에는 GitHub Pages 같은 정적 서버 환경에서 왜 HashRouter가 유리한지, History API가 왜 404를 일으키는지까지 알게 됐다.

이벤트 위임의 필요성

React에선 컴포넌트 내부에서 직접 이벤트를 달아도 문제 없었지만, DOM을 직접 다루는 환경에선 얘기가 다르다.
렌더링 시점마다 이벤트를 바인딩하는 게 비효율적이라는 걸 느끼고, 전역 이벤트 위임 방식을 도입하게 되었다.

🤔 고민했던 지점들

정적 서버에서 SPA를 배포한다는 것

GitHub Pages는 정적 파일만 서빙하는 구조라, History API 기반 라우팅과는 맞지 않았다.
이를 우회하기 위해 404.html 리디렉트를 구현하고, SPA 구조와 서버 사이의 관계를 처음부터 다시 이해해야 했다.
그 과정에서 생각보다 많은 시간이 소요되었고, 단순한 정적 배포가 SPA에겐 생각보다 까다롭다는 걸 알게 됐다.

라우터 구조, 이게 최선일까?

싱글톤 패턴으로 하나의 Router 인스턴스를 유지하고, 내부에서 hash/history 모드를 분기 처리했다.
처음엔 깔끔해 보였지만, 분기 처리 로직이 쌓일수록 점점 복잡해졌다.
"어디까지를 하나로 묶고, 어디서부터 나눠야 할까?"라는 고민이 계속 따라붙었다.

관심사 분리, 어디까지가 맞는 선일까?

로컬 스토리지 → 서비스 → 인터페이스로 나누긴 했지만, 실제로 코드를 작성하다 보면 이게 과연 필요한 분리였는지, 오히려 더 꼬인 건 아닌지 헷갈리는 순간도 있었다.
아직은 이런 구조적인 결정을 할 기준과 경험이 부족하다는 걸 느꼈다.

라우트 가드의 위치에 대한 고민

현재는 각 라우트 함수 안에서 직접 접근 권한을 확인하고, 리다이렉트를 return하는 방식이다.
간단하고 직관적이긴 하지만, 이게 유지보수나 확장성 측면에서 좋은 방식인지는 고민이 남았다.

이벤트 위임, 그리고 그 다음

전역 document에 이벤트를 위임하는 방식은 SPA에서 유효했고, 잘 동작했다.
하지만 이벤트가 많아질수록 하나의 service에서 모든 걸 처리하는 구조에 부담을 느꼈다.
이벤트 분리나 컴포넌트 단위 설계로 확장하려면 어떻게 해야 할까?

SPA의 본질을 바닐라 JS로 직접 구현해보며, 당연하게 여겼던 동작들의 이면을 이해하게 된 시간이었다.
앞으로 더 나은 구조를 고민할 수 있는 좋은 출발점이 된 것 같다.

과제 결과 및 코드

profile
세상에 못할 일은 없어!

2개의 댓글

comment-user-thumbnail
2025년 4월 4일

그는 GOD.. ONE..STAR...

1개의 답글