JavaScript 비동기 2 : 콜백, Promise

1. 콜백이란?

콜백 함수는 자바스크립트에서 비동기 작업을 처리하는 가장 기본적인 방법이다.

콜백은 다른 함수에 인자로 전달되는 함수로, 특정 작업이 완료된 후 실행된다.

자바스크립트의 함수가 일급 객체(first-class citizen)이기 때문에 가능한 패턴이다.

 

콜백 함수의 특징

  • 함수를 다른 함수의 인자로 전달한다
  • 특정 이벤트가 발생하거나 작업이 완료되면 호출된다
  • 실행 순서가 보장되어 함수의 실행 흐름을 제어하는 데 사용된다

 

1.1. 비동기 작업에서의 콜백 패턴

비동기 프로그래밍에서 콜백은 작업이 완료된 후 실행되어야 할 코드를 지정하는 데 사용된다.

 

ex)

// 비동기 콜백 예제
console.log("요청 시작");

setTimeout(function() {
  console.log("타이머 완료 후 실행");
}, 2000);

console.log("다음 작업 진행");

 

출력 결과:

요청 시작
다음 작업 진행
타이머 완료 후 실행

 

setTimeout에 전달된 콜백 함수는 2초 후에 비동기적으로 실행된다.

 

1.3. 콜백 지옥(Callback Hell)

중첩된 비동기 작업을 처리할 때, 콜백 함수가 여러 단계로 중첩되는 현상이 발생할 수 있다.

이를 콜백 지옥이라고 한다.

 

ex)

getUserData(userId, function(userData) {
    getArticles(userData.name, function(articles) {
      getComments(articles[0].id, function(comments) {
        ... // 중첩된 콜백
      }, function(error) {
        handleError(error);
      });
    }, function(error) {
      handleError(error);
    });
  }, function(error) {
    handleError(error);
  });

 

콜백 지옥의 문제점

1) 가독성 저하: 코드가 오른쪽으로 깊게 들여쓰기되어 읽기 어렵다.

2) 유지보수 어려움: 로직 변경 시 중첩된, 콜백 구조 전체를 수정해야 할 수 있다.

3) 에러 처리 복잡성: 각 단계마다 에러 처리를 반복해야 하는 경우가 많다.

4) 디버깅 어려움: 스택 트레이스가 복잡해져 오류 발생 위치를 파악하기 어렵다.

 

1.4. 콜백 지옥 개선 방법

1) 함수 분리: 콜백 함수를 분리하여 명명된 함수로 만들기

2) 모듈화: 관련 기능을 모듈로 분리하여 관리

3) Promise 사용: 콜백 대신 Promise 기반 코드로 변환

4) async/await 사용: Promise 기반 비동기 코드를 더 동기적인 스타일로 작성

 

 

2. Promise의 기본 개념

Promise는 비동기 작업의 최종 완료(또는 실패)와 그 결과값을 나타내는 객체이다.

ES6(ES2015)에서 표준으로 도입되어 콜백 패턴의 여러 문제를 해결한다.

 

2.1. Promise의 상태

Promise는 다음 세 가지 상태 중 하나를 가진다.

1) 대기(pending): 초기 상태, 이행되거나 거부되지 않은 상태

2) 이행(fulfilled): 작업이 성공적으로 완료됨

3) 거부(rejected): 작업이 실패함

 

Promise가 이행되거나 거부되면 '처리됨(settled)'이라고 하며, 이 상태에서는 더 이상 다른 상태로 변경되지 않는다.

 

2.2. Promise 생성

Promise 객체는 new Promise() 생성자로 만들며, 실행 함수를 인자로 받는다.

 

ex)

const myPromise = new Promise((resolve, reject) => {
  // 비동기 작업 수행
  const success = Math.random() > 0.5;

  setTimeout(() => {
    if (success) {
      resolve("작업 성공!"); // 이행(fulfilled) 상태로 변경
    } else {
      reject(new Error("작업 실패!")); // 거부(rejected) 상태로 변경
    }
  }, 2000);
});

 

