본문 바로가기
프로그래밍/자바스크립트

[JavaScript] Callback, Promise, async/await

by ennak 2025. 2. 10.
반응형

자바스크립트는 기본적으로 단일 스레드로 동작하는 언어다. 이 말은 한 번에 하나의 작업만 처리할 수 있다는 뜻이다. 하지만 현대 웹 애플리케이션은 네트워크 요청, 파일 읽기/쓰기, 타이머 등 비동기 작업이 필수적이다. 이런 작업들을 효율적으로 처리하기 위해 자바스크립트는 다양한 비동기 처리 방식을 제공한다. 대표적으로 콜백(callback), 프로미스(Promise), 그리고 async/await가 있다. 각각의 방식은 등장한 배경과 철학이 다르고, 적재적소에 활용해야 한다.
개발자로서 이 세 가지를 제대로 이해하고 사용하는 것은 필수다. 특히 협업 환경에서는 코드의 가독성과 유지보수성이 중요하기 때문에 비동기 처리를 다루는 방식에 따라 생산성에 큰 차이가 생긴다. 이 글에서는 각 방식의 특징과 장단점을 살펴보고, 개인적인 경험을 바탕으로 어떤 상황에서 어떤 방식을 선택해야 할지 정리해 보겠다.

Callback(콜백): 비동기의 시작점

콜백은 자바스크립트에서 가장 기본적인 비동기 처리 방식이다. 함수 A가 실행된 후 특정 시점에 호출될 함수 B를 인자로 전달하는 방식이다. 예를 들어 다음과 같은 코드가 있다.

function fetchData(callback) {
  setTimeout(() => {
    callback("데이터를 가져왔다!");
  }, 1000);
}

fetchData((data) => {
  console.log(data);
});

위 코드는 setTimeout으로 1초 뒤에 콜백 함수를 호출한다. 간단해 보이지만, 콜백 방식에는 치명적인 단점이 있다. 바로 콜백 지옥(callback hell)이다.

콜백 지옥의 문제

콜백 지옥은 여러 개의 비동기 작업이 중첩될 때 발생한다. 예를 들어, 데이터를 가져오고, 이를 가공한 뒤 저장하는 작업을 생각해 보자.

fetchData((data) => {
  processData(data, (processedData) => {
    saveData(processedData, (result) => {
      console.log(result);
    });
  });
});
fetchData((data) => {
  processData(data, (processedData) => {
    saveData(processedData, (result) => {
      console.log(result);
    });
  });
});

코드가 들여쓰기로 인해 점점 오른쪽으로 밀려나며 읽기가 어려워진다. 이뿐만 아니라 에러 처리가 복잡해진다는 문제도 있다. 각 단계에서 에러가 발생할 가능성이 있는데, 이를 관리하려면 모든 콜백에서 에러 핸들링 로직을 추가해야 한다.
개인적으로 콜백 지옥을 처음 경험했을 때는 "비동기를 이렇게 어렵게 처리해야 하나?"라는 생각이 들었다. 특히 프로젝트 규모가 커질수록 이런 코드가 디버깅과 유지보수를 어렵게 만든다는 것을 뼈저리게 느꼈다.

Promise(프로미스): 콜백의 대안

프로미스는 ES6(ECMAScript 2015)에서 도입된 기능으로, 콜백의 단점을 해결하기 위해 만들어졌다. 프로미스는 비동기 작업의 결과를 나타내는 객체다. 작업이 성공하면 resolve를 호출하고, 실패하면 reject를 호출한다. 프로미스를 사용하면 다음과 같은 코드로 작성할 수 있다.

function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("데이터를 가져왔다!");
    }, 1000);
  });
}

fetchData()
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
    console.error(error);
  });

프로미스의 장점

1. 체이닝

.then() 메서드를 사용해 여러 비동기 작업을 순차적으로 실행할 수 있다. 예를 들어:

fetchData()
  .then((data) => processData(data))
  .then((processedData) => saveData(processedData))
  .then((result) => console.log(result))
  .catch((error) => console.error(error));

위와 같이 작성하면 콜백 지옥에서 벗어날 수 있다.

2. 에러 처리

