Ray Book
에세이

Navigation API가 드디어 Baseline이 됐다

SPA 라우터가 하는 일은 대부분 브라우저가 안 해주는 일을 대신하는 것이다. pushState 위에 클릭 가로채기, 스크롤 복원, 포커스 관리를 직접 구현해온 16년. Navigation API가 Baseline이 되면서 이 일들이 브라우저로 넘어가기 시작했다.

essayjavascriptnavigation-apispaweb-platformopinion

라우터가 실제로 하는 일

React Router든 Vue Router든, SPA 라우터가 하는 일을 하나씩 나열해보면 이렇다.

문서 안의 모든 <a> 클릭을 가로챈다. preventDefault()를 부른다. history.pushState()로 주소만 바꾼다. 화면은 직접 갈아 끼운다. 스크롤은 브라우저가 복원해주지 않으니 위치를 어딘가에 저장해뒀다가 직접 되돌린다. 포커스도 마찬가지다. 스크린 리더 사용자는 페이지가 바뀐 걸 모르니, 새 페이지 제목을 읽어주는 처리를 또 직접 넣는다. 그리고 이것과 별개로, 뒤로 가기 버튼을 위해 popstate 리스너를 하나 더 단다.

이게 라우터다. 정리하면 브라우저가 안 해주는 일들의 목록이고, 이 목록을 16년 동안 모든 라우터가 각자 구현해왔다.

왜 이렇게 됐을까. 답은 간단하다. 브라우저가 제공한 도구가 애초에 SPA용이 아니었기 때문이다.

pushState는 애초에 SPA용이 아니었다

history.pushState()는 2010년에 나왔다. HTML5 시절이다. 그런데 이 API가 해주는 일은 "주소를 바꾸되 페이지는 새로 안 불러온다"가 전부다. 주소와 화면을 분리해준 것까지는 좋았는데, 분리한 다음의 일은 전부 개발자 몫으로 남겨놨다.

문제도 많았다. popstate 이벤트는 뒤로 가기나 앞으로 가기 때만 발생한다. 정작 pushState()를 부를 때는 아무 이벤트도 발생하지 않는다. 그래서 "지금 페이지 이동이 일어났다"는 걸 한 군데서 알 방법이 없다. 링크 클릭은 클릭 리스너로, 코드에서 일으킨 이동은 함수를 부르는 시점에, 뒤로 가기는 popstate로, 세 갈래를 따로 처리해야 한다.

히스토리 스택은 더 심하다. 지금 몇 번째 항목에 있는지 알 수 없다. 전체 스택을 읽을 수도 없다. 사용자가 뒤로 가기를 눌렀는지 앞으로 가기를 눌렀는지 방향조차 알려주지 않는다. 그래서 "뒤로 가기일 때만 왼쪽으로 슬라이드하는 애니메이션" 같은 걸 만들려면, 히스토리 인덱스를 state에 심어두고 크기를 비교하는 꼼수를 써야 했다. 다들 그렇게 했다. 그것 말고는 방법이 없었으니까.

가장 근본적인 한계는 이거다. popstate는 이동이 이미 끝난 뒤에 발생하는 이벤트라서, 막을 수가 없다. "작성 중인 글이 있는데 정말 나가시겠어요?" 같은 확인창을 뒤로 가기 버튼에는 제대로 붙일 방법이 없었다. 여러 라이브러리가 우회책을 만들었지만 전부 히스토리 스택을 조작하는 방식이었고, 엣지 케이스에서 어김없이 깨졌다.

이벤트 하나로 전부 모은다

Navigation API는 이 문제를 정면으로 푼다. 출발점은 2021년 WICG의 app-history 제안이고, 핵심 설계는 한 문장이다. 어떤 이동이든 전부 navigate라는 이벤트 하나로 들어온다.

링크 클릭이든, 폼 제출이든, location.href 대입이든, 뒤로 가기 버튼이든 예외가 없다. 그리고 이 이벤트는 이동이 끝난 뒤의 통보가 아니라 이동 전의 요청이라서, 가로챌 수 있다.

navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept || event.hashChange) return;

  event.intercept({
    async handler() {
      const data = await fetchPageData(event.destination.url);
      render(data);
    },
  });
});

이 열 줄 남짓이 라우터의 핵심을 대체한다. 클릭 리스너를 문서 전체에 다는 일도, preventDefault()도, pushState() 직접 호출도 없다. intercept()를 부르면 주소는 브라우저가 알아서 바꿔주고, handler가 반환하는 Promise가 끝날 때까지를 하나의 "이동"으로 처리한다. 브라우저 탭의 로딩 스피너가 돌고, 다 끝나면 멈춘다. SPA의 화면 전환이 처음으로 브라우저 입장에서도 내비게이션이 된 거다.

그동안 직접 구현하던 일들도 플랫폼 안으로 들어갔다. 스크롤 복원은 기본으로 처리되고, API 응답을 기다렸다가 복원해야 하면 scroll: "manual"로 시점만 넘겨받으면 된다. 포커스 처리도 마찬가지다. 히스토리 스택은 navigation.entries()로 전부 읽을 수 있고, 항목마다 key가 있으니 traverseTo(key)로 원하는 지점까지 한 번에 갈 수도 있다. 방향을 알아내려고 인덱스를 심어두던 꼼수는 이제 필요가 없다.

