브라우저 API를 제대로 쓴다는 것의 의미

브라우저 API를 제대로 쓴다는 것의 의미

빌드가 성공해도 런타임이 실패할 수 있다—Baseline API 강제와 Web Locks로 배우는 플랫폼 API 신뢰 설계

Baseline API Web Locks API JWT 크로스탭 동기화 TypeScript 타입 선언 브라우저 호환성 SPA 인증 Vite 빌드 타겟
광고

'빌드가 통과됐으니 괜찮다.' 프론트엔드 개발자라면 누구나 한 번쯤 이 전제를 믿었을 것이다. Vite는 번들링에 성공했고, TypeScript는 타입 에러를 뱉지 않았으며, CI는 초록불이다. 그런데 사용자의 브라우저에서 document.startViewTransition is not a function 같은 에러가 조용히 발생하고 있다면? 혹은 여러 탭을 열어놓은 사용자가 갑자기 로그아웃되고 있다면? 두 상황 모두 '브라우저 API를 올바르게 다루지 못했다'는 신호다.

빌드 타겟은 API 계약이 아니다

dev.to에 올라온 'Your Build Target Is Not an API Contract'는 이 착각을 정면으로 짚는다. Vite 7은 기본 프로덕션 빌드 타겟으로 Baseline Widely Available을 사용한다. 그러나 이것이 '사용한 API가 모든 대상 브라우저에서 작동한다'는 보장은 아니다. Vite의 TypeScript 지원은 기본적으로 트랜스파일 전용이다—타입 검사를 직접 수행하지 않는다. 마찬가지로 TypeScript의 lib.dom.d.ts는 DOM API의 존재를 선언하지만, 그 API가 Baseline 정책을 충족하는지는 따로 판단하지 않는다.

실제로 Promise.withResolvers()document.startViewTransition()을 사용하는 코드를 작성하면, vite buildtsc도 아무 에러를 내지 않는다. 하지만 전자는 Baseline 2024 Newly Available, 후자는 Baseline 2025 Newly Available 상태다—즉, 아직 모든 주요 브라우저에서 안정적으로 지원된다고 보기 어렵다. 빌드가 통과한 것은 Vite와 TypeScript가 버그를 낸 게 아니다. 각 도구가 자신의 역할을 충실히 수행했을 뿐이고, 그 사이의 빈 레이어가 존재한다는 사실이 드러난 것이다.

해결책은 생각보다 명확하다. typescript-baseline-lib@baseline-types/dom-widely-available 두 패키지를 설치하고, noLib: true 옵션으로 TypeScript 기본 라이브러리를 제거한 별도의 tsconfig.baseline.json을 구성한다. 이 설정으로 tsc를 실행하면 Baseline Widely Available을 벗어난 API는 컴파일 에러로 잡힌다. 기존 빌드 설정을 건드리지 않고, CI에 check:baseline 스크립트 하나를 추가하는 것만으로 Baseline 정책이 코드 계약이 된다. 이 설정을 통해 Array.prototype.findLast()fetch()는 통과하고, Promise.withResolvers(), Array.fromAsync(), document.startViewTransition()은 실제로 에러를 뱉는다.

탭 간 동시성—메모리 경계를 착각한 대가

두 번째 문제는 더 교묘하다. 같은 dev.to에 올라온 'The Multiple Browser Tab Token Trap'은 SPA 개발자가 실제로 마주치는 런타임 동시성 문제를 해부한다. 시나리오는 단순하다. Axios 응답 인터셉터에 isRefreshing 플래그와 실패 큐를 구현했다. 단일 탭에서는 완벽하게 동작한다. 그런데 사용자가 4개 탭을 열어둔 상태에서 JWT 액세스 토큰이 만료되면, 4개 탭이 동시에 POST /auth/refresh-token을 호출한다.

