SPA에서의 라우팅 (History vs Hash)

neo_622·2025년 5월 6일
post-thumbnail

1. SPA에서의 라우팅


Single Page Application(SPA)은 전체 애플리케이션이 하나의 HTML 파일로 구성되고, 페이지 전환 없이도 다양한 화면을 보여줄 수 있는 구조다.
하지만 여기서 중요한 문제는 하나의 HTML만을 사용하면서도 "어떻게 서로 다른 경로(URL)"를 지원할 수 있느냐는 것이다.

그 해답이 바로 라우팅(Routing)에 있다. 이 글에서는 SPA 라우팅의 구조와 원리를 이해하고, Hash RouterHistory Router를 비교한 뒤, 어떤 상황에 어떤 방식을 선택하면 좋은지 직접 구현한 예제를 통해 구체적으로 살펴보려한다.


1-1. 왜 SPA에 라우팅이 필요한가?

전통적인 MPA(Multi Page Application)에서는 각 경로마다 HTML 파일이 존재하고, 링크를 클릭하면 새로운 HTML을 서버로부터 받아온다(새로고침).
그러나 SPA에서는 페이지 전체가 처음 로드되고, 이후에는 자바스크립트를 통해 동적으로 콘텐츠를 교체한다.

하지만 사용자나 검색 엔진은 여전히 URL 경로를 통해 현재 위치를 파악하고 싶어 한다.

1-2. Router의 동작 원리

SPA에서의 라우터는 다음과 같은 역할을 수행한다.

  1. 브라우저 주소창(URL)을 감지
  2. 경로에 따라 특정 컴포넌트 또는 뷰를 렌더링
  3. 링크 클릭, 브라우저 뒤로가기/앞으로가기, 새로고침 등을 감지하고 대응

SPA에서 사용하는 라우터는 크게 Histroy API 를 사용하는 라우터와 Hash 를 사용하는 라우터로 구분할 수 있다.
위에서 설명한 라우터의 역할은 다음과 같은 방식으로 구현할 수 있다.

  • window.location.pathname 또는 location.hash를 사용해 현재 경로를 파악
  • history.pushState() / replaceState()로 URL을 바꾸되 새로고침은 하지 않음
  • popstate (History) 또는 hashchange (Hash)를 이용해 브라우저 이벤트 감지 (뒤로가기, 앞으로가기 등)
  • 경로에 따라 컴포넌트를 동적으로 교체

2. History Router vs Hash Router


위에서 설명했듯이, SPA 라우터는 크게 두 가지 방식으로 구현된다. 뒤에서 자세히 설명하겠지만, 아주 간단히 사용 관점에서만 보면:

History Router

  • pushState()replaceState() 를 이용해 URL을 조작
  • 깔끔한 URL 제공 (/login, /main 등)
  • popstate 이벤트로 브라우저 네비게이션 감지

Hash Router

  • URL의 # 뒷부분을 경로로 사용 (#/login)
  • hashchange 로 이벤트 감지

우선 각각의 예제를 통해 두가지 라우터의 사용 방법에 대해 알아보고, 나아가 차이점을 분석해보자.


2-1. History Router

간단한 SPA의 History API를 사용하여 구현한 History Router이다.

const routes = {
  [`${base}`]: MainPage,
  [`${base}login`]: LoginPage,
  [`${base}profile`]: ProfilePage,
};

export const Router = {
  render: () => {
    document.body.innerHTML = '<div id="root"></div>';
    
    const root = document.getElementById("root");
    const currentUser = getUser();
    const path = window.location.pathname;
    
    const page = routes[path] || ErrorPage;
    root.innerHTML = page.render();

    if (path === `${base}profile` && !currentUser) {
      history.replaceState(null, "", "/login");
      Router.render();
    }
    if (path === `${base}login` && !currentUser) {
      history.replaceState(null, "", "/");
      Router.render();
    }
  },
  navigate: (path) => {
    history.pushState(null, "", path);
    Router.render();
  },
  init: () => {
    Router.render();
    window.addEventListener("popstate", () => {
      Router.render();
    });
  },
};

이 라우터는 navigate(path)로 URL을 변경하고 render()에서 현재 경로에 맞는 페이지 컴포넌트를 렌더링한다.
뒤로가기/앞으로가기 같은 브라우저 내비게이션은 popstate 이벤트로 감지해 다시 렌더링된다.
로그인되지 않은 사용자가 특정 경로 접근 시 replaceState()로 강제 리다이렉션을 수행해 라우팅 흐름을 제어한다.

2-2. Hash Router

이번에는 Hash Router이다.

const routes = {
  [`${base}#/`]: MainPage,
  [`${base}#/login`]: LoginPage,
  [`${base}#/profile`]: ProfilePage,
};

