Logo

자바스크립트의 History API와 클라이언트 단 라우팅

URL이 바뀔 때 마다 새로운 페이지를 서버로 요청하지 않는 SPA(Single Page Application)에서는 보통 클라이언트 단에서 라우팅(routing)을 하는데요. 그래서 React, Svelte, Vue.js와 같은 대부분의 프론트엔드 프레임워크을 사용할 때는 이러한 클라이언트 단 라우팅을 지원하는 라이브러리와 함께 쓰는 경우가 많습니다.

그런데 이러한 라우팅 라이브러리는 대부분은 내부적으로 자바스크립트의 History API를 사용하고 있다는 것을 알고 계셨나요? 이번 글에서는 클라이언트 단 라우팅을 이해하는데 핵심적인 자바스크립트의 History API에 대해서 알아보겠습니다.

History API란?

History API는 브라우저가 관리하는 세션 히스토리(session history), 즉 페이지 방문 이력을 제어하기 위한 웹 표준 API 입니다. 여기서 세션은 브라우저마다 살짝 다를 수 있지만 보통 사용자가 새 창이나 탭을 열 때 생성되고 해당 창이나 탭을 닫을 때 소멸합니다.

지금 이 포스팅을 보고 계신 브라우저에서 새 탭을 열어보시면 “뒤로 가기”와 “앞으로 가기” 버튼이 비활성화 되어 있을텐데요. 이 것은 현재 세션 히스토리가 비어있다는 뜻이고 방문 이력을 하나도 없기 때문에 뒤로 가거나 앞으로 갈 페이지가 없다는 뜻입니다.

자바스크립트에서 History API는 기본적으로 history 전역 객체를 통해서 사용해 볼 수 있으며 windowdocument 전역 객체를 통해서도 접근할 수 있습니다. 예를 들어서, 현재 세션에서 얼마나 많은 페이지를 방문했는지 알고 싶다면 다음과 같이 History API에서 제공하는 length 프로퍼티에 접근하면 됩니다.

history.length; // 1
window.history.length; // 1
document.history.length; // 1

여러 페이지를 방문 후에 history.length를 확인해보시면 더 큰 숫자를 확인하실 수 있으실 겁니다.

히스토리 내 페이지 이동

History API가 제공하는 가장 기본적인 기능은 히스토리에 기록된 페이지 방문 이력을 따라 이동하는 것입니다. 쉽게 말해서 사용자가 브라우저에서 뒤로 가기나 앞으로 가기를 하는 행위를 자바스크립트로 대신 해줄 수 있다고 생각하시면 될 것 같습니다.

뒤로 가기 효과를 내고 싶다면 back() 메서드를 호출하면 되고, 앞으로 가기 효과를 내고 싶다면 forward() 메서드를 호출하면 됩니다.

history.back(); // 뒤로 가기
history.forward(); // 앞으로 가기

History API에는 go()라는 back()forward()를 모두 대체할 수 있는 좀 더 범용적인 메서드도 있는데요. 이 메서드는 정수를 인자로 받는데, 0을 인자로 넘기면 현재 페이지를 새로 고침하는 효과가 납니다. 인자로 음수를 넘기면 그 만큼 뒤로 가기가 되고 반대로 인자로 양수를 넘기마녀 그 만큼 앞으로 가기가 됩니다.

history.go(-2); // 뒤로 2번 가기
history.go(-1); // 뒤로 1번 가기
history.go(0); // 새로 고침
history.go(1); // 앞으로 1번 가기
history.go(2); // 앞으로 2번 가기

그런데 history.back(), history.forward(), history.go() 메서드는 SPA에서 잘 사용되지 않습니다. 왜냐하면 이 3개의 메서드를 호출하면 브라우저는 해당 페이지를 리로드(reload)하게 되는데요. 서문에서 말씀드렸듯이 SPA에서는 일반적으로 URL이 바뀌더라도 전체 페이지를 다시 로딩하지 않기 때문입니다. 대신 업데이트가 필요한 부분만 클라이언트에서 다시 그리죠.

참고로 세션 히스토리 내에서 페이지를 이동하는 것이 아니라 완전히 새로운 페이지로 이동해야하는 경우에는 자바스크립트의 Location API를 사용해야합니다. 이 부분에 대해서는 별도 포스팅에서 자세히 다루고 있으니 참고 바랍니다.

브라우저의 현재 URL 조작

History API는 브라우저의 현재 URL 조작할 수 있도록 pushState()replaceState() 메서드를 제공하는데요. 위에서 다룬 실제로 페이지가 이동하는 메서드와 다르게 이 2개의 메서드는 브라우저 주소 표시줄의 URL만 갱신되고 실제로 해당 페이지가 다시 불러오지는 않습니다.

