javascript · high priority
JavaScript 비동기의 진화사
Callback → Promise → async/await → AsyncContext — 어떤 문제를 어떻게 풀어왔는가
학습 개요
탄생 배경
해결하려 했던 문제
초창기 브라우저 환경에서 비동기는 `onload`, `setTimeout`, `XHR onreadystatechange` 같은 개별 콜백 API 뿐이었다. 통일된 추상화가 없어 라이브러리마다 비동기 규약이 달랐고, 에러 처리와 합성(compose)이 지옥이었다.
역사적 맥락
2009 년 Node.js 가 error-first 콜백 컨벤션 `(err, result)` 로 서버 비동기를 정리했고, 2011 년 jQuery 1.5 가 `$.Deferred` 로 promise-like 객체를 대중화했다. 2012 년 Promises/A+ 커뮤니티 스펙이 라이브러리 간 thenable 상호 운용을 표준화했고, ES6(2015) 가 `Promise` 를 언어에 편입했다. ES2017 의 `async/await` 는 Promise 체인을 동기식으로 읽히게 했고, 이후 `allSettled`(2020) · `any`(2021) · top-level await(2022) 가 차례로 추가됐다. 최신 흐름은 TC39 의 **AsyncContext** 제안으로, await 를 넘나드는 컨텍스트(trace ID, transaction, i18n locale) 추적 문제를 언어 차원에서 푼다.
이전에는 어떻게 했나
jQuery Deferred 는 Promises/A+ 비호환이었고(에러 forwarding 부족, 다중 resolve 허용), Q/Bluebird/When.js 는 표준 이전 시절 대안이었으나 ES6 Promise 도입 이후 점점 밀려났다. RxJS 는 "이벤트 스트림" 추상화로 별도 생태계를 유지한다 — Promise 가 "단발 값" 이라면 Observable 은 "다발 값".
쉬운 설명
복잡한 개념을 실생활 비유로 설명합니다.
“우편 제도의 진화”
초창기 콜백은 "심부름꾼에게 "다녀와서 나한테 다시 전해줘" 라고 부탁" 하는 것 — 심부름꾼이 안 올 수도, 두 번 올 수도, 엉뚱한 물건을 가져올 수도 있다. Promise 는 "받을 물건의 영수증" 을 먼저 주는 택배 시스템 — 한 번만 발행되고, 발행 후 취소는 없다. async/await 는 "택배가 올 때까지 잠깐 낮잠 자는 대신 눈을 감았다 뜨면 이미 도착해 있는" 마법이고, AsyncContext 는 "택배 상자마다 누가 보낸 건지 적힌 송장" 을 끝까지 유지하는 개선판이다.
핵심 개념
| 연도 | 표준/런타임 | 추가된 것 | 푼 문제 |
|---|---|---|---|
| 1995 | Netscape | setTimeout · 이벤트 핸들러 | 브라우저가 "지연 실행" 을 표현할 방법이 생김 |
| 2009 | Node.js | error-first callback (err, res) | 서버 I/O 에러 전파의 일관된 컨벤션 |
| 2011 | jQuery 1.5 | $.Deferred / $.Promise | callback hell → 체이닝 가능한 객체 |
| 2012 | Promises/A+ | 커뮤니티 표준 | 라이브러리 간 thenable 상호운용 |
| 2015 | ES6 | Promise 언어 내장 | 표준 Promise, .then/.catch |
| 2017 | ES2017 | async / await | then 체인 verbosity · try/catch 복구 |
| 2018 | ES2018 | async iterator · for await...of | 비동기 스트림 순회 |
| 2019 | ES2019 | Promise.prototype.finally | 체인 끝 cleanup 표준 |
| 2020 | ES2020 | Promise.allSettled · ?? · ?. | 부분 실패 허용 집약 |
| 2021 | ES2021 | Promise.any · AggregateError | 다중 소스 fallback |
| 2022 | ES2022 | top-level await (ESM) | 모듈 초기화에서 비동기 표현 |
| 2024+ | TC39 Stage 3 | AsyncContext (제안) | await 경계를 넘는 컨텍스트 보존 |
"진화" 의 방향성
비동기 API 의 진화는 언제나 **"코드가 동기 코드처럼 읽혀야 한다"** 는 방향으로 움직여 왔다. 콜백 → Promise(체이닝) → async/await(선형) → AsyncContext(암묵적 컨텍스트) 모두 "사람이 비동기임을 덜 의식하게" 만드는 시도다.
실무 적용
어떤 상황에서 사용하는가
팀이 레거시 콜백 기반 코드를 Promise 로, 다시 async/await 로 점진 마이그레이션하는 중. Node 서버에서 transaction ID 를 로그에 심고 싶은데 await 를 넘으면서 ID 가 섞이는 문제를 발견했다.
어떻게 적용하는가
첫째, 콜백 API 는 `util.promisify` 로 일괄 Promise 화 — 수동 래핑은 에러 원본을 잃기 쉬우니 지양. 둘째, Promise 는 가능한 경우 async/await 로 이전하되, fan-out 병렬은 `Promise.all` 을 유지. 셋째, transaction 컨텍스트는 Node `AsyncLocalStorage` 로 열고 모든 로거가 `als.getStore()` 를 조회하도록 통일. 브라우저에서도 같은 니즈가 생기면 TC39 AsyncContext 폴리필로 임시 대응하다가 표준화되면 정식 API 로 전환.
흔한 실수와 안티패턴
- `util.promisify` 쓰면서 `this` 바인딩을 잊음 — 메서드는 `.bind(obj)` 필요
- async 함수를 `setTimeout` 같은 콜백 API 에 직접 넘기고 에러를 잡지 못함
- 전역 변수로 request context 를 구현 → async 동시성 환경에서 교차 오염
- `Promise.resolve().then(fn)` 으로 "다음 틱" 을 구현했는데 microtask 라 실제로는 같은 턴에 실행됨
- AsyncLocalStorage 를 worker_threads 에 넘길 수 있을 거라 착각 — 스레드 경계에서는 전파 안 됨
흔한 오해
"async/await 은 Promise 의 완전한 대체재다."
교정병렬 실행 · 분기 합류 · 스트림 합성에서는 여전히 Promise combinator 가 표현력이 높다. async/await 는 **순차 흐름에 강한** 표현.
왜 중요async 함수 본문이 기본적으로 순차 실행이라 `await` 를 루프에 넣으면 직렬화된다. 의도적 병렬은 `Promise.all(map(...))` 이 올바른 방식.
"top-level await 는 모든 모듈에서 쓸 수 있다."
교정ECMAScript 모듈(ESM) 에서만 동작한다. CommonJS 는 동기 `require` 세만틱상 지원 불가.
왜 중요CJS 의 `require` 는 모듈을 동기로 반환해야 하는데 top-level await 는 모듈 평가를 중단한다 — 근본적으로 호환되지 않는다.
"AsyncLocalStorage 는 성능 페널티가 없다."
교정`async_hooks` 는 모든 비동기 자원 생성에 훅을 끼워 측정 가능한 오버헤드가 있다. V8 팀이 지속 최적화 중이지만, hot path 에서는 프로파일링해서 사용.
왜 중요Node 공식 문서도 "가장 자주 사용되는 건 성능 비용이 있다" 고 명시. 일부 고성능 프로덕션은 context 전파를 경량 수동 구현으로 대체하기도 한다.
면접 질문
답변 방향 힌트
각 단계에서 "무엇이 불가능/어려웠는지" 와 "어떻게 해결되는지" 를 매칭하세요.
반드시 언급할 키워드
- Callback 문제: 피라미드 · 제어권 반전 · 호출 횟수 불보장
- Promise 해법: 체이닝 · 1회 settle · thenable 상호운용
- 하지만 Promise 도 분기/루프에서 verbose — async/await 가 제어 흐름 복원
- async/await 의 한계: await 경계에서 컨텍스트 손실 → AsyncContext
- 각 단계는 이전 단계를 완전히 대체가 아니라 "선형 코드 표현력" 을 확장
예상 꼬리 질문
- 왜 callback 에서 Promise 로 넘어갈 때 `then` 메서드 이름이 선택됐을까요?
- "Promise is eager, Observable is lazy" 라는 표현의 의미는?
자기 점검
콜백 방식의 3가지 근본 문제를 Promise 가 각각 어떻게 해결했는지 답해보세요.
기대 키워드
자주 하는 오해
Promise 가 "읽기 쉬워졌다" 만 기억하는 경우가 많습니다. 본질은 "호출 횟수 보장" 과 "에러 자동 전파" 입니다.
async/await 이 Promise 와 구별되는 엔진 구현 차이는?
기대 키워드
자주 하는 오해
"단순 문법 설탕" 이라는 오해 — 실제로는 컴파일 타임에 state machine 으로 변환돼 디버거와 스택 트레이스 기능이 개선됩니다.
AsyncLocalStorage / AsyncContext 가 필요한 구체적 시나리오를 하나 설명해보세요.
기대 키워드
자주 하는 오해
"그냥 전역 변수로 되지 않나" 라고 생각하지만, 이벤트 루프가 여러 요청을 병행 처리하는 동안 전역 변수는 시점마다 다른 요청의 값을 가리킵니다.
학습 자료
- Using Promises — JavaScript | MDNcallback → Promise → async/await 로 리팩토링하는 과정을 단계별로 보여주는 공식 튜토리얼.DocMDN Web Docs
- AsyncContext — TC39 Proposal언어 차원의 비동기 컨텍스트 전파 표준. 동기/이유/디자인 · AsyncLocalStorage 와의 관계 포함.DocTC39
- AsyncLocalStorage — Node.js DocsNode 의 async_hooks 기반 컨텍스트 전파 API. 실전 사용 패턴과 성능 고려사항.DocNode.js 공식
- Promises/A+ 스펙커뮤니티 스펙 원문. ES6 Promise 의 설계 근간. thenable 해결 절차 `[[Resolve]]` 포함.DocPromises/A+
- Faster async functions and promisesV8 엔진이 await 의 microtask 오버헤드를 줄여간 과정. async/await 이 "단순 설탕" 이 아님을 보여주는 기록.BlogV8 Blog · 2018