JavaScript 비동기 프로그래밍 | Promise, async/await 완벽 정리

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::asynclaunch 정책에 따라 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. 동기 코드: 1, 4
  2. Microtask Queue (Promise): 3
  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);
    }
}

정리

핵심 요약

  1. 비동기 처리 방법:

    • 콜백: 가장 기본, 콜백 지옥 문제
    • Promise: 체이닝, 에러 처리 개선
    • async/await: 동기 코드처럼 작성 (가장 권장)
  2. Promise 메서드:

    • Promise.all(): 모두 완료 대기
    • Promise.allSettled(): 모두 완료 대기 (실패 무시)
    • Promise.race(): 가장 먼저 완료
    • Promise.any(): 가장 먼저 성공
  3. 에러 처리:

    • Promise: .catch()
    • async/await: try-catch
  4. 성능:

    • 순차: await 순서대로
    • 병렬: Promise.all()
  5. 이벤트 루프:

    • Microtask (Promise) > Macrotask (setTimeout)

베스트 프랙티스

  1. async/await 우선 사용
  2. ✅ 병렬 처리 가능하면 Promise.all()
  3. ✅ 항상 에러 처리 (try-catch)
  4. ✅ 타임아웃 설정
  5. ✅ 재시도 로직 구현

다음 단계

  • JavaScript DOM 조작
  • JavaScript 클래스
  • JavaScript 모듈

관련 글

  • Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await
  • C++ future와 promise |
  • Swift 비동기 프로그래밍 | async/await, Task
  • C++ 코루틴 |
  • JavaScript 함수 | 함수 선언, 화살표 함수, 콜백, 클로저 완벽 정리