이러한 특징 때문에 pushState()replaceState() 메서드는 클라이언트 단 라우팅에서 핵심적인 역할을 하게 되는데요. 왜냐하면 브라우저가 서버로 페이지를 재요청하지 않고 URL만 바꿔주니, 이 때 자바스크립트로 해당 URL에 맞게 페이지를 부분 업데이트할 수 있기 때문이죠.

이 두 개의 메서드는 3개의 인자를 받으며 API가 직관적이지 않기로 악명이 높은데요. 우선 첫 번째 인자는 이 URL에 연관된 상태 객체를 넘길 수 있습니다. 이 상태 객체에 대한 부분은 다음 섹션에서 다룰 PopState 이벤트에 대해서 설명드릴 때 좀 더 알아보기로 하고요.

두 번째 인자는 하위 호환성을 위해서 존재하나 현재는 사용되지 않기 때문에 그냥 빈 문자열을 넘기면 되고요. 비로서 마지막 세 번째 인자에 변경할 URL을 넘길 수 있으면 절대 경로 상대 경로 모두 사용이 가능합니다. 단, 보안상의 이유로 다른 출처(origin), 즉, 프로토콜, 호스트네임, 포트가 다른 URL을 사용할 수 없습니다.

pushState() 메서드와 replaceState() 메서드는 이름에서도 유추가 되듯이 세션 히스토리를 변경하는 방식에서 중요한 차이점이 있는데요.

pushState()는 인자로 넘어온 URL을 현재 페이지의 바로 다음 방문 기록으로 추가하고, 그 이후의 방문 이력을 있다면 모두 지워버립니다. 따라서, pushState() 메서드를 호출 전과 호출 후를 도식화해보면 다음과 같은 모습이 됩니다.

// a -> b -> c -> d -> e
//      👆 현재 페이지
history.pushState({}, "", "x");
// a -> b -> x
//           👆 현재 페이지
history.back();
// a -> b -> x
//      👆 현재 페이지

pushState() 메서드를 호출하면 원래 페이지는 이전 페이지로 보존이 되고 인자로 넘긴 URL이 현재 페이지가 됩니다. 따라서 사용자가 브라우저에서 “뒤로 가기” 버튼을 눌러서 원래 페이지로 돌아가능 것이 가능합니다. 하지만 “앞으로 가기” 버튼은 비활성화될 것입니다.

반면에 replaceState()는 인자로 넘어온 URL로 현재 페이지를 완전히 덮어 써버리죠. 대신에 현재 페이지 다음에 있는 방문 이력은 건들지 않습니다. 마찬가지로 도식화를 해보았습니다.

// a -> b -> c -> d -> e
//      👆 현재 페이지
history.replaceState({}, "", "x");
// a -> x -> c -> d -> e
//      👆 현재 페이지
history.back();
// a -> x -> c -> d -> e
// 👆 현재 페이지

replaceState() 메서드를 호출하면 원래 페이지가 인자로 넘긴 URL로 대체가 됩니다. 따라서 사용자가 브라우저에서 “뒤로 가기” 버튼을 누르면 원래 페이지의 이전 페이지로 이동하게 됩니다. 하지만 “앞으로 가기” 버튼을 누르면 여전히 다음 페이지로 이동할 수 있습니다.

보사다시피 API가 직관적이지 않기 때문에 대부분의 라우팅 라이브러리는 이 부분을 깔끔하게 추상화시켜주고 있습니다. 예를 들어, React Router v6 이전에는 push()replace()라는 메서드를 통해서 URL을 첫 번째 인자로 받고, 상태 객체를 두 번째 인자로 받았으며, React Router v6부터는 navigate()라는 통합된 메서드를 통해서 push 또는 replace 여부를 옵션 인자로 받고 있습니다.

PopState 이벤트

History API의 pushState() 또는 replaceState() 메서드를 사용하면 페이지 리로딩 없이 URL만 갱신함으로써 클라이언트 단에서 기본적인 라우팅이 가능해지는데요. 사용자가 브라우저에서 뒤로 가기나 앞으로 가기를 하면 어떻게 될까요? 이 때는 back()이나 forward() 메서드를 호출한 것처럼 브라우저가 페이지를 다시 불러오겠죠? 그러면 SPA에서는 분명히 문제가 될 수 있을 것입니다.

여기서 중요한 부분은 “뒤로 가기”나 “앞으로 가기” 버튼은 보통 브라우저의 주소 표시줄 좌측에 위치하잖아요? 즉, 우리가 통제할 수 있는 페이지 상 밖에 있으며, 따라서 기본적으로 페이지 상에 있는 일반적인 버튼처럼 클릭(click) 이벤트를 감지할 수는 없습니다.

이 재미있는 문제를 해결해주는 것이 바로 History API의 PopStateEvent 인데요. 이 이벤트는 사용자가 브라우저에서 뒤로 가기나 앞으로 가기를 할 때 window 전역 객체에서 발생합니다. 그러므로 우리는 자바스크립트로 이벤트 핸들러를 걸어서, 해당 URL에 부합하는 내용을 클라이언트에서 그려줄 수 있게 됩니다.

