React Router — History API를 활용한 SPA 라우팅 구현
리액트가 부드럽고 빠르게 화면을 전환하는 이유를 묻는다면 많은 사람들이 Single Page Application(SPA)
를 떠올린다.
그리고 그 중심에는 언제나 라우터
가 있다. 어떤 기능을 만들 때 “핵심을 먼저 정의하고 구현하는 습관”은, 특히 사용자 경험(UX)
이 중요한 서비스 개발에서 큰 힘을 발휘한다.
이번 글에서는 History API
를 활용해, “브라우저 주소에 맞는 화면을 제공하는 라우터”를 직접 만들어 보면서 React Router의 내부 동작 방식을 차근차근 이해해본다.
물론 잘 만들어진 라이브러리를 가져다 쓰는 것도 좋은 선택이지만, 원리를 알면
- 예상치 못한 문제 상황에서 유연하게 대응할 수 있고
- 서비스에 맞게
커스터마이징
하여 더 나은 사용자 경험을 줄 수 있다
사실 처음 리액트를 배울 때는 라이브러리를 빨리 적용하는 것
만 신경 썼다. 하지만 시간이 지나면서 깨달은 건, "내부 동작 원리를 이해하는 습관"이 결국 더 단단한 개발 실력을 만든다
는 점이었다. 그래서 이제는 조금 더 천천히, 메커니즘 자체를 파고드는 연습을 하고 있다.
웹 서비스의 발전과 History API의 등장
웹은 원래 문서와 문서가 링크로 연결된 구조
였다.
링크를 클릭하면 브라우저는 새로운 URL로 요청을 보내고, 서버가 새로운 HTML을 내려주면 화면 전체가 리렌더링됐다. 하지만 웹이 점점 애플리케이션화되면서 한계가 드러났다.
- 매번 새 문서를 받아오니 속도가 느리고
- 동일한 리소스를 반복적으로 로드하며
- 화면 전환이 매끄럽지 않았다
이를 해결하기 위해 HTML5에서 History API
가 등장했다. 이제는 주소창의 URL은 바꾸되, 전체 리로드는 막고 필요한 부분만 교체
할 수 있게 된 것이다.
pushState와 History Stack
history.pushState
는 브라우저의 히스토리 스택(history stack)
에 새로운 상태를 쌓는 동작이다. 주소창은 바뀌지만, 네트워크 요청은 발생하지 않는다.
- 초기 상태
| /matthew | <- 현재
markdown
- pushState(‘/aeong’) 호출
| /aeong | <- 현재
| /matthew |
markdown
- pushState(‘/joy’) 호출
| /joy | <- 현재
| /aeong |
| /matthew |
markdown
- 뒤로가기 클릭 → popstate 이벤트 발생
| /joy |
| /aeong | <- 현재
| /matthew |
markdown
즉, pushState
는 새로운 경로를 스택에 추가하고, 뒤로가기/앞으로가기(popstate 이벤트)
시에는 스택 항목이 사라지는 게 아니라 현재 위치 포인터만 이동
한다.
SPA 라우팅의 실제 동작 흐름 🎮
SPA 라우팅은 사실 단순한 트릭이다.
링크 클릭 시
- 원래 서버에 요청하려는 동작을
preventDefault()
로 막는다 history.pushState()
로 주소창만 변경한다- “경로가 바뀌었어”라는 신호를 커스텀 이벤트로 앱에 전달한다
- 원래 서버에 요청하려는 동작을
뒤로가기 / 앞으로가기 시
- 브라우저가 자동으로 URL을 바꾼다
popstate
이벤트가 발생한다- 이 이벤트를 감지해 화면을 교체한다
겉보기에는 여러 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
을 읽어 요청된 주소와 맞는 컴포넌트를 보여줄 수 있다.
일치하는 경로가 없다면 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가지 작업을 해주면 된다.
- 서버 요청을 막고
pushState()
로 URL만 바꾸며- 상태 변화를 감지해 리렌더링을 트리거
이를 위한 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 라우팅의 원리
를 직접 구현하며 체득할 수 있었다.