자바스크립트 중급 #2 비동기 입문 — Promise와 async/await

#1 클래스 다음, 이번엔 자바스크립트의 가장 큰 특징 중 하나 — 비동기를 정리합니다. 처음 만나면 어렵지만, 한 번 익숙해지면 자바스크립트의 진짜 표현력이 열려요.

동기 vs 비동기 — 차이가 무엇인가? #

자바스크립트는 기본적으로 한 줄씩 실행합니다. 위에서 아래로요.

동기 — 한 줄씩 차례로
console.log('1');
console.log('2');
console.log('3');
// 1, 2, 3 (이 순서로)

문제는 시간이 걸리는 작업(파일 읽기, 네트워크 요청, 타이머 등)입니다. 자바스크립트가 이걸 동기로 기다리면 그 사이 다른 일이 멈춥니다. 그래서 자바스크립트는 이런 작업을 비동기로 처리합니다 — “나중에 끝나면 알려줘” 형태로.

비동기 — setTimeout 예시
console.log('1');
setTimeout(() => console.log('2'), 1000);
console.log('3');
// 1, 3, 2 (이 순서로)

setTimeout은 1초 뒤 실행할 함수를 등록만 하고, 본문은 계속 진행됩니다. 그 사이 console.log('3')이 먼저 실행됩니다.

콜백의 시대 — 그리고 한계 #

옛 자바스크립트는 비동기를 콜백 함수로 다뤘습니다.

옛 콜백 스타일
fetchUser(userId, (user) => {
  fetchPosts(user.id, (posts) => {
    fetchComments(posts[0].id, (comments) => {
      console.log(comments);
    });
  });
});

이게 유명한 콜백 지옥(callback hell) 입니다. 들여쓰기가 점점 깊어지고, 에러 처리가 모든 곳에 분산됩니다.

에러 처리까지 더하면
fetchUser(userId, (err, user) => {
  if (err) return handleError(err);
  fetchPosts(user.id, (err, posts) => {
    if (err) return handleError(err);
    fetchComments(posts[0].id, (err, comments) => {
      if (err) return handleError(err);
      console.log(comments);
    });
  });
});

ES2015에서 Promise가 등장하면서 이 문제가 풀렸습니다.

Promise — 비동기 결과를 표현하는 객체 #

Promise는 “지금은 결과가 없지만 나중에 생길 값” 을 객체로 표현한 것입니다. 세 가지 상태 중 하나를 가집니다.

  • pending (대기) — 아직 끝나지 않음
  • fulfilled (성공) — 결과값이 정해짐
  • rejected (실패) — 에러가 발생

Promise 만들기 — new Promise #

Promise 직접 만들기
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    if (Math.random() > 0.5) {
      resolve('성공');
    } else {
      reject(new Error('실패'));
    }
  }, 1000);
});

resolvereject 두 함수를 인자로 받습니다. 비동기 작업이 끝나면 둘 중 하나를 부르면 됩니다.

직접 만드는 일은 사실 흔하지 않습니다. 거의 모든 비동기 API(fetch, fs.promises, 타이머)가 이미 Promise를 반환합니다.

.then / .catch / .finally #

Promise의 결과를 다루는 메서드들입니다.

기본 사용
promise
  .then((value) => console.log('성공:', value))
  .catch((err) => console.error('실패:', err))
  .finally(() => console.log('끝'));
  • .then(callback) — 성공 시
  • .catch(callback) — 실패 시
  • .finally(callback) — 성공/실패 무관하게 마지막에

Chain — 결과를 다음으로 넘기기 #

.then의 콜백이 반환하는 값이 다음 .then의 인자가 됩니다. 콜백이 또 Promise를 반환하면 그 결과가 풀려서 다음으로 갑니다.

chain
fetchUser(userId)
  .then((user) => fetchPosts(user.id))      // user를 받아 posts Promise 반환
  .then((posts) => fetchComments(posts[0].id))   // posts를 받아 comments Promise
  .then((comments) => console.log(comments))
  .catch((err) => handleError(err));

콜백 지옥이 평평하게 펼쳐졌습니다. 에러 처리도 마지막 .catch 한 곳에 모여요.

async / await — Promise를 직선화 #

ES2017에 추가된 문법. Promise 위에 얹혀서 마치 동기 코드처럼 쓸 수 있게 해 줍니다.

async / await — 같은 일을 더 깔끔하게
async function loadComments(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchPosts(user.id);
  const comments = await fetchComments(posts[0].id);
  return comments;
}

loadComments(userId)
  .then((comments) => console.log(comments))
  .catch((err) => handleError(err));

규칙:

  1. 함수 앞에 async를 붙이면 그 함수는 항상 Promise를 반환
  2. async 함수 안에서만 await를 쓸 수 있고, await는 Promise를 풀어서 값을 돌려줌

이 두 가지가 핵심입니다. await를 만나면 자바스크립트가 그 지점에서 잠깐 멈춰 결과를 기다리고, 결과가 나오면 다음 줄로 진행합니다(다른 코드는 그동안 동작 가능).

에러는 try/catch로 #

try/catch 와 async/await
async function loadComments(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    return comments;
  } catch (err) {
    handleError(err);
    throw err;
  }
}

동기 코드처럼 try/catch로 모든 비동기 에러를 한 군데서 잡을 수 있습니다. 이게 async/await가 가져다준 가장 큰 이점 중 하나입니다.