이 Promise는 2초 후 50% 확률로 성공 또는 실패한다.

  • resolve 함수: Promise를 이행 상태로 변경하고 결과값 반환
  • reject 함수: Promise를 거부 상태로 변경하고 에러 반환

 

2.3. Promise 사용

Promise 객체는 .then(), .catch(), .finally() 메서드를 통해 비동기 작업의 결과를 처리한다.

 

ex)

myPromise
  .then(result => {
    console.log(result); // "작업 성공!"
    return "다음 단계";
  })
  .then(nextResult => {
    console.log(nextResult); // "다음 단계"
  })
  .catch(error => {
    console.error(error.message); // "작업 실패!"
  })
  .finally(() => {
    console.log("무조건 실행됨"); // 성공하든 실패하든 항상 실행
  });
  • .then(): Promise가 이행되었을 때 실행할 콜백 등록
  • .catch(): Promise가 거부되었을 때 실행할 콜백 등록
  • .finally(): Promise가 처리되었을 때(이행 또는 거부) 실행할 콜백 등록

 

 

3. Promise 체이닝

Promise의 가장 유용한 기능 중 하나는 여러 비동기 작업을 순차적으로 연결할 수 있는 체이닝이다.

 

3.1. 기본 체이닝

.then()은 새로운 Promise를 반환하므로, 여러 .then()을 연결할 수 있다.

 

ex)

fetchUser(userId)
  .then(user => {
    console.log("사용자 정보:", user);
    return fetchArticles(user.name);
  })
  .then(articles => {
    console.log("작성한 글:", articles);
    return fetchComments(articles[0].id);
  })
  .then(comments => {
    console.log("댓글:", comments);
  })
  .catch(error => {
    console.error("오류 발생:", error);
  });

 

각 .then()에서 Promise를 반환하면 자동으로 다음 .then()에서 그 Promise의 결과를 받을 수 있다.

이를 통해 콜백 지옥을 평평한 구조로 변환할 수 있다.

 

3.2. 값 변환

Promise 체인에서는 이전 단계의 결과를 변환하여 다음 단계로 전달할 수 있다.

 

ex)

fetch('https://api.example.com/data')
  .then(response => response.json()) // 응답을 JSON으로 변환
  .then(data => {
    return {
      processed: true,
      count: data.length,
      items: data.map(item => item.name)
    };
  })
  .then(transformedData => {
    console.log(transformedData);
  });

 

각 .then()에서 반환된 값은 자동으로 Promise로 래핑되어 다음 .then()으로 전달된다.

 

3.3. 에러 처리와 복구

.catch()를 체인 중간에 배치하여 에러 처리 후 체인을 계속 이어갈 수 있다.

 

ex)

fetchData()
  .then(data => {
    if (!data) throw new Error("데이터 없음");
    return processData(data);
  })
  .catch(error => {
    console.warn("데이터 처리 중 오류:", error);
    return getDefaultData(); // 에러 발생 시 기본 데이터로 복구
  })
  .then(result => {
    // 정상 데이터나 기본 데이터로 작업 진행
    displayResult(result);
  });

 

이 패턴을 사용하면 에러가 발생해도 애플리케이션이 계속 동작할 수 있다.

 

 

4. Promise의 정적 메서드

4.1. Promise.resolve() 와 Promise.reject()

즉시 이행 또는 거부 상태의 Promise를 생성한다.

 

ex)

// 즉시 이행되는 Promise
const fulfilledPromise = Promise.resolve("이미 완료된 데이터");

// 즉시 거부되는 Promise
const rejectedPromise = Promise.reject(new Error("즉시 실패"));

fulfilledPromise.then(data => console.log(data));
rejectedPromise.catch(error => console.error(error.message));

 

일반 값을 Promise로 변환하거나, 동기 코드와 비동기 코드를 일관되게 처리할 때 유용하다.

 

4.2. Promise.all()

