티스토리 뷰

반응형

자바스크립트 개발을 시작하는 분들이라면 "비동기(Asynchronous)"라는 단어를 한 번쯤 들어보셨을 겁니다. 때로는 이해하기 어렵고 예측 불가능한 동작으로 혼란을 주기도 하지만, 비동기 처리는 현대 웹 애플리케이션 개발에 있어 선택이 아닌 필수적인 요소입니다. 사용자의 요청을 기다리면서도 화면이 멈추지 않고, 여러 작업을 동시에 처리하는 듯한 경험을 제공하는 마법이 바로 이 비동기 처리에서 나옵니다.

이 가이드는 자바스크립트가 비동기 코드를 어떻게 다루는지, 그리고 콜백(Callback)에서 프라미스(Promise), 최종적으로 Async/Await에 이르기까지 비동기 처리 방식이 어떻게 발전해왔는지 심층적으로 분석합니다. 각 방식의 장단점을 명확히 이해하고, 실제 코드 예시를 통해 직접 비동기 코드를 작성하고 문제 해결하는 능력을 키울 수 있도록 돕겠습니다. 이 글을 통해 자바스크립트 비동기 처리의 핵심 개념을 확실히 잡고, 더 나아가 현업에서 활용할 수 있는 베스트 프랙티스까지 습득하여 여러분의 개발 역량을 한 단계 높이시길 바랍니다.


자바스크립트 비동기: 왜 필요하고 어떻게 동작할까? (싱글 스레드와 이벤트 루프)

웹 페이지에서 사용자가 버튼을 클릭하거나, 서버에서 데이터를 가져오는 동안 화면이 멈춰버린다면 어떨까요? 사용자 경험은 크게 저하될 것이고, 이는 웹 애플리케이션의 실패로 이어질 수 있습니다. 이러한 문제를 해결하기 위해 비동기 처리가 필수적입니다. 자바스크립트가 왜 비동기 처리를 필요로 하는지 이해하기 위해서는 먼저 자바스크립트의 기본적인 동작 방식을 알아야 합니다.

자바스크립트의 싱글 스레드(Single Thread) 특성

자바스크립트는 기본적으로 싱글 스레드(Single Thread) 언어입니다. 여기서 스레드(Thread)는 프로그램 내에서 실제 작업을 수행하는 일꾼이라고 생각할 수 있습니다. 싱글 스레드라는 것은 자바스크립트가 한 번에 하나의 작업만 처리할 수 있다는 의미입니다. 비유하자면, 한 명의 요리사가 모든 주문을 순서대로 처리하는 레스토랑과 같습니다. 이 요리사는 한 번에 하나의 요리만 만들 수 있으며, 다른 요리가 완성되기 전까지 다음 요리를 시작할 수 없습니다.

console.log("첫 번째 작업"); // 1번
alert("두 번째 작업 (시간이 오래 걸리는 작업)"); // 2번: alert 창을 닫기 전까지 다음 코드 실행 안 됨
console.log("세 번째 작업"); // 3번

위 코드에서 alert() 함수는 사용자가 확인 버튼을 누를 때까지 스크립트 실행을 완전히 멈춰버립니다. 이러한 현상을 블로킹(Blocking)이라고 합니다. 만약 웹 환경에서 서버로부터 대용량 데이터를 불러오는 작업(네트워크 요청)이나 복잡한 이미지 처리 같은 시간이 오래 걸리는 작업이 블로킹 방식으로 처리된다면, 사용자는 해당 작업이 끝날 때까지 페이지의 다른 요소들과 상호작용할 수 없게 됩니다. 이는 치명적인 사용자 경험 저하를 야기합니다.

블로킹(Blocking)과 비동기(Asynchronous)의 차이

  • 블로킹(Blocking) 방식: 어떤 작업이 완료될 때까지 다음 작업을 시작하지 않고 기다리는 방식입니다. 동기(Synchronous) 방식과 유사하며, 작업의 순서가 명확하게 정해져 있고 순차적으로 진행됩니다. 직관적이라는 장점이 있지만, 시간이 오래 걸리는 작업이 전체 시스템을 멈춰버릴 수 있습니다.
  • 비동기(Asynchronous) 방식: 어떤 작업이 시작되면, 그 작업이 완료되기를 기다리지 않고 즉시 다음 작업을 시작하는 방식입니다. 오래 걸리는 작업은 "나중에 완료되면 알려줘"라고 요청한 뒤, 일단 다른 작업을 먼저 처리합니다. 비유하자면, 요리사(메인 스레드)가 오래 걸리는 찜 요리(네트워크 요청)를 오븐에 넣어두고(Web API에 위임), 그 찜 요리가 완성될 때까지 기다리지 않고 다른 간단한 샐러드나 음료(다른 자바스크립트 코드)를 만드는 것과 같습니다. 찜 요리가 완성되면 "다 됐어요!" 하고 요리사에게 알려주는 것이죠.

웹 애플리케이션에서의 비동기 처리의 중요성

웹 애플리케이션 환경, 특히 브라우저 환경에서 자바스크립트는 사용자 인터페이스(UI)를 조작하고, 이벤트를 처리하며, 네트워크 요청을 보냅니다. 이러한 작업들은 종종 예상치 못한 시간을 소요할 수 있습니다.

  • 네트워크 요청: 서버에서 데이터를 가져오는 fetch API나 XMLHttpRequest는 네트워크 상황에 따라 응답 시간이 크게 달라질 수 있습니다.
  • 파일 입출력: 파일을 읽거나 쓰는 작업도 마찬가지입니다.
  • 타이머: setTimeout, setInterval과 같이 특정 시간 후에 코드를 실행하는 기능도 비동기적으로 작동합니다.
  • 사용자 이벤트: 클릭, 키보드 입력 등 사용자 이벤트는 언제 발생할지 예측할 수 없습니다.

이러한 작업들을 동기적으로 처리한다면, 웹 페이지는 응답 없는 상태가 되어 "먹통"이 될 것입니다. 비동기 처리는 이러한 블로킹을 방지하여, 자바스크립트 메인 스레드가 항상 사용자 입력에 반응하고 UI를 부드럽게 업데이트할 수 있도록 합니다.

자바스크립트 엔진 자체는 싱글 스레드이지만, 브라우저(또는 Node.js 런타임 환경)가 제공하는 Web API(또는 Node.js API)와 이벤트 루프(Event Loop)라는 메커니즘을 통해 비동기 처리가 가능해집니다. setTimeout, fetch 같은 비동기 함수들은 Web API 영역에서 실행되며, 작업이 완료되면 태스크 큐(Task Queue)에 콜백 함수를 등록합니다. 그리고 자바스크립트 엔진의 호출 스택(Call Stack)이 비어있을 때(즉, 현재 실행 중인 동기 작업이 없을 때), 이벤트 루프가 태스크 큐에서 콜백 함수를 꺼내와 호출 스택으로 옮겨 실행합니다.

이렇게 복잡하게 들릴 수 있지만, 핵심은 자바스크립트가 한 번에 하나의 작업만 처리하면서도, 시간이 오래 걸리는 작업은 다른 곳에 위임하여 "나중에 처리"함으로써 사용자 경험을 해치지 않고 반응성을 유지한다는 것입니다. 이제부터 이 비동기 처리를 구현하는 세 가지 핵심 방식인 콜백, 프라미스, Async/Await에 대해 자세히 살펴보겠습니다.


