JavaScript Asynchronous Execution
"JavaScript는 오래 걸리는 작업을 기다리는 동안 UI를 멈추지 않으려고 어떤 수단을 쓰며, 콜백과 Promise와 async/await는 서로 어떤 관계인가?" 이 page는 그 질문에 답한다. 비동기 프로그래밍은 작업이 끝나기를 기다리지 않고, 오래 걸리는 작업을 시작해 둔 채 그동안 다른 이벤트에 응답하게 하는 기술이다.[1] JavaScript Language Model의 "함수는 값이다" 규칙이 콜백을 가능하게 하고, 같은 page의 ES6 문제 목록에 있는 "콜백 중첩"이 이 page의 Promise와 async/await로 이어진다. SPA and CSR이 말하는 initial load 이후의 data 요청과 부분 UI 갱신도 이 실행 흐름 위에서 일어난다고 볼 수 있다.
왜 비동기가 필요한가
- JavaScript는 오래 걸리는 작업을 기다리는 동안 전체 실행이 멈추지 않도록 비동기 처리를 쓴다. 작업을 요청해 두고, 결과가 준비되면 이어서 처리하는 방식이다.[2]
- 브라우저에서 시간이 걸리는 대표 작업으로
fetch()(HTTP 요청),getUserMedia()(카메라·마이크 접근),showOpenFilePicker()(파일 선택 요청)가 있다.[3] - 서버 API 요청, 파일 읽기, 타이머, 사용자 이벤트, 데이터베이스 요청도 대표적인 비동기 작업으로 정리된다.[4]
- 이런 작업을 동기로 처리하면 응답이 올 때까지 다음 코드가 실행되지 않으므로 UI가 멈추거나 화면이 멈춘 것처럼 느껴진다.[5][6]
콜백: 나중에 부를 함수를 넘긴다
- 콜백은 적절한 시점에 호출될 함수를 다른 함수에 전달하는 패턴이고, 이벤트 핸들러도 콜백의 한 유형이다.[7]
- 콜백이 가능한 이유는 JavaScript에서 함수가 변수에 담기고 인자로 넘겨지는 값이기 때문이다.[8] 이 규칙은 JavaScript Language Model의 "함수는 값이다" 절에서 설명한다.
- 순차 작업을 콜백으로 이으면 중첩이 깊어질수록 가독성이 떨어지는 콜백 지옥이 된다. 아래 코드에서는 실패 처리 콜백
failureCallback도 단계마다 따로 넘긴다.[9]
// 콜백 지옥 — 중첩이 깊어질수록 가독성이 떨어짐
chooseToppings(function (toppings) {
placeOrder(toppings, function (order) {
collectOrder(order, function (pizza) {
eatPizza(pizza);
}, failureCallback);
}, failureCallback);
}, failureCallback);
XMLHttpRequest는 초기 비동기 API로, 아래 코드처럼 완료와 에러를onload와onerror콜백으로 받는다.[10]
const xhr = new XMLHttpRequest();
xhr.onload = function() { /* 완료 처리 */ };
xhr.onerror = function() { /* 에러 처리 */ };
xhr.open('GET', url);
xhr.send();
Promise: 비동기 결과를 표현하는 객체
- Promise는 비동기 작업의 결과를 표현하는 객체다. Pending(아직 결과가 없음), Fulfilled(성공), Rejected(실패)의 세 상태를 가지며, 한 번에 성공과 실패 중 하나만 가진다.[11][12]
- Promise는 이전 작업이 끝날 때까지 다음 작업을 미루거나 에러를 깔끔하게 처리하게 해 준다.[12:1]
- Promise는 결과가 언제 돌아올지 보장하지 않지만, 결과가 돌아오면 다음 코드로 진행시키고, 그동안 다음 코드 블록을 막지 않는다.[12:2]
then()도 Promise를 반환하므로then()을 이어 붙여 작업 순서를 표현할 수 있다. 각then()은 이전 단계의 결과를 받아 다음 단계로 넘기고, 실패는 체인 끝의catch()에서 처리한다.[13][14]- 위 콜백 지옥과 같은 피자 주문 흐름을 Promise chain으로 쓴 원문 코드다. 두 코드를 비교하면 중첩이 사라지고 실패 처리가
catch()한 곳으로 모인다는 점은 이 Wiki가 코드에서 읽은 것이다.[9:1][13:1]
// Promise 체이닝
chooseToppings()
.then((toppings) => placeOrder(toppings))
.then((order) => collectOrder(order))
.then((pizza) => eatPizza(pizza))
.catch(failureCallback);
- 앞 요청의 결과로 다음 요청을 만드는 흐름도 같은 방식으로 이어진다. 아래 예제는 사용자 목록을 받은 뒤 첫 사용자의
id로 게시글을 요청한다.[14:1]
fetch("/users")
.then((response) => response.json())
.then((users) => {
console.log(users);
return fetch(`/posts?userId=${users[0].id}`);
})
.then((response) => response.json())
.then((posts) => {
console.log(posts);
})
.catch((error) => {
console.error(error);
});
async/await: Promise 위의 동기식 표기
async/await는 Promise 기반 비동기 코드를 동기 코드처럼 작성하게 하는 문법이다. Promise를 대체하는 별개 기술이 아니라 내부적으로 Promise를 기반으로 동작한다.[15]await는 Promise가 처리될 때까지 기다린 뒤 결과값을 반환한다.[16]- 아래 두 코드는 표현 방식만 다를 뿐 같은 비동기 흐름을 다룬다.[17]
fetch("/users")
.then((response) => response.json())
.then((users) => console.log(users));
const response = await fetch("/users");
const users = await response.json();
console.log(users);
- Promise chaining의 에러는
catch()로,async/await의 에러는 일반 동기 코드처럼try-catch로 처리한다. 그래서async/await에서는 비동기 에러를 동기 예외 처리와 비슷하게 다룰 수 있다.[18]
async function getUsers() {
try {
const response = await fetch("/users");
const users = await response.json();
console.log(users);
} catch (error) {
console.error(error);
}
}
await는 일반적으로async함수 안에서만 쓴다. ESM 모듈의 최상위에서는 top-level await를 쓸 수 있지만, 일반 script에서는 쓸 수 없다.[19] 일반 script와 module script의 차이는 Browser DOM and Script Loading에서 비교한다.
순차 처리와 동시 대기
- 서로 의존하지 않는 비동기 작업은 순서대로 기다릴 필요가 없다.
Promise.all()을 쓰면 여러 Promise를 함께 처리하고, 모두 완료됐을 때 결과를 배열로 받는다.[20]
const [users, posts] = await Promise.all([
fetch("/users").then((response) => response.json()),
fetch("/posts").then((response) => response.json()),
]);
- 앞 요청의 결과로 다음 요청을 만들어야 하면 순차 처리가 필요하다. 서로 관련 없는 요청이라면
Promise.all()로 병렬 처리하는 편이 효율적이다.[21] - 원문은
Promise.all()이 여러 Promise를 "병렬로 실행"한다고 적는다.[20:1] 그런데 위 코드에서 두fetch()는 배열을 만드는 시점에 이미 호출된다. 그래서Promise.all()은 작업을 직접 실행한다기보다, 이미 시작된 여러 Promise가 모두 끝나기를 함께 기다리는 도구로 읽는 편이 정확하다고 보인다. 이 해석은 원문 코드를 읽은 이 Wiki의 것이다.
이벤트 루프: 콜백이 실행되는 시점
- 원문은 비동기 작업의 콜백이 이벤트 큐에 등록되고, 이벤트 루프가 콜 스택이 비었을 때 큐에서 꺼내 실행한다고 설명한다.[22]
- 아래 그림은 이 한 문장을 옮긴 단순화 모델이다. "작업이 끝난 뒤 등록된다"는 시점 표시는 이 Wiki의 해석이고, 원문에 없는 Web API 영역이나 큐의 종류는 그리지 않았다.
flowchart LR
Start["비동기 작업 시작"] -- "작업 완료 후" --> Queue["콜백이 이벤트 큐에 등록"]
Queue --> Check{"콜 스택이 비었는가"}
Check -- "예" --> Run["이벤트 루프가 콜백을 꺼내 실행"]- 이 모델은 큐를 하나로만 설명한다. Promise 콜백과 타이머 콜백의 실행 순서가 어떻게 다른지는 원문으로 설명할 수 없다.[23]
세 방식의 관계
- 아래 표는 콜백, Promise, async/await를 같은 질문으로 비교한 것이다. 각 칸은 원문 근거를 따르지만, 세 방식을 한 표로 엮은 것은 이 Wiki의 해석이다.
- 표가 보여주듯 세 방식은 서로를 대체하는 별개 기술이라기보다, 같은 비동기 흐름을 점점 읽기 쉬운 형태로 표현하는 층으로 볼 수 있다. async/await가 Promise 위에서 동작한다는 점은 원문이 직접 밝힌다.[15:1]
| 방식 | 순서를 표현하는 방법 | 실패 처리 | 주의할 점 |
|---|---|---|---|
| 콜백 | 다음 단계를 콜백 안에 중첩한다.[9:2] | 단계마다 실패 콜백을 넘긴다.[9:3] | 중첩이 깊어질수록 가독성이 떨어진다.[9:4] |
| Promise | then()을 이어 붙인다.[13:2][14:2] |
체인 끝의 catch() 한 곳에서 처리한다.[13:3][18:1] |
결과가 언제 돌아올지는 보장하지 않는다.[12:3] |
| async/await | await를 동기 코드처럼 차례로 쓴다.[17:1] |
try-catch로 처리한다.[18:2] |
async 함수 안이나 module 최상위에서만 await를 쓸 수 있다.[19:1] |
흔한 실패 모드
- 시간이 걸리는 작업을 동기로 처리해 UI가 멈춘다.[5:1][6:1]
- 순차 작업을 콜백으로 중첩해 콜백 지옥을 만들고, 실패 처리를 단계마다 반복한다.[9:5]
- 서로 의존하지 않는 요청을 하나씩
await한다. 원문 기준으로는 순서대로 기다릴 필요가 없는 작업이므로Promise.all()이 더 효율적이다.[20:2][21:1] - 일반 script에서 top-level await를 쓴다.[19:2]
경계와 원문 공백
Promise.all()에 넘긴 작업 중 하나가 실패할 때의 동작과Promise.allSettled(),Promise.race()는 원문에 없다.[24]- 원문이 링크한 "이벤트 루프와 Pub-Sub" 노트 파일은 Raw에 없다. 비동기 이벤트 매니저와 Pub/Sub를 다룬 내용은
Raw/courses/Naver BoostCamp/Challenge/Day9/학습정리.md101줄부터 있지만 이번 batch에서는 읽지 않았다.[25] - 이 page의 원문은 Vue의 DOM update batching을 다루지 않는다. 그래서 Vue Reactivity가 근거 공백으로 남겨 둔 batching 설명의 근거로 이 page를 쓰지 않는다.
관련
- JavaScript Language Model: 함수가 값이라 콜백이 가능하고, ES6 문제 목록의 "콜백 중첩"이 이 page로 이어진다.
- Browser DOM and Script Loading: top-level await를 쓸 수 있는 module script와 일반 script의 차이, 사용자 이벤트를 받는 DOM 쪽 맥락을 설명한다.
- Vue app instance: watcher가 비동기 작업이나 비용이 큰 부수 효과를 수행하는 경계라는 설명이 이 page의 Promise와 async/await 흐름과 맞닿는다고 볼 수 있다.
- SPA and CSR: initial load 이후 필요한 data만 요청해 UI 일부를 갱신하는 흐름이 이 page의 비동기 요청 위에서 일어난다고 볼 수 있다.
테스트 질문
async/await는 Promise를 대체하는 기술인가?- 두 API 요청이 서로 의존하지 않을 때 두 번 연속
await하는 대신 무엇을 쓰고, 언제는 순차 처리가 필요한가? - 원문 기준으로 이벤트 루프는 콜백을 언제 실행하며, 이 설명이 빠뜨린 것은 무엇인가?
출처
비동기 프로그래밍.md 11줄 — "작업이 완료될 때까지 기다리지 않고, 오래 실행되는 작업을 시작한 뒤 그 동안 다른 이벤트에 응답할 수 있게 하는 기술." ↩︎
ECMAScript.md (Study) 1027, 1039줄 — "JavaScript는 오래 걸리는 작업을 기다리는 동안 전체 실행이 멈추지 않도록 비동기 처리를 사용한다.", "비동기 처리는 오래 걸리는 작업을 요청해두고, 결과가 준비되면 이어서 처리하는 방식이다." ↩︎
비동기 프로그래밍.md 17-19줄 — "
fetch()— HTTP 요청", "getUserMedia()— 카메라/마이크 접근", "showOpenFilePicker()— 파일 선택 요청" ↩︎ECMAScript.md (Study) 1031-1035줄 — "서버 API 요청", "파일 읽기", "타이머", "사용자 이벤트", "데이터베이스 요청" ↩︎
비동기 프로그래밍.md 21줄 — "이처럼 시간이 걸리는 작업을 동기로 처리하면 UI가 멈춘다." ↩︎ ↩︎
ECMAScript.md (Study) 1037줄 — "동기 방식으로 네트워크 요청을 처리하면 응답이 올 때까지 다음 코드가 실행되지 않는다. 브라우저에서는 화면이 멈춘 것처럼 느껴질 수 있다." ↩︎ ↩︎
비동기 프로그래밍.md 29줄 — "이벤트 핸들러는 콜백의 특정 유형." 그리고 콜백 정의 "적절한 시점에 호출될 함수를 다른 함수에 전달" ↩︎
ECMAScript.md (Study) 338-354줄 — "JavaScript에서 함수는 값이다.", "이 특성 때문에 콜백 함수, 고차 함수, 비동기 처리 같은 패턴이 가능하다." ↩︎
비동기 프로그래밍.md 31-40줄 — 코드 주석 "콜백 지옥 — 중첩이 깊어질수록 가독성이 떨어짐". 코드를 그대로 옮겼다. 단계마다
failureCallback을 넘기는 점은 코드에서 읽은 것이다. 원문은 참고 자료로 MDN 비동기 JS 소개 문서를 적는다(84줄). ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎비동기 프로그래밍.md 62-70줄 — 절 제목 "XMLHttpRequest (초기 비동기 API)"와
onload·onerror코드. 코드를 그대로 옮겼다. ↩︎ECMAScript.md (Study) 1043-1049줄 — "Promise는 비동기 작업의 결과를 표현하는 객체다.", "Pending: 아직 결과가 나오지 않은 상태", "Fulfilled: 작업이 성공한 상태", "Rejected: 작업이 실패한 상태" ↩︎
비동기 프로그래밍.md 44-49줄 — "이전 작업이 완료될 때까지 다음 작업을 연기하거나 에러를 깔끔하게 처리할 수 있는 객체.", "결과 반환 시간을 보장하지는 않지만, 반환 시 다음 코드로 진행시킴", "한 번에 성공/실패 중 하나만 가짐", "다음 코드 블록을 막지 않음 (Non-blocking)" ↩︎ ↩︎ ↩︎ ↩︎
비동기 프로그래밍.md 51-60줄 — Promise 체이닝 코드와 "
then()도 Promise를 반환하기 때문에 체이닝이 가능하다." 코드를 그대로 옮겼다. ↩︎ ↩︎ ↩︎ ↩︎ECMAScript.md (Study) 1065-1085줄 — "Promise는
then()을 연결해서 비동기 작업을 순서대로 처리할 수 있다.", "각then()은 이전 단계의 결과를 받아 다음 단계로 넘긴다.", "실패가 발생하면catch()에서 처리할 수 있다." 코드 1067-1081줄을 그대로 옮겼다. ↩︎ ↩︎ ↩︎ECMAScript.md (Study) 1089, 1106줄 — "
async/await는 Promise 기반 비동기 코드를 더 동기 코드처럼 작성하게 해주는 문법이다.", "중요한 점은async/await가 Promise를 대체하는 별개의 기술이 아니라는 것이다. 내부적으로는 Promise를 기반으로 동작한다." ↩︎ ↩︎ECMAScript.md (Study) 1104줄 — "
await는 Promise가 처리될 때까지 기다린 뒤 결과값을 반환한다." ↩︎ECMAScript.md (Study) 1108-1120줄 — "즉, 다음 두 방식은 표현 방식이 다를 뿐 같은 비동기 흐름을 다룬다." 두 코드를 그대로 옮겼다. ECMAScript.md (Computing) 75-88줄에도 같은
fetch흐름을 Promise와 async-await로 나란히 쓴 예시가 있다. ↩︎ ↩︎ECMAScript.md (Study) 1124-1143줄 — "Promise chaining에서는
catch()를 사용한다.", "async/await에서는 일반적인 동기 코드처럼try-catch를 사용할 수 있다.", "async/await의 장점은 비동기 에러를 동기 예외 처리 방식과 비슷하게 다룰 수 있다는 점이다." 코드는 같은 노트 1091-1102줄을 그대로 옮겼다. ↩︎ ↩︎ ↩︎ECMAScript.md (Study) 1147-1160줄 — "일반적으로
await는async함수 내부에서만 사용할 수 있다.", "하지만 ESM 환경에서는 모듈 최상위에서도await를 사용할 수 있다.", "단, top-level await는 일반 script가 아니라 module 환경에서 사용할 수 있다." ↩︎ ↩︎ ↩︎ECMAScript.md (Study) 1164-1173줄 — "서로 의존하지 않는 여러 비동기 작업은 순서대로 기다릴 필요가 없다.", "
Promise.all()을 사용하면 여러 Promise를 병렬로 실행하고, 모두 완료되었을 때 결과를 배열로 받을 수 있다." 코드 1168-1172줄을 그대로 옮겼다. ↩︎ ↩︎ ↩︎ECMAScript.md (Study) 1175줄 — "사용자 목록을 가져온 뒤 그 결과로 게시글 요청을 해야 한다면 순차 처리가 필요하다. 하지만 서로 관련 없는 요청이라면
Promise.all()로 병렬 처리하는 것이 효율적이다." ↩︎ ↩︎비동기 프로그래밍.md 76줄 — "비동기 작업의 콜백은 **이벤트 큐(Event Queue)**에 등록되고, 이벤트 루프가 콜 스택이 비었을 때 꺼내 실행한다." ↩︎
출처 공백 — 비동기 프로그래밍.md 76줄은 큐를 하나로만 설명하고, Promise 콜백과 타이머 콜백을 구분하지 않는다. microtask와 task 큐의 차이는 이 batch의 어느 Raw에도 없다. MDN의 event loop 문서 같은 공식 자료가 들어오면 보강한다. ↩︎
출처 공백 — ECMAScript.md (Study) 1162-1175줄은 모든 작업이 성공하는 경우만 다룬다. 실패가 섞일 때의
Promise.all()동작과 다른 조합 함수는 Raw로 확인하지 못했다. ↩︎출처 공백 — 비동기 프로그래밍.md 78줄이 관련 노트로 "이벤트 루프와 Pub-Sub"를 링크하지만 그 노트는 Raw에 없다. 원문이 학습 출처로 적은 부스트캠프 챌린지 Day11 학습정리(85줄)와, 비동기 이벤트 매니저·Pub/Sub를 다룬 Day9 학습정리(
Raw/courses/Naver BoostCamp/Challenge/Day9/학습정리.md101줄부터)는 이번 batch에서 읽지 않았다. ↩︎