자바스크립트 async await - 순차와 병렬, Promise.all 로 절반 줄이기 (JS 중급 13단원)
이 단원에서 배우는 것
12단원에서 콜백 중첩을 then 체인으로 폈다. 그래도 남는 불편이 있다. 앞 단계의 결과를 두 단계 뒤에서 다시 써야 하면 값을 손으로 들고 다녀야 하고, 조건 분기가 들어가면 체인이 다시 지저분해진다. async/await 는 같은 Promise 를 동기 코드처럼 보이게 쓰는 문법이다. 문법 설탕이지만, 무엇이 설탕이고 무엇이 실제 동작인지 구분하지 못하면 "동시에 보내도 되는 요청을 줄줄이 세워 3초 걸리는 화면"을 만든다. 이 단원에서 그 경계를 잡는다. 앞 단원의 findBook·saveLoan 을 그대로 쓴다.
await가 "멈추는" 것이 아니라 함수의 나머지를 마이크로태스크로 미루는 것임을 설명한다.try/catch/finally로 비동기 오류를 처리하고, 어디까지 잡히는지 경계를 안다.- 순차와 병렬을 의도적으로 선택하고,
Promise.all·allSettled를await와 함께 쓴다.
왜 필요한가
대출 처리에 조건이 하나 붙었다고 하자. "정지 회원이면 거절하고, 예약자가 있으면 예약 대기로 돌리고, 아니면 대출한다." then 체인으로 쓰면 이렇게 된다.
function borrow(memberId, bookId) {
return loadMember(memberId).then((member) => {
if (member.suspended) throw new Error('대출 정지 회원');
return findBook(bookId).then((book) => {
return countReservations(bookId).then((n) => {
if (n > 0) return addToWaitlist(member, book); // member 를 여기서 또 써야 한다
return saveLoan(book, member.id);
});
});
});
}
중첩이 돌아왔다. member 를 세 단계 아래에서 써야 하니 체인을 평평하게 만들 수 없다. 12단원이 해결한 것은 순차 연결이지 변수 공유가 아니었다.
async/await 로 쓰면 이렇다.
async function borrow(memberId, bookId) {
const member = await loadMember(memberId);
if (member.suspended) throw new Error('대출 정지 회원');
const book = await findBook(bookId);
const waiting = await countReservations(bookId);
if (waiting > 0) return addToWaitlist(member, book);
return saveLoan(book, member.id);
}
변수는 그냥 지역 변수고, 분기는 그냥 if 다. 이게 async/await 가 존재하는 이유의 전부다. 실행 방식이 달라진 것은 하나도 없다.
문법과 예제
async 함수는 항상 Promise 를 반환한다
async function currentBranch() {
return '3층 자료실'; // 값을 반환해도
}
console.log(currentBranch()); // Promise { '3층 자료실' }
currentBranch().then((v) => console.log(v)); // 3층 자료실
async function fail() {
throw new Error('실패'); // throw 하면
}
fail().catch((e) => console.log('잡힘:', e.message)); // rejected Promise 가 된다
async 는 두 가지를 한다. (1) 반환값을 Promise 로 감싼다. (2) 함수 안에서 await 를 쓸 수 있게 한다. 그러니 async 함수를 호출하는 쪽도 결국 await 하거나 then 을 붙여야 한다. 이걸 잊으면 12단원의 "처리되지 않은 rejection" 으로 돌아간다.
await 는 무엇을 하는가
await 는 스레드를 멈추지 않는다. 자바스크립트는 여전히 단일 스레드고, 다른 코드는 계속 돈다. 실제로 일어나는 일은 "이 함수의 나머지 부분을 그 Promise 의 then 콜백으로 등록하고 지금은 반환한다"이다.
async function report() {
console.log('2. report 시작');
const book = await findBook('B001'); // 여기서 report 는 일단 반환된다
console.log('4. await 이후'); // 마이크로태스크로 나중에 실행
return book.title;
}
console.log('1. 호출 전');
report().then((t) => console.log('5.', t));
console.log('3. 호출 직후');
// 1. 호출 전
// 2. report 시작
// 3. 호출 직후
// 4. await 이후
// 5. 자바스크립트 완벽 가이드
await 앞줄까지는 동기적으로 실행된다는 점이 핵심이다. 3번이 4번보다 먼저 찍히는 이유가 여기 있다. 12단원의 마이크로태스크 큐 이야기가 그대로 적용된다.
try / catch / finally
async function borrow(memberId, bookId) {
const start = Date.now();
try {
const member = await loadMember(memberId);
if (member.suspended) throw new Error('대출 정지 회원');
const book = await findBook(bookId);
if (book.stock <= 0) throw new Error(`${book.title}: 재고 없음`);
return await saveLoan(book, member.id);
} catch (err) {
console.error(`[대출 실패] ${memberId}/${bookId}: ${err.message}`);
throw err; // 처리 방침은 호출자에게 넘긴다
} finally {
console.log(`소요 ${Date.now() - start}ms`);
}
}
동기 예외(throw new Error)와 비동기 실패(await 한 Promise 의 rejection)를 같은 catch 하나로 잡는다. 12단원 도입부에서 콜백의 구조적 결함으로 꼽았던 두 번째 문제가 여기서 완전히 사라진다.
return await saveLoan(...) 에서 await 를 붙인 것에 주의한다. return saveLoan(...) 이라고 쓰면 Promise 를 그대로 반환하므로, saveLoan 이 실패했을 때 이 함수의 catch 를 거치지 않는다. 이미 함수를 빠져나간 뒤에 실패하기 때문이다. try 블록 안에서 반환할 때는 return await 를 쓴다. 그 외 자리에서는 await 를 붙이나 안 붙이나 결과가 같다.
순차와 병렬
가장 자주 성능을 망치는 지점이다. 아래 두 함수는 결과가 같지만 걸리는 시간이 두 배 차이 난다.
// findBook 은 한 건에 100ms 가 걸린다고 하자
async function loadTwoSequential() {
const a = await findBook('B001'); // 100ms 기다린 뒤
const b = await findBook('B003'); // 다시 100ms
return [a.title, b.title];
}
// 실측 202ms
async function loadTwoParallel() {
const [a, b] = await Promise.all([ // 두 요청을 동시에 보낸다
findBook('B001'),
findBook('B003')
]);
return [a.title, b.title];
}
// 실측 101ms
판단 기준은 단순하다. 뒤 요청이 앞 결과를 인자로 쓰는가. 쓰면 순차일 수밖에 없고, 안 쓰면 병렬로 바꿀 수 있다. 위에서 findBook('B003') 은 a 를 전혀 참조하지 않으므로 순차일 이유가 없다.
코드 리뷰에서 await 가 연달아 두 줄 이상 나오면 항상 이 질문을 한다. 요청이 5개면 500ms 와 100ms 의 차이가 된다.
실무형 예제: 여러 건을 처리하되 실패를 개별로 다룬다
async function borrowMany(memberId, bookIds) {
const results = await Promise.allSettled(
bookIds.map((id) => borrow(memberId, id))
);
const ok = [];
const fail = [];
results.forEach((r, i) => {
if (r.status === 'fulfilled') ok.push(r.value.loanId);
else fail.push({ bookId: bookIds[i], reason: r.reason.message });
});
return { ok, fail };
}
const summary = await borrowMany('M01', ['B001', 'B002', 'B999']);
console.log(summary);
// { ok: [ 'L-B001-M01' ],
// fail: [ { bookId: 'B002', reason: '리팩터링: 재고 없음' },
// { bookId: 'B999', reason: '도서 없음: B999' } ] }
Promise.all 이었다면 B002 하나 때문에 B001 의 성공 결과도 알 수 없다. 여러 건을 각각 처리하는 배치성 작업에는 allSettled 를 쓴다. 단, allSettled 는 절대 reject 하지 않으므로 실패를 직접 세서 보고하지 않으면 조용히 묻힌다.
fail 이 비지 않았을 때 어떻게 할지는 도메인이 정한다. 대출은 부분 성공이 말이 되지만, 결제라면 하나라도 실패했을 때 전부 되돌려야 할 수도 있다.
동시 실행 개수 제한
도서 1,000건을 Promise.all 로 한꺼번에 조회하면 서버가 1,000개 요청을 동시에 받는다. 실무에서는 대개 막힌다. 순차도 병렬도 아닌 중간이 필요하다.
async function mapWithLimit(items, limit, task) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++; // 인덱스를 먼저 확보한다
results[index] = await task(items[index], index);
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker)
);
return results;
}
const ids = ['B001', 'B002', 'B003', 'B004', 'B005'];
const books = await mapWithLimit(ids, 2, (id) => findBook(id));
console.log(books.length); // 5 — 동시 2건씩만 나간다
워커 limit 개를 동시에 돌리고 각 워커가 남은 일을 하나씩 집어 간다. cursor++ 가 await 보다 먼저 실행되므로 두 워커가 같은 항목을 잡는 일은 없다. 자바스크립트는 단일 스레드라 await 사이에서만 다른 코드가 끼어들 수 있고, 이 한 줄 안에서는 끼어들 수 없기 때문이다.
실무에서 자주 틀리는 것
1. forEach 안에서 await 한다
const titles = [];
['B001', 'B003'].forEach(async (id) => {
const book = await findBook(id);
titles.push(book.title);
});
console.log(titles.length); // 0 ← 하나도 안 들어와 있다
forEach 는 콜백이 반환한 Promise 를 버린다. 콜백이 async 라 Promise 를 반환해도 forEach 는 그걸 볼 방법이 없고, 기다리지도 않는다. 게다가 그 안에서 실패하면 아무도 잡지 않아 처리되지 않은 rejection 이 된다. map·filter·some 도 같은 문제를 갖는다(filter(async ...) 는 Promise 객체가 항상 truthy 라 전부 통과시킨다).
고치는 방법은 두 가지고, 순차인지 병렬인지에 따라 고른다.
// 순차: for...of 안에서는 await 가 제대로 동작한다
for (const id of ['B001', 'B003']) {
const book = await findBook(id);
titles.push(book.title);
}
// 병렬: map 으로 Promise 배열을 만들고 한 번에 기다린다
const books = await Promise.all(['B001', 'B003'].map(findBook));
titles.push(...books.map((b) => b.title));
기억할 규칙: await 는 for...of·for·while 안에서만 기다린다. 배열 메서드 콜백 안에서는 기다리지 않는다.
2. Promise.all 은 실패해도 나머지를 취소하지 않는다
try {
await Promise.all([
borrow('M01', 'B999'), // 즉시 실패
borrow('M01', 'B001') // 그래도 계속 진행되어 대출이 기록된다
]);
} catch (e) {
console.log(e.message); // 도서 없음: B999
}
Promise.all 이 reject 하는 것은 "결과를 더 안 기다리겠다"는 뜻이지 "진행 중인 작업을 멈추겠다"가 아니다. 자바스크립트에 Promise 를 취소하는 기능은 없다. 위 코드는 B001 대출을 실제로 저장한 채로 에러를 반환한다. 데이터가 어중간하게 남는다.
대책은 두 가지다. 부작용이 있는 작업이면 allSettled 로 전부 기다린 뒤 결과를 보고 보정한다. 취소가 정말 필요하면 AbortController 를 각 작업에 넘겨 직접 중단시킨다(16단원에서 fetch 와 함께 다룬다).
3. await 를 붙이지 않고 호출한다
async function handleReturn(loanId) {
updateStock(loanId); // ← await 없음
sendNotice(loanId); // ← await 없음
return { ok: true }; // 두 작업이 끝나기 전에 응답한다
}
서버 핸들러에서 이렇게 쓰면 응답을 보낸 뒤에 DB 갱신이 일어나거나, 프로세스가 먼저 끝나 아예 실행되지 않는다. 실패해도 catch 가 없어 로그조차 안 남는다.
일부러 기다리지 않는 경우(fire-and-forget)도 있다. 그럴 때는 의도임을 코드에 남긴다.
void sendNotice(loanId).catch((e) => logger.warn('알림 실패', e));
ESLint 를 쓴다면 no-floating-promises(typescript-eslint) 규칙을 켜 두는 것이 가장 확실하다. 사람 눈으로 잡기 어려운 종류의 실수다.
4. 병렬로 만들려다 오히려 처리되지 않은 rejection 을 만든다
// 요청은 동시에 보내되 나중에 await 하려는 시도
const bookP = findBook('B999'); // 즉시 실패한다
const memberP = loadMember('M01'); // 이건 300ms 걸린다
const member = await memberP; // 먼저 이걸 기다리는 동안
const book = await bookP; // bookP 의 실패는 아무도 안 잡은 상태였다
Promise 는 만들어지는 순간 실행이 시작된다. bookP 가 memberP 보다 먼저 실패하면, 그 시점에는 catch 도 await 도 붙어 있지 않다. Node 15 이상에서는 이것만으로 프로세스가 종료될 수 있다.
이 패턴 자체는 유용하다. 안전하게 쓰려면 만들자마자 Promise.all 로 묶는다.
const [book, member] = await Promise.all([findBook('B999'), loadMember('M01')]);
5. 최상위에서 await 를 쓰고 왜 안 되는지 모른다
// script.js (CommonJS 또는 <script> 태그)
const book = await findBook('B001');
// SyntaxError: await is only valid in async functions and the top level bodies of modules
최상위 await(top-level await)는 ES 모듈에서만 된다. 브라우저에서는 <script type="module">, Node 에서는 .mjs 파일이거나 package.json 에 "type": "module" 이 있을 때다. CommonJS(require) 파일에서는 문법 오류다. 이 구분은 15단원에서 자세히 다룬다. 당장은 async function main() { ... } main() 으로 감싸면 어디서든 동작한다.
스스로 확인하기
- 다음 코드의 출력 순서를 쓰라.
async function f() { console.log('B'); await null; console.log('D'); } console.log('A'); f(); console.log('C'); - 아래 함수는 도서 3건 조회에 300ms 가 걸린다. 100ms 로 줄여라. 그리고
notice는 세 건이 모두 조회된 뒤에 한 번만 보내야 한다.async function loadThree() { const a = await findBook('B001'); const b = await findBook('B003'); const c = await findBook('B004'); await sendNotice('조회 완료'); return [a, b, c]; } - 다음 함수는 실패한 대출을
failed배열에 모으려 하지만 항상 빈 배열을 반환한다. 원인과 수정안을 쓰라.async function tryBorrowAll(memberId, bookIds) { const failed = []; bookIds.forEach(async (id) => { try { await borrow(memberId, id); } catch (e) { failed.push({ id, reason: e.message }); } }); return failed; }
정답
A B C D.f()를 호출하면await를 만나기 전까지는 동기 실행이라B가 바로 찍힌다.await null에서 함수가 반환되고 나머지(D)는 마이크로태스크로 밀린다. 그래서C가 먼저다.await뒤가 Promise 가 아닌 값이어도 미뤄지는 것은 같다.
세 조회는 서로를 참조하지 않으므로 병렬로 바꿀 수 있다. 반면async function loadThree() { const books = await Promise.all([ findBook('B001'), findBook('B003'), findBook('B004') ]); await sendNotice('조회 완료'); return books; }sendNotice는 "모두 조회된 뒤"라는 조건이 있으므로Promise.all안에 넣으면 안 된다. 순서 의존이 실제로 있는 유일한 지점이다.- 원인:
forEach가async콜백을 기다리지 않으므로,return failed가 실행되는 시점에 대출 시도는 아직 시작만 된 상태다.failed.push는 함수가 이미 반환된 뒤에 일어난다.
순차로 처리해야 한다면async function tryBorrowAll(memberId, bookIds) { const results = await Promise.allSettled( bookIds.map((id) => borrow(memberId, id)) ); return results.flatMap((r, i) => r.status === 'rejected' ? [{ id: bookIds[i], reason: r.reason.message }] : [] ); }for...of를 쓴다.for (const id of bookIds) { try { await borrow(memberId, id); } catch (e) { failed.push({ id, reason: e.message }); } }