IT의 IT 블로그

자바스크립트 비동기 흐름 정리 본문

Frontend Basic (기초 이론)

자바스크립트 비동기 흐름 정리

IT의 IT 블로그 2025. 12. 8. 19:15

안녕하세요.
비동기 처리를 공부하면서 이해가 더 필요했던 부분들을 기록해두고자 글을 작성했습니다.
혹시 보완할 점이 있다면 알려주시면 감사하겠습니다.

 

JS는 비동기 호출을 하는 방식이 3가지 있습니다.

각각의 사용법과 문제점 등을 나열하면서 설명하도록 하겠습니다.

 

콜백 → Promise → async/await 순으로 흐름을 정리하여 보았습니다.

 

1. 콜백(callback) 방식

비동기 작업이 끝난 뒤 실행할 함수를 직접 전달하는 방식입니다.

function getData(callback) {
  setTimeout(() => {
    callback("데이터 도착");
  }, 2000);
}

console.log("요청 시도");

getData((result) => {
  console.log("콜백 실행:", result);
});

console.log("다음 코드 실행");

출력 흐름

요청 시도
다음 코드 실행
(2초 뒤)
콜백 실행: 데이터 도착

 

콜백의 문제: 콜백 지옥(Callback Hell)

비동기 로직이 여러 단계로 이어질 때 중첩이 깊어지고 흐름이 복잡해집니다.

 

콜백 방식의 단점

  • 중첩이 깊어짐 → 읽기 어려움
  • 어디서 시작/끝인지 구분 어려움
  • 에러 처리 어려움
  • 유지보수 어려움
function getUser(callback) {
  setTimeout(() => {
    console.log("1단계: 유저 조회 완료");
    callback({ id: 1, name: "Tom" });
  }, 1000);
}

function getOrders(user, callback) {
  setTimeout(() => {
    console.log("2단계: 주문 목록 조회 완료");
    callback(["order1", "order2"]);
  }, 1000);
}

function getPayments(orders, callback) {
  setTimeout(() => {
    console.log("3단계: 결제 내역 조회 완료");
    callback(["payment1"]);
  }, 1000);
}

// 콜백 지옥
getUser((user) => {
  getOrders(user, (orders) => {
    getPayments(orders, (payments) => {
      console.log("최종 결과:", payments);
    });
  });
});

 

이 문제를 해결하기 위해 등장한 것이 Promise입니다.

 

2. Promise

Promise는 비동기 작업의 상태를 표현하는 객체입니다.
아래 3가지 상태를 가집니다.

 

상태의미

pending 대기 중
fulfilled 성공(이행됨)
rejected 실패(거절됨)

 

Promise는 상호 배타적이며 한 번 상태가 결정되면 바뀌지 않습니다.

기본 구조 : 

new Promise((resolve, reject) => {
    // 성공 → resolve()
    // 실패 → reject()
});

 

비동기 예제 :

function getData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("데이터 도착");
    }, 2000);
  });
}

console.log("요청 시도");

getData()
  .then((result) => {
    console.log("Promise then:", result);
  });

console.log("다음 코드 실행");

 

출력 흐름 :

요청 시도
다음 코드 실행
(2초 뒤)
Promise then: 데이터 도착

 

프로미스의 then()을 활용하면 체이닝이 가능하고 코드가 깊이 중첩되지 않는다는 장점이 있습니다.

 

프로미스 체이닝 기법

function work(msg, time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(msg);
      resolve();
    }, time);
  });
}

work("1단계", 1000)
  .then(() => work("2단계", 1000))
  .then(() => work("3단계", 1000))
  .then(() => work("완료", 1000));

 

출력 흐름 

1단계
2단계
3단계
완료

 

그러나 Promise 를 사용한다고해서 콜백 지옥이 해결되는건 아닙니다. 

프로미스 지옥 또한 존재합니다

 

콜백 방식에 비해선 선형적으로 보이고, 들여쓰기가 깊어지지 않는다는 장점이 있지만

.then().then().then() 체인이 길어지면 결국 코드의 가독성과 유지보수성이 떨어지는 문제가 발생합니다. 

function getUser() {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("1단계: 유저 조회 완료");
      resolve({ id: 1, name: "Tom" });
    }, 1000);
  });
}

function getOrders(user) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("2단계: 주문 목록 조회 완료");
      resolve(["order1", "order2"]);
    }, 1000);
  });
}

function getPayments(orders) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("3단계: 결제 내역 조회 완료");
      resolve(["payment1"]);
    }, 1000);
  });
}

// Promise 체이닝
getUser()
  .then((user) => getOrders(user))
  .then((orders) => getPayments(orders))
  .then((payments) => {
    console.log("최종 결과:", payments);
  });

 

 

3. async / await

ES2017에 도입된 문법으로,
Promise를 동기 코드처럼 자연스럽게 작성할 수 있게 만들어줍니다.

 

단, 사용 규칙이 있습니다.

 

async 함수

async는 function 앞에 위치합니다.

async function f() {
  return 1;
}

function 앞에 async를 붙이면 해당 함수는 항상 Promise를 반환합니다.

Promise가 아닌 값을 반환하더라도 이행 상태의 Promise (resolved promise)로 값을 감싸 이행된 Promise가 반환되도록 합니다.

아래 예시의 함수를 호출하면 result가 1인 이행 Promise가 반환됩니다. 

반환값이 1일지라도, 내부적으로는 Promise.resolve(1) 형태로 감싸집니다.

async function f() {
  return 1;
}

f().then(alert); // 1

 

await 함수

await는 Promise가 처리될 때까지 기다렸다가 결과를 반환합니다.

단, async 함수 내부에서만 사용 가능합니다.

 

async function f() {

  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("완료!"), 1000)
  });

  let result = await promise; // Promise가 이행될 때까지 기다림 (*)

  alert(result); // "완료!"
}

f();

await는 말 그대로 Promise 로직을 처리될 때까지 함수 실행을 기다리게 만듭니다.

Promise가 처리되면 그 결과와 함께 실행이 시작됩니다.

 

Promise가 처리되길 기다리는 동안엔 엔진이 다른 일(다른 스크립트를 실행, 이벤트 처리 등)을 할 수 있기 때문에,

CPU 리소스가 낭비되지 않습니다.

 

async/await Promise 체이닝

function work(msg, time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(msg);
      resolve();
    }, time);
  });
}

async function runAll() {
  await work("1단계", 1000);
  await work("2단계", 1000);
  await work("3단계", 1000);
  await work("완료", 1000);
}

runAll();

 

 

Promise에서 catch()를 쓰는 대신,
동기 코드처럼 깔끔하게 예외를 다룰 수 있습니다.

 

function getData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("에러 발생");
    }, 1000);
  });
}

async function run() {
  try {
    const result = await getData();
    console.log(result);
  } catch (error) {
    console.log("catch 실행:", error);
  }
}

run();

마무리 정리

 

비동기 처리에서 가장 중요한 부분은 결국 코드를 얼마나 읽기 쉽고 이해하기 쉽게 구성하느냐라는 점입니다.
콜백에서 시작해 Promise를 거쳐 async/await로 이어진 흐름을 살펴보니,
각 방식이 서로를 완전히 대체한다기보다는 비동기 로직을 더 자연스럽고 직관적으로 표현하기 위해 발전해온 과정이라는 점을 알 수 있었습니다.

이번 정리가 비동기 처리 개념을 이해하는 데 작은 도움이 되었길 바랍니다.
이상으로 포스팅을 마칩니다.