History API: pushState와 popstate로 새로고침 없이 URL 바꾸기

History API는 브라우저 탭의 방문 기록(세션 히스토리)을 JavaScript로 읽고 바꾸는 window.history 인터페이스다. pushState()는 페이지를 새로 불러오지 않고 같은 출처의 URL로 기록을 하나 더하고, 사용자가 뒤로 가기나 앞으로 가기로 그 기록에 돌아오면 popstate 이벤트가 그때 저장한 state를 돌려준다.

JavaScript

React 앱에서 이미 쓰고 있던 History API

  1. Next.js로 만든 앱에서 <Link>를 누르면 주소창 URL은 바뀌는데 페이지는 새로고침되지 않는다.

  2. 그 상태에서 브라우저 뒤로 가기를 누르면 이전 화면이 나오고, 이때도 새로고침은 없다.

  3. React 자체에는 주소창 URL을 바꾸는 기능이 없고, 이 두 동작은 Next.js나 React Router 같은 라우터 라이브러리가 브라우저의 History API를 불러서 만든다.

  4. History API는 브라우저가 window.history 객체로 열어 둔 기능으로, 탭의 방문 기록을 JavaScript로 읽고 바꾸게 해 준다.

  5. 자주 쓰던 라우터 기능을 History API 호출로 바꿔 보면 아래와 같다(Next.js 16.2.9, React Router 8.4.0의 BrowserRouter 소스 기준).

    // Next.js
    <Link href="/blog">블로그</Link>      // 클릭하면 history.pushState()
    router.push("/blog");                 // history.pushState()
    router.replace("/blog?page=2");       // history.replaceState()
    router.back();                        // history.back()
    
    // React Router
    navigate("/blog");                    // history.pushState()
    navigate(-1);                         // history.go(-1)

History API와 세션 히스토리

  1. 브라우저 탭은 그 탭에서 거쳐 간 페이지를 순서대로 기억한다.

  2. 이 기록을 세션 히스토리(session history)라고 부르고, 탭마다 따로 있다.

  3. 세션 히스토리의 한 칸을 엔트리(entry)라고 부른다.

  4. 한 탭에서 google.com을 보다가 limjaejoon.com 링크를 누르고, 다시 /blog 링크를 누르면 세 페이지가 엔트리 세 개로 이 순서로 쌓인다.

  5. 브라우저는 그중 지금 화면에 보이는 엔트리를 현재 엔트리로 표시해 둔다.

  6. 뒤로 가기 버튼은 현재 엔트리 표시를 이전 엔트리로, 앞으로 가기 버튼은 다음 엔트리로 옮긴다.

  7. 엔트리 하나에는 URL, 그 URL을 보여 주는 문서, state 값이 들어 있다.

  8. 문서(document)는 서버에서 받은 HTML로 브라우저가 만든 페이지이고, JavaScript에서는 document 객체로 이 문서를 다룬다.

  9. state는 JavaScript가 엔트리에 붙여 둘 수 있는 값이고, 아무것도 붙이지 않았으면 null이다.

  10. 링크를 눌러 이동하면 브라우저가 새 문서를 만들어 새 엔트리에 담는다.

  11. 뒤에서 볼 pushState()는 문서를 그대로 둔 채 엔트리만 늘리므로, 한 문서에 여러 엔트리가 이어질 수 있다.

  12. History API는 이 세션 히스토리를 JavaScript에서 다루게 해 주는 window.history 객체다.

  13. history.length는 엔트리 개수이고 history.state는 현재 엔트리의 state 값이며, 둘 다 읽기 전용이다.

  14. window.history에는 다른 엔트리의 URL을 읽는 속성이나 메서드가 없어서, 이 객체로는 현재 엔트리의 URL만 알 수 있다.

