사파리의 top-level await 버그는 2022년 7월에 보고됐고(WebKit bug 242740), 2025년 10월에는 이 버그 때문에 top-level await가 Baseline(주요 브라우저가 다 지원하는지 알려주는 웹 표준 지원 지표)에서 “Limited"로 내려갔습니다. 수정은 2026년 4월에 머지되어 5월 Safari Technology Preview 243부터 실렸고, WebKit 팀이 9월 2일 블로그에 올린 Fixing top-level await in Safari는 그 포스트모템(사후 분석)입니다.
궁금한 건 두 가지였습니다. 정확히 무엇이 깨져 있었나, 그리고 지금 내 맥의 사파리에서도 그런가. 블로그의 재현 코드를 브라우저용으로 옮겨 Safari 26.6, iOS 26.5 시뮬레이터, Chrome에서 돌려봤습니다. 재현은 됐고, 블로그가 보여주지 않은 더 큰 증상이 하나 따라 나왔습니다.
top-level await(TLA)가 뭔가 — 30초 정리#
top-level await는(이하 TLA) ES 모듈의 최상위에서 async 함수 없이 바로 await를 쓰는 문법입니다(ES2022). 이 모듈을 import하는 쪽은 그 await가 끝난 뒤에야 자기 본문을 시작합니다.
// config.mjs — 설정을 먼저 받아온 뒤에야 export가 완성된다
export const config = await fetch('/config.json').then((r) => r.json())// app.mjs — config.mjs의 await가 끝난 다음에 실행된다
import { config } from './config.mjs'
console.log(config.apiBase)편리한 대신 엔진이 할 일이 하나 늘어납니다. 모듈 그래프에서 누가 누구를 기다려야 하는지 정확히 계산해야 하거든요. 사파리가 어긋나 있던 지점이 여기였습니다.
무엇이 깨져 있었나 — 같은 모듈을 세 번 import하면#
WebKit 블로그의 재현 코드는 단순합니다. 10ms를 기다리는 모듈 하나를, 코드 실행 중에 함수처럼 부르는 동적 import()로 세 번 부릅니다.
// test-module.mjs
await new Promise((resolve) => setTimeout(resolve, 10))
export function someFunction() {
return 'Hello!'
}
export const someArray = []// main.mjs — <script type="module">로 실행. 블로그의 print는 jsc 셸 내장 함수라 console.log로 바꿨다
const print = (...args) => console.log(...args)
async function load(index) {
try {
print('Importing', index)
const module = await import('./test-module.mjs')
print('Imported', index)
try {
print(`Keys for ${index}:`, Object.keys(module)) // export를 하나씩 읽어본다
} catch (e) {
print('Accessing', index, 'failed:', e.message)
}
} catch (e) {
print('Importing', index, 'failed:', e.message)
}
}
await Promise.all([1, 2, 3].map(load))기대 순서는 1, 2, 3이고(블로그도 그렇게 씁니다), 어느 import에서든 someArray에 바로 접근할 수 있어야 합니다. 다만 이 글의 판정 기준은 순서가 아니라 초기화 전 접근 오류가 나느냐입니다. 완료 순서는 Chrome에서도 반복하면 가끔 뒤바뀌거든요. 수정 전 사파리는 이렇게 나옵니다.
Importing 1
Importing 2
Importing 3
Imported 2
Accessing 2 failed: Cannot access 'someArray' before initialization.
Imported 3
Accessing 3 failed: Cannot access 'someArray' before initialization.
Imported 1
Keys for 1: someArray,someFunction첫 번째 import가 모듈을 평가(evaluation, 모듈 본문을 실행해 export를 채우는 단계)하다가 await에서 멈춘 사이, 두 번째와 세 번째 import의 promise가 평가가 끝나기도 전에 먼저 resolve돼 버립니다. 그 시점엔 export const someArray가 아직 초기화되지 않았으니 Object.keys가 그 자리에서 ReferenceError를 냅니다. 블로그의 표현을 빌리면 “두 번째 import의 promise는 첫 번째 import의 평가가 끝난 뒤에 resolve되어야 하는데, 옛 모듈 로더의 버그 때문에 즉시 resolve되었다"는 겁니다.

