이 글에서 다루는 것
5년 넘은 구형 브라우저에서 어떤 페이지의 상호작용이 전부 죽는 버그를 고치며 배운 것을 정리한다. 특정 제품·수치는 빼고 어디서나 통하는 일반 개념만 다룬다. 가장 큰 배움은 "구형 브라우저 지원 = babel 하나"라는 오해를 깬 것이다 — 실제로는 문법 다운레벨·런타임 폴리필·조건 로드·CSS 폴백·실브라우저 게이트가 협력하는 다층 계약이다.
오늘 다룬 것
| 작업 | 무엇을 하고 싶었나 | 한 일 | 결과 |
|---|---|---|---|
| 동시 사망 진단 | 여러 기능이 한꺼번에 죽는 이유를 찾고 싶었다 | 셋 다 이벤트 핸들러임을 보고 하이드레이션 불발로 규명 | 버그 3개가 아니라 단일 근본원인 |
| 문법 vs API | 옛 브라우저에서 코드를 살리고 싶었다 | 문법은 babel로 낮추고, 없는 함수는 폴리필로 채움 | 문법 통과 ≠ API 커버를 확인 |
| 폴리필 조건 로드 | 최신 브라우저에 부담 없이 폴리필을 주고 싶었다 | feature-detect로 옛 브라우저에만 동기 로드 | 최신 브라우저 비용 ≈ 0 |
| 스모크 보강 | 회귀를 실제로 잡고 싶었다 | 클릭·제출 인터랙션 + 넓은 콘솔 필터 추가 | 렌더-only 스모크의 맹점 제거 |
1. 여러 기능이 동시에 죽으면 하이드레이션을 의심하라
어떤 상황이었나. 한 페이지에서 토글, 계정 로그인, 제출이 셋 다 동작하지 않았다. 언뜻 버그 3개처럼 보였지만, 셋의 공통점은 전부 클릭·제출 이벤트 핸들러라는 것이었다.
핵심 개념. 하이드레이션(hydration)은 서버가 만든 정적 HTML에 브라우저에서 JavaScript가 나중에 이벤트를 붙여 살아나게 하는 과정이다. 이게 끝나기 전엔 버튼이 그림일 뿐이다. 그래서 클라이언트 JS가 애초에 실행되지 못하면(문법 파싱 실패나 없는 API 호출로) 그 화면의 모든 핸들러가 한꺼번에 죽는다. 화면 자체는 서버 HTML이라 멀쩡히 보여서 더 헷갈린다.
한 일. 증상 여러 개를 "버그 N개"가 아니라 "하나의 실행 실패"로 가설을 세우고, 콘솔 첫 에러를 봤다. 특정 옛 브라우저에서만 재현된다는 점도 호환성 원인을 가리켰다.
교훈. 여러 UI 기능이 동시에 죽으면 개별 버그가 아니라 하이드레이션/번들 파싱 같은 공통 근본원인을 먼저 의심한다.
2. 문법 다운레벨과 런타임 폴리필은 별개 층이다
어떤 상황이었나. 옛 브라우저에서 코드가 죽는 원인이 한 가지가 아니었다. 문법을 낮춰도 여전히 죽는 지점이 남았다.
핵심 개념. 두 문제를 구분해야 한다. (1) 새 문법(a?.b, a ?? b)은 옛 파서가 못 읽어 SyntaxError로 파일 전체가 죽는다 — 트랜스파일(babel)로 옛 문법으로 낮추면 해결된다. (2) 없는 런타임 함수(structuredClone, Array.prototype.at, String.prototype.replaceAll)는 트랜스파일로 생기지 않는다 — 호출하는 순간 is not a function이 난다. 이건 폴리필(없는 함수를 JS로 재구현해 주입)로만 해결된다. 문법 통과는 API 커버가 아니다.
한 일. 문법은 @babel/preset-env에 타깃을 줘서 서드파티(node_modules)까지 다운레벨하고, 없는 API는 core-js-compat이 타깃에서 결손 목록을 계산하게 해 폴리필 번들을 만들었다. 폴리필 목록을 사람이 손으로 관리하면 드리프트하므로 회귀 테스트로 실사용 API 존재를 강제했다.
교훈. 문법 다운레벨과 런타임 API는 반드시 분리해서 생각한다. 콘솔 첫 에러가 SyntaxError면 문법, TypeError: is not a function이면 API 문제다.
3. 폴리필은 옛 브라우저에만 조건 로드한다
어떤 상황이었나. 폴리필 번들은 수십 KB인데, 이걸 모든 방문자에게 주면 폴리필이 필요 없는 최신 브라우저 사용자(대다수)까지 손해다.
핵심 개념. feature detection(기능 감지)은 "이 브라우저에 이 기능이 있나?"를 'at' in Array.prototype처럼 실제로 테스트하는 것이다(UA 문자열 추측보다 견고). <head>의 작은 동기 스크립트가 대표 API를 검사해, 하나라도 없을 때만 폴리필을 동기 로드한다. 감지 비용은 수십 바이트라 최신 브라우저 비용은 사실상 0이다.
한 일. "빌드타임 생성 번들 + feature-detect 조건 로드"를 택했다. 엔트리에 정적 포함하면 최신 브라우저에 상시 비용, UA 기반 서버 동적 생성은 서버 운영이 필요해 과했다. 타깃이 사실상 "옛 vs 최신" 이분법이라 조건 로드가 최적이었다. 폴리필은 외부 CDN이 아니라 같은 오리진에서 제공했다(공급망 공격 이력·가용성).
교훈. 폴리필은 feature-detect로 옛 브라우저에만 조건 로드해 최신 브라우저 비용을 0에 가깝게 만든다. 감지 스크립트는 앱 번들보다 먼저 실행돼야 한다.
4. 호환성 스모크는 실제 인터랙션을 실행해야 한다
어떤 상황이었나. 기존 스모크 테스트는 이 버그를 잡지 못하고 통과했다. 왜 통과했는지가 궁금했다.
핵심 개념. 런타임 TypeError는 코드가 실제 실행되는 시점에만 터진다. 스모크가 렌더링만 검사(행 개수·텍스트)하면 정작 깨지는 핸들러는 한 번도 실행되지 않아 초록으로 통과한다. 또 5년 넘은 옛 브라우저는 최신 자동화 도구의 엔진으로는 재현이 안 돼, 그 버전 브라우저·드라이버 바이너리를 고정 버전으로 받아 구동해야 한다.
한 일. 페이지별 클릭·토글·제출·라우팅 인터랙션을 추가하고, 콘솔 에러 필터에 referenceerror·is not a function·is not defined를 넣되 네트워크성 Failed to fetch는 오탐이라 제외했다. 폴리필 존재 체크와 인터랙션 테스트는 역할이 다르므로 분리했다.
교훈. 렌더만 검사하는 스모크는 핸들러가 전부 죽어도 통과한다. 실제 클릭·제출 인터랙션 + 넓은 콘솔 에러 필터가 있어야 회귀를 잡는다.