콜백(Callback) 함수: 비동기 처리의 시작과 "콜백 헬"의 이해

자바스크립트에서 비동기 처리를 구현하는 가장 기본적인 방법은 바로 콜백(Callback) 함수를 사용하는 것입니다. 콜백은 비동기 처리의 근간을 이루며, 다른 더 발전된 비동기 처리 방식들도 궁극적으로는 콜백의 개념 위에 구축되어 있습니다. 하지만 콜백 함수만을 이용한 복잡한 비동기 로직은 개발자에게 큰 어려움을 안겨주기도 하는데, 이를 콜백 헬(Callback Hell)이라고 부릅니다.

콜백 함수의 정의와 동작 원리

콜백 함수는 "나중에 호출될 함수(A function that is called back later)"라는 의미를 가집니다. 다른 함수의 인자로 전달되어, 특정 이벤트가 발생하거나 특정 시점에 도달했을 때 그 함수 내부에서 호출되는 함수를 말합니다. 비유하자면, 친구에게 "이 심부름(함수)이 끝나면 나에게 전화(콜백)해 줘"라고 말하는 것과 같습니다. 친구는 심부름을 마치면 약속한 대로 전화를 걸어 결과를 알려줄 것입니다.

자바스크립트에서 콜백 함수가 가장 흔하게 사용되는 곳은 비동기 작업입니다. 예를 들어, setTimeout 함수는 특정 시간 후에 코드를 실행하도록 예약하는 비동기 Web API입니다.

// 콜백 함수를 사용한 간단한 비동기 처리 예시
console.log("작업 시작!");

setTimeout(function() { // 익명 함수가 콜백 함수로 전달됨
    console.log("2초 후 이 메시지가 출력됩니다.");
}, 2000); // 2000 밀리초 = 2초

console.log("다음 작업은 바로 실행됩니다.");

// 예상 출력 순서:
// 작업 시작!
// 다음 작업은 바로 실행됩니다.
// (2초 후) 2초 후 이 메시지가 출력됩니다.

위 코드에서 setTimeout 함수는 두 번째 인자인 2000ms(2초) 후에 첫 번째 인자로 전달된 익명 함수(콜백 함수)를 실행하도록 스케줄링합니다. 중요한 점은 setTimeout이 콜백 함수를 스케줄링한 직후, 즉시 다음 코드인 console.log("다음 작업은 바로 실행됩니다.");를 실행한다는 것입니다. 2초를 기다리지 않습니다. 2초가 지나면, 이벤트 루프가 콜백 함수를 실행 스택으로 가져와 실행하게 됩니다.

비동기 코드에서 콜백의 활용

