자바스크립트 중급 #2 비동기 입문 — Promise와 async/await
#1 클래스 다음, 이번엔 자바스크립트의 가장 큰 특징 중 하나 — 비동기를 정리합니다. 처음 만나면 어렵지만, 한 번 익숙해지면 자바스크립트의 진짜 표현력이 열려요.
동기 vs 비동기 — 차이가 무엇인가? #
자바스크립트는 기본적으로 한 줄씩 실행합니다. 위에서 아래로요.
console.log('1');
console.log('2');
console.log('3');
// 1, 2, 3 (이 순서로)
문제는 시간이 걸리는 작업(파일 읽기, 네트워크 요청, 타이머 등)입니다. 자바스크립트가 이걸 동기로 기다리면 그 사이 다른 일이 멈춥니다. 그래서 자바스크립트는 이런 작업을 비동기로 처리합니다 — “나중에 끝나면 알려줘” 형태로.
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
#
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.5) {
resolve('성공');
} else {
reject(new Error('실패'));
}
}, 1000);
});resolve와 reject 두 함수를 인자로 받습니다. 비동기 작업이 끝나면 둘 중 하나를 부르면 됩니다.
직접 만드는 일은 사실 흔하지 않습니다. 거의 모든 비동기 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를 반환하면 그 결과가 풀려서 다음으로 갑니다.
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 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));규칙:
- 함수 앞에
async를 붙이면 그 함수는 항상 Promise를 반환 async함수 안에서만await를 쓸 수 있고,await는 Promise를 풀어서 값을 돌려줌
이 두 가지가 핵심입니다. await를 만나면 자바스크립트가 그 지점에서 잠깐 멈춰 결과를 기다리고, 결과가 나오면 다음 줄로 진행합니다(다른 코드는 그동안 동작 가능).
에러는 try/catch로 #
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을 씁니다.
async function loadDashboard(userId) {
const [user, notifications, settings] = await Promise.all([
fetchUser(userId),
fetchNotifications(userId),
fetchSettings(userId),
]);
return { user, notifications, settings };
}세 요청이 동시에 시작되고, 모두 끝나면 결과가 배열로 들어옵니다. 디스트럭처링(#5 객체와 배열)으로 받습니다.
Promise.all의 함정 — 하나라도 실패하면 전체 실패
#
const results = await Promise.all([
fetchA(), // 성공
fetchB(), // 실패
fetchC(), // 성공
]);
// 전체가 reject — fetchA, fetchC 결과는 못 받음
이게 부담스러우면 Promise.allSettled를 쓰세요.
const results = await Promise.allSettled([
fetchA(),
fetchB(),
fetchC(),
]);
// results = [
// { status: 'fulfilled', value: ... },
// { status: 'rejected', reason: ... },
// { status: 'fulfilled', value: ... },
// ]
각 결과가 객체로 와서, 성공/실패를 개별적으로 처리할 수 있습니다.
그 외 자주 쓰는 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 패턴에서 자주 사용합니다.
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는 한 줄씩 잠깐 멈춥니다
#
// 순차 — 합산 시간이 됨
const a = await fetchA();
const b = await fetchB();
// 병렬 — 더 빠른 쪽
const [a, b] = await Promise.all([fetchA(), fetchB()]);상위 코드의 loadComments가 순차였던 이유는 — posts는 user가 필요하고, comments는 posts가 필요해서, 의존이 있었기 때문입니다. **의존이 없는 경우는 항상 Promise.all**이 빠릅니다.
2) 루프 안에서 await 주의
#
for (const id of userIds) {
const user = await fetchUser(id);
console.log(user);
}이건 사용자 100명이면 fetch가 차례로 100번 — 매우 느려요. 병렬로 해도 되면:
const users = await Promise.all(
userIds.map((id) => fetchUser(id))
);100배 가까이 빨라집니다.
3) 잊으면 안 되는 await
#
async function process() {
const result = fetchData(); // ✗ Promise 객체 자체가 들어감
console.log(result); // [object Promise]
console.log(result.data); // undefined
}await를 빠뜨리면 result가 Promise 객체 자체가 됩니다. 컴파일러가 잡아주지 않으니 직접 의식해야 합니다(타입스크립트 + ESLint가 도와줍니다).
Top-level await — ES2022 #
ES Modules 에서는 함수 밖에서도 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와 chainasync함수는 Promise 반환,await가 결과를 풀어 줌- 의존 없는 비동기는
Promise.all로 병렬 실행 Promise.allSettled/race/any- 루프 안
await주의,await빠뜨림 주의 - ESM에서 top-level await
다음 글(#3 이터레이터와 제너레이터)에서는 for...of가 어떤 약속을 따르는지부터, 자기만의 이터러블을 만들고 제너레이터로 게으른 시퀀스를 표현하는 단계까지 다룹니다.