back(), forward(), go()로 엔트리 사이를 옮기기

  1. window.history의 메서드는 다섯 개이고, 하는 일에 따라 두 무리로 나뉜다.

  2. back(), forward(), go()는 이미 쌓인 엔트리 사이에서 현재 엔트리를 옮기고, 엔트리를 더하거나 고치지 않는다.

  3. 옮겨 간 엔트리가 다른 문서의 엔트리라면, 브라우저는 그 문서를 다시 보여 주기 위해 불러오거나 저장해 둔 것을 복원한다.

  4. pushState(), replaceState()는 엔트리를 새로 더하거나 현재 엔트리를 고쳐 엔트리 목록을 바꾼다.

  5. history.back()은 현재 엔트리를 이전 엔트리로 옮기고, 브라우저의 뒤로 가기 버튼과 같은 일을 한다.

  6. history.forward()는 현재 엔트리를 다음 엔트리로 옮기고, 앞으로 가기 버튼과 같은 일을 한다.

  7. history.go(n)은 현재 엔트리에서 n칸 떨어진 엔트리로 옮기고, n이 음수면 이전 쪽, 양수면 다음 쪽으로 간다.

  8. 그래서 back()은 go(-1), forward()는 go(1)과 같다.

  9. go(0)이나 인자 없는 go()는 현재 페이지를 새로고침한다.

  10. 옮길 엔트리가 없으면 아무 일도 일어나지 않고 오류도 나지 않는다.

  11. 첫 엔트리에서 back()을 부르거나, 엔트리가 세 개뿐인데 go(10)을 부르는 경우가 여기에 해당한다.

  12. 세 메서드는 비동기로 동작한다.

  13. 비동기라는 말은 메서드가 이동을 예약만 하고 곧바로 돌아오며, 실제 이동은 그 뒤에 일어난다는 뜻이다.

  14. HTML 명세에서 go()는 엔트리를 옮기는 작업을 탭의 세션 히스토리 이동 큐(session history traversal queue)에 넣고 바로 돌아온다.

  15. 이 큐는 들어온 작업을 순서대로 꺼내 처리하는 대기열이고, 명세는 이 큐를 페이지의 JavaScript 실행과 병렬로 도는 큐(parallel queue)로 정해 두었다.

  16. 그래서 back()을 부른 바로 다음 줄은 이동이 일어나기 전에 실행되고, 이때 location은 아직 옮기기 전 URL이다.

    // 현재 URL이 ?page=4이고 이전 엔트리가 ?page=3이라고 하자
    history.back();               // 이동을 예약하고 바로 돌아온다
    console.log(location.search); // 즉시 출력: "?page=4" (아직 이동 전이다)
  17. 같은 문서의 엔트리로 옮겼다면, 이동이 끝난 시점은 뒤에서 볼 popstate 이벤트로 알 수 있다.

pushState()와 replaceState()는 불러오기 없이 엔트리를 바꾼다

  1. history.pushState(state, unused, url)은 현재 엔트리 다음에 새 엔트리를 하나 더하고, 그 엔트리를 현재 엔트리로 만든다.

  2. 첫 번째 인자 state는 새 엔트리에 붙일 값이다.

  3. 세 번째 인자 url은 새 엔트리의 URL이고, 주소창에도 이 URL이 보인다.

  4. 두 번째 인자 unused는 이름 그대로 지금은 쓰이지 않는 자리다.

  5. 이 자리는 원래 엔트리의 제목(title)을 받던 자리였고, Safari도 18 버전부터 이 값을 무시해 지금 주요 브라우저는 모두 이 값을 쓰지 않는다.

  6. HTML 명세도 지금은 이 인자의 이름을 unused로 적고, 메서드 동작에 쓰지 않는다.

  7. 인자는 순서대로 넘기므로, 세 번째 인자 url을 넘기려면 두 번째 자리를 무언가로 채워야 한다.

  8. MDN은 이 자리에 빈 문자열 ""을 넣으라고 권한다.

    // state, unused, url 순서로 넘긴다. 두 번째 자리는 빈 문자열로 채운다
    history.pushState({ page: 1 }, "", "?page=1");
  9. pushState()를 불러도 브라우저는 url의 문서를 서버에 요청하지 않는다.

  10. 링크를 눌러 /blog로 이동하면, 브라우저는 서버에 /blog를 요청하고 받은 HTML로 새 문서를 만들어 현재 문서로 바꾼다.

  11. 새 문서의 JavaScript는 처음부터 다시 실행되므로, 이전 문서에서 만든 변수에 접근할 수 없다.

  12. pushState(null, "", "/blog")는 서버에 요청을 보내지 않고 주소창 URL과 엔트리 목록만 바꾸므로, 화면의 DOM과 JavaScript 변수는 호출 전 그대로다.

  13. 로컬 서버에서 아래 코드를 실행했을 때, 서버에 /blog 파일이 없어도 오류가 나지 않았고 서버 로그에는 /blog 요청이 남지 않았다.

    window.counter = 42;
    history.pushState(null, "", "/blog"); // 서버에 /blog가 없어도 오류가 나지 않는다
    console.log(location.pathname);       // 즉시 출력: "/blog"
    console.log(window.counter);          // 즉시 출력: 42 (같은 문서라 변수가 남아 있다)
  14. 그래서 /blog에 맞는 화면을 그리는 일은 pushState()를 부른 코드가 직접 한다.

  15. url은 상대 경로로 써도 되고, 문서의 기준 URL(보통 현재 URL이고, <base href>가 있으면 그 값)을 기준으로 해석된다.

  16. url을 생략하면 현재 URL을 그대로 쓴다.

  17. history.replaceState(state, unused, url)는 같은 인자를 받지만, 엔트리를 더하지 않고 현재 엔트리의 state와 URL을 바꾼다.

    // 현재 URL이 /list라고 하자
    history.pushState({ page: 1 }, "", "?page=1");
    console.log(location.search);  // 즉시 출력: "?page=1"
    console.log(history.state);    // 즉시 출력: { page: 1 }
    
    // 엔트리 수는 그대로 두고 현재 엔트리만 ?page=3으로 바꾼다
    history.replaceState({ page: 3 }, "", "?page=3");
    console.log(location.search);  // 즉시 출력: "?page=3"
  18. back()과 달리 두 메서드는 동기로 끝나서, 호출 바로 다음 줄에서 location과 history.state가 이미 새 값이다.

  19. 이전 엔트리로 돌아간 상태에서 pushState()로 엔트리를 더하면, 앞으로 가기로 갈 수 있던 다음 엔트리는 모두 지워진다.

  20. HTML 명세는 엔트리를 더할 때마다 앞으로 가기 기록을 지우는 단계를 거치게 정해 두었고, 링크를 눌러 옮길 때도 같은 단계를 거친다.