콜백 함수는 setTimeout 외에도 웹 개발에서 다양한 비동기 상황에 활용됩니다.

  • 이벤트 핸들러: 사용자가 버튼을 클릭하거나 마우스를 움직일 때 실행될 함수를 콜백으로 등록합니다.
  • document.getElementById('myButton').addEventListener('click', function() { console.log("버튼이 클릭되었습니다!"); // 클릭 시 실행될 콜백 함수 });
  • 데이터 요청: 서버에서 데이터를 가져온 후 실행될 함수를 콜백으로 전달합니다.
  • function fetchData(url, callback) { // 실제로는 XMLHttpRequest 또는 fetch API를 사용합니다. // 여기서는 가상으로 데이터를 가져오는 척 합니다. setTimeout(() => { const data = { id: 1, name: "Sample Data" }; console.log(`URL: ${url} 에서 데이터 로드 완료`); callback(data); // 데이터 로드 후 콜백 실행 }, 1500); } fetchData('/api/data', function(result) { console.log("가져온 데이터:", result); });

이처럼 콜백 함수는 비동기 작업의 결과를 받아 다음 로직을 처리하는 데 매우 유용합니다.

"콜백 헬(Callback Hell)"이란?

콜백 함수는 간단한 비동기 작업에 효과적이지만, 여러 비동기 작업이 서로 의존하며 순차적으로 실행되어야 할 때 문제가 발생합니다. 하나의 콜백 함수가 또 다른 비동기 작업을 시작하고, 그 작업의 결과로 인해 또 다른 콜백 함수가 호출되는 식으로 코드가 깊이 중첩되는 현상콜백 헬(Callback Hell) 또는 피라미드 오브 둠(Pyramid of Doom)이라고 부릅니다.

콜백 헬 예시: 사용자 정보 조회 시나리오

가상의 API를 통해 다음과 같은 비동기 작업을 순차적으로 수행해야 한다고 가정해 봅시다.

  1. getUser(id): 사용자 ID로 사용자 정보를 가져옵니다.
  2. getOrders(userId): 사용자 ID로 해당 사용자의 주문 내역을 가져옵니다.
  3. getProductDetails(orderId): 특정 주문에 포함된 상품의 상세 정보를 가져옵니다.

각 함수가 비동기적으로 작동하며 콜백을 인자로 받는다고 가정하면 코드는 다음과 같을 것입니다.

// 가상의 API 호출 함수 (콜백 기반)
function getUser(id, callback) {
    console.log(`[콜백] 사용자 ${id} 정보 조회 시작...`);
    setTimeout(() => {
        const user = { id: id, name: "Alice" };
        console.log(`[콜백] 사용자 ${id} 정보 조회 완료: ${user.name}`);
        callback(user);
    }, 1000);
}

function getOrders(userId, callback) {
    console.log(`[콜백] 사용자 ${userId}의 주문 내역 조회 시작...`);
    setTimeout(() => {
        const orders = ["Order_A123", "Order_B456"];
        console.log(`[콜백] 사용자 ${userId}의 주문 내역 조회 완료: ${orders.length}개`);
        callback(orders);
    }, 1500);
}

function getProductDetails(orderId, callback) {
    console.log(`[콜백] 주문 ${orderId}의 상품 상세 조회 시작...`);
    setTimeout(() => {
        const product = { orderId: orderId, item: "Gaming Laptop", price: 1500 };
        console.log(`[콜백] 주문 ${orderId}의 상품 상세 조회 완료: ${product.item}`);
        callback(product);
    }, 800);
}

// 콜백 헬 발생 지점
console.log("--- 콜백 헬 시나리오 시작 ---");
getUser(1, user => {
    console.log("1. 사용자 정보:", user);
    getOrders(user.id, orders => {
        console.log("2. 주문 내역:", orders);
        getProductDetails(orders[0], product => {
            console.log("3. 첫 번째 주문 상품 상세:", product);
            // 여기에 또 다른 비동기 작업이 있다면...
            // getShippingInfo(product.orderId, shippingInfo => {
            //     console.log("4. 배송 정보:", shippingInfo);
            //     // ... 무한 반복
            // });
            console.log("--- 모든 콜백 헬 작업 완료! ---");
        });
    });
});
console.log("--- 콜백 헬 시나리오 스케줄링 완료 ---");

위 코드를 실행하면 다음과 같은 문제에 직면하게 됩니다.

  • 가독성 저하: 코드가 오른쪽으로 계속 들여쓰기(indentation)되면서 전체적인 코드의 흐름을 한눈에 파악하기 어렵습니다. 마치 깊은 숲 속으로 들어가는 것과 같습니다.
  • 에러 처리의 복잡성: 각 비동기 작업마다 독립적으로 에러 처리를 해주어야 합니다. 만약 getOrders에서 에러가 발생하면, 그 에러를 getProductDetails 콜백 외부에서 처리하는 것이 쉽지 않습니다. 모든 콜백에 if (error) { return; }과 같은 로직을 추가해야 하므로 코드가 더욱 지저분해집니다.
  • 유지보수 어려움: 중간에 로직이 변경되거나 새로운 비동기 작업이 추가되면 기존의 중첩된 구조를 해치지 않으면서 코드를 수정하는 것이 매우 어렵습니다.
  • 디버깅의 어려움: 에러가 발생했을 때, 어느 콜백에서 어떤 에러가 발생했는지 추적하기가 어렵습니다.

콜백 함수는 비동기 처리의 기본을 제공하지만, 위와 같은 단점들 때문에 복잡한 시나리오에서는 적합하지 않습니다. 이러한 콜백 헬의 문제점을 해결하기 위해 등장한 것이 바로 다음 섹션에서 다룰 프라미스(Promise)입니다.


프라미스(Promise): 콜백 헬에서 벗어나기 위한 구원투수

콜백 헬의 복잡성과 가독성 저하 문제에 직면하면서, 자바스크립트 개발자들은 더 나은 비동기 처리 방식을 갈망했습니다. 그 해답으로 ES6(ECMAScript 2015)에서 공식적으로 도입된 것이 바로 프라미스(Promise)입니다. 프라미스는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체로, 콜백 헬에서 벗어나 코드를 더 읽기 쉽고 관리하기 좋게 만들어주는 "구원투수" 역할을 합니다.

프라미스의 등장 배경

프라미스는 "미래의 어떤 시점에 값을 반환하거나 오류를 발생시킬 비동기 작업의 최종 완료 또는 실패를 나타내는 객체"로 정의됩니다. 쉽게 말해, 프라미스는 비동기 작업의 "미래의 결과"를 나타내는 약속장과 같습니다.

비유하자면, 식당에서 음식을 주문(비동기 작업 시작)하면, 웨이터가 "주문 접수 완료! (Promise 객체 반환)"라고 말하며 하나의 약속장(Promise)을 줍니다. 이 약속장에는 다음과 같은 상태가 표시될 수 있습니다.

  • 음식 준비 중 (pending): 아직 요리가 완료되지 않았습니다.
  • 음식 나옴 (fulfilled/resolved): 요리가 성공적으로 완료되어 먹을 수 있습니다.
  • 주문 실패 (rejected): 재료 소진 등의 이유로 요리를 만들 수 없습니다.

이 약속장을 통해 우리는 음식이 나올 때까지 무작정 기다리는 대신, 약속장(프라미스)의 상태를 보고 적절한 후속 조치를 취할 수 있습니다.

프라미스의 3가지 상태

프라미스 객체는 비동기 작업의 진행 상황을 나타내기 위해 세 가지 상태 중 하나를 가집니다.

  1. pending (대기): 비동기 작업이 아직 완료되지 않은 초기 상태. 주문 접수 후 요리 중인 상태입니다.
  2. fulfilled (이행/성공): 비동기 작업이 성공적으로 완료된 상태. 프라미스가 결과를 반환합니다. 요리가 완성되어 손님에게 나간 상태입니다. resolve 함수를 호출하여 이 상태로 전환됩니다.
  3. rejected (거부/실패): 비동기 작업이 실패한 상태. 프라미스가 에러를 반환합니다. 요리를 만들 수 없어 주문이 취소된 상태입니다. reject 함수를 호출하여 이 상태로 전환됩니다.

중요한 점은 프라미스의 상태는 한 번 변경되면 다시 변경될 수 없다는 것입니다. 즉, pending에서 fulfilled 또는 rejected로 한 번만 전환될 수 있습니다.

new Promise()를 이용한 프라미스 생성

프라미스는 new Promise() 생성자를 통해 생성됩니다. 이 생성자는 (resolve, reject) 두 개의 인자를 받는 실행자(executor) 함수를 인자로 받습니다.

  • resolve: 비동기 작업이 성공했을 때 호출하며, 프라미스를 fulfilled 상태로 만듭니다. 성공 결과를 인자로 전달합니다.
  • reject: 비동기 작업이 실패했을 때 호출하며, 프라미스를 rejected 상태로 만듭니다. 에러 객체를 인자로 전달합니다.
// 프라미스 생성 예시
function delayedOperation(shouldSucceed) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (shouldSucceed) {
                console.log("[Promise] 비동기 작업 성공!");
                resolve("성공적으로 데이터를 처리했습니다."); // 성공 시 resolve 호출
            } else {
                console.error("[Promise] 비동기 작업 실패!");
                reject(new Error("작업 중 오류 발생!")); // 실패 시 reject 호출
            }
        }, 1500);
    });
}

// 성공하는 경우
delayedOperation(true)
    .then(message => { // 성공(fulfilled) 시 실행될 콜백
        console.log("결과:", message);
    })
    .catch(error => { // 실패(rejected) 시 실행될 콜백
        console.error("오류:", error.message);
    })
    .finally(() => { // 성공/실패 무관하게 항상 실행
        console.log("작업 종료 (finally 블록)");
    });

// 실패하는 경우
delayedOperation(false)
    .then(message => {
        console.log("결과:", message);
    })
    .catch(error => {
        console.error("오류:", error.message); // 이 부분이 실행됩니다.
    })
    .finally(() => {
        console.log("작업 종료 (finally 블록)");
    });

.then(), .catch(), .finally() 메서드를 활용한 체이닝

프라미스의 강력한 기능 중 하나는 바로 .then(), .catch(), .finally() 메서드를 이용한 체이닝(Chaining)입니다. 이를 통해 여러 비동기 작업을 순차적으로 연결하고, 콜백 헬 없이 깔끔하게 코드를 작성할 수 있습니다.

  • .then(onFulfilled, onRejected): 프라미스가 fulfilled 상태가 되면 onFulfilled 함수가 실행되고, rejected 상태가 되면 onRejected 함수가 실행됩니다. 일반적으로 에러 처리를 위해 onRejected 대신 별도의 .catch()를 사용하는 것이 권장됩니다. .then() 메서드는 항상 새로운 프라미스를 반환하므로, 이를 통해 체이닝이 가능합니다. onFulfilled 콜백에서 반환하는 값은 다음 .then()의 인자로 전달됩니다.
  • .catch(onRejected): 프라미스가 rejected 상태가 되면 실행됩니다. 체인 중간에 발생한 모든 에러를 한 곳에서 처리할 수 있게 해줍니다.
  • .finally(onFinally): 프라미스가 fulfilled 또는 rejected 상태가 되면(즉, 비동기 작업이 완료되면) 항상 실행됩니다. 주로 로딩 스피너 제거, 리소스 해제 등 마지막 정리 작업에 사용됩니다.

프라미스 체이닝으로 콜백 헬 해결하기

콜백 헬 예시에서 사용했던 사용자 정보 조회 시나리오를 프라미스 기반으로 다시 작성해 봅시다. 각 가상 API 함수를 프라미스를 반환하도록 수정합니다.

