JavaScript 비동기 프로그래밍 | Promise, async/await 완벽 정리
이 글의 핵심
JavaScript 비동기 프로그래밍에 대한 실전 가이드입니다. Promise, async/await 완벽 정리 등을 예제와 함께 상세히 설명합니다.
들어가며
비동기 프로그래밍이란?
비동기(Asynchronous)는 네트워크·타이머처럼 끝나는 시점이 불확실한 작업을 기다리느라 다음 줄을 막지 않고, 완료 시 콜백·프로미스·async/await로 결과를 받는 방식입니다.
동기 vs 비동기:
// 동기 (Synchronous): 순차적 실행
console.log("1");
console.log("2");
console.log("3");
// 출력: 1 2 3
// 비동기 (Asynchronous): 기다리지 않음
console.log("1");
setTimeout(() => console.log("2"), 1000);
console.log("3");
// 출력: 1 3 2 (1초 후)
왜 비동기가 필요한가?
- ✅ 네트워크 요청: API 호출 (수백 ms ~ 수 초)
- ✅ 파일 읽기/쓰기: 디스크 I/O
- ✅ 타이머:
setTimeout,setInterval - ✅ 사용자 입력: 클릭, 키보드 이벤트
Node.js는 런타임마다 다르지만, 전형적으로 단일 스레드 이벤트 루프 위에서 비슷한 패턴의 비동기 I/O를 씁니다. 반면 C++의 std::async는 launch 정책에 따라 OS 스레드에 가까운 실행을 염두에 두므로, “비동기”의 이미지가 JS와는 다를 수 있습니다. Kotlin 코루틴이나 Rust의 async/await는 그 사이에서 런타임이 협력적 스케줄링을 맡는 경우가 많습니다.
1. 콜백 (Callback)
기본 콜백
function fetchData(callback) {
setTimeout(() => {
const data = { id: 1, name: "홍길동" };
callback(data);
}, 1000);
}
console.log("시작");
fetchData(data => {
console.log("데이터:", data);
});
console.log("끝");
// 출력:
// 시작
// 끝
// 데이터: { id: 1, name: '홍길동' } (1초 후)
콜백 지옥 (Callback Hell)
// 콜백 지옥: 가독성 나쁨
getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
console.log(details);
});
});
});
// 피라미드 구조 (Pyramid of Doom)
2. Promise
Promise란?
Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다.
3가지 상태:
- Pending: 대기 (초기 상태)
- Fulfilled: 이행 (성공)
- Rejected: 거부 (실패)
Promise 생성
// Promise 생성
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = true;
if (success) {
resolve("성공!"); // 이행
} else {
reject("실패!"); // 거부
}
}, 1000);
});
// Promise 사용
promise
.then(result => {
console.log(result); // 성공!
})
.catch(error => {
console.error(error);
})
.finally(() => {
console.log("완료");
});
Promise 체이닝
Promise 체이닝으로 여러 비동기 작업을 순차적으로 실행할 수 있습니다:
// 비동기 함수들 정의
function fetchUser(userId) {
// Promise 반환
return new Promise((resolve) => {
// 1초 후 사용자 데이터 반환 (API 호출 시뮬레이션)
setTimeout(() => {
resolve({ id: userId, name: "홍길동" });
}, 1000);
});
}
function fetchOrders(userId) {
return new Promise((resolve) => {
// 1초 후 주문 데이터 반환
setTimeout(() => {
resolve([{ id: 1, product: "노트북" }]);
}, 1000);
});
}
function fetchOrderDetails(orderId) {
return new Promise((resolve) => {
// 1초 후 주문 상세 데이터 반환
setTimeout(() => {
resolve({ id: orderId, price: 1200000 });
}, 1000);
});
}
// Promise 체이닝으로 순차 실행
fetchUser(1)
// 1단계: 사용자 조회
.then(user => {
// user: { id: 1, name: "홍길동" }
console.log("사용자:", user);
// 다음 Promise 반환 (중요!)
// return을 해야 다음 .then()으로 전달됨
return fetchOrders(user.id);
})
// 2단계: 주문 조회
.then(orders => {
// orders: [{ id: 1, product: "노트북" }]
console.log("주문:", orders);
// 다음 Promise 반환
return fetchOrderDetails(orders[0].id);
})
// 3단계: 주문 상세 조회
.then(details => {
// details: { id: 1, price: 1200000 }
console.log("상세:", details);
// 마지막 단계는 return 불필요
})
// 에러 처리: 어느 단계에서든 에러 발생 시 catch로 이동
.catch(error => {
console.error("에러:", error);
})
// finally: 성공/실패 관계없이 항상 실행
.finally(() => {
console.log("모든 작업 완료");
});
// 출력 (3초 후):
// 사용자: { id: 1, name: '홍길동' } (1초)
// 주문: [{ id: 1, product: '노트북' }] (2초)
// 상세: { id: 1, price: 1200000 } (3초)
// 모든 작업 완료
체이닝의 핵심 규칙:
// ✅ 올바른 체이닝: Promise 반환
promise
.then(result => {
return anotherPromise(); // Promise 반환
})
.then(result2 => {
// anotherPromise의 결과 받음
});
// ❌ 잘못된 체이닝: return 없음
promise
.then(result => {
anotherPromise(); // return 없음!
})
.then(result2 => {
// result2는 undefined
});
콜백 지옥 vs Promise 체이닝:
// ❌ 콜백 지옥 (Callback Hell)
getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
console.log(details);
});
});
});
// 들여쓰기가 깊어지고 가독성 나쁨
// ✅ Promise 체이닝 (깔끔)
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => console.log(details))
.catch(error => console.error(error));
// 평평한 구조, 가독성 좋음
Promise 정적 메서드
Promise는 여러 비동기 작업을 조합할 수 있는 정적 메서드를 제공합니다:
// 1. Promise.resolve(): 즉시 이행된 Promise 생성
Promise.resolve(42).then(x => console.log(x)); // 42
// 이미 값이 있을 때 Promise로 감싸기
// 동기 값을 비동기 체인에 포함시킬 때 유용
// 2. Promise.reject(): 즉시 거부된 Promise 생성
Promise.reject("에러").catch(e => console.error(e)); // 에러
// 테스트나 에러 시뮬레이션에 사용
// 3. Promise.all(): 모든 Promise가 완료될 때까지 대기
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);
Promise.all([p1, p2, p3])
.then(results => console.log(results)); // [1, 2, 3]
// 배열의 모든 Promise가 성공해야 then 실행
// 결과는 입력 순서와 동일한 배열로 반환
// ❌ 하나라도 실패하면 전체 실패
Promise.all([
Promise.resolve(1),
Promise.reject("에러"), // 이 에러로 인해
Promise.resolve(3)
]).catch(error => console.error(error)); // 에러
// 첫 번째 에러만 catch로 전달됨
// 나머지 Promise는 무시됨
// 4. Promise.allSettled(): 모두 완료 대기 (실패 무시)
Promise.allSettled([
Promise.resolve(1),
Promise.reject("에러"),
Promise.resolve(3)
]).then(results => console.log(results));
// [
// { status: 'fulfilled', value: 1 }, // 성공
// { status: 'rejected', reason: '에러' }, // 실패
// { status: 'fulfilled', value: 3 } // 성공
// ]
// 각 Promise의 성공/실패 여부와 결과를 모두 반환
// 실패해도 전체가 실패하지 않음 (all과의 차이)
// 5. Promise.race(): 가장 먼저 완료되는 것만 반환
Promise.race([
new Promise(resolve => setTimeout(() => resolve(1), 1000)),
new Promise(resolve => setTimeout(() => resolve(2), 500)), // 가장 빠름
new Promise(resolve => setTimeout(() => resolve(3), 1500))
]).then(result => console.log(result)); // 2 (500ms 후)
// 타임아웃 구현에 유용:
// Promise.race([fetchData(), timeout(5000)])
// 6. Promise.any(): 가장 먼저 성공하는 것 반환 (ES2021+)
Promise.any([
Promise.reject("에러1"), // 실패 무시
new Promise(resolve => setTimeout(() => resolve(2), 500)), // 첫 성공
new Promise(resolve => setTimeout(() => resolve(3), 1000))
]).then(result => console.log(result)); // 2
// race와 달리 실패는 무시하고 첫 성공만 반환
// 모두 실패하면 AggregateError 발생
실전 활용 예시:
// 병렬 API 호출 (Promise.all)
async function loadDashboard() {
try {
// 3개 API를 동시에 호출 (병렬)
const [user, orders, notifications] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchNotifications()
]);
// 모두 완료되면 한 번에 결과 받음
renderDashboard(user, orders, notifications);
} catch (error) {
console.error("대시보드 로딩 실패:", error);
}
}
// 타임아웃 구현 (Promise.race)
function timeout(ms) {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error("시간 초과")), ms)
);
}
async function fetchWithTimeout(url, ms = 5000) {
return Promise.race([
fetch(url),
timeout(ms)
]);
}
3. async/await
async/await 기본
// async 함수는 항상 Promise 반환
async function fetchData() {
return "데이터";
}
fetchData().then(data => console.log(data)); // 데이터
// await: Promise가 완료될 때까지 대기
async function getData() {
const data = await fetchData();
console.log(data); // 데이터
}
getData();
async/await 실전
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchUser(userId) {
await delay(1000);
return { id: userId, name: "홍길동" };
}
async function fetchOrders(userId) {
await delay(1000);
return [{ id: 1, product: "노트북" }];
}
async function fetchOrderDetails(orderId) {
await delay(1000);
return { id: orderId, price: 1200000 };
}
// async/await로 순차 실행
async function main() {
try {
console.log("시작");
const user = await fetchUser(1);
console.log("사용자:", user);
const orders = await fetchOrders(user.id);
console.log("주문:", orders);
const details = await fetchOrderDetails(orders[0].id);
console.log("상세:", details);
console.log("완료");
} catch (error) {
console.error("에러:", error);
}
}
main();
// 출력 (3초 후):
// 시작
// 사용자: { id: 1, name: '홍길동' }
// 주문: [{ id: 1, product: '노트북' }]
// 상세: { id: 1, price: 1200000 }
// 완료
병렬 실행
// 순차 실행 (느림)
async function sequential() {
const user1 = await fetchUser(1); // 1초
const user2 = await fetchUser(2); // 1초
const user3 = await fetchUser(3); // 1초
return [user1, user2, user3]; // 총 3초
}
// 병렬 실행 (빠름)
async function parallel() {
const [user1, user2, user3] = await Promise.all([
fetchUser(1),
fetchUser(2),
fetchUser(3)
]);
return [user1, user2, user3]; // 총 1초
}
// 또는
async function parallel() {
const p1 = fetchUser(1);
const p2 = fetchUser(2);
const p3 = fetchUser(3);
const user1 = await p1;
const user2 = await p2;
const user3 = await p3;
return [user1, user2, user3];
}
4. 에러 처리
try-catch
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("에러 발생:", error.message);
return null;
} finally {
console.log("요청 완료");
}
}
Promise 에러 처리
// then/catch
fetch("https://api.example.com/data")
.then(response => {
if (!response.ok) {
throw new Error("HTTP error");
}
return response.json();
})
.then(data => console.log(data))
.catch(error => console.error(error))
.finally(() => console.log("완료"));
// catch는 체인 어디서든 발생한 에러 처리
Promise.resolve(1)
.then(x => {
throw new Error("에러!");
return x + 1;
})
.then(x => x * 2)
.catch(error => console.error(error.message)) // 에러!
.then(() => console.log("계속 실행")); // 계속 실행
5. 실전 예제
예제 1: API 호출
async function fetchGitHubUser(username) {
try {
const response = await fetch(`https://api.github.com/users/${username}`);
if (!response.ok) {
throw new Error(`User not found: ${response.status}`);
}
const user = await response.json();
return {
name: user.name,
bio: user.bio,
repos: user.public_repos
};
} catch (error) {
console.error("에러:", error.message);
return null;
}
}
// 사용
fetchGitHubUser("torvalds").then(user => {
if (user) {
console.log(user);
}
});
예제 2: 재시도 로직
async function fetchWithRetry(url, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
} catch (error) {
console.log(`시도 ${i + 1} 실패`);
if (i === maxRetries - 1) {
throw new Error("최대 재시도 횟수 초과");
}
// 지수 백오프
await new Promise(resolve =>
setTimeout(resolve, 1000 * Math.pow(2, i))
);
}
}
}
// 사용
fetchWithRetry("https://api.example.com/data")
.then(data => console.log(data))
.catch(error => console.error(error));
예제 3: 타임아웃
function timeout(ms) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("Timeout")), ms);
});
}
async function fetchWithTimeout(url, ms = 5000) {
try {
const response = await Promise.race([
fetch(url),
timeout(ms)
]);
return await response.json();
} catch (error) {
if (error.message === "Timeout") {
console.error("요청 시간 초과");
}
throw error;
}
}
// 사용
fetchWithTimeout("https://api.example.com/data", 3000)
.then(data => console.log(data))
.catch(error => console.error(error));
예제 4: 병렬 처리
async function fetchMultipleUsers(userIds) {
const promises = userIds.map(id => fetchUser(id));
try {
const users = await Promise.all(promises);
return users;
} catch (error) {
console.error("사용자 조회 실패:", error);
return [];
}
}
// 사용
fetchMultipleUsers([1, 2, 3]).then(users => {
console.log(users);
});
// allSettled로 부분 실패 허용
async function fetchMultipleUsersSettled(userIds) {
const promises = userIds.map(id => fetchUser(id));
const results = await Promise.allSettled(promises);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
}
예제 5: 순차 처리
// 순차 처리: 이전 결과가 다음에 필요
async function processSequentially(items) {
let result = 0;
for (let item of items) {
result = await processItem(item, result);
}
return result;
}
// reduce로 순차 처리
async function processSequentially(items) {
return items.reduce(async (accPromise, item) => {
const acc = await accPromise;
return await processItem(item, acc);
}, Promise.resolve(0));
}
6. 이벤트 루프 (Event Loop)
이벤트 루프란?
JavaScript는 싱글 스레드지만, 이벤트 루프로 비동기를 처리합니다.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// 출력: 1 4 3 2
실행 순서:
- 동기 코드:
1,4 - Microtask Queue (Promise):
3 - Macrotask Queue (setTimeout):
2
Microtask vs Macrotask
// Macrotask: setTimeout, setInterval
setTimeout(() => console.log("Macrotask"), 0);
// Microtask: Promise, queueMicrotask
Promise.resolve().then(() => console.log("Microtask"));
console.log("Sync");
// 출력:
// Sync
// Microtask (먼저!)
// Macrotask
7. 실전 패턴
패턴 1: 로딩 상태 관리
class DataFetcher {
constructor() {
this.loading = false;
this.data = null;
this.error = null;
}
async fetch(url) {
this.loading = true;
this.error = null;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
this.data = await response.json();
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
return this.data;
}
}
// 사용
const fetcher = new DataFetcher();
fetcher.fetch("https://api.example.com/data");
// React 스타일
async function loadData() {
setLoading(true);
try {
const data = await fetchData();
setData(data);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
}
패턴 2: 캐싱
class CachedFetcher {
constructor() {
this.cache = new Map();
}
async fetch(url) {
if (this.cache.has(url)) {
console.log("캐시에서 반환");
return this.cache.get(url);
}
console.log("네트워크 요청");
const response = await fetch(url);
const data = await response.json();
this.cache.set(url, data);
return data;
}
}
// 사용
const fetcher = new CachedFetcher();
await fetcher.fetch("https://api.example.com/data"); // 네트워크 요청
await fetcher.fetch("https://api.example.com/data"); // 캐시에서 반환
패턴 3: 큐 처리
class TaskQueue {
constructor(concurrency = 1) {
this.concurrency = concurrency;
this.running = 0;
this.queue = [];
}
async add(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.process();
});
}
async process() {
if (this.running >= this.concurrency || this.queue.length === 0) {
return;
}
this.running++;
const { task, resolve, reject } = this.queue.shift();
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.process();
}
}
}
// 사용
const queue = new TaskQueue(2); // 동시 실행 2개
for (let i = 0; i < 10; i++) {
queue.add(async () => {
console.log(`작업 ${i} 시작`);
await delay(1000);
console.log(`작업 ${i} 완료`);
return i;
});
}
8. 자주 하는 실수와 해결법
실수 1: await 없이 Promise 사용
// ❌ 잘못된 방법
async function getData() {
const data = fetchData(); // await 누락!
console.log(data); // Promise { <pending> }
}
// ✅ 올바른 방법
async function getData() {
const data = await fetchData();
console.log(data); // 실제 데이터
}
실수 2: 순차 vs 병렬
// ❌ 불필요한 순차 실행 (느림)
async function slow() {
const user1 = await fetchUser(1); // 1초
const user2 = await fetchUser(2); // 1초
return [user1, user2]; // 총 2초
}
// ✅ 병렬 실행 (빠름)
async function fast() {
const [user1, user2] = await Promise.all([
fetchUser(1),
fetchUser(2)
]);
return [user1, user2]; // 총 1초
}
실수 3: forEach에서 await
// ❌ forEach는 async 무시
async function processItems(items) {
items.forEach(async (item) => {
await processItem(item); // 기다리지 않음!
});
console.log("완료"); // 즉시 출력됨
}
// ✅ for...of 사용
async function processItems(items) {
for (let item of items) {
await processItem(item); // 순차 대기
}
console.log("완료"); // 모두 완료 후 출력
}
// ✅ Promise.all로 병렬
async function processItems(items) {
await Promise.all(items.map(item => processItem(item)));
console.log("완료");
}
실수 4: try-catch 누락
// ❌ 에러 처리 없음
async function getData() {
const data = await fetchData(); // 실패 시 프로그램 중단
return data;
}
// ✅ 에러 처리
async function getData() {
try {
const data = await fetchData();
return data;
} catch (error) {
console.error("에러:", error);
return null;
}
}
9. 연습 문제
문제 1: Promise 체이닝을 async/await로 변환
// Promise 체이닝
function getDataPromise() {
return fetchUser(1)
.then(user => fetchOrders(user.id))
.then(orders => fetchOrderDetails(orders[0].id))
.catch(error => console.error(error));
}
// async/await 변환
async function getDataAsync() {
try {
const user = await fetchUser(1);
const orders = await fetchOrders(user.id);
const details = await fetchOrderDetails(orders[0].id);
return details;
} catch (error) {
console.error(error);
}
}
문제 2: 병렬 + 순차 조합
여러 사용자를 병렬로 가져온 후, 각 사용자의 주문을 순차적으로 처리하세요.
async function processUsers(userIds) {
// 1단계: 사용자 병렬 조회
const users = await Promise.all(
userIds.map(id => fetchUser(id))
);
// 2단계: 각 사용자의 주문 순차 처리
const results = [];
for (let user of users) {
const orders = await fetchOrders(user.id);
results.push({ user, orders });
}
return results;
}
// 테스트
processUsers([1, 2, 3]).then(results => {
console.log(results);
});
문제 3: 프로미스 래퍼
콜백 기반 함수를 Promise로 변환하세요.
// 콜백 기반
function readFileCallback(filename, callback) {
setTimeout(() => {
callback(null, `${filename}의 내용`);
}, 1000);
}
// Promise 래퍼
function readFilePromise(filename) {
return new Promise((resolve, reject) => {
readFileCallback(filename, (error, data) => {
if (error) {
reject(error);
} else {
resolve(data);
}
});
});
}
// 사용
readFilePromise("test.txt")
.then(data => console.log(data))
.catch(error => console.error(error));
// async/await
async function main() {
try {
const data = await readFilePromise("test.txt");
console.log(data);
} catch (error) {
console.error(error);
}
}
정리
핵심 요약
-
비동기 처리 방법:
- 콜백: 가장 기본, 콜백 지옥 문제
- Promise: 체이닝, 에러 처리 개선
- async/await: 동기 코드처럼 작성 (가장 권장)
-
Promise 메서드:
Promise.all(): 모두 완료 대기Promise.allSettled(): 모두 완료 대기 (실패 무시)Promise.race(): 가장 먼저 완료Promise.any(): 가장 먼저 성공
-
에러 처리:
- Promise:
.catch() - async/await:
try-catch
- Promise:
-
성능:
- 순차:
await순서대로 - 병렬:
Promise.all()
- 순차:
-
이벤트 루프:
- Microtask (Promise) > Macrotask (setTimeout)
베스트 프랙티스
- ✅
async/await우선 사용 - ✅ 병렬 처리 가능하면
Promise.all() - ✅ 항상 에러 처리 (
try-catch) - ✅ 타임아웃 설정
- ✅ 재시도 로직 구현
다음 단계
- JavaScript DOM 조작
- JavaScript 클래스
- JavaScript 모듈
관련 글
- Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await
- C++ future와 promise |
- Swift 비동기 프로그래밍 | async/await, Task
- C++ 코루틴 |
- JavaScript 함수 | 함수 선언, 화살표 함수, 콜백, 클로저 완벽 정리