모회사 실무테스트에서 react-router-dom 의 사용이 금지되었고, URL QueryParameter가 변경될 때replaceState를 통해 페이지가 갱신되지 않고 히스토리에 남지 않아야한다고 했다.
사실 자바스크립트 만으로 코딩을 했을 때는 게임, URL 변경이 없이 스크롤만으로 정보를 전달하던 페이지만 제작해보았고,
바로 Next.js 나 React 개발로 넘어가서 React-router-dom 만 사용했었기에 Web API 인history.replaceState 를 사용해본 적이 없었다. 제약 조건이 생겼단 이유로 코딩하는게 불편했다는 사실에 내가 얼마나 부족했는지 다시금 깨닫는 시간이 되었다.
이미 중, 고등학생 때 포토샵을 사용해봤던 입장에서 히스토리에 대해 알고있었고 이 뜻이 웹에서도 크게 다르진 않았다.
포토샵에서 히스토리는 작업 과정을 기록으로 남겨놓아 원하는 시점으로 돌아갈 수 있다. 혹은 그 당시에 어떤 작업을 했는지 확인도 할 수 있다.

웹페이지에서의 히스토리도 똑같은데 브라우저가 사용자가 방문했던 웹 페이지들의 목록을 기록해두는 것을 의미한다. 사용자는 이 기록을 통해 이전 페이지나 다음 페이지로 쉽게 이동할 수 있으며, 필요에 따라 특정 페이지로 돌아갈 수도 있다.
웹 개발을 하는 우리는 이 브라우저의 히스토리도 다룰 수 있는데 이것을 가능하게 해주는 것이 History interface 이다. 세션 기록, 즉 현재 페이지를 불러온 탭 또는 프레임의 방문 기록을 조작할 수 있는 방법을 제공한다.
이제 맨 위의 요구조건을 만족하기 위해
URL QueryParameter를 조작하며 페이지가 갱신되지 않고 히스토리에 남지 않도록 만들어보자.
맨 위의 요구조건은 2가지 였다.
1. URL QueryParameter가 변경될 때 페이지가 갱신되어선 안된다.
2. URL QueryParameter가 변경될 때 히스토리에 남아있으면 안된다.
뜬금없이 SPA 로 넘어갔는데 사실 SPA 가 pushState / replaceState 로 구현하는 웹 어플리케이션이다. 위에서 제시했던 실무테스트도 SPA 이다.
Single Page Application(SPA)는 말그대로 단일페이지이므로, 전통적인 페이지 이동 방식인 멀티페이지(Multi Page Application)의 설계방식을 벗어난다. 그래서 브라우저가 설계한 '페이지 기반 히스토리 탐색(뒤로가기/앞으로가기)'과는 맞지 않는다."
https://en.wikipedia.org/wiki/Single-page_application
저게 무슨 소리일까?
SPA 는 페이지 전체를 새로고침하지 않고 URL을 바꾸고 화면을 갱신하는 방식
다시 말하면URL이 바뀌면 새로운 페이지를 받지 않고, 서버에서 제공하는 데이터를 이용하여 동적으로 페이지의UI만 변경. 이때 사용자 눈에서만 새로운 페이지가 로드 된다. 브라우저는 페이지가 새로 바뀐지 모른다.
만약 네이버가 초창기 SPA 라고 가정해서www.naver.com에 접근해서 내 페이지로 가자.www.naver.com/mypage로 이동하게 되는데
우리 눈에는 새로운 페이지가 로드된 것 같지만 초창기SPA로 설계한 경우 사용자의 눈에만 그렇게 보이지 브라우저 입장에서는 처음www.naver.com에서URL이 바뀐 후UI만 변경되었고, 새로운 페이지를 가져오지 않았다.
그래서 히스토리 스택에 아무것도 쌓이지 않는다.
화면이 바뀌길래 "페이지가 바뀌었다"고 생각하고
뒤로가기를 누르면, 실제로는 브라우저가 아예 이전 페이지로 돌아가버림
사용자는 SPA 내부의 이전 화면을 기대했지만, SPA 자체가 언로드되어 버림
www.google.com -> www.naver.com -> www.naver.com/mypage에서 뒤로가기를 눌러도www.naver.com가 아니라www.google.com가 나온다.
URL이 바뀌지만 실제HTML문서를 다시 받지 않기 때문에 기본 브라우저 동작(새로고침, 뒤로가기 등)을 무시하거나 잘 작동하지 않는 앱이 초창기에는 많았다.
어떤 문제?
1. 뒤로가기를 눌러도 전체
SPA페이지를 벗어남.
즉,URL을 변경해도 새로 히스토리에 쌓이는 것이 아니기 때문에 히스토리 스택에 뒤로 이동할 곳의 정보가 없어SPA로 만든 어플리케이션 동작 전 페이지로 이동.
만약www.google.com에서SPA페이지인www.page1.com으로 이동.
여기서 내 정보로 이동.www.page1.com/mypage
현재 페이지에서 뒤로가기를 눌러도www.page1.com이 아니라www.google.com로 이동.
2.URL을 강제로 바꾸면 전체 페이지 리로드.
분명 SPA 에서는 URL 을 바꿔도 새로고침이 발생하지 않는다. 이것은 SPA 설계 원칙에 어긋났다.
3. 새로고침하면 현재 상태가 사라지기 때문에 사용자가 본 페이지가 초기화됨.
URL을 기준으로 서버에서 동적으로 데이터를 가져왔고, 히스토리에는www.google.com/mypage가 아니라www.google.com만 남아있으므로 새로고침하면www.google.com로 이동하여www.google.com/mypage에서 필요한 데이터를 서버에서 제공받지 못한다.
그래서 초기에는 위의 문제를 해결하기 위해 hash routing 을 도입했다.
www.google.com
www.google.com/#/mypage
www.google.com/#/settings
와 같이 #/mypage 로 URL 에 표시한다.
주의할 점이 이것은
HTML 앵커(Anchor)와는 다르다.
| 해쉬 | 앵커 | |
|---|---|---|
| 표기법 | www.google.com/#/mypage | www.google.com/#mypage |
| 동작 | JavaScript로 경로에 따라 UI 렌더링 | 페이지 내 특정 위치로 스크롤 이동 |
우리가 나무위키 같은 문서나 이곳 velog 에서 목차를 클릭해서 해당 항목으로 이동하는 방식이 HTML 앵커이다.
// Hash routing 예시
<!DOCTYPE html>
<html>
<head>
<title>Hash Routing</title>
</head>
<body>
<nav>
<a href="#/home">Home</a>
<a href="#/about">About</a>
<a href="#/products">Products</a>
</nav>
<div id="app"></div>
<script>
function render() {
const route = location.hash.slice(1); // #/home → /home
const app = document.getElementById('app');
switch (route) {
case '/home':
app.innerHTML = '<h1>Home Page</h1>';
break;
case '/about':
app.innerHTML = '<h1>About Page</h1>';
break;
case '/products':
app.innerHTML = '<h1>Products Page</h1>';
break;
default:
app.innerHTML = '<h1>404 Not Found</h1>';
}
}
window.addEventListener('hashchange', render);
window.addEventListener('load', render);
</script>
</body>
</html>
hash routing 이 그래서 초창기 SPA의 문제를 어떻게 해결했나?
hash routing 은 히스토리에 데이터가 쌓여 뒤로가기를 지원했기에 브라우저의 원래 기능을 잘보여줬다.
wikipedia 에서도 아래와 같이 설명했다.
https://en.wikipedia.org/wiki/Single-page_application#Browser_history
With a SPA being, by definition, "a single page", the model breaks the browser's design for page history navigation using the "forward" or "back" buttons. This presents a usability impediment when a user presses the back button, expecting the previous screen state within the SPA, but instead, the application's single page unloads and the previous page in the browser's history is presented.
SPA는 본질적으로 '한 개의 페이지(single page)'이므로, 브라우저가 설계한 '페이지 기반 히스토리 탐색(뒤로가기/앞으로가기)'과는 맞지 않는다. 이로 인해 사용자가 뒤로 가기 버튼을 눌렀을 때, SPA 내에서 이전 화면 상태로 돌아가기를 기대하지만, 실제로는 애플리케이션의 단일 페이지가 언로드되고 브라우저 히스토리의 이전 페이지가 나타나는 사용성 문제가 발생합니다.
The traditional solution for SPAs has been to change the browser URL's hash fragment identifier in accord with the current screen state. This can be achieved with JavaScript, and causes URL history events to be built up within the browser. As long as the SPA is capable of resurrecting the same screen state from information contained within the URL hash, the expected back-button behavior is retained.
SPA에서는 전통적으로 브라우저 URL의 hash fragment identifier (# 뒤의 값)를 변경하는 방식이 사용되었다. 이 방법은 JavaScript로 쉽게 구현 가능하고, 이때 브라우저의 히스토리에 쌓이게 된다. SPA가 # 뒤의 값으로부터 동일한 화면 상태를 복원할 수 있다면, 사용자 입장에서는 뒤로가기/앞으로 가기 버튼이 자연스럽게 작동하는 것처럼 보이게 된다. ⬅️ 히스토리에는 페이지의 당시 화면 상태가 아니라 URL 만 보관되므로, 뒤로가기를 눌러도 SPA가 바뀐 URL 을 가지고 화면을 다시 렌더링할 능력을 갖춰야한다.
To further address this issue, the HTML specification has introduced pushState and replaceState providing programmatic access to the actual URL and browser history.
이 문제를 보다 효과적으로 해결하기 위해, HTML 명세에서는 pushState와 replaceState를 도입하여 실제 URL과 브라우저 히스토리에 프로그래밍적으로 접근할 수 있는 기능을 제공하고 있습니다.
우리는 앞으로가기/뒤로가기 문제를 해결하여 사용자 경험을 높였다.
그리고 이를 더욱 더 효과적으로 해결하기 위해 pushState와 replaceState가 도입되었다.
이제 위의 두 API를 사용하여 가장 처음 나왔던 문제들을 해결해보자.
- URL QueryParameter가 변경될 때 페이지가 갱신되어선 안된다.
- URL QueryParameter가 변경될 때 히스토리에 남아있으면 안된다.
바로 #3 으로 넘어가도 되지만 이 두 개의 차이를 확실히 하고 싶었다.
위에서 결국 뒤로가기, 앞으로가기를 해결했는데 그럼 MPA와 뭐가 다를까? 라는 생각이 순간적으로 들었다.
물론 뒤로가기, 앞으로가기를 해결해도 다른 점이 꽤 있지만 한 번 정리해보고 싶었다.
| MPA | SPA | |
|---|---|---|
| 페이지 렌더링 | 전체 페이지 리로드 | 페이지는 그대로, JS로 화면 일부만 변경 |
| 페이지 전환 속도 | 느림 | 빠름 |
| 초기 로딩 속도 | 필요한 페이지만 받아서 빠름 | 모든 코드 미리 받아야해서 느림 |
| 히스토리 관리 | 브라우저가 자동으로 처리 | pushState 등으로 개발자가 수동으로 처리 |
| 이전 페이지 데이터/상태 유지 | 어려우며 이전 페이지로 갔다오면 초기화 | 동적으로 쉽게 처리 가능 |
| 적합한 서비스 | SEO가 중요하고, 페이지 간 연관성이 낮은 서비스 | 자주 전환되며, 데이터 상태를 오래 유지하고 빠른 반응이 필요한 곳 |
여기서 중요한 것이 히스토리 관리 이다.
pushState 등으로 개발자가 수동으로 처리한다고 했는데 SPA는 개발자가 화면 전환을 완전히 제어할 수 있다.
예를 들어 화면을 전환하면서 모달창을 연다거나, 슬라이드 전환 등 애니메이션 효과를 넣는다거나, 상태를 유지한다던가 가 가능하다.
| 항목 | 질문 | SPA 적합 | MPA 적합 |
|---|---|---|---|
| 사용자 경험 | 페이지 간 이동이 자주 일어나고 빠른 반응이 필요한가? | ✅ 예 | ❌ 아니오 |
| 상태 유지 | 입력값, 필터, 폼 데이터 등 상태를 유지한 채 화면만 전환해야 하는가? | ✅ 예 | ❌ 아니오 |
| 화면 전환 제어 | 모달, 탭 전환, 애니메이션 등 UI 전환을 JS로 정밀하게 제어하고 싶은가? | ✅ 예 | ❌ 아니오 |
| 성능 최적화 | 한번 로딩된 화면을 캐시하여 빠르게 재사용하고 싶은가? | ✅ 예 | ❌ 아니오 |
| SEO (검색엔진 최적화) | 검색엔진 노출이 매우 중요한가? | ❌ 제한됨 (대책은 있음) | ✅ 아주 적합 |
| 개발 복잡도 | 개발 속도, 유지 보수가 중요한가? | ❌ 복잡도 높음 | ✅ 구조 단순 |
| 프로젝트 규모 | 앱이 복잡하고 상호작용이 많은가? | ✅ 예 | ❌ 아니오 |
| 접근성(SSR 필요 여부) | 크롤러, 소셜미디어, 노출에 강한 SSR이 꼭 필요한가? | ❌ 직접 구현 필요 | ✅ 자동 제공됨 |
| 초기 로딩 속도 | 초기 화면이 매우 빨라야 하나요? | ❌ JS 번들 큼 | ✅ 빠름 |
| 모바일 앱 느낌 | 앱처럼 빠르게 반응하고 부드러운 UX가 중요한가? | ✅ 예 | ❌ 아니오 |
| 구분 | 설명 |
|---|---|
| SPA | React/Vue 어플리케이션 (예: Notion, Trello) |
| MPA | 네이버 뉴스, Wikipedia 등 전통적인 서버 라우팅 |
| 하이브리드 | Facebook, Instagram, YouTube – 일부만 SPA처럼 동작 |
위의 요구조건을 해결하고 구현하기 위한 코드가 아래에 있다.
아래의 코드는 웹툰 리스트에서 검색어를 입력하면 URL에 QueryParameter 가 추가되어 해당 QueryParameter 를 읽어 검색했던 웹툰을 필터링해서 보여준다.

해당 코드는 어떠한 라이브러리의 도움도 받지 않은 바닐라 자바스크립트 코드이다.
붉은 박스에서 input 에 입력한 값을 이용해 원하는 페이지로 이동하는 코드이다.
이때 사용하는 것은 window.location.search 값을 조작하는 것이다.
파란색 박스로 현재 URL 을 콘솔에 출력하도록 만들었는데,
이것은 요구조건 1번을 확인하기 위한 것이다.
아래의 사진을 보자.

검색어를 입력하면 URL 에 쿼리파라미터가 들어가며 원하는 데이터를 필터링해서 가져온다.
그리고 동시에 콘솔에 현재의 URL 이 출력된다는 것을 알 수 있다.
요구조건에서는 페이지가 갱신되어선 안된다고 했는데 페이지가 갱신되며 페이지의 콘솔도 갱신된다는 것을 확인할 수 있다.
만약 갱신되지 않았다면 콘솔의 값이 /index.html/ 그대로 남아있을 것이다.

여기서 뒤로가기를 하면 URL이 변경되기 전 페이지로 이동하게 된다. 즉 검색하기 직전의 페이지로 되돌아간다.
다시 한 번 원하는 요구조건으로 돌아가보자.
URL QueryParameter가 변경될 때replaceState를 통해 페이지가 갱신되지 않고 히스토리에 남지 않아야한다고 했다.
아쉽게도 window.location.search 로 페이지를 바꾸게되면 새로고침이 발생하며 히스토리에도 페이지가 남는다.
2가지의 조건이 있었다.
물론 요구조건에서 이미 replaceState 를 사용하라고 답을 줬지만 천천히 알아가보자.
이 중에서 1번을 먼저 고쳐보자.
1번을 고치기 위해 pushState 가 필요하다.
pushState란? 히스토리 스택에 항목을 추가하지만 페이지의 이동은 발생하지 않게 만들어준다.
즉, 위에서처럼 검색어를 입력하면 새로운 페이지로 이동하여 필터링된 검색 내용을 보여주는 것과 달리 페이지의 이동없이 히스토리에 기록만 남기게 해주는 API 이다.
위의 코드를 변경해보자.

pushState 에 전달되는 값들은 조금 있다가 알아보고, 어쨌든 원하는 URL 로 현재 URL 을 변경한다는 사실만 알아두자.
아까 window.location.search 를 사용할 때의 코드랑 비교하면 붉은 박스만 바뀌었다.
실행 결과는 아래와 같다.

위의 사진에서 검색어를 입력했더니 Query parameters 가 잘반영되어 URL 이 바뀌었지만 콘솔의 값이 변하지 않는다.
다행히 원하는대로 동작하는 것 같다.
하지만 요구조건과 상관없이 하나의 문제가 추가로 발생했다.
원하는 웹툰이 필터링되지 않았다.
위에서 해결하지 못했던, 원하는 웹툰이 필터링되지 않은 문제를 해결하기 위해 알아야한다.
popstate이벤트는 사용자의 세션 기록 탐색으로 인해 현재 활성화된 기록 항목이 바뀔 때 발생합니다.
즉, 앞으로가기, 뒤로가기 등으로 인해 history를 조작할 때 발생하는 이벤트이다.
그리고
history.pushState나history.replaceState를 통해 전달된state를 보관하고 있다.
우리가 위에서 작성했던 pushSate 는 아래와 같다.
history.pushState({title : value}, '', newUrl);
여기서 pushState 나 replaceState 의 첫 번째 매개변수가 popstate 이벤트에 전달되는 state 이다.
history.pushState({title : value}, '', newUrl);
window.addEventListner("popstate", (event) => {
console.log(event.state);
})
라고 작성하면 위의 코드 기준 /index.html?title="호랑이" 라는 URL 에서
{title : 호랑이} 라는 객체가 출력되며, 해당 URL 에서 이 데이터를 사용할 수 있게 된다.
여기서 주의할 점은
popstate이벤트는history.pushState나history.replaceState가 호출될 때 실행되는 것이 아니고, 아래의 상황에서 발생한다.
1. 앞으로가기 / 뒤로가기 버튼 누르기
2. history.forward() / history.back() 실행 시
뭔가 history.pushState 얘나 history.replaceState 얘가 동작해야
이제 popstate 를 알았으니 수정해보자.

처음 pushState 로 바꾼 코드는 URL 은 잘 변경되었지만 검색 내용이 필터링되진 않았다.
그렇기에 내가 검색버튼을 누르면 항상 loadWebtoons 를 실행하도록
pushState 아래에 추가해줬다.
그리고 추가로 앞으로가기, 뒤로가기를 할 때에도 새로고침하지 않기때문에 loadWebtoons 를 수행해야한다.

그리고 항상 로드할 때에는 검색 목록 컨테이너를 비워서 새롭게 써야한다.
새로고침이 되지 않기때문에 만약 호랑이를 검색해서 호랑이를 검색한 결과를 얻고, 그 다음 사자를 검색해서 사자를 검색한 결과를 얻으면
호랑이를 검색한 결과 아래에 추가로 사자를 검색한 결과가 붙여지는 경우가 발생한다.
아래는 실행 결과이다.

새로고침이 발생하지 않기때문에 콘솔의 값도 변하지 않는다.
우리는 이제 요구사항 1번을 해결했다.
이제 요구사항 2번인 URL QueryParameter가 변경될 때 히스토리에 남아있으면 안된다. 를 해결해야한다.
pushState는 히스토리 스택에 항목을 추가하지만 페이지의 이동은 발생하지 않게 만들어준다.
페이지 이동이 발생하지 않는건 좋지만 히스토리 스택에도 추가되어서는 안된다.
이를 해결하기 위해 replaceState 를 사용한다.

pushState 에서 replaceState 로 변경만 해주면 된다.

위에서 보는 것 처럼
localhost:5500/index.html
-> localhost:5500/index.html?title=호랑이
-> 뒤로가기 클릭
-> www.google.com
으로 localhost:5500/index.html 으로 이동하지 않고 곧바로 www.google.com 로 이동한다.
히스토리 스택에 안쌓여있다는 것을 알 수 있다.