Frontend/JAVASCRIPT

Callback Hell에서 탈출하기: Promise와 async/await의 깊은 이해

미니임 2026. 3. 2. 11:31

 

현대 웹 애플리케이션은 '기다림'의 연속입니다. API 서버에서 데이터를 가져오고, 대용량 이미지를 로드하며, 사용자의 입력에 실시간으로 반응해야 하죠. 하지만 자바스크립트는 **싱글 스레드(Single Thread)**로 동작합니다. 한 번에 하나의 일만 처리할 수 있는 이 언어가 어떻게 끊김 없는 사용자 경험을 만들어낼까요?

그 중심에는 비동기 처리의 진화가 있습니다. 과거의 콜백(Callback) 패턴이 가져온 '지옥'을 지나, 이제는 동기 코드처럼 읽히는 비동기 코드를 작성하는 시대에 도달했습니다.


1. Deep Dive: 왜 비동기를 제어해야 하는가?

자바스크립트 엔진은 비동기 작업을 만나면 이를 브라우저나 Node.js의 **Web APIs(또는 Libuv)**로 위임합니다. 작업이 완료되면 결과값이 **태스크 큐(Task Queue)**에 쌓이고, 이벤트 루프에 의해 메인 스레드로 돌아오죠.

문제는 이 '결과값'이 돌아오는 시점을 예측할 수 없다는 것입니다.

비유로 이해하는 비동기 제어

맛집에 가서 줄을 서는 상황을 상상해 보세요.

  • Callback: 점원이 "자리가 나면 제가 직접 당신을 찾아가서 데려올게요"라고 말합니다. 그런데 자리가 나기 전에 화장실을 가거나 전화를 받으러 가면 점원은 당신을 찾지 못해 혼란에 빠집니다. (제어권 상실)
  • Promise: 점원이 진동벨을 줍니다. 당신은 벨을 쥐고 카페에 가거나 산책을 할 수 있습니다. 벨이 울리면(Fulfilled) 음식을 받고, 재료가 떨어지면 빨간 불(Rejected)이 들어옵니다. (상태 중심 제어)
  • async/await: 진동벨을 들고 있지만, 마치 음식이 바로 나올 것처럼 식탁 앞에 앉아서 기다리는 것처럼 코드를 작성합니다. 하지만 실제로 당신의 몸이 굳어있는 건 아니죠. (동기적 표현)

2. Hands-on: 실전 이커머스 결제 로직 구현

단순한 setTimeout 예제가 아닌, 재고 확인 -> 결제 처리 -> 이메일 발송으로 이어지는 실제 비즈니스 파이프라인을 구축해 보겠습니다.

Step 1: Promise 기반의 워크플로우

JavaScript
 
const checkInventory = (productId) => {
  return new Promise((resolve, reject) => {
    console.log("📦 재고 확인 중...");
    setTimeout(() => {
      const isAvailable = true; // 실제 DB 조회 로직 대체
      isAvailable ? resolve({ productId, price: 50000 }) : reject(new Error("품절된 상품입니다."));
    }, 1000);
  });
};

const processPayment = (orderInfo) => {
  return new Promise((resolve, reject) => {
    console.log(`💳 ${orderInfo.price}원 결제 진행 중...`);
    setTimeout(() => {
      const success = Math.random() > 0.2; // 80% 확률로 결제 성공
      success ? resolve({ ...orderInfo, status: "PAID" }) : reject(new Error("결제 승인 거절"));
    }, 1500);
  });
};

const sendConfirmation = (orderInfo) => {
  return new Promise((resolve) => {
    console.log("📧 결제 완료 이메일 발송 중...");
    setTimeout(() => resolve(`Order ${orderInfo.productId} 완료!`), 500);
  });
};

Step 2: async/await를 이용한 세련된 합성

위의 Promise들을 연쇄적으로 호출할 때, then 체이닝보다 async/await가 가독성 측면에서 압도적입니다.

JavaScript
 
/**
 * 결제 파이프라인 실행 함수
 * @param {string} productId 
 */
async function completePurchase(productId) {
  try {
    // 1. 재고 확인 (await를 통한 결과값 변수 할당)
    const item = await checkInventory(productId);
    
    // 2. 결제 처리 (이전 단계의 결과값을 인자로 전달)
    const receipt = await processPayment(item);
    
    // 3. 이메일 발송
    const result = await sendConfirmation(receipt);
    
    console.log("✅ 최종 결과:", result);
  } catch (error) {
    // 통합 에러 핸들링: 어느 단계에서 발생하든 이곳으로 수렴합니다.
    handleError(error);
  } finally {
    console.log("🔄 트랜잭션 종료");
  }
}

function handleError(error) {
  console.error("❌ 처리 실패:", error.message);
  // 서비스 특성에 따른 로깅이나 사용자 알림 로직
}

completePurchase("MACBOOK_PRO_2026");

3. Troubleshooting: 자주 겪는 실수들

병렬 처리의 누락 (The Waterfall Trap)

여러 개의 독립적인 API 호출을 할 때, 무심코 await를 순차적으로 나열하면 성능 저하가 발생합니다.

  • Bad: 첫 번째 데이터 로드가 끝날 때까지 두 번째 로드가 시작되지 않음.
  • Good: Promise.all을 사용하여 동시 처리.
JavaScript
 
// 두 명의 사용자 정보를 가져올 때 (서로 연관 없음)
async function fetchUsers(id1, id2) {
  // 동시에 시작하여 가장 늦게 끝나는 작업 시간에 수렴함
  const [user1, user2] = await Promise.all([
    fetch(`/api/user/${id1}`),
    fetch(`/api/user/${id2}`)
  ]);
}

에러 삼킴 (Error Swallowing)

async 함수 내부에서 try...catch 없이 비동기 함수를 호출하고 이를 적절히 반환하지 않으면, 에러가 전역으로 퍼지거나 조용히 사라져 디버깅이 불가능해집니다. 항상 에러 경계를 설정하세요.


4. Trade-offs: 무엇을 선택할 것인가?

특성 Promise (.then) async / await
가독성 중첩 시 복잡도가 올라감 동기 코드와 유사하여 직관적임
에러 처리 .catch() 로 처리 try...catch 로 처리
제어 흐름 분기 처리가 까다로움 if, for 문 등 내장 제어문 활용 용이
디버깅 콜 스택 확인이 어려울 수 있음 중단점(Breakpoint) 설정이 용이함

하지만 주의하세요. async/await가 모든 것을 해결하지는 않습니다. 자바스크립트의 비동기는 여전히 이벤트 루프를 기반으로 합니다. 만약 루프 내부에서 CPU 집약적인 연산($O(n^2)$ 이상의 복잡도를 가진 로직 등)을 수행한다면, await 여부와 관계없이 메인 스레드는 차단됩니다.


결론: 비동기는 기술이 아니라 '흐름'이다

비동기 프로그래밍의 핵심은 단순히 문법을 익히는 것이 아니라, **"지금 실행되지 않아도 될 것"**과 **"반드시 순서대로 실행되어야 하는 것"**을 구분하는 설계 능력에 있습니다.

Promise는 비동기 작업의 상태를 객체화했고, async/await는 그 객체를 인간의 언어에 가깝게 표현할 수 있게 해주었습니다. 이제 여러분의 코드에서 콜백의 흔적을 지우고, 견고한 비동기 파이프라인을 구축해 보시기 바랍니다.

반응형