그리고 뒤로 가기도 navigate 이벤트로 들어오니, "저장 안 된 변경이 있으면 확인창을 띄우는" 기능이 드디어 우회 없이 구현된다. 이건 편의 기능이 아니라, 유저랜드에서는 애초에 불가능했던 일이다. 플랫폼이 아니면 못 푸는 문제였고, 16년 만에 플랫폼이 풀었다.

Chrome에서 Baseline까지 4년

Chrome은 이걸 2022년 5월, 102 버전에 실었다. 그리고 3년 넘게 Chrome 전용 API였다.

상황을 바꾼 건 Interop 2025다. 브라우저 벤더들이 매년 상호운용성 목표를 합의하는 프로젝트인데, 2025년 포커스 영역에 Navigation API가 들어갔다. WebKit 쪽 구현은 Apple이 아니라 Igalia가 했다. Interop 2025 전체 포커스 영역의 통과율이 연초 29%에서 연말 97%까지 올라갔고, 그 결과 Safari 26.2와 Firefox 147이 지원을 시작하면서 2026년 1월, Navigation API는 Baseline Newly Available이 됐다.

MDN Navigation API 문서의 Baseline 2026 Newly Available 표시. Chrome, Edge, Firefox, Safari 네 브라우저 모두 지원 체크가 붙어 있다
MDN Navigation API 문서의 Baseline 표시. 2026년 1월부터 네 브라우저 전부에서 동작한다. MDN

pushState가 나온 지 16년, Chrome 출시로부터 4년 가까이. 브라우저 표준이 자리 잡는 속도가 이렇다. 스펙이 잘 만들어져 있어도, 추진하는 쪽이 확실해도, 나머지 브라우저 두 개가 구현하기 전까지 그 API는 없는 것과 같다. 그리고 그 두 개를 움직인 건 스펙의 완성도가 아니라 Interop이라는 벤더 간 합의였다.

그래서 라우터들이 갈아탈까

여기서부터는 냉정해질 필요가 있다.

Baseline Newly Available은 "모든 최신 브라우저에 들어갔다"는 뜻이지 "아무 데나 써도 된다"는 뜻이 아니다. 통상 Widely Available, 그러니까 마음 놓고 쓰는 기준까지는 그로부터 30개월을 더 잡는다. 2028년 중반이다. 오래된 iOS 기기, 업데이트가 멈춘 안드로이드 웹뷰가 실서비스 트래픽에서 사라지려면 그 정도는 걸린다.

기존 라우터들의 사정도 있다. React Router와 Vue Router는 History API 위에서 이미 잘 돌아간다. 스크롤 복원도, 이동 차단도, 온갖 엣지 케이스 대응도 수년에 걸쳐 다 만들어놨다. 이들에게 Navigation API는 "밑바닥을 갈아 끼우면 코드가 깨끗해지는" 리팩토링이지, 사용자가 뭔가 달라졌다고 느낄 기능이 아니다. 게다가 갈아 끼우는 순간 구형 브라우저 지원이 끊기니, 두 구현을 나란히 유지하는 과도기를 몇 년 버텨야 한다. fetch가 표준이 된 뒤로도 axios가 계속 쓰이는 것과 같은 구도다. 잘 돌아가는 걸 굳이 뜯어고치는 사람은 없다.

그러니 현실적인 그림은 이렇다. 기존 라우터의 내부 교체는 느리게, 조용히 진행될 거다. 반면 새로 시작하는 쪽은 계산이 다르다. 사내 도구, 웹뷰 기반 앱처럼 타겟 브라우저를 통제할 수 있는 환경이라면, 위에서 본 열 줄 남짓으로 라우터 의존성 하나가 통째로 사라진다. 어떤 라우터를 쓸지가 아니라, 라우터라는 계층이 필요한지부터 다시 따져볼 수 있게 된 거다.

브라우저가 SPA를 정식으로 지원하기 시작했다

이 이야기는 Navigation API 하나로 끝나지 않는다. 같이 봐야 할 API가 하나 더 있다. View Transitions다. 화면 전환에 애니메이션을 넣는 API인데, Navigation API의 intercept()와 맞물리도록 설계됐다. 하나는 이동 처리를, 하나는 전환 애니메이션을 맡는다.

둘을 같이 보면 방향이 분명하다. 브라우저는 오랫동안 페이지 단위 이동을 전제로 설계돼 있었고, SPA는 그 전제를 우회하면서 동작해왔다. 주소 변경도, 스크롤 복원도, 스크린 리더 대응도 전부 라이브러리가 브라우저 동작을 흉내 내는 구조였다. 그게 16년 치 쌓인 게 지금의 라우터 생태계다.

Navigation API가 Baseline이 됐다는 건, 브라우저가 이 방식을 정식 기능으로 받아들였다는 뜻이다. SPA의 화면 전환이 이제 플랫폼이 직접 지원하는 개념이 됐다.

물론 Baseline이 됐다고 당장 아무 데나 쓸 수 있는 건 아니다. Widely Available까지 2년 반, 라우터들이 갈아타는 시간까지 합치면 더 길게 봐야 한다. 하지만 방향이 정해진 것과 안 정해진 것의 차이는 크다. 16년 동안 라이브러리가 대신 해온 일들은 이제 하나씩 브라우저로 넘어갈 일만 남았다.

참고 자료