블로그 목록
frontend

Navigation API로 클라이언트 라우팅 가로채기 — history.pushState + popstate 수동 관리 그만

간단한 SPA 라우터를 직접 만들다 보면 금방 지저분해진다. 링크 클릭은 click 이벤트로 가로채서 preventDefault 하고, 주소는 history.pushState로 바꾸고, 뒤로/앞으로 가기는 또 popstate 이벤트로 따로 처리해야 한다. 가로채는 지점이 두 군데라 로직이 쪼개지고, 어느 한쪽을 빠뜨리기 쉽다.

navigate 이벤트 하나로 묶기

Navigation API는 페이지 내 모든 이동 — 링크 클릭이든 뒤로 가기든 location.href 변경이든 — 을 navigate 이벤트 하나로 흘려보낸다. intercept()로 가로채고 핸들러만 넘기면 끝이다.

navigation.addEventListener('navigate', (event) => {
  // 같은 출처가 아니거나 가로채면 안 되는 이동은 그냥 넘긴다
  if (!event.canIntercept || event.hashChange || event.downloadRequest) {
    return;
  }

  const url = new URL(event.destination.url);
  if (url.origin !== location.origin) return;

  event.intercept({
    async handler() {
      const html = await loadRoute(url.pathname);
      document.querySelector('#app')!.innerHTML = html;
    },
  });
});

pushState 호출도, popstate 리스너도 없다. 뒤로 가기를 눌러도 같은 navigate 이벤트가 발동하므로 핸들러가 그대로 재사용된다. 주소 표시줄 갱신은 브라우저가 알아서 한다.

로딩·취소까지 공짜로 따라온다

intercept()가 반환하는 Promise가 곧 "전환 진행 중" 상태다. 전환 중에 사용자가 다른 링크를 누르면 이전 핸들러의 signal이 자동으로 abort된다. 데이터 페칭에 그 신호를 그대로 물려주면 취소가 공짜로 처리된다.

event.intercept({
  async handler() {
    showSpinner();
    const res = await fetch(`/api/page?path=${url.pathname}`, {
      signal: event.signal, // 다음 이동이 시작되면 자동 취소
    });
    render(await res.json());
    hideSpinner();
  },
});

전환 성공/실패는 navigatesuccess, navigateerror 이벤트로도 들어온다. 로딩 바를 한곳에서 켜고 끄기 좋다.

주의할 점

  • Chrome·Edge는 잘 되지만 Safari는 2026년에도 아직 부분 지원이고 Firefox도 뒤처져 있다. 메인 라우터로 쓸 땐 'navigation' in window로 감지해서 폴백을 준비하자.
  • 가로채면 안 되는 경우(다운로드 링크, 외부 출처, 해시 이동)를 위에서 먼저 걸러내야 한다. 안 그러면 파일 다운로드까지 막아버린다.

링크 클릭과 뒤로 가기를 따로 붙잡던 코드가 navigate 이벤트 하나로 합쳐진다. 로딩 상태와 취소 신호까지 이벤트에 딸려 오니, 프레임워크 없이 라우팅을 다룰 때 history API를 손으로 꿰매던 수고가 확 줄어든다.