당근 인턴 면접에서, 새로고침 없이 페이지를 이동하는 라우팅 구현 문제가 라이브 코딩에서 나왔다.
이때,pushState로url을 넣어주면 새로고침 되지 않는다는 것만 기억나서, 라이브 코딩은 망하고 면접 보는 도중에 계속 상태…상태…상태…만 얘기했던 기억이 있다.
그래서 이번 기회에 history API와 SPA 클라이언트 라우팅을 공부해 정리해봤다.
history 전역 객체를 통해 브라우저 세션 히스토리에 대한 접근을 제공한다.
사용자의 방문 기록을 앞뒤로 탐색하고, 방문 기록 스택의 내용을 조작할 수 있다.
메인 스레드의 전역 객체인 window 를 사용할 수 있는 곳에서만 활용 가능한 API이기 때문에, 메인 스레드에서 동작하지 않는 Worker 와 같은 곳에서는 접근할 수 없다.
pushState : 세션 히스토리에 새로운 URL 상태를 쌓는다.replaceState: 세션 히스토리에 새로운 URL을 쌓지 않고, 현재 URL을 대체한다.실제로는 화면 이동이 일어나지 않지만, 히스토리에는 URL 스택이 쌓이고, 현재 URL을 바꿔줄 수 있다.
이를 활용해 실제론 페이지 이동(페이지 이동시 발생하는 새로고침 없이)을 하지 않고, URL에 따른 화면을 다시 그릴 수 있다.
즉, history API를 활용해 SPA를 구현하는 것이 가능하며, 우리가 흔히 사용하는 router 가 history API를 기반으로 한다.

