Devin.KR
로그인

자바스크립트 async await - 순차와 병렬, Promise.all 로 절반 줄이기 (JS 중급 13단원)

개발자 조회 1

이 단원에서 배우는 것

12단원에서 콜백 중첩을 then 체인으로 폈다. 그래도 남는 불편이 있다. 앞 단계의 결과를 두 단계 뒤에서 다시 써야 하면 값을 손으로 들고 다녀야 하고, 조건 분기가 들어가면 체인이 다시 지저분해진다. async/await 는 같은 Promise 를 동기 코드처럼 보이게 쓰는 문법이다. 문법 설탕이지만, 무엇이 설탕이고 무엇이 실제 동작인지 구분하지 못하면 "동시에 보내도 되는 요청을 줄줄이 세워 3초 걸리는 화면"을 만든다. 이 단원에서 그 경계를 잡는다. 앞 단원의 findBook·saveLoan 을 그대로 쓴다.

  • await 가 "멈추는" 것이 아니라 함수의 나머지를 마이크로태스크로 미루는 것임을 설명한다.
  • try/catch/finally 로 비동기 오류를 처리하고, 어디까지 잡히는지 경계를 안다.
  • 순차와 병렬을 의도적으로 선택하고, Promise.all·allSettledawait 와 함께 쓴다.

왜 필요한가

대출 처리에 조건이 하나 붙었다고 하자. "정지 회원이면 거절하고, 예약자가 있으면 예약 대기로 돌리고, 아니면 대출한다." 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));

기억할 규칙: awaitfor...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 는 만들어지는 순간 실행이 시작된다. bookPmemberP 보다 먼저 실패하면, 그 시점에는 catchawait 도 붙어 있지 않다. 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() 으로 감싸면 어디서든 동작한다.

스스로 확인하기

  1. 다음 코드의 출력 순서를 쓰라.
    async function f() {
      console.log('B');
      await null;
      console.log('D');
    }
    console.log('A');
    f();
    console.log('C');
  2. 아래 함수는 도서 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];
    }
  3. 다음 함수는 실패한 대출을 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;
    }

정답

  1. A B C D. f() 를 호출하면 await 를 만나기 전까지는 동기 실행이라 B 가 바로 찍힌다. await null 에서 함수가 반환되고 나머지(D)는 마이크로태스크로 밀린다. 그래서 C 가 먼저다. await 뒤가 Promise 가 아닌 값이어도 미뤄지는 것은 같다.
  2. async function loadThree() {
      const books = await Promise.all([
        findBook('B001'),
        findBook('B003'),
        findBook('B004')
      ]);
      await sendNotice('조회 완료');
      return books;
    }
    세 조회는 서로를 참조하지 않으므로 병렬로 바꿀 수 있다. 반면 sendNotice 는 "모두 조회된 뒤"라는 조건이 있으므로 Promise.all 안에 넣으면 안 된다. 순서 의존이 실제로 있는 유일한 지점이다.
  3. 원인: forEachasync 콜백을 기다리지 않으므로, 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 }); }
    }