// 가상의 API 호출 함수 (프라미스 기반)
function getUserPromise(id) {
    console.log(`[Promise] 사용자 ${id} 정보 조회 시작...`);
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (id === 1) { // 특정 ID만 성공한다고 가정
                const user = { id: id, name: "Alice" };
                console.log(`[Promise] 사용자 ${id} 정보 조회 완료: ${user.name}`);
                resolve(user);
            } else {
                reject(new Error(`사용자 ID ${id}를 찾을 수 없습니다.`));
            }
        }, 1000);
    });
}

function getOrdersPromise(userId) {
    console.log(`[Promise] 사용자 ${userId}의 주문 내역 조회 시작...`);
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (userId === 1) {
                const orders = ["Order_A123", "Order_B456"];
                console.log(`[Promise] 사용자 ${userId}의 주문 내역 조회 완료: ${orders.length}개`);
                resolve(orders);
            } else {
                reject(new Error(`사용자 ${userId}의 주문 내역을 가져올 수 없습니다.`));
            }
        }, 1500);
    });
}

function getProductDetailsPromise(orderId) {
    console.log(`[Promise] 주문 ${orderId}의 상품 상세 조회 시작...`);
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (orderId === "Order_A123") {
                const product = { orderId: orderId, item: "Gaming Laptop", price: 1500 };
                console.log(`[Promise] 주문 ${orderId}의 상품 상세 조회 완료: ${product.item}`);
                resolve(product);
            } else {
                reject(new Error(`주문 ${orderId}의 상품 상세 정보를 찾을 수 없습니다.`));
            }
        }, 800);
    });
}

// 프라미스 체이닝으로 비동기 작업 순차 처리
console.log("--- 프라미스 체이닝 시나리오 시작 ---");
getUserPromise(1) // 1. 사용자 정보 가져오기
    .then(user => { // 2. 사용자 정보가 성공적으로 오면
        console.log("1. 사용자 정보 (Promise):", user);
        return getOrdersPromise(user.id); // 3. 사용자 ID로 주문 내역 가져오기 (새로운 프라미스 반환)
    })
    .then(orders => { // 4. 주문 내역이 성공적으로 오면
        console.log("2. 주문 내역 (Promise):", orders);
        return getProductDetailsPromise(orders[0]); // 5. 첫 번째 주문 ID로 상품 상세 가져오기
    })
    .then(product => { // 6. 상품 상세가 성공적으로 오면
        console.log("3. 첫 번째 주문 상품 상세 (Promise):", product);
        console.log("--- 모든 프라미스 체이닝 작업 완료! ---");
    })
    .catch(error => { // 7. 체인 중 어느 단계에서든 에러가 발생하면 catch 블록이 실행됩니다.
        console.error("오류 발생 (Promise):", error.message);
    })
    .finally(() => { // 8. 성공 여부와 상관없이 마지막에 실행됩니다.
        console.log("--- 프라미스 체이닝 시나리오 종료 ---");
    });
console.log("--- 프라미스 체이닝 시나리오 스케줄링 완료 ---");

프라미스 체이닝을 통해 코드가 훨씬 평평(flat)해지고 가독성이 향상되었음을 알 수 있습니다. 각 then 블록은 이전 프라미스의 성공적인 결과를 받아 다음 비동기 작업을 시작합니다. 또한, .catch() 블록 하나로 전체 체인에서 발생할 수 있는 모든 에러를 중앙 집중적으로 처리할 수 있어 에러 처리도 훨씬 간결해집니다.

프라미스는 콜백 헬의 고통에서 개발자들을 구원했으며, 비동기 코드를 구조화하고 관리하는 데 있어 혁신적인 발전을 가져왔습니다. 하지만 여전히 .then() 체인이 길어지면 코드의 흐름이 다소 끊어지는 느낌을 줄 수 있습니다. 이를 더욱 동기 코드처럼 자연스럽게 작성할 수 있도록 해주는 것이 바로 다음에 소개할 Async/Await입니다.


Async/Await: 비동기 코드를 동기 코드처럼 깔끔하게

프라미스는 콜백 헬 문제를 해결하고 비동기 코드의 가독성을 크게 향상시켰지만, 여전히 .then() 메서드의 연속적인 체이닝으로 인해 코드의 흐름이 비동기적임을 인지해야 했습니다. 이에 자바스크립트는 ES8(ECMAScript 2017)에서 Async/Await 문법을 도입하여 비동기 코드를 마치 동기 코드처럼 작성할 수 있도록 했습니다. Async/Await는 프라미스 기반 위에 구축된 "문법적 설탕(Syntactic Sugar)"으로, 비동기 코드를 더 간결하고 직관적으로 만들어 줍니다.

Async/Await의 등장과 목적

Async/Await는 비동기 작업을 순차적으로 처리해야 할 때 코드를 훨씬 더 읽기 쉽게 만들어주는 것을 목표로 합니다. 특히 여러 비동기 작업이 서로 의존하며 순차적으로 실행되어야 하는 복잡한 로직에서 그 진가를 발휘합니다. async 함수 안에서 await 키워드를 사용하면, 프라미스가 처리(resolve 또는 reject)될 때까지 해당 await 문 다음의 코드 실행을 일시 중지합니다. 마치 동기 코드처럼 위에서 아래로 읽히는 흐름을 만들어줍니다.

async 키워드: 비동기 함수 선언

async 키워드는 함수 앞에 붙여 해당 함수가 비동기 함수임을 선언합니다. async 함수는 항상 프라미스를 반환합니다.

  • async 함수가 일반 값을 반환하면, 그 값은 Promise.resolve()로 감싸져 프라미스로 변환됩니다.
  • async 함수가 프라미스를 반환하면, 그 프라미스는 그대로 반환됩니다.
  • async 함수 내에서 에러를 throw하면, 그 에러는 Promise.reject()로 감싸져 프ra미스로 반환됩니다.
// async 함수는 항상 프라미스를 반환합니다.
async function getHelloMessage() {
    return "안녕하세요, Async/Await!"; // Promise.resolve("...")와 동일
}

getHelloMessage().then(message => console.log(message)); // "안녕하세요, Async/Await!" 출력

async function getErrorMessage() {
    throw new Error("Async 함수 내부에서 발생한 오류!"); // Promise.reject(new Error("..."))와 동일
}

getErrorMessage().catch(error => console.error(error.message)); // "Async 함수 내부에서 발생한 오류!" 출력

await 키워드: 프라미스 결과를 기다리기

await 키워드는 async 함수 내부에서만 사용할 수 있습니다. await는 프라미스 앞에 붙여 사용하며, 해당 프라미스가 fulfilled 상태가 될 때까지 async 함수의 실행을 일시 중지합니다. 프라미스가 성공적으로 처리되면 await는 프라미스의 결과 값을 반환하고, 함수는 일시 중지된 지점부터 다시 실행됩니다.

만약 await 뒤의 프라미스가 rejected 상태가 되면, await는 해당 에러를 발생시키며, async 함수는 종료되거나 try...catch 블록으로 에러를 잡을 수 있습니다.

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function fetchAndLogData() {
    console.log("데이터 요청 시작...");
    await delay(2000); // 2초 동안 함수 실행을 일시 중지합니다.
    const data = "서버로부터 받아온 데이터";
    console.log("데이터 수신 완료:", data);
    return data;
}

