React Router와 History API: SPA는 어떻게 페이지를 전환할까?

크리스가 처음 리액트로 웹사이트를 만들 때 겪는 흔한 실수가 있다. 페이지를 이동시키기 위해 HTML의 정석대로 <a> 태그를 사용한 것이다.
// ❌ 이렇게 하면 안 된다
<a href="/about">소개 페이지로 이동</a>링크를 클릭하는 순간, 화면이 하얗게 깜빡이더니 전체 페이지가 새로고침 된다. 열심히 관리하던 리액트의 state는 전부 초기화되어 날아갔다. SPA(Single Page Application)를 만든다면서, 전통적인 MPA(Multi Page Application)처럼 동작하게 만든 것이다.
우리가 원하는 건 "URL은 바뀌지만, 페이지는 새로고침 되지 않는" 마법이다. 이 마법을 가능하게 하는 브라우저의 핵심 기술, History API와 React Router의 원리를 파헤쳐 본다.
1. SPA의 딜레마: URL을 속여라
SPA는 이름 그대로 단 하나의 HTML 페이지(index.html)만 존재한다. 그런데 사용자는 /home, /about, /profile 등 여러 페이지를 오가고 싶어 한다.
여기서 모순이 생긴다.
이 문제를 해결하기 위해 HTML5에 등장한 것이 바로 History API다.
2. 엔진: History API (pushState)
브라우저의 window 객체에는 방문 기록을 관리하는 history 객체가 있다. 이 중 리액트 라우터가 사용하는 핵심 메서드는 pushState다.
// browser-console.ts
// 1. 현재 주소: /home
// 2. pushState 실행 (데이터, 제목, 새로운 URL)
window.history.pushState({ page: 'about' }, '', '/about');
// 3. 결과: 주소창이 /about으로 바뀐다.
// ✨ 중요: 브라우저는 서버에 요청을 보내지 않고, 새로고침도 하지 않는다!이 메서드는 브라우저에게 "주소창의 글자만 조용히 바꿔줘, 아무것도 로딩하지 말고"라고 명령하는 것과 같다.
하지만 이것만으로는 부족하다. 주소창만 바뀌었지, 실제 화면(View)은 그대로이기 때문이다. 주소가 바뀌었음을 감지하고 컴포넌트를 바꿔줄 감시자가 필요하다.
3. 운전사: React Router
코드로 보는 원리
// MiniRouter.tsx (개념적 구현)
import { useState, useEffect } from 'react';
function MiniRouter() {
const [path, setPath] = useState(window.location.pathname);
useEffect(() => {
// 뒤로가기 버튼(popstate)을 감지하는 이벤트 리스너
const onPopState = () => setPath(window.location.pathname);
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const navigate = (url: string) => {
window.history.pushState({}, '', url); // 1. URL 변경
setPath(url); // 2. 상태 변경 -> 리렌더링 유발
};
return (
<div>
<button onClick={() => navigate('/home')}>홈</button>
<button onClick={() => navigate('/about')}>소개</button>
{/* 3. 현재 path에 따라 컴포넌트 교체 */}
{path === '/home' && <Home />}
{path === '/about' && <About />}
</div>
);
}4. 배포 시 주의할 점 (새로고침 문제)
크리스가 개발 서버(localhost)에서는 잘 동작하던 앱을 실제 서버(S3, Nginx 등)에 배포했다. 그런데 /about 페이지에 직접 접속하거나 새로고침을 했더니 404 Not Found 에러가 떴다.
왜일까?
브라우저가 /about이라는 파일을 서버에 요청했기 때문이다. 하지만 서버에는 index.html 하나밖에 없다. /about이라는 폴더나 파일은 물리적으로 존재하지 않는다.
해결책: Fallback 설정
서버 설정을 통해 "어떤 경로로 들어오든 무조건 index.html을 돌려줘라"라고 해야 한다. 그러면 index.html이 로드되고, 자바스크립트가 실행되면서 현재 URL(/about)을 보고 알맞은 페이지를 렌더링해준다.
# nginx.conf 예시
location / {
try_files $uri $uri/ /index.html;
}핵심 정리
이제 페이지 이동의 원리를 알았다. 그런데 컴포넌트 안에서 데이터를 가져오거나 DOM을 건드리는 작업은 어디서 해야 할까? 가장 많이 쓰지만 가장 많이 틀리는 훅, useEffect를 제대로 다룰 시간이다.
"useEffect의 의존성 배열 거짓말: Stale Closure 문제 해결과 useLayoutEffect가 필요한 순간" 편에서 계속된다.