export const Router = {
  render: () => {
    document.body.innerHTML = '<div id="root"></div>';
    
    const root = document.getElementById("root");
    const path = window.location.hash;
    const currentUser = getUser();
    
    const page = routes[`${base}${path}`] || ErrorPage;
    root.innerHTML = page.render();

    if (path === "#/profile" && !currentUser) {
      location.hash = "#/login";
      Router.render();
    }
    if (path === "#/login" && !currentUser) {
      location.hash = "#/";
      Router.render();
    }
  },
  navigate: (path) => {
    location.hash = path;
    Router.render();
  },
  init: () => {
    Router.render();
    window.addEventListener("hashchange", () => {
      Router.render();
    });
  },
};

이 라우터는 navigate(path)로 해시를 변경하고, render() 함수는 현재 해시 경로에 맞는 페이지 컴포넌트를 찾아 렌더링한다.
해시가 바뀔 때마다 hashchange 이벤트로 자동 렌더링이 트리거되며, 로그인되지 않은 사용자가 특정 경로 접근 시에는 location.hash를 직접 수정해 간단한 리다이렉션도 수행한다.

2-3. 두 Routing 방식의 차이점

처음 두가지 개념을 배웠을 때, 어떤 차이가 있는지 정확히 이해하지 못했었다. 하지만 두 방식은 브라우저의 동작 입장에서 보면 명확한 차이가 있다.

브라우저는 #(Hash) 뒤에 있는 부분은 서버로 전송하지 않는다.

여기 Github Pages로 정적 호스팅이 된 SPA가 있다.

사용자는 프로필 링크를 클릭하였다.
이에 따라 SPA에서는 history.pushState() 또는 location.hash 코드를 실행시켜 도메인 주소를 example.com/profile 또는 example.com/#/profile로 변경한다.
(History Router / Hash Router)

여기서 만약 사용자가 해당 경로에서 브라우저 새로고침을 한다면 어떻게 될까?
새로고침에 따라 브라우저는 서버에 도메인 주소에 대한 GET 요청을 보내게 될 것이다.
여기서 두가지 방식의 라우터의 차이가 드러나게 된다.

History Router를 사용할 경우

  • 브라우저는 서버에 GET /profile 요청을 보낸다.
  • 하지만 호스팅 서버에는 index.html만 존재하므로 /profile에 대한 리소스를 찾을 수 없다.
  • 결과: 404 Not Found

Hash Router를 사용할 경우

  • 앞서 설명했듯이, 브라우저는 #(Hash) 뒤에 있는 부분은 서버로 전송하지 않는다.
  • 따라서 오직 example.com/#/profile 에서 해시 뒷 부분을 제외한 / 만을 서버에 요청한다.
  • 결과: index.html이 정상적으로 로드되고, 그와 함께 구현되어 있는 Router가 #/profile을 감지하여 컴포넌트를 다시 랜더링하게 된다.

이처럼 위와 같은 상황에서는 History API를 사용한 라우터는 별다른 설정 없이 새로고침 시, 서버가 해당 리소스를 찾으려 하므로, 404 Error를 반환할 위험이 있다.
따라서 모든 요청을 index.html로 리다이렉트해주는 서버 설정이 필수적이다.

2-4. 마무리: 그래서 SPA에선 뭘 쓰면 되는가?

결국 상황에 따라 다르다고 생각한다.
어떤 라우터를 선택할지는 서버 설정 가능 여부, SEO 필요 여부, 호스팅 환경에 따라 결정된다.

조건추천 라우터비고
정적 호스팅 사용 시 (GitHub Pages, Netlify 등)Hash Router서버 설정 없이도 새로고침 OK
서버 설정 가능 (ex. Nginx, Express)History Routerindex.html로 모든 요청 리다이렉트 필요
검색엔진 최적화(SEO)가 중요한 경우History Router + SSRReact + Next.js 등 고려
내부용 툴, 관리자 페이지 등 SEO가 중요하지 않은 경우Hash Router구현과 배포가 간편함
브라우저 주소를 깔끔하게 유지하고 싶을 때History Router/profile 같은 경로 사용 가능

요약하자면:

  • 빠르게 배포하고 싶고 서버 설정이 어렵다면 → Hash Router
  • 좀 더 깔끔한 URL과 SEO가 필요하다면 → History Router + 서버 설정 필수
  • Next.js, Nuxt.js 같은 프레임워크를 사용하면 서버 설정과 라우팅을 더 쉽게 통합할 수 있다.

리액트로 개발을 해오면서 React-Router에 BrowserRouterHashRouter가 있는 것은 알고 있었지만, 두 개념에 대해 정확히 알고있지는 않았다.
이번에 직접 공부해 보면서 단순히 구현되어 있는 라우터를 사용하는 것 보다 브라우저의 동작 방식과 서버의 역할까지 이해해야 진짜 라우팅을 알 수 있다는 걸 느꼈다.
그리고 기술의 선택은 “어떤 환경에서 어떻게 쓸 것인가” 가 중요하다는 걸 다시 체감할 수 있었다.

profile
프론트엔드 개발자입니다.

0개의 댓글