JavaScript 비동기 프로그래밍: 콜백, Promise, async/await

이 글의 핵심

네트워크 요청이나 타이머처럼 끝나는 시점을 알 수 없는 작업을 기다리면서도 화면을 멈추지 않으려면 비동기 모델을 이해해야 합니다. await를 루프 안에 두어 병렬로 돌 요청을 순차로 만들어 버리는 흔한 실수, catch를 빠뜨린 Promise의 에러 전파를 짚고, 이벤트 루프 순서로 실행 결과를 예측하게 합니다.

들어가며

비동기 프로그래밍이란?

비동기(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초 후)

왜 비동기가 필요한가? 브라우저의 JavaScript는 메인 스레드 하나에서 화면 렌더링과 코드 실행을 함께 처리합니다. API 호출(수백 ms에서 수 초), 디스크 I/O, setTimeout·setInterval 타이머, 클릭·키보드 같은 사용자 입력처럼 언제 끝날지 모르는 작업을 동기로 기다리면 그동안 화면이 멈추므로, 결과가 준비되면 이어서 실행하는 비동기 방식이 필요합니다. Node.js는 런타임마다 다르지만, 전형적으로 단일 스레드 이벤트 루프 위에서 비슷한 패턴의 비동기 I/O를 씁니다. 반면 C++의 std::async는 launch 정책에 따라 OS 스레드에 가까운 실행을 염두에 두므로, “비동기”의 이미지가 JS와는 다를 수 있습니다. Kotlin 코루틴이나 Rust의 async/await는 그 사이에서 런타임이 협력적 스케줄링을 맡는 경우가 많습니다.


콜백과 콜백 지옥

기본 콜백

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)

콜백 지옥의 진짜 문제는 들여쓰기보다 에러 처리와 제어권입니다. 각 단계마다 (err, data)를 검사해야 하고, 하나라도 빠뜨리면 에러가 조용히 사라집니다. 또 콜백을 받는 쪽 코드가 콜백을 두 번 부르거나, 동기적으로 부르거나, 아예 부르지 않아도 호출한 쪽은 막을 방법이 없습니다(이른바 제어의 역전). Promise는 상태가 한 번만 바뀌고 then 콜백은 항상 비동기로 실행된다는 규칙을 언어 차원에서 보장해 이 문제를 줄입니다.


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 발생

Promise.all이 첫 에러로 실패해도 나머지 작업이 취소되지는 않는다는 점을 기억해 두세요. 이미 보낸 요청은 끝까지 진행되고 결과만 버려집니다. 그래서 “하나라도 실패하면 나머지는 의미가 없다”면 all, “성공한 것만이라도 쓰겠다”면 allSettled, “여러 미러 중 가장 빨리 성공한 하나”면 any를 고르는 식으로 실패 시의 의미를 기준으로 선택합니다. 실전 활용 예시:

// 병렬 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)
    ]);
}

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];
}

두 번째 방식은 Promise를 먼저 만들어 요청을 동시에 출발시키고 나중에 하나씩 await하므로 소요 시간은 Promise.all과 같습니다. 다만 함정이 있습니다. p1을 기다리는 동안 p3가 먼저 reject되면, 그 시점에는 p3에 아직 핸들러가 붙어 있지 않아 Node.js에서 UnhandledPromiseRejection 경고(버전에 따라 프로세스 종료)가 날 수 있습니다. 에러가 날 수 있는 작업이라면 Promise.all이나 allSettled로 한 번에 묶는 편이 안전합니다.

제가 코드 리뷰에서 가장 자주 지적하는 것도 이 부분입니다. for 루프 안에 await fetch(...)를 넣어 서로 독립적인 요청 수십 개를 하나씩 보내는 코드는 동작은 정확해서 테스트를 통과하지만, 실서비스에서 응답 시간이 요청 수에 비례해 늘어나고 나서야 문제가 드러납니다. 반대로 수백 개를 Promise.all로 한꺼번에 보내면 상대 서버의 rate limit에 걸리므로, 뒤의 “큐 처리” 패턴처럼 동시 실행 수를 제한하는 것이 현실적인 절충입니다.