문제의 핵심은 JavaScript 변수가 탭 메모리를 공유하지 않는다는 사실이다. Tab A의 isRefreshing = true는 Tab B에 전혀 영향을 주지 않는다. 백엔드가 단일 사용 리프레시 토큰 로테이션을 강제한다면—토큰을 사용하는 순간 이전 토큰을 무효화하는 방식이라면—Tab B의 갱신 요청이 Tab A가 받은 새 토큰을 무효화하고, Tab C의 요청이 Tab B의 토큰을 무효화한다. 결과는 전체 세션 파괴, 즉 사용자 강제 로그아웃이다.

해법은 navigator.locksWeb Locks API다. Chrome 69+, Firefox 96+, Safari 15.4+ 이상에서 지원되는 이 네이티브 브라우저 API는 같은 오리진에서 실행되는 탭들이 배타적 잠금을 요청할 수 있게 한다. 구조는 단순하지만 강력하다. 401 에러가 발생하면 탭은 'auth_token_refresh_lock'이라는 이름의 배타적 잠금을 요청한다. Tab A가 잠금을 획득하면 Tab B와 C는 브라우저 수준에서 대기한다. Tab A가 토큰 갱신을 마치고 localStorage에 새 토큰과 타임스탬프를 저장한 뒤 잠금을 해제하면, Tab B가 잠금을 획득하고 타임스탬프를 확인한다. '5초 이내에 다른 탭이 갱신했다'는 조건이 참이면 네트워크 요청 없이 새 토큰을 재사용한다. 이중 확인 패턴(Double-Check Pattern)이 탭 간 경쟁 조건을 완전히 제거한다.

공통 구조: 플랫폼 API에 대한 착각

두 문제는 표면적으로 달라 보이지만 동일한 구조적 착각에서 비롯된다. '내가 작성한 코드가 작동하는 환경에 대해 충분히 알고 있다'는 전제다. Baseline API 문제는 빌드 도구가 API 가용성을 보장해줄 것이라는 착각이고, 크로스탭 JWT 문제는 인메모리 플래그가 탭 경계를 넘을 것이라는 착각이다. 두 착각 모두 브라우저 플랫폼의 실제 경계를 도구나 코드 패턴으로 오해한 결과다.

흥미로운 점은 해법 역시 같은 방향을 가리킨다는 것이다. 브라우저가 이미 제공하는 네이티브 API—Baseline 정책 타입 선언, Web Locks—를 신뢰 구조의 기반으로 삼는 것이다. 직접 구현한 뮤텍스보다 navigator.locks가 더 신뢰할 수 있고, 직접 작성한 lint 규칙보다 Baseline 타입 선언이 더 정밀하다. 플랫폼이 제공하는 것을 제대로 이해하고 활용하는 것이 곧 올바른 브라우저 API 사용의 의미다.

시사점: 속도 이전에 경계를 이해하라

프로토타이핑 단계에서는 이런 문제들이 잘 드러나지 않는다. 단일 탭에서 테스트하고, 최신 Chrome에서 확인하면 모든 것이 작동한다. 문제는 프로덕션에서—여러 탭을 쓰는 실제 사용자, 다양한 브라우저 버전의 실제 환경에서—조용히 발생한다. Baseline API 강제는 PR 단계에서 이 문제를 미리 잡는 게이트이고, Web Locks 기반 크로스탭 동기화는 런타임 동시성 문제를 플랫폼 수준에서 해결하는 설계다.

앞으로 View Transitions API, Popover API 같은 새 브라우저 API들이 빠르게 Baseline Widely Available 상태에 진입할 것이다. 동시에 SPA 아키텍처는 더 많은 탭, 더 많은 워커, 더 복잡한 인증 흐름을 다루게 된다. 브라우저 플랫폼 API를 올바르게 다룬다는 것은 단순히 MDN을 읽는 것이 아니다. 어떤 도구가 무엇을 보장하고 무엇을 보장하지 않는지, 브라우저 런타임의 어떤 경계가 코드 경계를 결정하는지를 이해하는 것이다. 그 이해 위에서 설계된 코드만이 사용자 경험을 실제로 보호한다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요