fetchAndLogData().then(result => console.log("최종 결과:", result));
// 예상 출력 순서:
// 데이터 요청 시작...
// (2초 후) 데이터 수신 완료: 서버로부터 받아온 데이터
// 최종 결과: 서버로부터 받아온 데이터

위 예시에서 await delay(2000); 라인에서 함수 실행은 2초 동안 멈추지만, 자바스크립트 엔진의 메인 스레드는 블로킹되지 않고 다른 작업을 처리할 수 있습니다. delay 프라미스가 resolve되면 fetchAndLogData 함수가 다시 시작됩니다.

Async/Await로 비동기 체인 구현하기

이제 프라미스 섹션에서 사용했던 사용자 정보 조회 시나리오를 Async/Await로 다시 작성해 보겠습니다. Promise를 반환하는 getUserPromise, getOrdersPromise, getProductDetailsPromise 함수는 그대로 사용합니다.

// (이전 섹션의 Promise 기반 API 함수들을 재사용)
// function getUserPromise(id) { ... }
// function getOrdersPromise(userId) { ... }
// function getProductDetailsPromise(orderId) { ... }

async function processUserDataWithAsyncAwait() {
    console.log("--- Async/Await 시나리오 시작 ---");
    try {
        // 1. 사용자 정보 가져오기
        const user = await getUserPromise(1);
        console.log("1. 사용자 정보 (Async/Await):", user);

        // 2. 사용자 ID로 주문 내역 가져오기
        const orders = await getOrdersPromise(user.id);
        console.log("2. 주문 내역 (Async/Await):", orders);

        // 3. 첫 번째 주문 ID로 상품 상세 가져오기
        const product = await getProductDetailsPromise(orders[0]);
        console.log("3. 첫 번째 주문 상품 상세 (Async/Await):", product);

        console.log("--- 모든 Async/Await 작업 완료! ---");
    } catch (error) {
        // 체인 중 어느 단계에서든 에러가 발생하면 catch 블록이 실행됩니다.
        console.error("오류 발생 (Async/Await):", error.message);
    } finally {
        console.log("--- Async/Await 시나리오 종료 ---");
    }
}

// 비동기 함수 호출
processUserDataWithAsyncAwait();
console.log("--- Async/Await 시나리오 스케줄링 완료 ---");

코드를 보면 마치 동기 코드를 작성하는 것처럼 자연스럽게 흐름을 따라갈 수 있습니다. await 키워드 덕분에 user 값을 먼저 받은 후에야 getOrdersPromise가 호출되고, 그 결과로 orders를 받은 후에야 getProductDetailsPromise가 호출됩니다. 이는 프라미스의 .then() 체인보다 훨씬 직관적이며, 읽기 쉽고 디버깅하기 쉽다는 장점이 있습니다.

Async/Await의 장점 및 고려 사항

장점:

  • 가독성 향상: 비동기 코드를 동기 코드처럼 읽고 쓸 수 있게 하여 코드의 흐름을 이해하기 쉽습니다.
  • 간결한 코드: .then() 체이닝으로 인한 콜백 중첩을 줄여 코드를 더 간결하게 만듭니다.
  • 에러 처리 용이: try...catch 문을 사용하여 동기 코드와 동일한 방식으로 에러를 처리할 수 있습니다. 이는 프라미스의 .catch()보다 더 익숙하고 직관적일 수 있습니다.
  • 디버깅 용이: await 덕분에 비동기 함수 내에서 코드가 일시 정지되므로, 디버거로 단계별 실행을 할 때 동기 코드처럼 동작하여 디버깅이 훨씬 수월합니다.

고려 사항:

  • async 함수 내부에서만 await 사용 가능: await는 반드시 async 함수 안에서 사용해야 합니다. (Node.js 14+ 및 최신 브라우저의 ES 모듈에서는 탑레벨 await가 지원되기도 하지만, 일반적인 스크립트에서는 async 함수로 감싸야 합니다.)
  • 프라미스 기반: Async/Await는 프라미스 위에 구축된 문법적 설탕이므로, 여전히 프라미스의 작동 원리를 이해하는 것이 중요합니다.
  • 병렬 처리: await는 기본적으로 순차적인 처리를 강제하므로, 여러 비동기 작업을 병렬로 실행해야 할 때는 Promise.all() 같은 프라미스 유틸리티를 await와 함께 사용하는 것이 효율적입니다. 이 부분은 뒤에서 더 자세히 다루겠습니다.

Async/Await는 현대 자바스크립트 비동기 처리의 표준이자 가장 권장되는 방식입니다. 이를 통해 개발자는 더 적은 노력으로 복잡한 비동기 로직을 우아하게 구현할 수 있습니다.


비동기 코드, 에러 처리는 어떻게 해야 할까요?

비동기 작업은 네트워크 불안정, 서버 오류, 데이터 불일치 등 예측 불가능한 다양한 상황에서 실패할 수 있습니다. 따라서 비동기 코드에서 발생하는 에러를 적절하게 처리하는 것은 안정적인 애플리케이션을 만드는 데 매우 중요합니다. 콜백, 프라미스, Async/Await 각 방식에서 에러를 어떻게 처리하는지 비교하며 살펴보겠습니다.

콜백 함수의 에러 처리

콜백 함수를 사용하는 비동기 코드에서 에러를 처리하는 가장 일반적인 패턴은 "에러 우선 콜백(Error-First Callback)" 또는 "Node.js 스타일 콜백"이라고 불리는 방식입니다. 이 방식에서는 콜백 함수의 첫 번째 인자로 error 객체를 전달하고, 두 번째 인자부터 성공 결과를 전달합니다.

// 콜백 기반 비동기 함수 (에러 처리 포함)
function performRiskyCallbackOperation(data, callback) {
    console.log(`[콜백 에러] 작업 시작: ${data}`);
    setTimeout(() => {
        const success = Math.random() > 0.6; // 60% 확률로 실패
        if (success) {
            console.log(`[콜백 에러] 작업 성공: ${data}`);
            callback(null, `${data} 처리 완료!`); // 성공 시 첫 인자는 null, 두 번째 인자에 결과
        } else {
            console.error(`[콜백 에러] 작업 실패: ${data}`);
            callback(new Error(`${data} 처리 중 오류 발생!`)); // 실패 시 첫 인자에 Error 객체
        }
    }, 1000);
}

// 콜백 함수를 이용한 에러 처리
performRiskyCallbackOperation("데이터1", (error, result) => {
    if (error) {
        console.error("콜백 에러 발생 (데이터1):", error.message);
        return; // 에러 발생 시 더 이상 진행하지 않음
    }
    console.log("콜백 결과 (데이터1):", result);

    // 중첩된 콜백의 경우, 각 단계마다 에러 체크가 필요
    performRiskyCallbackOperation("데이터2", (error2, result2) => {
        if (error2) {
            console.error("콜백 에러 발생 (데이터2):", error2.message);
            return;
        }
        console.log("콜백 결과 (데이터2):", result2);
    });
});

문제점:

  • 중복된 에러 처리: 콜백 헬과 마찬가지로, 각 콜백마다 if (error) 체크 로직을 반복적으로 작성해야 합니다. 이는 코드의 중복을 야기하고 가독성을 떨어뜨립니다.
  • 에러 전파의 어려움: 중첩된 콜백 구조에서 상위 스코프로 에러를 효과적으로 전파하기 어렵습니다. 특정 깊이에서 발생한 에러가 전체 체인의 흐름을 중단하고 적절히 처리되도록 하는 것이 복잡합니다.
  • try...catch 한계: try...catch 블록은 동기적으로 발생하는 에러만 잡을 수 있으며, 비동기 콜백 내부에서 발생하는 에러는 잡을 수 없습니다.

