React Router — History API를 활용한 SPA 라우팅 구현

React Router — History API를 활용한 SPA 라우팅 구현

리액트가 부드럽고 빠르게 화면을 전환하는 이유를 묻는다면 많은 사람들이 Single Page Application(SPA)를 떠올린다.

그리고 그 중심에는 언제나 라우터가 있다. 어떤 기능을 만들 때 “핵심을 먼저 정의하고 구현하는 습관”은, 특히 사용자 경험(UX)이 중요한 서비스 개발에서 큰 힘을 발휘한다.

이번 글에서는 History API를 활용해, “브라우저 주소에 맞는 화면을 제공하는 라우터”를 직접 만들어 보면서 React Router의 내부 동작 방식을 차근차근 이해해본다.

물론 잘 만들어진 라이브러리를 가져다 쓰는 것도 좋은 선택이지만, 원리를 알면

  • 예상치 못한 문제 상황에서 유연하게 대응할 수 있고
  • 서비스에 맞게 커스터마이징하여 더 나은 사용자 경험을 줄 수 있다

사실 처음 리액트를 배울 때는 라이브러리를 빨리 적용하는 것만 신경 썼다. 하지만 시간이 지나면서 깨달은 건, "내부 동작 원리를 이해하는 습관"이 결국 더 단단한 개발 실력을 만든다는 점이었다. 그래서 이제는 조금 더 천천히, 메커니즘 자체를 파고드는 연습을 하고 있다.


웹 서비스의 발전과 History API의 등장

History API
History API

웹은 원래 문서와 문서가 링크로 연결된 구조였다.

링크를 클릭하면 브라우저는 새로운 URL로 요청을 보내고, 서버가 새로운 HTML을 내려주면 화면 전체가 리렌더링됐다. 하지만 웹이 점점 애플리케이션화되면서 한계가 드러났다.

  • 매번 새 문서를 받아오니 속도가 느리고
  • 동일한 리소스를 반복적으로 로드하며
  • 화면 전환이 매끄럽지 않았다

이를 해결하기 위해 HTML5에서 History API가 등장했다. 이제는 주소창의 URL은 바꾸되, 전체 리로드는 막고 필요한 부분만 교체할 수 있게 된 것이다.


pushState와 History Stack

history.pushState는 브라우저의 히스토리 스택(history stack)에 새로운 상태를 쌓는 동작이다. 주소창은 바뀌지만, 네트워크 요청은 발생하지 않는다.

  1. 초기 상태
| /matthew | <- 현재markdown
  1. pushState(‘/aeong’) 호출
| /aeong | <- 현재
| /matthew |markdown
  1. pushState(‘/joy’) 호출
| /joy | <- 현재
| /aeong |
| /matthew |markdown
  1. 뒤로가기 클릭 → popstate 이벤트 발생
| /joy |
| /aeong | <- 현재
| /matthew |markdown

즉, pushState는 새로운 경로를 스택에 추가하고, 뒤로가기/앞으로가기(popstate 이벤트) 시에는 스택 항목이 사라지는 게 아니라 현재 위치 포인터만 이동한다.


SPA 라우팅의 실제 동작 흐름 🎮

SPA 라우팅은 사실 단순한 트릭이다.

  • 링크 클릭 시
    1. 원래 서버에 요청하려는 동작을 preventDefault()로 막는다
    2. history.pushState()로 주소창만 변경한다
    3. “경로가 바뀌었어”라는 신호를 커스텀 이벤트로 앱에 전달한다
  • 뒤로가기 / 앞으로가기 시
    1. 브라우저가 자동으로 URL을 바꾼다
    2. popstate 이벤트가 발생한다
    3. 이 이벤트를 감지해 화면을 교체한다

겉보기에는 여러 HTML 페이지를 오가는 것 같지만, 실제로는 한 페이지에서 주소와 컴포넌트만 바뀌는 것이다. 덕분에 전체 리로드 없이 빠르고 부드러운 전환이 가능하다. ✨


핵심 원칙 1. 경로마다 다른 화면을 보여주기

라우터의 본질은 간단하다. URL 경로에 따라 다른 컴포넌트를 렌더링하는 것.

const MatthewPage = () => <h1>매튜 페이지</h1>;
const AeongPage = () => <h1>애옹 페이지</h1>;
const JoyPage = () => <h1>조이 페이지</h1>;
const NotFound = () => <h1>Not Found</h1>;

function App() {
  const { pathname } = window.location;

  switch (pathname) {
    case '/matthew':
      return <MatthewPage />;
    case '/aeong':
      return <AeongPage />;
    case '/joy':
      return <JoyPage />;
    default:
      return <h1>404</h1>;
  }
}

export default App;tsx

window.location.pathname을 읽어 요청된 주소와 맞는 컴포넌트를 보여줄 수 있다.

/matthew와 일치하는 컴포넌트를 보여줌
/matthew와 일치하는 컴포넌트를 보여줌