try-catch와 Promise 에러 처리

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("계속 실행"));  // 계속 실행

API 호출, 재시도, 타임아웃, 병렬·순차 처리 예제

예제 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));

이 예제는 네트워크 예외(fetch가 reject되는 경우)만 재시도한다는 점에 주의하세요. fetch는 404나 500 응답에서도 reject되지 않고 response.ok === false로 resolve되므로, 위 코드에서 서버가 계속 500을 돌려주면 대기 없이 바로 다음 시도로 넘어가고 루프가 끝나면 undefined를 반환합니다. 실무에서는 !response.ok일 때도 throw해서 같은 백오프 경로를 타게 하고, 재시도해도 의미 없는 4xx(400, 401, 404)는 즉시 실패시키며, 여러 클라이언트가 동시에 재시도하지 않도록 대기 시간에 약간의 무작위 값(jitter)을 더하는 것이 일반적입니다.

예제 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));

Promise.race 방식은 “기다리기를 그만두는 것”이지 요청을 끊는 것이 아닙니다. 타임아웃이 나도 fetch는 백그라운드에서 계속 진행되고 소켓과 메모리를 잡고 있으며, 타이머도 정리되지 않습니다. 최신 브라우저와 Node.js 18+에서는 fetch(url, { signal: AbortSignal.timeout(ms) })로 실제 요청을 중단할 수 있고, 이때 에러의 name은 "TimeoutError"(수동으로 AbortController.abort()를 부르면 "AbortError")이므로 메시지 문자열 대신 error.name으로 구분하는 편이 안정적입니다.

예제 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));
}

이벤트 루프와 Microtask·Macrotask

이벤트 루프란?

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

규칙은 “현재 실행 중인 동기 코드가 끝나면 마이크로태스크 큐를 완전히 비운 뒤 매크로태스크 하나를 꺼낸다”입니다. 그래서 setTimeout(fn, 0)은 0ms 뒤가 아니라 “대기 중인 모든 Promise 콜백이 끝난 다음”에 실행됩니다. 반대로 마이크로태스크가 계속 새 마이크로태스크를 만들면(재귀적인 then이나 queueMicrotask) 매크로태스크와 렌더링이 끝없이 밀려 화면이 멈출 수 있습니다. await 한 번도 마이크로태스크 하나를 거치므로, await 뒤의 코드는 같은 틱의 동기 코드보다 항상 늦게 실행된다고 생각하면 출력 순서를 예측하기 쉽습니다.


로딩 상태, 캐싱, 작업 큐 패턴

패턴 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;
    });
}

await 누락과 forEach 안의 await

실수 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;
    }
}

”✅ 에러 처리” 예시처럼 모든 함수에서 에러를 잡아 null을 반환하는 것이 항상 좋은 것은 아닙니다. 호출한 쪽은 실패와 “데이터 없음”을 구분할 수 없게 되고, 원래 에러의 스택도 사라집니다. 에러를 복구하거나 사용자에게 보여 줄 수 있는 계층(요청 핸들러, UI 이벤트 핸들러)에서 한 번 잡고, 그 아래 함수들은 그대로 던지게 두는 편이 디버깅이 쉽습니다. 어디서도 잡지 않은 rejection은 브라우저에서는 unhandledrejection 이벤트, Node.js에서는 process.on('unhandledRejection')으로 최후에 기록할 수 있습니다.


연습 문제: 변환, 병렬+순차, Promise 래퍼

문제 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. 재시도 로직 구현

다음 단계


관련 글


자주 묻는 질문 (FAQ)

Q. forEach 안에서 await를 쓰면 왜 기다리지 않나요?

A. forEach는 콜백이 반환하는 Promise를 전혀 기다리지 않고 바로 다음 요소로 넘어가므로, 루프 뒤의 코드가 작업이 끝나기 전에 실행됩니다. 순서대로 하나씩 처리해야 하면 for...of 안에서 await를 쓰고, 서로 독립적인 작업이면 await Promise.all(items.map(...))으로 병렬 처리합니다. 요청 수가 많다면 Promise.all로 한꺼번에 보내기보다 동시 실행 수를 제한하는 편이 안전합니다.