프라미스의 에러 처리 (.catch())

프라미스는 에러 처리를 위한 훨씬 강력하고 깔끔한 메커니즘을 제공합니다. Promiserejected 상태가 되면, 해당 에러는 .then() 체인을 따라 전파되며 가장 가까운 .catch() 블록에서 잡히게 됩니다.

// 프라미스 기반 비동기 함수 (에러 처리 포함)
function performRiskyPromiseOperation(data) {
    console.log(`[프라미스 에러] 작업 시작: ${data}`);
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = Math.random() > 0.6; // 60% 확률로 실패
            if (success) {
                console.log(`[프라미스 에러] 작업 성공: ${data}`);
                resolve(`${data} 처리 완료!`);
            } else {
                console.error(`[프라미스 에러] 작업 실패: ${data}`);
                reject(new Error(`${data} 처리 중 프라미스 오류 발생!`));
            }
        }, 1000);
    });
}

// 프라미스 체이닝을 이용한 에러 처리
performRiskyPromiseOperation("데이터A")
    .then(resultA => {
        console.log("프라미스 결과 (데이터A):", resultA);
        return performRiskyPromiseOperation("데이터B"); // 다음 프라미스 반환
    })
    .then(resultB => {
        console.log("프라미스 결과 (데이터B):", resultB);
        // 여기서 새로운 에러를 throw 하면 바로 catch 블록으로 이동
        // throw new Error("체인 중간에 강제로 에러 발생!");
        return performRiskyPromiseOperation("데이터C");
    })
    .then(resultC => {
        console.log("프라미스 결과 (데이터C):", resultC);
    })
    .catch(error => { // 체인 중 어느 곳에서든 발생한 모든 에러를 한 번에 처리
        console.error("프라미스 에러 발생 (체인):", error.message);
    })
    .finally(() => {
        console.log("프라미스 체인 작업 종료 (finally)");
    });

장점:

  • 중앙 집중식 에러 처리: .catch() 블록 하나로 전체 프라미스 체인에서 발생하는 모든 에러를 처리할 수 있습니다.
  • 에러 전파: 에러가 발생하면 .catch() 블록을 만날 때까지 자동으로 체인을 따라 전파됩니다.
  • 가독성: 에러 처리 로직이 비즈니스 로직과 분리되어 코드가 더 깔끔해집니다.

주의 사항:

  • .catch() 블록을 생략하면 rejected된 프라미스의 에러가 처리되지 않아 "Unhandled Promise Rejection" 경고 또는 오류로 이어질 수 있습니다. 예상치 못한 애플리케이션 충돌을 방지하기 위해 항상 .catch()를 사용하여 에러를 처리하는 것이 중요합니다.
  • .then()의 두 번째 인자(onRejected 콜백)로 에러를 처리할 수도 있지만, 일반적으로 .catch()를 사용하는 것이 권장됩니다. .then(success, error) 방식은 특정 단계에서만 에러를 잡고 싶을 때 유용할 수 있지만, 전체 체인 에러 처리는 .catch()가 더 명확합니다.

Async/Await의 에러 처리 (try...catch)

Async/Await는 비동기 코드를 동기 코드처럼 작성하게 해주므로, 에러 처리 역시 동기 코드에서 사용하는 try...catch 문을 그대로 활용할 수 있습니다. 이는 Async/Await의 가장 큰 장점 중 하나입니다.

// (이전 섹션의 Promise 기반 API 함수들을 재사용)
// function performRiskyPromiseOperation(data) { ... }

async function processDataWithAsyncAwaitErrorHandling() {
    console.log("--- Async/Await 에러 처리 시나리오 시작 ---");
    try {
        const result1 = await performRiskyPromiseOperation("InitData");
        console.log("Async/Await 결과 (InitData):", result1);

        // 첫 번째 await에서 에러가 발생하면, 아래 코드는 실행되지 않고 바로 catch 블록으로 이동
        const result2 = await performRiskyPromiseOperation("ProcessingData");
        console.log("Async/Await 결과 (ProcessingData):", result2);

        // 여기서 강제로 에러를 발생시켜 catch 블록으로 보낼 수 있습니다.
        // throw new Error("Async 함수 내에서 강제 오류 발생!");

        const result3 = await performRiskyPromiseOperation("FinalData");
        console.log("Async/Await 결과 (FinalData):", result3);

        console.log("--- 모든 Async/Await 작업 완료! ---");
    } catch (error) {
        // try 블록 내의 어떤 await 문에서든 프라미스가 rejected 되면,
        // 해당 에러는 이 catch 블록에서 잡힙니다.
        console.error("Async/Await 에러 발생:", error.message);
    } finally {
        console.log("--- Async/Await 에러 처리 시나리오 종료 ---");
    }
}

processDataWithAsyncAwaitErrorHandling();

장점:

  • 직관적이고 익숙함: 동기 코드와 동일한 try...catch 문법을 사용하므로, 에러 처리 로직이 매우 직관적입니다.
  • 명확한 에러 범위: try 블록 내에서 발생한 에러만 catch 블록에서 처리되므로, 에러의 발생 지점과 처리 범위가 명확합니다.
  • 간결함: 여러 단계의 비동기 작업에 대한 에러를 하나의 try...catch 블록으로 효율적으로 관리할 수 있습니다.

각 에러 처리 방식 비교 요약:

방식 장점 단점
콜백 가장 기본적이고 단순한 비동기 처리 콜백 헬, 각 콜백마다 에러 처리 필요, 전파 어려움
프라미스 .catch()로 중앙 집중 에러 처리, 체이닝 .then() 체인이 길어지면 여전히 복잡, try...catch 사용 불가
Async/Await try...catch로 직관적 에러 처리, 동기 코드 같은 가독성, 디버깅 용이 async 함수 내에서만 await 사용 가능

현대 자바스크립트 개발에서는 Async/Await와 try...catch를 조합하여 비동기 에러를 처리하는 것이 가장 일반적이고 권장되는 방법입니다. 이를 통해 비동기 코드의 안정성과 유지보수성을 크게 높일 수 있습니다.


현업 비동기 처리 베스트 프랙티스: Promise & Async/Await 실전 활용

자바스크립트 비동기 처리에 대한 기본적인 이해와 Callback, Promise, Async/Await의 사용법을 익혔다면, 이제 실제 프로젝트에서 효율적이고 견고한 비동기 코드를 작성하기 위한 현업의 모범 사례들을 살펴볼 차례입니다. 효과적인 비동기 코드 관리는 애플리케이션의 성능, 안정성, 그리고 유지보수성에 직접적인 영향을 미칩니다.

여러 비동기 작업을 동시에 처리하기: Promise.all(), Promise.race(), Promise.any(), Promise.allSettled()

때로는 여러 개의 독립적인 비동기 작업을 동시에 시작하고 그 결과들을 기다려야 할 필요가 있습니다. 이때 await를 사용하여 각 작업을 순차적으로 기다리면 불필요하게 시간이 지연될 수 있습니다. Promise 객체는 이러한 병렬 처리를 위한 유용한 정적 메서드들을 제공합니다.

