📚 CS/JavaScript

[JavaScript] Promise와 async / await

dev.daisy 2025. 9. 8. 20:24
이번에 Promise async/await를 다시 정리하면서 단순히 문법적인 차이를 아는 것에 그치지 않고, 실제 프로젝트에서 어떤 방식이 더 적합한지 고민하게 되었습니다.

처음에는 콜백 기반 코드로 비동기를 처리했는데, API 호출이 여러 번 중첩되다 보니 코드 가독성이 너무 떨어지고 디버깅도 쉽지 않았습니다. 그때 Promise를 적용하면서 에러 처리를 명확하게 할 수 있었고, 체인 형태로 이어가는 흐름 덕분에 코드 구조가 훨씬 깔끔해진 경험이 있습니다. 하지만 Promise.then 체인이 길어질수록 여전히 코드가 복잡해지는 문제가 있었고, 실제로 제가 진행했던 프로젝트에서 채팅 메시지를 보여주는 기능을 구현할 때 어려움을 느꼈습니다. 그때 async/await를 적용했더니 마치 동기 코드처럼 자연스럽게 읽히고, 로직을 순서대로 파악하기가 훨씬 쉬워졌습니다. 

이번 경험을 통해 느낀 점은, Promise와 async/await는 서로 대체재가 아니라 상황에 따라 선택하는 도구라는 것입니다. 예를 들어 여러 API를 동시에 호출할 때는 Promise.all이 효율적이고, 직렬 흐름이 필요할 때는 async/await가 직관적입니다. 결국 중요한 건 “어떤 방식이 이 상황에서 가장 유지보수하기 좋은가”라는 관점이라는 걸 다시 한번 깨달았습니다. 앞으로는 이번 경험을 바탕으로 단순히 동작하는 코드에 만족하지 않고, 가독성과 협업 효율성을 고려한 비동기 처리 방식을 선택하는 개발자가 되려고 합니다.

 

Callback (콜백 함수)의 한계

 

자바스크립트에서 비동기 처리란 현재 실행 중인 작업과는 별도로 다른 작업을 수행하는 것을 말합니다. 예를 들어 서버에서 데이터를 받아오는 네트워크 요청은 시간이 오래 걸리므로, 이 작업을 기다리는 동안 자바스크립트는 다른 코드를 계속 실행합니다.

 

이런 비동기 작업의 결과를 다루기 위해 전통적으로 콜백 함수(callback function)가 사용되었습니다. 콜백 함수란 “비동기 작업이 끝난 후 호출되는 함수”로, 비동기 함수의 매개변수로 전달되어 실행됩니다.

 

하지만 콜백 함수에는 다음과 같은 문제가 있습니다.

  • 여러 개의 비동기 작업을 순차적으로 실행하려면 콜백 함수 안에 또 다른 콜백 함수를 중첩해야 함
  • 코드의 깊이가 지나치게 깊어져 콜백 지옥(callback hell) 발생
  • 에러 처리를 각 콜백마다 따로 해줘야 해서 유지보수 어려움
function increaseAndPrint(n, callback) {
  setTimeout(() => {
    const increased = n + 1;
    console.log(increased);
    if (callback) {
      callback(increased); // 콜백 함수 호출
    }
  }, 1000);
}

increaseAndPrint(0, n => {
  increaseAndPrint(n, n => {
    increaseAndPrint(n, n => {
      increaseAndPrint(n, n => {
        increaseAndPrint(n, n => {
          console.log('끝!');
        });
      });
    });
  });
});

 

이 코드는 숫자를 하나씩 증가시키며 1초 간격으로 출력하는 단순한 예제이지만, 결과적으로 함수가 중첩되어 들여쓰기 깊이가 깊어진다는 점입니다. 이런 형태는 코드 가독성을 떨어뜨리고, 에러 추적도 매우 힘들어진다는 단점이 있습니다.


Promise: 비동기 처리의 새로운 패러다임

콜백 함수의 한계를 극복하기 위해 ES6에서 도입된 것이 Promise 입니다.

Promise'아직 완료되지 않았지만, 미래에 완료될 것으로 기대되는 값'을 나타내는 객체입니다.

 