length: history 스택에 쌓인 페이지 수를 의미한다.
현재 3개의 페이지가 스택에 담겨있는 모습이다.
scrollRestoration: 스크롤 복원에 대한 프로퍼티
auto: 사용자가 스크롤한 페이지의 위치가 복원된다.
manual: 페이지의 위치가 복원되지 않는다. 사용자가 직접 스크롤하여 해당 위치로 이동해야 한다.
history.scrollrestoration = "manual";
const scrollRestoration = history.scrollRestoration;
if(scrollRestoration === 'manual') {
console.log('scroll location is not restored');
}
state: pushState 와 replaceState 함수의 첫 번째 인자로 전달할 값이 저장된다.
history.pushSate(state, title, url);
history.pushState({scrollY: window.scrollY}, '', '/new-page');
// 이후 popstate 이벤트에서 사용 가능
window.addEventListener('popstate', (event) => {
console.log(event.state); // {scrollY: ...}
});
state: 개발자가 임의로 넣을 수 있는 객체 데이터
초기값은 null이며, 추가 정보를 저장하고자 할 때 사용한다.
pushState, replaceState를 써야만 값이 들어간다.
title: 현재는 거의 무시되며, 대부분의 브라우저가 사용하지 않는다.
url: 바뀔 주소
여기서 velog로 실제로 스크롤 위치가 저장되는지 scrollRestoration 을 테스트해봤다.
하지만 다음 게시글로 넘어갔다 돌아와도 스크롤 위치가 고정되지 않고, 최상단으로 이동했다.
알아보니, SPA 특성상 기본 브라우저 동작이 비활성화 되어 있기 때문이라 한다.
페이지 전환이 실제로는 URL만 바뀌고, 콘텐츠는 자바스크립트가 동적으로 교체하기 때문에 브라우저는 페이지 이동이라고 인식하지 못하고, 스크롤 위치 복원도 자동으로 동작하지 않는다고 한다.
MPA 기준으론 별도로 조작하지 않으면 기본적으로 이전 페이지의 스크롤 위치를 기억하고 복원한다.
하지만 여기서 이해되지 않는 부분이 있다.
‘
history스택이 변하는게 결국은 페이지 이동을 했다는 것을 의미하니까, 결국 똑같은거 아닌가?’
정확히 보면, 브라우저의 history 스택에 새로운 항목이 추가된다고 해서 브라우저가 ‘페이지 전환’으로 인식하는 것은 아니다.
pushState, replaceState 로 history 스택에 새로운 entry가 추가된다.
이때 URL은 바뀌지만, 브라우저가 이를 새로운 페이지를 요청해 로딩했다고 인식하지는 않는다.
단순히 주소와 상태만 바뀌었다고 본다.
브라우저가 ‘페이지 전환’으로 인식하는 경우는 다음과 같다.
<a href=”/page2”> 클릭 시 서버에 새 HTML 요청이때만 기본적으로 이전 페이지의 스크롤 위치를 기억하고, 뒤로 가기 시 복원한다.
React Router, Next.js, Vue Router 같은 클라이언트 라우팅 라이브러리들은 pushSate 를 사용해 URL만 변경한다.
그렇기에 기본적으론 스크롤 복원이 자동으로 되지 않는 것이며, 개발자가 직접 처리해야 한다.
history.pushState(state, title, url)url은 세션 히스토리에 새로 push할 URL 값이다.
a 태그를 클릭하거나 location.href 로 URL을 변경하는 것과는 달리, 이 URL이 변경된다고 해서 화면이 리로드되지 않는다.
말 그대로, URL만 바뀌게 된다.
history.replaceState(state, title, url)기본적으로 pushState와 같다.
다른 점은, 히스토리에 새 URL 상태를 쌓지 않고, 현재 URL을 넣어준 url 값으로 대체한다.
history.back();
history.go(-1);
위는 모두 방문 기록의 뒤로 이동하는 방법이다.
브라우저 도구 모음에서 [뒤로 가기] 버튼을 클릭한 것과 동일하다.
history.forward();
history.go(1);
위는 모두 방문 기록의 앞으로 이동하는 방법이다.
브라우저 도구 모음에서 [앞으로 가기] 버튼을 클릭한 것과 동일하다.
history.go(2);
history.go(-2);
방문 기록의 특정 지점으로 이동하고자 한다면, 위처럼 현재 위치에 대한 상대 위치로 식별되는 특정 페이지로 로드하면 된다.
현재 위치는 0 이다.
history.go(0);
history.go();
위는 현재 페이지를 새로고침하는 방법이다.
history.length();
스택의 사이즈를 통해 페이지 수를 확인할 수 있다.
브라우저에서 페이지를 이동하게 되면 popstate 라는 이벤트가 발생한다.
history.pushState() 또는 history.replaceState() 를 호출하는 것만으로는 popstate 이벤트가 트리거되지 않는다.
이벤트는 history.back() , history.forward() 와 같은 뒤로, 또는 앞으로 가기 버튼을 클릭하는 것과 같은 브라우저 동작을 수행하거나 JavaScript에서 popstate 를 호출할 때 트리거된다.
pushState, replaceState에서 popstate 이벤트가 발생하지 않는 이유history entry가 실제로 변경될 때 발생한다고 하는데,
pushState,replaceState로 entry가 변경된 것인거 아닌가?
라는 생각이 들었다.
이에 대한 답은 다음과 같다.
히스토리 스택 내에서 ‘기존 entry 간에 이동(스택 간 이동)’을 한 경우에만 popstate 이벤트가 발생한다고 한다.
pushState는 url을 변경하긴 하지만, 기존 entry 간의 이동이 아닌, 새 entry를 추가하고 그 entry가 활성화되는 것이기 때문에 이벤트가 발생하지 않는 것이다.이 과정은 새로운 페이지를 불러온 게 아니라, 자바스크립트가 페이지 상태를 바꾼 것일 뿐이기 때문에 이벤트는 발생하지 않는다.
replaceState는 현재 entry를 교체하는 것이므로, 히스토리 내에서 이동이 전혀 일어나지 않는다.현재 활성화된 history entry를 교체하는(현재 entry를 덮어쓰는) 동작만을 하기 때문에 이벤트가 발생하지 않는 것이다.
SPA는 페이지를 이동할 때마다 새로고침되지 않는다.
history의 pushState 를 사용해 URL만 바꾸고 해당 URL에 맞는 컴포넌트를 렌더링시키는 것이다.
주요 아이디어는 다음과 같다.
history.pushState() 를 사용해 URL만 바꾸고 페이지를 새로 고치지 않음popstate 이벤트를 이용해 앞/뒤로 가기 대응import React, { useEffect, useMemo, useState } from 'react';
const routes = [
{ path: /^\/$/, component: () => <h2>홈 페이지</h2> },
{ path: /^\/about$/, component: () => <h2>소개 페이지</h2> },
{ path: /^\/contact$/, component: () => <h2>연락처 페이지</h2> },
{
path: /^\post\/(\d+)$/,
component: (params) => <h2>포스트 ID: {params[1]} 페이지</h2>,
},
];
export default function App() {
const [currentPath, setCurrentPath] = useState(window.location.pathname);
useEffect(() => {
const onPopState = () => {
setCurrentPath(window.location.pathname);
};
window.addEventListener('popstate', onPopState);
return () => {
window.removeEventListener('popstate', onPopState);
};
}, []);
const navigate = (path) => {
// state, title, url
window.history.pushState({}, '', path);
setCurrentPath(path);
};
// 리렌더링을 고려하여 useMemo로 감싸줌
const MatchedComponent = useMemo(() => {
let component = () => <h2>404 페이지를 찾을 수 없습니다.</h2>;
routes.some((route) => {
const match = currentPath.match(route.path);
if (match) {
component = () => route.component(match);
return true;
}
return false;
});
return component;
}, [currentPath]);
return (
<div className="p-4 space-y-4">
<nav className="space-x-4">
<button
type="button"
onClick={() => navigate('/')}
className="text-blue-500 underline"
>
홈
</button>
<button
type="button"
onClick={() => navigate('/about')}
className="text-blue-500 underline"
>
소개
</button>
<button
type="button"
onClick={() => navigate('/contact')}
className="text-blue-500 underline"
>
연락처
</button>
<button
type="button"
onClick={() => navigate('/post/123')}
className="text-blue-500 underline"
>
포스트 123
</button>
<button
type="button"
onClick={() => navigate('/post/456')}
className="text-blue-500 underline"
>
포스트 456
</button>
</nav>
<hr />
<MatchedComponent />
</div>
);
}
const routes = [
{ path: /^\/$/, component: () => '<h2>홈 페이지</h2>' },
{ path: /^\/about$/, component: () => '<h2>소개 페이지</h2>' },
{ path: /^\/contact$/, component: () => '<h2>연락처 페이지</h2>' },
{
path: /^\/post\/(\d+)$/,
component: (params) => `<h2>포스트 ID: ${params[1]} 페이지</h2>`,
},
];
function matchRoute(pathname) {
let matchedComponent = null;
routes.some((route) => {
const match = pathname.match(route.path);
if (match) {
matchedComponent = route.component(match);
return true;
}
return false;
});
return matchedComponent || '<h2>404 페이지를 찾을 수 없습니다.</h2>';
}
function render(html) {
document.getElementById('app').innerHTML = html;
}
function navigate(path) {
window.history.pushState({}, '', path);
const html = matchRoute(path);
render(html);
}
// popstate 이벤트 (뒤/앞으로 가기)
window.addEventListener('popstate', () => {
const html = matchRoute(window.location.pathname);
render(html);
});
// 초기 렌더링
const html = matchRoute(window.location.pathname);
render(html);