여러 Promise를 병렬로 실행하고, 모두 이행되었을 때 결과를 배열로 반환한다.

하나라도 거부되면 전체가 거부된다.

 

ex)

const promises = [
  fetch('https://api.example.com/users'),
  fetch('https://api.example.com/posts'),
  fetch('https://api.example.com/comments')
];

Promise.all(promises)
  .then(responses => Promise.all(responses.map(res => res.json())))
  .then(([users, posts, comments]) => {
    console.log("사용자:", users);
    console.log("포스트:", posts);
    console.log("댓글:", comments);
  })
  .catch(error => {
    console.error("하나 이상의 요청 실패:", error);
  });

 

모든 API 요청이 완료된 후에만 데이터를 처리해야 할 때 유용하다.

 

4.3. Promise.allSettled()

모든 Promise가 처리될 때까지 기다리고, 각각의 결과 상태를 객체 배열로 반환한다.

ES2020에서 도입되었다.

 

ex)

const promises = [
  fetch('https://api.example.com/endpoint-1'),
  fetch('https://api.example.com/endpoint-2'),
  Promise.reject(new Error("항상 실패"))
];

Promise.allSettled(promises)
  .then(results => {
    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        console.log(`Promise ${index} 성공:`, result.value);
      } else {
        console.log(`Promise ${index} 실패:`, result.reason);
      }
    });
  });

 

일부 실패가 전체 작업을 중단시키지 않아야 할 때 유용하다.

 

4.4. Promise.race()

여러 Promise 중 가장 먼저 처리되는 것의 결과를 반환한다.

 

ex)

const timeoutPromise = new Promise((_, reject) => {
  setTimeout(() => reject(new Error("시간 초과")), 5000);
});

const dataPromise = fetch('https://api.example.com/data');

Promise.race([dataPromise, timeoutPromise])
  .then(response => response.json())
  .then(data => console.log("데이터:", data))
  .catch(error => console.error("오류:", error.message));

 

4.5. Promise.any()

여러 Promise 중 가장 먼저 이행되는 것의 값을 반환한다.

모든 Promise가 거부되면 AggregateError가 발생한다.

ES2021에서 도입되었다.

 

ex)

const promises = [
  fetch('https://api-1.example.com/data').then(r => r.json()),
  fetch('https://api-2.example.com/data').then(r => r.json()),
  fetch('https://api-3.example.com/data').then(r => r.json())
];

Promise.any(promises)
  .then(firstSuccessfulData => {
    console.log("가장 빠른 응답:", firstSuccessfulData);
  })
  .catch(error => {
    console.error("모든 API 요청 실패:", error);
  });

 

여러 소스에서 동일한 데이터를 가져오고 가장 빠른 응답을 사용하려는 경우 유용하다.

 

 

5. 콜백과 Promise 비교

5.1. 콜백 패턴의 단점

  1. 가독성: 중첩된 콜백은 코드의 가독성을 크게 저하시킨다
  2. 에러 처리: 각 콜백에서 개별적으로 에러를 처리해야 한다
  3. 제어 흐름: 복잡한 비동기 흐름을 관리하기 어렵다
  4. 코드 재사용: 콜백 기반 코드는 재사용이 어려울 수 있다

 

5.2. Promise의 장점

  1. 예측 가능성: Promise는 한 번만 처리되며 상태가 변경된 후에는 불변
  2. 통합 에러 처리: .catch()를 통한 중앙화된 에러 처리
  3. 조합 가능성: Promise.all() 등을 통한 비동기 작업 조합
  4. 타이밍 보장: 이미 처리된 Promise에 등록한 콜백도 적절히 호출됨

'학습 > JavaScript' 카테고리의 다른 글

JavaScript 비동기 3 : async/await  (0) 2025.04.11
JavaScript 비동기 1 : 이벤트 기반  (0) 2025.04.09
프로토타입과 Class  (0) 2025.04.08
var, let, const  (0) 2025.04.07
Javascript와 C++  (0) 2024.12.15