즉, 네트워크 요청 같은 비동기 작업을 실행하고 그 결과를 Promise 형태로 돌려줍니다.

1) Promise 생성하기

const myPromise = new Promise((resolve, reject) => {
  const data = true; // 비동기 작업 성공 여부 가정
  if (data) {
    resolve("성공");
  } else {
    reject("실패");
  }
});

 

Promise 생성자는 executor 함수를 인자로 받습니다. 이 함수는 resolvereject 두 가지 인자를 가지며 로직에 따라 둘 중 하나를 호출하게 됩니다.

  • resolve(value) → 비동기 작업 성공 시 호출
  • reject(error) → 실패 시 호출

 

2) Promise 처리하기

Promise의 결과는 .then, .catch, .finally 메서드로 처리할 수 있습니다.

myPromise
  .then(value => { // 성공 시 실행
    console.log("결과:", value);
  })
  .catch(error => { // 실패 시 실행
    console.error("에러:", error);
  })
  .finally(() => { // 항상 실행
    console.log("완료");
  });

 

3) Promise의 3가지 상태

Promise는 생성되자마자 “비동기 작업”을 시작합니다.

그 진행 상태는 다음과 같습니다.

  1. Pending (대기): 아직 작업이 끝나지 않음
  2. Fulfilled (이행): 작업 성공
  3. Rejected (거부): 작업 실패
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("완료");
  }, 3000);
});

console.log(promise); // 처음에는 Pending 상태

3초 뒤에는 resolve가 호출되면서 fulfilled 상태로 변경됩니다.

4) Promise 체이닝

Promise는 .then()을 계속 이어붙여 여러 비동기 작업을 순차적으로 처리할 수 있습니다. 이것을 체이닝(chaining) 이라고 합니다.

function increaseAndPrint(n) {
  return new Promise(resolve => {
    setTimeout(() => {
      const increased = n + 1;
      console.log(increased);
      resolve(increased);
    }, 1000);
  });
}

increaseAndPrint(0)
  .then(n => increaseAndPrint(n))
  .then(n => increaseAndPrint(n))
  .then(n => increaseAndPrint(n))
  .then(n => increaseAndPrint(n));

 

Promise.then()에서 반환된 값을 다시 새로운 Promise로 감싸서 넘기기 때문에 이런 연결이 가능합니다.

/* Promise Hell */
fetch("<https://example.com/api>")
  .then((res) => res.json())
  .then((data) => fetch(`https://example.com/api/${data.id}`))
  .then((res) => res.json())
  .then((data) => fetch(`https://example.com/api/${data.id}/details`))
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((error) => console.error(error));

 

콜백보다는 훨씬 깔끔하지만, .then 체인이 길어지면 여전히 가독성이 떨어지고, 이를 Promise Hell이라고 부릅니다.


async & await: 동기 코드처럼 쓰는 비동기

이런 문제들을 해결하기 위해 ES2017에서 등장한 문법이 async/await입니다.

  • async 키워드를 함수 앞에 붙이면 해당 함수는 항상 Promise를 반환합니다.
  • await 키워드는 Promise가 처리될 때까지 기다렸다가 결과를 반환해줍니다.
async function getData() {
  const response = await fetch("<https://example.com/api>");
  const data = await response.json();

  const response2 = await fetch(`https://example.com/api/${data.id}`);
  const data2 = await response2.json();

  const response3 = await fetch(`https://example.com/api/${data.id}/details`);
  const data3 = await response3.json();

  console.log(data3);
}

getData();

 

마치 동기 코드처럼 한 줄 한 줄 읽히기 때문에 가독성과 유지보수성이 크게 향상됩니다.

1) async / await의 특징과 주의점

  • async 함수는 항상 Promise 객체를 반환합니다.
  • 내부에서 값을 단순 반환하더라도 자동으로 Promise.resolve()로 감싸집니다.
  • 에러는 try/catch로 처리할 수 있어 직관적입니다.
async function func1() {
  return 1;
}
console.log(func1()); // Promise { 1 }

2) 에러 처리

async/await에서는 비동기 함수 안에서 발생한 에러도 일반적인 동기 코드처럼 try/catch로 잡을 수 있다는 점이 큰 장점입니다.

