# Safari 27이 고친 top-level await 버그, 재봤더니 로딩이 두 배 느렸다

> 사파리 26.6과 iOS 26.5에서 top-level await 재현 코드를 돌려 ReferenceError를 확인하고, 뒤에 선언된 모듈까지 앞 TLA를 기다리는 직렬화 증상을 실측했습니다. Safari 27이 고친 것과 그 전 버전 대응법까지.

**Published:** 2026-09-03 | **Updated:** 2026-09-03

---


사파리의 top-level await 버그는 2022년 7월에 보고됐고([WebKit bug 242740](https://bugs.webkit.org/show_bug.cgi?id=242740)), 2025년 10월에는 이 버그 때문에 top-level await가 Baseline(주요 브라우저가 다 지원하는지 알려주는 웹 표준 지원 지표)에서 "Limited"로 내려갔습니다. 수정은 2026년 4월에 머지되어 5월 Safari Technology Preview 243부터 실렸고, WebKit 팀이 9월 2일 블로그에 올린 [Fixing top-level await in Safari](https://webkit.org/blog/18227/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`가 끝난 뒤에야 자기 본문을 시작합니다.

```js
// config.mjs — 설정을 먼저 받아온 뒤에야 export가 완성된다
export const config = await fetch('/config.json').then((r) => r.json())
```

```js
// app.mjs — config.mjs의 await가 끝난 다음에 실행된다
import { config } from './config.mjs'
console.log(config.apiBase)
```

편리한 대신 엔진이 할 일이 하나 늘어납니다. 모듈 그래프에서 누가 누구를 기다려야 하는지 정확히 계산해야 하거든요. 사파리가 어긋나 있던 지점이 여기였습니다.

## 무엇이 깨져 있었나 — 같은 모듈을 세 번 import하면

WebKit 블로그의 재현 코드는 단순합니다. 10ms를 기다리는 모듈 하나를, 코드 실행 중에 함수처럼 부르는 동적 `import()`로 세 번 부릅니다.

```js
// test-module.mjs
await new Promise((resolve) => setTimeout(resolve, 10))

export function someFunction() {
  return 'Hello!'
}
export const someArray = []
```

```js
// 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에서도 반복하면 가끔 뒤바뀌거든요. 수정 전 사파리는 이렇게 나옵니다.

```text
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되었다"는 겁니다.

{{< img src="images/contents/tla-import-order.png" alt="같은 모듈을 세 번 import했을 때의 타임라인 비교 다이어그램 - 스펙과 Chrome, Safari 27은 세 import가 모두 평가 완료 뒤 1, 2, 3 순서로 끝나지만, Safari 26 이하는 두 번째와 세 번째 import가 평가 도중에 미리 완료되어 순서가 2, 3, 1이 되고 초기화 전 접근 오류가 난다" >}}

## 사파리 26.6과 iOS 26.5에서 돌려봤습니다

위 코드를 [데모 페이지](https://isaaceryn.github.io/demo_codes/safari-top-level-await/)로 만들었습니다. 실험을 하나 더 얹었어요. 진입점(맨 처음 실행되는 모듈)이 서로 아무 관계 없는 모듈 세 개를 `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 — 직렬 |

{{< img class="phone" src="images/contents/ios-safari-26-tla-bug.png" alt="iOS 26.5 시뮬레이터 사파리에서 데모 페이지를 연 화면 - 실행 로그에 Imported 2, Accessing 2 failed: Cannot access 'someArray' before initialization, Imported 3, 같은 실패, Imported 1, Keys for 1 순서로 찍혔고, 그 아래 판정에 초기화 전 접근 실패 2회로 표시돼 WebKit 블로그의 버그 출력이 그대로 재현됐다" caption="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 제안 문서](https://github.com/tc39/proposal-top-level-await#why-doesnt-top-level-await-block-the-import-of-an-adjacent-module)는 X1·Y·X2 예제로 "먼저 import했다고 해서 암묵적 의존이 생기지는 않는다"고 못 박습니다. 블로그의 재현 코드는 promise 조기 resolve만 다루지만, 로더를 새로 쓴 [PR #57827](https://github.com/WebKit/WebKit/pull/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을 쓰기 때문에 브라우저를 바꿔도 같은 버그입니다. 그동안 지킬 만한 선은 세 가지라고 봅니다.

1. **공유 청크나 라이브러리 코드에는 TLA를 두지 말고, 진입점에서 정적 import로 첫 동적 import 이전에 평가를 끝내두기.** 재현된 버그의 조건은 평가 중인 TLA 모듈을 동시에 여러 번 동적 import하는 것입니다. 그런데 여러 페이지가 함께 쓰는 코드는 번들러가 공유 청크 하나로 묶어두죠. 그게 화면 전환이나 미리 불러오기(프리로드)로 시간차를 두고 import되면 `import()`를 직접 두 번 쓰지 않아도 같은 조건이 됩니다. bug 242740에 SvelteKit·Astro·Stencil·ArcGIS SDK가 이렇게 걸린 기록이 있고, 제 다이아몬드 실험처럼 정적 import 그래프는 옛 로더에서도 정상이었습니다.
2. **초기화 시점을 통제해야 하면 `init()`을 export하기.** TLA 제안서가 정적 분석을 해친다며 버리라고 한 패턴을 브라우저 버그 때문에 잠시 되살리는 셈입니다. 서드파티 라이브러리가 TLA를 쓰면 이 지침으로는 막을 수 없고요.
3. **번들러가 TLA를 어떻게 내보내는지 확인하기.** webpack은 TLA를 자체 런타임 코드로 변환해 브라우저에 네이티브 TLA가 나가지 않으니 사실상 영향이 없습니다. Vite·esbuild·Rollup의 ESM 출력은 TLA를 그대로 남깁니다. 배포 번들에서 함수 밖 최상위 `await`를 검색해보면 어느 쪽인지 바로 알 수 있어요.

```js
// 지침 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이 아니라면 번들 출력을 확인할 것.

{{< faq >}}

## 마치며

4년 걸린 버그의 포스트모템을 벤더가 직접 써주는 일은 흔치 않습니다. 이번엔 정말 브라우저 탓이었어요. 단, Safari 27이 깔린 기기 앞에서는 그 말을 아끼세요. 거기서는 이제 여러분 코드 문제거든요.

**참고 자료**

- [Fixing top-level await in Safari — WebKit Blog (2026-09-02)](https://webkit.org/blog/18227/fixing-top-level-await-in-safari/)
- [WebKit bug 242740 (2022-07 보고)](https://bugs.webkit.org/show_bug.cgi?id=242740)
- [WebKit PR #57827 — 로더 재작성 (2026-04 머지)](https://github.com/WebKit/WebKit/pull/57827)
- [web-features #2957 — Baseline 재고 이슈](https://github.com/web-platform-dx/web-features/issues/2957)
- [tc39 proposal-top-level-await — 인접 모듈 항목](https://github.com/tc39/proposal-top-level-await#why-doesnt-top-level-await-block-the-import-of-an-adjacent-module)
- [Safari top-level await 재현 데모 (이 글의 실측 페이지)](https://isaaceryn.github.io/demo_codes/safari-top-level-await/)