일치하는 경로가 없다면 404 페이지를 내려준다.

/not-found는 일치하는 경로가 없어 404 페이지를 내려줌
/not-found는 일치하는 경로가 없어 404 페이지를 내려줌


핵심 원칙 2. 페이지 이동

전통적인 방식

const Header = () => {
  return (
    <nav style={{ display: 'flex', gap: '10px' }}>
      <a href='/matthew'>MATTHEW</a>
      <a href='/aeong'>AEONG</a>
      <a href='/joy'>JOY</a>
      <a href='/not-found'>NOT FOUND</a>
    </nav>
  );
};tsx

<a> 태그를 클릭하면 브라우저가 서버로 요청을 보내고, 새로운 HTML 문서를 받아와 전체를 다시 렌더링한다. 즉, 라우팅의 주체는 서버다.


우리가 원하는 방식 (SPA)

SPA 라우팅은 프론트엔드가 직접 URL을 제어하고, 필요한 컴포넌트만 갈아끼운다.

이를 위해서는 크게 3가지 작업을 해주면 된다.

  1. 서버 요청을 막고
  2. pushState()로 URL만 바꾸며
  3. 상태 변화를 감지해 리렌더링을 트리거

이를 위한 Link 구현은 다음과 같다:

import type { MouseEvent } from 'react';
import type { LinkProps } from './types';
import { getCurrentPath, navigateTo } from './utils';

export const Link = ({ to, children }: LinkProps) => {
  const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
    e.preventDefault();
    if (getCurrentPath() === to) return;
    navigateTo(to);
  };

  return (
    <a href={to} onClick={handleClick}>
      {children}
    </a>
  );
};tsx

핵심 원칙 3. 경로를 상태로 관리하기

window.location.pathname은 값만 읽을 뿐, 변경돼도 리렌더링을 유발하지 않는다.

따라서 경로를 state로 관리해야 한다.

const MatthewPage = () => {
  const path = useCurrentPath(); // 경로 변경 감지
  return <div>현재 경로: {path}</div>;
};tsx

useCurrentPath 훅은 커스텀 이벤트(PUSHSTATE_EVENT, POPSTATE_EVENT)를 구독해 상태를 갱신한다.


핵심 원칙 4. 선언적이고 확장 가능해야 한다

경로마다 if/switch 문을 계속 늘리면 관리가 어렵다.

따라서 Route로 라우팅 테이블을 정의하고, Router가 매칭되는 경로를 찾아 렌더링하도록 만든다.

// Route.tsx
export const Route = ({ component: Component }: RouteProps) => {
  return <Component />;
};tsx
// Router.tsx
export const Routes: FC<RoutesProps> = ({ children }) => {
  const currentPath = useCurrentPath();
  const activeRoute = useMemo(() => {
    const routes = Children.toArray(children).filter(isRouteElement);
    return routes.find((route) => route.props.path === currentPath);
  }, [children, currentPath]);

  if (!activeRoute) return null;
  return cloneElement(activeRoute);
};tsx

실제 사용 예시

import './App.css';
import { Link, Route, Routes } from './router';

const MatthewPage = () => <h1>매튜 페이지</h1>;
const AeongPage = () => <h1>애옹 페이지</h1>;
const JoyPage = () => <h1>조이 페이지</h1>;
const NotFoundPage = () => <h1>404</h1>;

const Header = () => {
  return (
    <nav style={{ display: 'flex', gap: '10px' }}>
      <Link to='/matthew'>MATTHEW</Link>
      <Link to='/aeong'>AEONG</Link>
      <Link to='/joy'>JOY</Link>
      <Link to='/not-found'>NOT FOUND</Link>
    </nav>
  );
};

function App() {
  return (
    <>
      <Header />
      <Routes>
        <Route path='/matthew' component={MatthewPage} />
        <Route path='/aeong' component={AeongPage} />
        <Route path='/joy' component={JoyPage} />
        <Route path='/not-found' component={NotFoundPage} />
      </Routes>
    </>
  );
}

export default App;tsx

마무리

실제로 지금까지 만든 라우터는 아래와 같이 잘 동작한다.

내가 만든 라우터
내가 만든 라우터

이번에 만든 라우터의 핵심은 다음과 같다:

  • Link 컴포넌트
    • preventDefault()로 새로고침 방지
    • pushState()로 URL만 변경
    • 커스텀 이벤트로 앱에 알림
  • Router 컴포넌트
    • pushstate 이벤트로 프로그래매틱 네비게이션 감지
    • popstate 이벤트로 브라우저 뒤/앞으로 가기 처리
    • 현재 URL과 일치하는 컴포넌트 렌더링

물론 실제 React Router는 훨씬 다양한 기능(중첩 라우팅, 동적 세그먼트, 데이터 로딩 등)을 제공하니 우리가 라이브러리를 사용한다.

하지만 이번 과정을 통해 SPA 라우팅의 원리를 직접 구현하며 체득할 수 있었다.