async function fetchData() {
  try {
    const res = await fetch(url);
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

3) 병렬 처리 (주의해야 할 점)

await는 Promise가 끝날 때까지 실행을 멈추기 때문에 무조건 순차 실행됩니다.

// 잘못된 사용 (총 2초 걸림)
async function getFruits() {
  const apple = await getApple(); // 1초
  const banana = await getBanana(); // 1초
  console.log(apple, banana);
}

 

이 경우 서로 의존성이 없는 작업이므로 Promise.all로 동시에 실행하는 게 효율적입니다.

// 올바른 사용 (총 1초 걸림)
async function getFruits() {
  const [apple, banana] = await Promise.all([getApple(), getBanana()]);
  console.log(apple, banana);
}

 


Promise vs async/await 비교

/ Promise async/await
코드 스타일 .then() 체이닝을 통해 순차적으로 동작을 연결 동기 코드처럼 한 줄씩 작성 가능, 가독성 ↑
에러 처리 .catch() 메서드 사용, 필요시 then마다 에러 핸들링 가능 try / catch 블록 사용, 동기 코드와 동일한 방식
가독성 단순한 작업엔 충분히 직관적 복잡한 로직(조건문, 반복문 포함)에서 훨씬 깔끔
병렬 실행 Promise.all, Promise.race 등 유틸 메서드 활용 await Promise.all() 가능, 문법적으로 동일
디버깅 체인이 길어지면 스택 추적 어려움 에러 발생 위치가 명확해 추적이 용이
흐름 제어 순차 실행 표현이 다소 복잡 await 키워드로 직렬/병렬 처리 구분 명확
실행 환경 ES6 이상 지원 ES2017 이상 지원, 구버전 브라우저는 트랜스파일 필요

 

실사용 예시

📍 Promise

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

delay(1000)
  .then(() => console.log("1초 후 실행"))
  .then(() => delay(1000))
  .then(() => console.log("2초 후 실행"))
  .catch((err) => console.error(err));
  • .then()을 체인처럼 이어가며 비동기 순서를 제어
  • 간단한 작업은 직관적이지만, 체인이 길어지면 콜백 지옥과 유사한 복잡성 발생 가능
  • .catch()를 통해 에러를 한 번에 처리할 수 있음

📍 async/await

async function run() {
  try {
    await delay(1000);
    console.log("1초 후 실행");
    await delay(1000);
    console.log("2초 후 실행");
  } catch (err) {
    console.error(err);
  }
}

run();
  • await 덕분에 코드가 동기식처럼 순차적으로 읽힘
  • try/catch 블록을 사용하여 에러 처리가 직관적
  • 특히 조건문, 반복문 안에서도 자연스럽게 작성 가능

장단점 정리

병렬 작업(여러 API 동시에 호출)에는 Promise가 더 직관적이고,
순차 작업(단계별 로직 처리)에는 async/await가 훨씬 가독성이 좋습니다.

📍 Promise

장점

  • ES6부터 도입된 비동기 처리의 표준 방식
  • .then() 체이닝으로 직렬 실행 가능
  • Promise.all, Promise.race로 병렬 실행 최적화에 유리

단점

  • .then 체인이 길어질수록 가독성이 떨어짐
  • 조건문, 반복문 안에서 사용 시 구조가 복잡해짐

 

📍 async/await

장점

  • 코드가 동기처럼 읽히므로 가독성이 뛰어남
  • try/catch를 통한 에러 처리 구조가 직관적
  • for, if 같은 제어문 안에서도 자연스럽게 사용 가능

단점

  • 순차 실행이 기본이라, 무분별하게 사용하면 병렬 실행에서 성능 손해 발생
  • 여러 개의 비동기 작업을 동시에 실행하려면 결국 Promise.all 같은 패턴이 필요

References

- 예제로 이해하는 async/await 문법
async/await로 자바스크립트에서 여러 Functions를 제대로 체이닝 해보기

- Promise는 왜 취소가 안 될까?

- Promise 실전에서 사용해보기

- 📚 자바스크립트 Async/Await 개념 & 문법 정복
- 📚 자바스크립트 Promise 개념 & 문법 정복하기