이것이 바로 pushState() 또는 replaceState() 메서드가 첫 번째 인자로 상태 객체를 받는 결정적인 이유입니다. PopStateEvent를 처리하는 핸들러 함수의 매개 변수로 이 상태 객체가 넘어오기 때문에 우리는 이 것을 읽어서 적절한 처리를 할 수가 있습니다.

window.addEventListener("popstate", (event) => {
  // 이벤트에 들어 있는 상태 객체를 읽어서 클라이언트 단 라우팅 처리
});

클라이언트 단 라우팅 직접 구현해보기

지금까지 배운 History API를 사용해서 직접 클라이언트 단 라우팅을 구현해보면 어떨까요?

우선 네비게이션을 위한 3개의 <button> 요소와 <h1> 요소로 이루어진 간단한 HTML 코드를 작성히겠습니다.

<header>
  <button id="home">Home</button>
  <button id="about">About</button>
  <button id="contact">Contact</button>
</header>

<main>
  <h1>HOME</h1>
</main>

그 다음 자바스크립트로 클라이언트 단에서 라우팅을 처리하기 위한 함수를 작성할께요. 이 navigate() 함수는 상태 객체를 인자로 받고, 상태 객체 안에 담겨있는 path(경로) 속성을 읽어서 대문자로 변환한 후 <h1> 요소의 내용으로 설정합니다. 따라서 이 함수를 호출하면 HTML에서 <h1> 요소의 내용이 바뀌는 효과가 나겠죠?

function navigate(state) {
  const h1 = document.querySelector("h1");
  h1.textContent = state.path.toUpperCase();
}

이제 3개의 버튼에 클릭 이벤트 핸들러를 설정해줄 차례인데요. 사용자가 각 버튼을 클릭하면 브라우저의 주소 표시줄의 URL이 바뀔 수 있도록 history.pushState() 메서드를 호출해줍니다. 첫 번째 인자에는 경로 정보가 담겨있는 상태 객체를 넘기고, 세 번째 인자로는 브라우저의 주소 표시줄에 표시할 경로를 문자열로 넘김니다. 마지막으로 위에서 작성한 navigate() 함수에 동일한 상태 객체를 넘겨서 <h1> 요소의 내용이 바뀌게 해주겠습니다.

["home", "about", "contact"].forEach((path) => {
  const button = document.querySelector("#" + path);
  button.addEventListener("click", () => {
    const state = { path };
    history.pushState(state, "", path);
    navigate(state);
  });
});

여기까지만 해주면 사용자가 버튼을 클릭할 때는 <h1> 요소의 내용이 잘 변경이 될 거에요. 하지만 브라우저에서 뒤로 가기나 앞으로 가기를 해보면 주소 표시줄의 URL만 바뀌고 <h1> 요소의 내용은 그대로 변하지 않을 것입니다.

이 문제를 해결하기 위해서 윈도우에서 발생하는 popstate 이벤트를 감지해서 라우팅 처리해줄 건데요. 위에서 history.pushState() 첫 번째 인자로 상태 객체를 넘겨주었기 때문에, 해당 페이지로 뒤로 가기나 앞으로 가기가 되면, 이벤트 핸들러에 매개 변수로 해당 상태 객체가 넘어오게 됩니다. 따라서 우리는 이 상태 객체를 그대로 navigate() 함수에 인자로 넘겨주기만 하면 됩니다.

window.addEventListener("popstate", (event) => {
  navigate(event.state);
});

실제로 웹 개발에 많이 쓰이고 있는 라우팅 라이브러리의 코드를 읽어보시면 대부분 이러한 메커니즘으로 구현되어 있다는 것을 확인해보실 수 있으실 것입니다.

전체 코드

본 포스팅에서 작성한 코드는 아래에서 확인하고 직접 실행해보실 수 있습니다. 코드펜에 들어가셔서 “Debug mode”에 들어가시면 실제로 브라우저의 주소 표시줄에서 URL이 변경되는 것 까지 볼 수 있으실 겁니다.

마치면서

지금까지 자바스크립트의 History API가 클라이언트 단 라우팅을 구현하는데 어떻게 활용될 수 있는지 살펴보고 간단한 실습도 진행해보았습니다.

실제 애플리케이션을 개발하실 때는 이미 많은 프로젝트에서 검증이 된 유명한 라우팅 라이브러리를 사용하실테니 이렇게 직접 라우팅을 구현할 일은 없으실 것입니다. 하지만 라우팅 라이브러리가 내부적으로 어떻게 동작하시는지 이해하신다면 버그가 생겼을 때 디버깅이 용이해지고 나중에 다른 프론트엔드의 라우팅 라이브러리를 배울 때도 큰 도움이 될 것입니다.