1. Promise.all(iterable): 모든 프라미스가 성공할 때까지 기다리기

Promise.all()은 여러 개의 프라미스를 배열로 받아, 모든 프라미스가 성공적으로 완료될 때까지 기다렸다가 모든 결과 값을 배열로 반환합니다. 만약 프라미스 중 하나라도 실패(rejected)하면, Promise.all()은 즉시 실패하며, 가장 먼저 실패한 프라미스의 에러를 반환합니다.

function fetchUserData() {
    return new Promise(resolve => setTimeout(() => resolve({ id: 1, name: "Alice" }), 1000));
}

function fetchUserPosts() {
    return new Promise(resolve => setTimeout(() => resolve(["Post1", "Post2"]), 1500));
}

function fetchUserComments() {
    return new Promise(resolve => setTimeout(() => resolve(["CommentA", "CommentB"]), 500));
}

async function getAllUserData() {
    try {
        console.log("모든 데이터 병렬 요청 시작...");
        const [userData, userPosts, userComments] = await Promise.all([
            fetchUserData(),
            fetchUserPosts(),
            fetchUserComments()
        ]);

        console.log("모든 데이터 로드 완료!");
        console.log("사용자 정보:", userData);
        console.log("사용자 게시물:", userPosts);
        console.log("사용자 댓글:", userComments);
    } catch (error) {
        console.error("데이터 로드 중 오류 발생:", error.message);
    }
}

getAllUserData();
// 이 코드는 가장 오래 걸리는 작업(fetchUserPosts: 1.5초) 시간에 맞춰 완료됩니다.

Promise.all()은 여러 리소스를 동시에 불러오거나, 여러 독립적인 유효성 검사를 병렬로 수행할 때 매우 유용합니다.

2. Promise.race(iterable): 가장 먼저 완료되는 프라미스의 결과 받기

Promise.race()는 여러 프라미스 중 가장 먼저 완료(fulfilled 또는 rejected)되는 프라미스의 결과 또는 에러를 반환하고, 나머지 프라미스는 무시합니다. 이는 "경쟁" 상황에서 유용합니다.

function fastPromise() {
    return new Promise(resolve => setTimeout(() => resolve("가장 빠름!"), 500));
}

function slowPromise() {
    return new Promise(resolve => setTimeout(() => resolve("느림..."), 2000));
}

function errorPromise() {
    return new Promise((_, reject) => setTimeout(() => reject(new Error("오류 발생! (race)")), 300));
}

async function racePromises() {
    try {
        console.log("프라미스 경쟁 시작...");
        const result = await Promise.race([slowPromise(), fastPromise(), errorPromise()]);
        console.log("가장 먼저 완료된 결과:", result); // "가장 빠름!" 또는 에러
    } catch (error) {
        console.error("가장 먼저 완료된 에러:", error.message); // "오류 발생! (race)"
    }
}

racePromises();
// 이 예시에서는 errorPromise가 가장 먼저 rejected 상태가 되므로 catch 블록이 실행됩니다.

Promise.race()는 타임아웃 처리 로직을 구현할 때 특히 유용하게 사용될 수 있습니다. (예: Promise.race([fetchData(), timeoutPromise(5000)]))

3. Promise.allSettled(iterable) (ES2020): 모든 프라미스의 성공/실패 결과 받기

Promise.allSettled()Promise.all()과 비슷하게 모든 프라미스가 완료될 때까지 기다리지만, 각 프라미스의 성공/실패 여부와 관계없이 모든 결과를 객체 배열로 반환합니다. 각 객체는 status (fulfilled/rejected)와 value (성공 시) 또는 reason (실패 시)를 가집니다.

function successfulPromise() {
    return new Promise(resolve => setTimeout(() => resolve("성공"), 1000));
}

function failedPromise() {
    return new Promise((_, reject) => setTimeout(() => reject(new Error("실패")), 500));
}

async function allSettledPromises() {
    console.log("모든 프라미스 결과 확인 시작...");
    const results = await Promise.allSettled([successfulPromise(), failedPromise()]);
    console.log("모든 프라미스 결과:", results);
    // [
    //   { status: 'fulfilled', value: '성공' },
    //   { status: 'rejected', reason: Error: 실패 }
    // ]
}

allSettledPromises();

Promise.allSettled()는 여러 독립적인 비동기 작업을 실행하고, 그중 일부가 실패하더라도 전체 결과에 영향을 주지 않고 모든 작업의 최종 상태를 확인해야 할 때 유용합니다. 주로 로깅이나 디버깅 목적으로 사용됩니다.

4. Promise.any(iterable) (ES2021): 가장 먼저 성공하는 프라미스 결과 받기

Promise.any()Promise.race()와 유사하지만, 프라미스 중 가장 먼저 성공(fulfilled)하는 프라미스의 결과만을 반환합니다. 모든 프라미스가 실패하면 AggregateError를 반환합니다.

function promiseA() {
    return new Promise((_, reject) => setTimeout(() => reject(new Error("A 실패")), 1000));
}

function promiseB() {
    return new Promise(resolve => setTimeout(() => resolve("B 성공!"), 500));
}

function promiseC() {
    return new Promise((_, reject) => setTimeout(() => reject(new Error("C 실패")), 200));
}

async function anyPromise() {
    try {
        console.log("프라미스 중 하나라도 성공하기 기다림...");
        const result = await Promise.any([promiseA(), promiseB(), promiseC()]);
        console.log("가장 먼저 성공한 결과:", result); // "B 성공!"
    } catch (error) {
        console.error("모든 프라미스가 실패했습니다:", error.errors); // 모든 실패 이유 배열
    }
}

anyPromise();

Promise.any()는 여러 서버 중 하나라도 응답을 주면 되는 경우, 또는 백업 리소스 중 하나라도 로드되면 되는 경우에 활용될 수 있습니다.

비동기 코드의 모듈화 및 재사용성

현업에서 복잡한 비동기 로직은 별도의 함수나 모듈로 분리하여 관리하는 것이 중요합니다. 이는 코드의 재사용성을 높이고, 특정 비동기 로직이 변경될 때 다른 부분에 미치는 영향을 최소화합니다.

// api.js
export async function fetchUserInfo(userId) {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
        throw new Error(`Failed to fetch user ${userId}: ${response.statusText}`);
    }
    return response.json();
}

export async function fetchUserOrders(userId) {
    const response = await fetch(`/api/users/${userId}/orders`);
    if (!response.ok) {
        throw new Error(`Failed to fetch orders for user ${userId}: ${response.statusText}`);
    }
    return response.json();
}

// userProfile.js
import { fetchUserInfo, fetchUserOrders } from './api.js';

async function displayUserProfile(userId) {
    try {
        const [userInfo, userOrders] = await Promise.all([
            fetchUserInfo(userId),
            fetchUserOrders(userId)
        ]);

        console.log("사용자 프로필:", userInfo);
        console.log("사용자 주문 내역:", userOrders);
    } catch (error) {
        console.error("사용자 프로필 로드 실패:", error.message);
    }
}

displayUserProfile(123);

이렇게 비동기 작업을 담당하는 함수들을 분리함으로써, 관심사 분리(Separation of Concerns) 원칙을 지키고 코드를 더 깨끗하게 유지할 수 있습니다.