병렬 실행 — Promise.all #

위 코드의 한 가지 함정 — 세 fetch가 차례로 실행됩니다(첫 번째 끝나야 두 번째 시작). 사실 동시에 시작해도 되는 경우에는 시간을 낭비하는 것입니다.

서로 의존하지 않는 비동기를 병렬로 실행하고 싶다면 Promise.all을 씁니다.

Promise.all — 병렬 실행
async function loadDashboard(userId) {
  const [user, notifications, settings] = await Promise.all([
    fetchUser(userId),
    fetchNotifications(userId),
    fetchSettings(userId),
  ]);
  return { user, notifications, settings };
}

세 요청이 동시에 시작되고, 모두 끝나면 결과가 배열로 들어옵니다. 디스트럭처링(#5 객체와 배열)으로 받습니다.

Promise.all의 함정 — 하나라도 실패하면 전체 실패 #

all 은 first error 로 빠짐
const results = await Promise.all([
  fetchA(),    // 성공
  fetchB(),    // 실패
  fetchC(),    // 성공
]);
// 전체가 reject — fetchA, fetchC 결과는 못 받음

이게 부담스러우면 Promise.allSettled를 쓰세요.

allSettled — 모두 끝까지
const results = await Promise.allSettled([
  fetchA(),
  fetchB(),
  fetchC(),
]);
// results = [
//   { status: 'fulfilled', value: ... },
//   { status: 'rejected', reason: ... },
//   { status: 'fulfilled', value: ... },
// ]

각 결과가 객체로 와서, 성공/실패를 개별적으로 처리할 수 있습니다.

그 외 자주 쓰는 Promise 정적 메서드 #

Promise 정적 메서드
// 가장 빠른 결과 (성공/실패 무관)
const fastest = await Promise.race([fetchA(), fetchB()]);

// 가장 빠른 성공만
const firstSuccess = await Promise.any([fetchA(), fetchB(), fetchC()]);

// 즉시 성공/실패하는 Promise
Promise.resolve(42);              // 즉시 fulfilled
Promise.reject(new Error('!'));    // 즉시 rejected

Promise.race는 timeout 패턴에서 자주 사용합니다.

timeout 패턴
function timeout(ms) {
  return new Promise((_, reject) =>
    setTimeout(() => reject(new Error('timeout')), ms)
  );
}

const result = await Promise.race([
  fetch('/api/slow'),
  timeout(5000),
]);

5초 안에 fetch가 끝나지 않으면 timeout이 먼저 reject 해서 race 결과로 나옵니다. 모던 자바스크립트는 AbortController도 같이 쓸 수 있는데, 그건 다음 #6 fetch API에서 다룹니다.

await의 사용 — 함정과 가이드 #

1) await는 한 줄씩 잠깐 멈춥니다 #

순차 vs 병렬
// 순차 — 합산 시간이 됨
const a = await fetchA();
const b = await fetchB();

// 병렬 — 더 빠른 쪽
const [a, b] = await Promise.all([fetchA(), fetchB()]);

상위 코드의 loadComments가 순차였던 이유는 — postsuser가 필요하고, commentsposts가 필요해서, 의존이 있었기 때문입니다. **의존이 없는 경우는 항상 Promise.all**이 빠릅니다.

2) 루프 안에서 await 주의 #

for...of + await — 한 번에 하나씩
for (const id of userIds) {
  const user = await fetchUser(id);
  console.log(user);
}

이건 사용자 100명이면 fetch가 차례로 100번 — 매우 느려요. 병렬로 해도 되면:

병렬 fetch
const users = await Promise.all(
  userIds.map((id) => fetchUser(id))
);

100배 가까이 빨라집니다.

3) 잊으면 안 되는 await #

await를 빠뜨리면
async function process() {
  const result = fetchData();        // ✗ Promise 객체 자체가 들어감
  console.log(result);                // [object Promise]
  console.log(result.data);           // undefined
}

await를 빠뜨리면 resultPromise 객체 자체가 됩니다. 컴파일러가 잡아주지 않으니 직접 의식해야 합니다(타입스크립트 + ESLint가 도와줍니다).

Top-level await — ES2022 #

ES Modules 에서는 함수 밖에서도 await를 쓸 수 있습니다.

top-level await
// app.js (ES module)
const data = await fetch('/api/init').then((r) => r.json());

console.log(data);

CommonJS 모듈은 안 됩니다. 모던 ESM 에서만 동작합니다. Vite, Next.js, Bun 등 모던 도구에서 사용 가능합니다.

마무리 #

이번 글에서 정리한 내용:

  • 자바스크립트는 한 줄씩 실행하지만 시간 걸리는 작업은 비동기로 처리
  • 콜백 → Promise → async/await로 표현 방식이 진화
  • Promise 세 상태 — pending / fulfilled / rejected
  • .then / .catch / .finally와 chain
  • async 함수는 Promise 반환, await가 결과를 풀어 줌
  • 의존 없는 비동기는 Promise.all로 병렬 실행
  • Promise.allSettled / race / any
  • 루프 안 await 주의, await 빠뜨림 주의
  • ESM에서 top-level await

다음 글(#3 이터레이터와 제너레이터)에서는 for...of가 어떤 약속을 따르는지부터, 자기만의 이터러블을 만들고 제너레이터로 게으른 시퀀스를 표현하는 단계까지 다룹니다.

X