
페이지 새로고침 없이 URL을 변경하는 마법, pushState API를 파헤쳐보자
history.pushState()는 HTML5 History API의 핵심 메서드입니다. 이 API는 브라우저의 세션 히스토리를 조작할 수 있게 해주는 강력한 도구로, 페이지를 새로고침하지 않고도 URL을 변경할 수 있습니다.
전통적인 웹에서는 URL이 바뀌면 항상 새로운 페이지를 서버에서 불러와야 했습니다. 하지만 현대의 SPA(Single Page Application)에서는 하나의 페이지에서 모든 것을 처리하면서도 URL을 통해 다양한 상태를 표현해야 합니다.
// 전통적인 방식 (페이지 새로고침 발생)
window.location.href = '/about';
// pushState 방식 (페이지 새로고침 없음)
history.pushState(null, null, '/about');
history.pushState(state, title, url);
매개변수 상세 설명:
// pushState: 새로운 히스토리 엔트리 추가
history.pushState({page: 1}, null, '/page1');
history.pushState({page: 2}, null, '/page2');
// 뒤로가기 시: /page2 → /page1 → 이전 페이지
// replaceState: 현재 히스토리 엔트리 교체
history.replaceState({page: 1}, null, '/page1');
history.replaceState({page: 2}, null, '/page2');
// 뒤로가기 시: /page2 → 이전 페이지 (page1 건너뜀)
// 현재 URL: https://example.com/
console.log(window.location.pathname); // "/"
// URL 변경 (페이지 새로고침 없음)
history.pushState({section: 'about'}, null, '/about');
console.log(window.location.pathname); // "/about"
// 상태 정보 확인
console.log(history.state); // {section: 'about'}
const userInfo = {
id: 123,
name: '김개발',
role: 'frontend'
};
// 상태와 함께 URL 변경
history.pushState(userInfo, null, '/user/123');
// 나중에 상태 정보 사용
if (history.state && history.state.role === 'frontend') {
console.log('프론트엔드 개발자입니다!');
}
사용자가 브라우저의 뒤로가기/앞으로가기 버튼을 클릭하면 popstate 이벤트가 발생합니다.
window.addEventListener('popstate', function(event) {
console.log('사용자가 뒤로가기/앞으로가기를 눌렀습니다');
console.log('현재 URL:', window.location.pathname);
console.log('상태 정보:', event.state);
});
function setupNavigation() {
// 페이지 변경 함수
function navigateTo(path, data = null) {
history.pushState(data, null, path);
renderPage(path, data);
}
// 뒤로가기/앞으로가기 처리
window.addEventListener('popstate', function(event) {
const currentPath = window.location.pathname;
renderPage(currentPath, event.state);
});
// 페이지 렌더링 함수
function renderPage(path, data) {
const content = document.getElementById('content');
switch(path) {
case '/':
content.innerHTML = '<h1>홈 페이지</h1>';
break;
case '/about':
content.innerHTML = '<h1>소개 페이지</h1>';
break;
case '/contact':
content.innerHTML = '<h1>연락처 페이지</h1>';
break;
}
}
// 네비게이션 버튼에 이벤트 추가
document.getElementById('home-btn').onclick = () => navigateTo('/');
document.getElementById('about-btn').onclick = () => navigateTo('/about');
document.getElementById('contact-btn').onclick = () => navigateTo('/contact');
}
import React, { useState, useEffect } from 'react';
function SimpleRouter() {
const [currentPath, setCurrentPath] = useState(window.location.pathname);
useEffect(() => {
// 뒤로가기/앞으로가기 감지
const handlePopState = () => {
setCurrentPath(window.location.pathname);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// 페이지 이동 함수
const navigateTo = (path) => {
history.pushState(null, null, path);
setCurrentPath(path);
};
// 현재 경로에 따른 컴포넌트 렌더링
const renderCurrentPage = () => {
switch (currentPath) {
case '/':
return <div>홈 페이지</div>;
case '/about':
return <div>소개 페이지</div>;
case '/contact':
return <div>연락처 페이지</div>;
default:
return <div>404 - 페이지를 찾을 수 없습니다</div>;
}
};
return (
<div>
<nav>
<button onClick={() => navigateTo('/')}>홈</button>
<button onClick={() => navigateTo('/about')}>소개</button>
<button onClick={() => navigateTo('/contact')}>연락처</button>
</nav>
<main>
{renderCurrentPage()}
</main>
</div>
);
}
import { useState, useEffect } from 'react';
function useRouter() {
const [location, setLocation] = useState({
pathname: window.location.pathname,
state: history.state
});
useEffect(() => {
const handlePopState = (event) => {
setLocation({
pathname: window.location.pathname,
state: event.state
});
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const push = (path, state = null) => {
history.pushState(state, null, path);
setLocation({ pathname: path, state });
};
return { location, push };
}
// 사용 예제
function App() {
const { location, push } = useRouter();
return (
<div>
<p>현재 경로: {location.pathname}</p>
<button onClick={() => push('/home', { from: 'button' })}>
홈으로 이동
</button>
</div>
);
}
인터셉팅 라우트는 특정 경로로의 이동을 가로채서 다른 방식(예: 모달)으로 표시하는 기법입니다.
import React, { useState, useEffect } from 'react';
function InterceptingRouter() {
const [currentPath, setCurrentPath] = useState(window.location.pathname);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
const handlePopState = () => {
const newPath = window.location.pathname;
setCurrentPath(newPath);
// 모달 상태에서 뒤로가기 시 모달 닫기
if (showModal && !newPath.includes('/photo/')) {
setShowModal(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showModal]);
// 일반 네비게이션
const navigateTo = (path) => {
history.pushState(null, null, path);
setCurrentPath(path);
setShowModal(false);
};
// 인터셉팅 네비게이션 (모달로 표시)
const openPhotoModal = (photoId) => {
history.pushState({ modal: true }, null, `/photo/${photoId}`);
setCurrentPath(`/photo/${photoId}`);
setShowModal(true);
};
// 모달 닫기
const closeModal = () => {
setShowModal(false);
history.pushState(null, null, '/gallery');
setCurrentPath('/gallery');
};
const renderPage = () => {
const basePath = showModal ? '/gallery' : currentPath;
switch (basePath) {
case '/':
return <div>홈 페이지</div>;
case '/gallery':
return (
<div>
<h1>갤러리</h1>
<button onClick={() => openPhotoModal(1)}>사진 1 보기</button>
<button onClick={() => openPhotoModal(2)}>사진 2 보기</button>
</div>
);
default:
return <div>404 페이지</div>;
}
};
const renderModal = () => {
if (!showModal) return null;
const photoId = currentPath.match(/\/photo\/(\d+)/)?.[1];
return (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>사진 {photoId}</h2>
<p>모달에서 표시되는 사진 상세 정보</p>
<button onClick={closeModal}>닫기</button>
</div>
</div>
);
};
return (
<div>
<nav>
<button onClick={() => navigateTo('/')}>홈</button>
<button onClick={() => navigateTo('/gallery')}>갤러리</button>
</nav>
{renderPage()}
{renderModal()}
</div>
);
}
패러럴 라우트는 하나의 페이지에서 여러 영역을 독립적으로 라우팅하는 기법입니다.
import React, { useState, useEffect } from 'react';
function ParallelRouter() {
const [routes, setRoutes] = useState({
main: '/',
sidebar: '/sidebar-home'
});
useEffect(() => {
const handlePopState = (event) => {
if (event.state?.routes) {
setRoutes(event.state.routes);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// 패러럴 라우트 업데이트
const updateRoute = (area, path) => {
const newRoutes = { ...routes, [area]: path };
// URL에 상태 반영
const url = `${newRoutes.main}?sidebar=${encodeURIComponent(newRoutes.sidebar)}`;
history.pushState({ routes: newRoutes }, null, url);
setRoutes(newRoutes);
};
const renderMain = () => {
switch (routes.main) {
case '/':
return <div>메인 홈</div>;
case '/about':
return <div>메인 소개</div>;
default:
return <div>메인 404</div>;
}
};
const renderSidebar = () => {
switch (routes.sidebar) {
case '/sidebar-home':
return <div>사이드바 홈</div>;
case '/sidebar-menu':
return <div>사이드바 메뉴</div>;
default:
return <div>사이드바 기본</div>;
}
};
return (
<div style={{ display: 'flex' }}>
<main style={{ flex: 1, padding: '20px' }}>
<nav>
<button onClick={() => updateRoute('main', '/')}>메인 홈</button>
<button onClick={() => updateRoute('main', '/about')}>메인 소개</button>
</nav>
{renderMain()}
</main>
<aside style={{ width: '200px', padding: '20px', background: '#f0f0f0' }}>
<nav>
<button onClick={() => updateRoute('sidebar', '/sidebar-home')}>사이드바 홈</button>
<button onClick={() => updateRoute('sidebar', '/sidebar-menu')}>사이드바 메뉴</button>
</nav>
{renderSidebar()}
</aside>
</div>
);
}
// pushState 지원 여부 확인
if (window.history && window.history.pushState) {
// pushState 사용
history.pushState(null, null, '/new-path');
} else {
// 폴백: 전통적인 방식
window.location.href = '/new-path';
}
// 페이지 제목과 메타 정보 업데이트
function updatePageMeta(title, description) {
document.title = title;
const metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc) {
metaDesc.setAttribute('content', description);
}
}
// 라우트 변경 시 SEO 정보 업데이트
function navigateWithSEO(path, pageInfo) {
history.pushState(pageInfo, null, path);
updatePageMeta(pageInfo.title, pageInfo.description);
}
// 디바운싱으로 과도한 히스토리 엔트리 방지
let pushTimeout;
function debouncedPush(path, state, delay = 300) {
clearTimeout(pushTimeout);
pushTimeout = setTimeout(() => {
history.pushState(state, null, path);
}, delay);
}
SPA에서 pushState를 사용할 때는 서버에서 모든 경로를 index.html로 리다이렉트하도록 설정해야 합니다.
하지 않는다면 에러가 발생할것입니다.
# Nginx 설정 예제
location / {
try_files $uri $uri/ /index.html;
}
pushState를 제대로 이해하면