명확한 이름 규칙과 로깅/디버깅

  • 명확한 이름 규칙: 비동기 작업을 수행하는 함수나 변수에는 그 역할이 명확히 드러나는 이름을 부여해야 합니다. 예를 들어, fetchData, loadUserAsync, getProductsPromise와 같이 fetch, load, get 접두사나 Async, Promise 접미사를 사용하여 비동기 함수임을 명시할 수 있습니다.
  • 충분한 로깅: 복잡한 비동기 흐름에서는 console.log()를 적절히 사용하여 각 단계의 시작과 완료, 그리고 전달되는 데이터를 로깅하는 것이 디버깅에 큰 도움이 됩니다. 특히 에러 발생 시 catch 블록에서 에러 메시지와 스택 트레이스를 상세하게 로깅해야 합니다.
  • 디버거 활용: 브라우저 개발자 도구의 디버거를 적극적으로 활용하세요. await 키워드 덕분에 Async/Await 코드는 디버거의 스텝 실행(step-by-step execution)에 매우 잘 작동합니다.

불필요한 await 피하기

모든 프라미스 앞에 await를 붙일 필요는 없습니다. 만약 여러 비동기 작업이 서로 의존하지 않고 병렬적으로 실행될 수 있다면, 각각 await 없이 프라미스를 시작시키고, 나중에 Promise.all()을 사용하여 모든 결과를 기다리는 것이 효율적입니다.

async function processIndependentTasks() {
    console.log("독립적인 작업 시작...");

    // 두 비동기 작업을 동시에 시작합니다.
    const promiseA = someAsyncTask("Task A", 2000);
    const promiseB = someAsyncTask("Task B", 1000);

    // Promise.all을 사용하여 모든 작업이 완료될 때까지 기다립니다.
    const [resultA, resultB] = await Promise.all([promiseA, promiseB]);

    console.log("Task A 결과:", resultA);
    console.log("Task B 결과:", resultB);
    console.log("모든 독립 작업 완료!");
}

function someAsyncTask(name, ms) {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log(`${name} 완료 (${ms}ms)`);
            resolve(`Result of ${name}`);
        }, ms);
    });
}

processIndependentTasks();
// 만약 'await promiseA;' 후 'await promiseB;'를 했다면 총 3초가 걸렸을 것입니다.
// Promise.all을 사용하면 가장 긴 시간인 2초 만에 완료됩니다.

AbortController를 이용한 비동기 작업 취소 (고급)

클라이언트 측에서 불필요하게 진행되는 네트워크 요청을 중간에 취소해야 할 때가 있습니다. 예를 들어, 사용자가 검색어 입력 중 빠르게 다른 검색어를 입력했을 때 이전 검색어에 대한 네트워크 요청은 취소하는 것이 리소스 낭비를 막고 예상치 못한 동작을 방지하는 데 도움이 됩니다. AbortController API는 이러한 프라미스 기반 비동기 작업을 취소할 수 있는 메커니즘을 제공합니다.

// AbortController 사용 예시 (fetch API와 함께)
async function fetchDataWithCancellation(url) {
    const controller = new AbortController();
    const signal = controller.signal;

    // 5초 후에 요청을 자동으로 취소
    const timeoutId = setTimeout(() => controller.abort(), 5000);

    try {
        console.log(`[취소 가능] ${url} 데이터 요청 시작...`);
        const response = await fetch(url, { signal });
        clearTimeout(timeoutId); // 요청이 성공하면 타임아웃 취소
        const data = await response.json();
        console.log(`[취소 가능] ${url} 데이터 로드 성공:`, data);
        return data;
    } catch (error) {
        clearTimeout(timeoutId);
        if (error.name === 'AbortError') {
            console.warn(`[취소 가능] ${url} 요청이 취소되었습니다.`);
        } else {
            console.error(`[취소 가능] ${url} 데이터 로드 실패:`, error.message);
        }
        throw error; // 에러 다시 throw
    }
}

// 사용 예시
// fetchDataWithCancellation('https://jsonplaceholder.typicode.com/posts/1');

// 다른 시나리오: 버튼 클릭으로 요청 취소 (HTML에 'fetchButton' ID를 가진 버튼이 있다고 가정)
let currentController; // 현재 활성화된 컨트롤러 저장

document.getElementById('fetchButton').addEventListener('click', async () => {
    if (currentController) {
        currentController.abort(); // 이전 요청 취소
        console.log("이전 요청 취소됨.");
    }
    currentController = new AbortController();
    const signal = currentController.signal;

    try {
        console.log("새로운 데이터 요청 시작...");
        const response = await fetch('https://jsonplaceholder.typicode.com/todos/1', { signal });
        const data = await response.json();
        console.log("새로운 데이터 로드 성공:", data);
    } catch (error) {
        if (error.name === 'AbortError') {
            console.warn("새로운 요청이 사용자에 의해 취소되었습니다.");
        } else {
            console.error("새로운 데이터 로드 실패:", error.message);
        }
    } finally {
        currentController = null; // 작업 완료 또는 취소 후 컨트롤러 초기화
    }
});

AbortControllerfetch API와 함께 주로 사용되며, 다른 프라미스 기반 API에서도 signal 객체를 통해 취소 기능을 구현할 수 있습니다. 이는 사용자 경험을 크게 향상시키고 불필요한 네트워크 트래픽을 줄이는 데 기여합니다.

이러한 베스트 프랙티스들을 숙지하고 적용함으로써, 여러분은 더욱 강력하고 안정적이며 유지보수하기 쉬운 자바스크립트 비동기 애플리케이션을 개발할 수 있을 것입니다.


마무리하며: 비동기 처리, 이제 두렵지 않다!

지금까지 자바스크립트 비동기 처리의 필요성부터 시작하여, 그 핵심 개념인 싱글 스레드와 이벤트 루프를 이해하고, 비동기 처리를 구현하는 세 가지 핵심 방식인 콜백(Callback), 프라미스(Promise), 그리고 Async/Await에 대해 상세히 살펴보았습니다. 각 방식의 동작 원리, 장단점, 그리고 에러 처리 방법까지 코드 예시를 통해 명확하게 파악하셨을 것입니다.

특히, 콜백 헬의 문제점을 해결하기 위해 프라미스가 등장했고, 이 프라미스 기반 위에 더 직관적이고 동기 코드와 유사한 Async/Await가 탄생했다는 흐름을 이해하는 것이 중요합니다. 현대 자바스크립트 개발에서는 Async/Await가 가장 강력하고 선호되는 비동기 처리 방식입니다.

마지막으로, 현업에서 비동기 코드를 효율적으로 관리하기 위한 Promise.all(), Promise.race()와 같은 유틸리티 메서드 활용법과 모듈화, 에러 처리, 그리고 AbortController를 통한 비동기 작업 취소와 같은 베스트 프랙티스까지 알아보았습니다.

이 가이드를 통해 자바스크립트 비동기 처리에 대한 막연한 두려움을 완전히 떨쳐내고, 여러분의 웹 애플리케이션이 더욱 반응적이고 효율적으로 동작하도록 만드는 데 확실한 자신감을 얻으셨기를 바랍니다. 이제 이 지식을 바탕으로 실제 프로젝트에 적용하며 여러분의 비동기 처리 능력을 더욱 갈고 닦으시길 진심으로 응원합니다!

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함
반응형