.catch() 메서드를 통해 모든 단계에서 발생한 에러를 한 곳에서 처리할 수 있다. 이는 코드의 가독성을 크게 향상시킨다.

3. 병렬 처리

Promise.all()이나 Promise.race() 같은 유틸리티 메서드를 사용하면 여러 비동기 작업을 병렬로 실행하거나 가장 빠른 결과만 가져올 수도 있다.

프로미스를 사용할 때 느낀 점

프로미스를 처음 접했을 때, 체이닝과 에러 처리가 깔끔하게 정리된다는 점에서 큰 매력을 느꼈다. 하지만 체이닝이 길어지면 여전히 코드가 복잡해질 수 있다는 단점도 느낄 수 있었다.

async/await

async/await는 ES8(ECMAScript 2017)에서 도입된 기능으로, 프로미스를 기반으로 동작한다. 하지만 문법적으로 동기 코드를 작성하는 것처럼 보이게 만들어 가독성을 크게 향상시킨다.

async function fetchAndProcess() {
  try {
    const data = await fetchData();
    const processedData = await processData(data);
    const result = await saveData(processedData);
    console.log(result);
  } catch (error) {
    console.error(error);
  }
}

async/await의 장점

1. 가독성

동기 코드처럼 작성할 수 있어 논리 흐름을 쉽게 파악할 수 있다. 체이닝보다 직관적이며, 코드가 더 간결하다.

2. 디버깅 용이성

디버깅할 때 스택 트레이스를 확인하기 쉽다. 프로미스 체이닝에서는 에러 발생 위치를 추적하기 어려운 경우가 있었지만, async/await는 그런 문제가 덜하다.

3. 코드 재사용성

async/await를 사용하면 함수 내부에서 비동기 로직을 깔끔하게 분리할 수 있어 재사용성이 높아진다.

async/await을 사용할 때 느낀 점

기존에 프로미스로 작성했던 코드를 async/await로 리팩터링했을 때 코드가 훨씬 깔끔해지고 유지보수가 쉬워졌다. 다만 await 키워드를 남발하면 성능 저하가 발생할 수 있다는 점은 주의해야 한다.

예를 들어 fetch 순서가 상관 없는 데이터들을 fetch한다고 했을 때, 다음 코드는 비효율적이다:

async function inefficient() {
  const data1 = await fetchData1();
  const data2 = await fetchData2();
  const data3 = await fetchData3();
}

위 코드는 각 작업이 순차적으로 실행되므로 시간이 오래 걸린다. 병렬로 실행하려면 Promise.all()을 사용해야 한다:

async function efficient() {
  const [data1, data2, data3] = await Promise.all([
    fetchData1(),
    fetchData2(),
    fetchData3(),
  ]);
}

세 가지 방식 비교와 선택 기준

특징 Callback Promise async/await
가독성 낮음 중간 높음
에러 처리 복잡 간단 간단
디버깅 용이성 어려움 중간 쉬움
병렬 처리 어렵거나 불편 가능 가능(Promise.all)

  • 콜백은 간단한 비동기 작업이나 레거시 코드와 호환해야 할 때 적합하다.
  • 프로미스는 체계적인 비동기 처리가 필요할 때 유용하다.
  • async/await는 대부분의 상황에서 가장 직관적이고 효율적이다.

마무리하며

비동기 처리는 자바스크립트 개발자의 필수 역량이다. 콜백부터 시작해서 프로미스와 async/await까지 발전해 온 과정을 이해하면 각각의 장단점을 명확히 파악하고 상황에 맞게 활용할 수 있다.
개인적으로는 새로운 프로젝트에서는 거의 항상 async/await를 사용한다. 하지만 기존 레거시 코드나 외부 라이브러리를 다룰 때는 여전히 콜백이나 프로미스를 마주하게 된다. 따라서 모든 방식을 이해하고 적절히 활용할 줄 아는 것이 중요하다.
결국 중요한 것은 도구 자체보다 이를 어떻게 활용하느냐다. 코드의 가독성과 유지보수성을 항상 염두에 두고 적합한 방식을 선택하는 것이 좋은 개발자로 성장하는 데 큰 도움이 될 것이다.

반응형