사파리 26.6과 iOS 26.5에서 돌려봤습니다#
위 코드를 데모 페이지로 만들었습니다. 실험을 하나 더 얹었어요. 진입점(맨 처음 실행되는 모듈)이 서로 아무 관계 없는 모듈 세 개를 tla-a(300ms TLA) → sibling(동기) → tla-b(300ms TLA) 순으로 정적 import(파일 맨 위에 import 문으로 적어두는 평범한 방식)합니다. 각 모듈이 언제 시작하고 끝나는지는 브라우저에 내장된 시각 기록 도구인 performance.mark로 잽니다.
| 환경 | 초기화 전 접근 실패 | 완료 순서 | TLA 두 개 정적 import 시 진입점 실행 |
|---|---|---|---|
| Chrome 148·152 | 0회 | 대체로 1, 2, 3 (반복 시 뒤바뀌기도) | 302ms — 병렬 |
| Safari 26.6 (macOS) | 2회 | 2, 3, 1 | 602ms — 직렬 |
| iOS 26.5 시뮬레이터 Safari | 2회 | 2, 3, 1 | 603ms — 직렬 |

iOS 26.5 시뮬레이터에서 라이브 데모를 연 화면. 붉은 판정 줄이 실패 2회를 가리킵니다.
첫 번째 실험은 블로그와 글자 단위로 같았습니다. 사파리 26.6과 iOS 26.5 둘 다 초기화 전 접근 실패 2회, Chrome은 0회. 여기까지는 확인이고, 두 번째 실험이 이 글의 이유입니다.
블로그에 없던 증상 — 뒤에 선언된 모듈은 앞 TLA가 끝나야 시작한다#
tla-a와 tla-b는 서로를 모릅니다. 스펙대로면 진입점의 import 목록을 한 번 훑는 동안 셋 다 시작되고, 두 TLA가 나란히 300ms를 기다린 뒤 진입점이 약 300ms에 실행됩니다. Chrome은 302ms였어요. 그런데 수정 전 사파리는 sibling도 tla-b도 302ms, 즉 tla-a의 await가 끝난 뒤에야 시작했고, 진입점은 602ms에 실행됐습니다. 뒤에 선언된 모듈이 앞 TLA를 전부 기다린 겁니다.
조건을 바꿔 다시 재봤습니다. sibling을 TLA 모듈보다 앞에 선언하면 사파리에서도 0ms에 평가됩니다. TLA 대기를 1000ms로 늘리면 뒤 모듈의 지연도 1000ms로 따라옵니다. 두 모듈이 같은 TLA 모듈을 import하는 다이아몬드 그래프는 정상이고요. 그러니 파일을 내려받는 속도 문제가 아닙니다. import 문을 적은 순서대로 하나씩 줄을 세워 평가하는 동작이에요. 빨래로 치면 세탁기 두 대에 나눠 돌리느냐, 한 대로 두 번 돌리느냐의 차이입니다.
TLA 모듈이 여럿이면 스펙은 가장 오래 걸리는 하나만큼만 쓰지만, 수정 전 사파리는 전부 더한 만큼 걸립니다. 설정 fetch와 wasm 초기화처럼 독립적인 TLA 모듈 두 개면 바로 두 배예요.
이게 스펙 위반이라는 건 제 해석이 아닙니다. WebKit 블로그 서두가 “await 중인 모듈에 의존하지 않는 sibling 모듈은 여전히 동시에 실행될 수 있다"고 쓰고, tc39 제안 문서는 X1·Y·X2 예제로 “먼저 import했다고 해서 암묵적 의존이 생기지는 않는다"고 못 박습니다. 블로그의 재현 코드는 promise 조기 resolve만 다루지만, 로더를 새로 쓴 PR #57827의 설명에 “모듈 평가 순서 오류"가 수정 대상으로 적혀 있어, 같은 로더 결함의 다른 얼굴로 보입니다.
주의할 점: 순서 뒤집기·1000ms·다이아몬드는 제 환경(Safari 26.6, iOS 26.5, Chrome)의 측정값입니다. Chrome은 백그라운드 탭에서 타이머가 늦춰져 값이 부풀 수 있어, 화면에 보이는 탭에서 잰 값입니다.
왜 그랬나 — 버려진 제안 위에 2021년 문법을 얹은 것#
사파리의 모듈 로더는 2016년 1월에 마지막으로 갱신된 WHATWG Loader 제안을 기준으로 짜였고, 그 제안은 이후 ECMAScript 표준의 모듈 절에 자리를 내주고 사라졌습니다. 2021년 사파리가 TLA를 출시할 때는 그 옛 로더 위에 얹은 셈이라, ES2022가 정한 비동기 모듈 평가 알고리즘과 어긋나 있었던 거예요. 그래서 WebKit은 2026년 1월부터 엔진 내부의 자체 JavaScript 코드를 걷어내고, 명세의 의사코드를 함수 단위로 C++로 옮겨 로더를 새로 썼습니다. test262(자바스크립트 공식 테스트 모음)의 모듈 테스트를 전부 통과시키고, 실패하던 WPT(브라우저 공통 테스트) 모듈 테스트를 여럿 고치고, 무작위 모듈 그래프로 다른 엔진과 출력을 비교하는 퍼저까지 돌렸다고 합니다.
지금 믿고 써도 되나#
Safari 27부터는 됩니다. 수정은 Safari Technology Preview 243(2026년 5월)과 Safari 27 베타에 이미 들어 있고, 블로그도 정식 27이 나오면 프로덕션에서 TLA에 기대도 된다고 적었습니다.
문제는 그 전 버전입니다. iOS는 OS 업데이트와 사파리 버전이 묶여 있고, 한국 사용자 기준으로는 iOS의 Chrome·Firefox도 시스템 WebKit을 쓰기 때문에 브라우저를 바꿔도 같은 버그입니다. 그동안 지킬 만한 선은 세 가지라고 봅니다.
- 공유 청크나 라이브러리 코드에는 TLA를 두지 말고, 진입점에서 정적 import로 첫 동적 import 이전에 평가를 끝내두기. 재현된 버그의 조건은 평가 중인 TLA 모듈을 동시에 여러 번 동적 import하는 것입니다. 그런데 여러 페이지가 함께 쓰는 코드는 번들러가 공유 청크 하나로 묶어두죠. 그게 화면 전환이나 미리 불러오기(프리로드)로 시간차를 두고 import되면
import()를 직접 두 번 쓰지 않아도 같은 조건이 됩니다. bug 242740에 SvelteKit·Astro·Stencil·ArcGIS SDK가 이렇게 걸린 기록이 있고, 제 다이아몬드 실험처럼 정적 import 그래프는 옛 로더에서도 정상이었습니다. - 초기화 시점을 통제해야 하면
init()을 export하기. TLA 제안서가 정적 분석을 해친다며 버리라고 한 패턴을 브라우저 버그 때문에 잠시 되살리는 셈입니다. 서드파티 라이브러리가 TLA를 쓰면 이 지침으로는 막을 수 없고요. - 번들러가 TLA를 어떻게 내보내는지 확인하기. webpack은 TLA를 자체 런타임 코드로 변환해 브라우저에 네이티브 TLA가 나가지 않으니 사실상 영향이 없습니다. Vite·esbuild·Rollup의 ESM 출력은 TLA를 그대로 남깁니다. 배포 번들에서 함수 밖 최상위
await를 검색해보면 어느 쪽인지 바로 알 수 있어요.
// 지침 2 — promise를 캐시해야 여러 곳에서 동시에 불러도 fetch가 한 번만 나간다
let pending = null
export function init() {
// ??= 는 왼쪽 값이 비어 있을 때만 오른쪽을 대입하는 연산자
return (pending ??= fetch('/config.json').then((r) => r.json()))
}한 장 요약#
- TLA: ES 모듈 최상위에서 바로
await. import하는 쪽은 그게 끝난 뒤에야 본문을 시작한다(ES2022). - 깨져 있던 것: 평가 중인 TLA 모듈을 동시에 여러 번 동적 import하면 두 번째부터 promise가 먼저 resolve돼
ReferenceError: Cannot access … before initialization. 사파리 26.6·iOS 26.5에서 그대로 재현. - 블로그에 없던 증상: 수정 전 사파리는 TLA 모듈 뒤에 선언된 모듈을 전부 직렬로 세운다. TLA 두 개면 Chrome 302ms, 사파리 602ms.
- 원인: 2016년 WHATWG Loader 제안 위에 얹힌 로더가 ES2022의 비동기 모듈 알고리즘과 어긋남. 2026년 1월부터 명세 의사코드를 C++로 옮겨 재작성, 4월 머지.
- 지금: STP 243·Safari 27 베타부터 스펙대로. 그 전 버전을 지원하는 동안은 공유 청크에 TLA를 두지 말고, webpack이 아니라면 번들 출력을 확인할 것.
질문으로 다시 보기#
사파리에서 top-level await가 제대로 동작하나요?
top-level await 버그가 있는 사파리를 아직 지원해야 하면 어떻게 하나요?
top-level await가 정확히 뭔가요?
마치며#
4년 걸린 버그의 포스트모템을 벤더가 직접 써주는 일은 흔치 않습니다. 이번엔 정말 브라우저 탓이었어요. 단, Safari 27이 깔린 기기 앞에서는 그 말을 아끼세요. 거기서는 이제 여러분 코드 문제거든요.
참고 자료