popstate는 엔트리 사이를 옮길 때 발생한다

  1. pushState()로 만든 엔트리들은 같은 문서를 쓰므로, 그 사이에서 뒤로 가기를 누르면 브라우저는 문서를 다시 불러오지 않고 주소창 URL과 현재 엔트리만 바꾼다.

  2. 문서가 그대로이니 화면도 그대로이고, 이전 화면을 보여 주려면 코드가 이동을 알아채고 화면을 다시 그려야 한다.

  3. popstate는 이때 쓰는 이벤트로, 같은 문서 안의 엔트리 사이를 옮겨 현재 엔트리가 바뀔 때 window에서 발생한다.

  4. 뒤로 가기·앞으로 가기 버튼과 back(), forward(), go()가 이 이동을 일으킨다.

  5. pushState()와 replaceState()는 popstate를 발생시키지 않는다.

  6. event.state에는 도착한 엔트리의 state 복사본이 들어 있다.

    window.addEventListener("popstate", (event) => {
      // 이 시점에 location은 이미 도착한 엔트리의 URL이다
      console.log(location.search, event.state);
    });
    
    history.pushState({ page: 1 }, "", "?page=1"); // popstate 없음
    history.pushState({ page: 2 }, "", "?page=2"); // popstate 없음
    history.back();
    // 잠시 뒤 이동이 끝나면 출력: "?page=1" { page: 1 }
  7. 페이지를 처음 열었을 때 만들어진 엔트리는 pushState()를 거치지 않았으므로 state가 null이다.

  8. event.state를 읽어 화면을 되돌리는 코드라면, 그 엔트리로 돌아왔을 때 event.state가 null이라 되돌릴 정보가 없다.

  9. 이때는 페이지를 열 때 replaceState()로 첫 엔트리에도 state를 붙여 두면 이 문제가 없어진다.

    // 첫 엔트리에 현재 화면 정보를 붙인다. URL은 그대로 둔다
    history.replaceState({ page: 0 }, "", location.href);
  10. pushState()는 해시만 다른 URL로 옮겨도 hashchange 이벤트를 발생시키지 않는다.

  11. MDN은 원치 않는 팝업을 막으려고 브라우저가 사용자와 상호작용하지 않은 페이지에는 popstate를 보내지 않을 수 있다고 적는다.

클라이언트 라우팅은 두 API를 짝지어 만든다

  1. 클라이언트 라우팅은 링크를 눌러도 서버에서 새 HTML을 받지 않고, JavaScript가 URL에 맞는 화면을 그리는 방식이다.

  2. 링크 클릭에서는 pushState()로 URL과 엔트리를 바꾸고, 뒤로 가기에서는 popstate로 바뀐 URL을 읽는다.

  3. 두 경로 모두 화면을 그리는 함수를 같은 render()로 부른다.

    // URL 경로에 맞는 화면을 그린다
    function render() {
      document.querySelector("#app").textContent = `현재 경로: ${location.pathname}`;
    }
    
    document.addEventListener("click", (event) => {
      const link = event.target.closest("a[data-link]");
      // 다른 출처 링크는 pushState가 SecurityError를 내므로 브라우저 기본 동작에 둔다
      if (!link || link.origin !== location.origin) return;
      event.preventDefault();                // 브라우저가 새 문서를 불러오지 않게 막는다
      history.pushState(null, "", link.href); // 엔트리를 더하고 주소창 URL을 바꾼다
      render();                               // pushState는 popstate를 부르지 않으니 직접 그린다
    });
    
    // 뒤로 가기·앞으로 가기는 popstate로만 알 수 있다
    window.addEventListener("popstate", render);
    
    render(); // 첫 화면

정리

  1. History API는 window.history로 탭의 세션 히스토리를 읽고 바꾸는 API이고, 세션 히스토리의 한 칸을 엔트리라고 부른다.
  2. Next.js의 <Link>·router.push()와 React Router(BrowserRouter)의 navigate()는 내부에서 pushState()를 부르고, 브라우저 뒤로 가기는 popstate 리스너로 받는다.
  3. back(), forward(), go()는 엔트리 사이를 비동기로 옮기고, 같은 문서 안에서 옮겼다면 이동이 끝난 뒤 popstate가 발생한다.
  4. pushState()는 서버에 요청하지 않고 엔트리를 더하며, replaceState()는 현재 엔트리를 바꾸고, 둘 다 동기로 끝난다.
  5. pushState()와 replaceState()는 popstate를 발생시키지 않으므로, 클라이언트 라우터는 클릭에서 직접 화면을 그리고 뒤로 가기에서는 popstate로 화면을 그린다.

댓글