[JavaScript] 비동기 처리 : 콜백 함수와 Promise

 

 

01. Callback

콜백 함수는 함수의 실행 권한을 다른 함수에 넘기는 것을 의미한다.

비동기 함수와 콜백은 별개의 개념이지만, 비동기 작업이 완료된 후 실행되어야 하는 로직을 처리할 때 자주 사용된다.

예를 들어, 서버에 데이터를 요청하는 등의 상황에서 작업이 완료된 후 특정 동작을 해야 할 때 사용된다.

콜백 함수의 역할은 작업이 완료된 후 어떤 작업을 해야 할 지 정의하는 것이며, 이를 통해 자바스크립트는 효율적인 비동기 처리를 할 수 있다.

function callback(param1, param2) {
  console.log(param1 + " and " + param2);
}

function caller(callback) {
  setTimeout(() => {
    callback("hello", "goodbye");
  }, 2000);
}

caller(callback);

 

위 코드는 비동기 작업을 처리하는 콜백 함수의 예시를 보여준다. 해당 코드를 실행하면 다음과 같이 동작한다.

  1. callback 함수 정의 : 두 개의 매개변수를 받아 그 값을 콘솔에 출력
  2. caller 함수 정의 : 인자로 전달된 콜백을 2초 후에 실행
  3. caller 함수 호출 : callback 함수가 caller 함수의 매개변수로 전달
  4. 비동기 처리(2초 대기) : caller 함수 내부의 setTimeout이 실행되면ㅅ, 2초 동안 대기. 이때 자바스크립트는 비동기적으로 동작하여, 2초가 끝날 때까지 다른 작업 수행 가능
  5. 콜백 실행 : 2초 후 callback("hello", "goodbye")가 실행

 

즉, 자바스크립트는 setTimeout 내부에서 콜백 함수를 대기 상태로 두고, 2초가 지난 후에야 콜백 함수를 실행하게 된다.

비동기 작업은 이러한 시간이 오래 걸리는 작업을 백그라운드에서 처리하고, 해당 작업이 끝났을 때 필요한 동작을 콜백 함수를 통해 수행하도록 한다. 하지만 서로 의존적인 비동기 작업들이 순차적으로 실행되어야 할 때 Callback Hell이 발생할 수 있다.

 

 

 

💡 Callback Hell

Callback Hell은 비동기 작업들이 중첩되어 실행될 때 발생하는 코드 구조의 복잡성을 의미한다.

자바스크립트에서는 비동기 작업을 처리할 때 콜백 함수를 자주 사용하지만, 비동기 작업들이 서로 의존적일 때, 즉 하나의 작업이 끝난 후에 다음 작업을 실행해야 하는 경우가 발생하면 콜백 함수가 중첩되기 시작한다.

이 중첩이 많아지면 코드를 이해하기 어렵고 가독성도 떨어지는 문제가 발생한다.

function getData1(callback) {
  setTimeout(() => {
    console.log("Data 1 fetched");
    callback();
  }, 1000);
}

function getData2(callback) {
  setTimeout(() => {
    console.log("Data 2 fetched");
    callback();
  }, 1000);
}

function getData3(callback) {
  setTimeout(() => {
    console.log("Data 3 fetched");
    callback();
  }, 1000);
}

function processAllData() {
  getData1(() => {
    getData2(() => {
      getData3(() => {
        console.log("All data processed");
      });
    });
  });
}

processAllData();

위 예시 코드는 Callback Hell을 볼 수 있는 예시이다.

콜백 함수들이 계속 중첩되면서 코드의 가독성이 크게 떨어지며, 콜백 함수들이 서로 얽혀 있어 에러가 발생했을 때 어느 부분에서 에러가 발생했는지 추적하기 어렵다. 이런 Callback Hell을 피하기 위해 Promise, Async / Await가 도입되었다.

 

 

 

 

02. Promise

Promise는 비동기 함수와 콜백 함수를 결합한 형태로, 비동기 작업이 완료되면 그 결과에 따라 다음 작업을 처리할 수 있도록 설계된 구조이다. Promise는 비동기 함수에서 실행 결과에 따라 성공 또는 실패를 처리하는 매커니즘을 제공하며, 이를 Producer-Consumer 패턴으로 이해할 수 있다.

function caller(callee) {
  var produced = producing(); // 비동기 작업 수행
  callee(produced);           // 결과값을 소비하는 콜백 호출
}

caller(function callee(produced) {
  consuming(produced);        // 결과값 처리
});
  • 비동기 함수(Executor) : caller = Producer : 성공적으로 마쳤을 때 resolve라는 콜백 함수를 통해 값을 전달
  • 비동기 처리 후 수행할 함수(Executee) : callee = consumer : then, catch, finally를 이용해 값을 받아오며, new Promise에서 정상적으로 수행이 되면(.then) 값을 받아와 원하는 기능을 수행하는 콜백 함수 실행

 

 

#1. Promise와 Callback 함수 코드 차이

◼︎ Callback 함수

function getData(callbackFunc) {
  $.get('url 주소/products/1', function(response) {
    callbackFunc(response); // 서버에서 받은 데이터 response를 callbackFunc() 함수에 넘겨줌
  });
}

getData(function(tableData) {
  console.log(tableData); // $.get()의 response 값이 tableData에 전달됨
});

위 예시 코드는 ajax 통신 API를 이용해 지정된 url에서 1번 상품 데이터를 받아오는 코드이다.

비동기 처리를 위해 콜백 함수를 사용했으며 이 코드에 Promise를 적용하면 아래와 같다.

 

 

◼︎ Promise

function getData() {
  // new Promise() 추가
  return new Promise(function(resolve, reject) {
    $.get('url 주소/products/1', function(response) {
      if (response) {
        // 데이터를 받으면 resolve() 호출
        resolve(response);
      } else {
        // 오류가 발생하면 reject() 호출
        reject(new Error("데이터를 가져오지 못했습니다."));
      }
    }).fail(function(error) {
      // $.get() 요청 실패 시 reject() 호출
      reject(error);
    });
  });
}

// getData()의 실행이 끝나면 호출되는 then(), catch(), finally()
getData()
  .then(function(tableData) {
    // resolve()의 결과 값이 여기로 전달됨
    console.log("데이터를 성공적으로 가져왔습니다:", tableData);
  })
  .catch(function(error) {
    // reject()의 결과 값이 여기로 전달됨
    console.error("에러 발생:", error.message);
  })
  .finally(function() {
    // 성공 여부와 관계없이 항상 실행
    console.log("데이터 요청 작업이 완료되었습니다.");
  });

 

콜백 함수 대신 Promise 구조로 코드를 변경하였다.

Promise를 사용하면 new Promise(), resolve(), then()과 같은 Promise API를 통해 비동기 작업을 보다 직관적이고 체계적으로 관리 가능하다. 이러한 Promise API는 아래에서 더 자세히 다룰 것이다.

 

  • then(): Promise가 성공적으로 완료되면 실행. 여기서 resolve()에 전달된 값이 then() 함수로 전달
  • catch(): Promise가 실패하거나 에러가 발생하면 실행. reject() 또는 네트워크 에러와 같은 문제에서 catch()가 호출
  • finally(): 성공 여부와 관계없이 Promise의 작업이 완료된 후 항상 실행. 

 

 

 

 

 

#2. Promise 3가지 상태

프로미스를 사용할 때 알아야 하는 가장 기본적인 개념이 바로 프로미스의 상태이다. 여기서 말하는 상태란 프로미스의 처리 과정을 의미한다. new Promise()로 프로미스를 생성하고 종료될 때까지 3가지 상태를 갖는다.

  • Pending(대기) : 비동기 처리 로직이 아직 완료되지 않은 상태
  • Fulfilled(이행) : 비동기 처리가 완료되어 프로미스가 결과 값을 반환해준 상태
  • Rejected(실패) : 비동기 처리가 실패하거나 오류가 발생한 상태

 

◼︎ Pending(대기)

new Promise(function(resolve, reject) {
  // ...
});

new Promise() 메서드를 호출하면 Pending(대기) 상태가 된다.

new Promise() 메서드를 호출할 때 콜백 함수를 선언할 수 있고, 콜백 함수의 인자는 resolve, reject가 있다.

 

 

◼︎ Fulfilled(이행)

function getData() {
  return new Promise(function(resolve, reject) {
    var data = 100;
    resolve(data);
  });
}

// resolve()의 결과 값 data를 resolvedData로 받음
getData().then(function(resolvedData) {
  console.log(resolvedData); // 100
});

콜백 함수의 인자 resolve를 위와 같이 실행하면 fulfilled(이행) 상태가 된다.

이행 상태가 되면 위와 같이 then()을 이용하여 처리 결과 값을 받을 수 있다.

 

 

◼︎ Rejected(실패)

function getData() {
  return new Promise(function(resolve, reject) {
    reject(new Error("Request is failed"));
  });
}

// reject()의 결과 값 Error를 err에 받음
getData().then().catch(function(err) {
  console.log(err); // Error: Request is failed
});

 

new Promise()로 프로미스 객체를 생성하면 콜백 함수 인자로 resolve reject를 사용할 수 있는데, 여기서 reject를 위와 같이 호출하면 Rejected(실패) 상태가 됩니다. 그리고, 실패 상태가 되면 실패 처리의 결과 값을 catch()로 받을 수 있다.

 

 

 

 

#3. Promise Hell = Nested Promise

Promise Hell은 여러 개의 비동기 작업이 서로 의존적이고 순차적으로 실행되어야 할 때, 코드가 중첩되면서 복잡해지고 가독성이 떨어지는 문제를 의미한다. Promise 의 결과가 그 다음 Promise 실행에 필요한 경우에 발생하며, 이는 콜백 함수가 중첩될 때 발생하는 Callback Hell과 비슷하게 Promise 사용 시에도 중첩 구조가 반복되면 발생한다.

step1()
  .then((result1) => {
    return step2(result1)
      .then((result2) => {
        return step3(result2)
          .then((result3) => {
            console.log(result3);
          });
      });
  })
  .catch((error) => console.error(error));

 

위 코드에서 하나의 then() 블록 안에서 또다른 then()을 중첩하여 호출하게 된다.

비동기 작업들이 깊이 중첩되면서 코드의 가독성이 떨어지고, 중첩된 구조에서는 각 단계마다 에러 처리가 필요하지만 관리가 어려운 문제가 발생한다.

 

 

 

 

 

#4. Promise Chain

Promise Chain은 위와 같은 Promise Hell의 문제를 해결하기 위해 체인 방식을 사용해 각 작업을 순차적으로 실행하고 결과를 전달하는 구조이다. 체이닝을 통해 중첩을 피하고, 한 번에 하나의 작업만을 처리하여 코드의 가독성과 유지보수성을 개선한다.

step1()
  .then((result1) => step2(result1))
  .then((result2) => step3(result2))
  .then((result3) => {
    console.log(result3);
  })
  .catch((error) => console.error(error));

 

중첩 없이 then()을 연속적으로 호출하여 작업을 처리하므로 코드의 가독성이 좋아진다.

또한 각 then()에서 이전 비동기 작업의 결과값을 받아 다음 작업을 수행하므로, 순차적인 실행 흐름을 명확하게 유지할 수 있으며, 모든 then() 블록을 통합해 catch()로 일관된 에러 처리를 할 수 있어 오류 발생 시 관리가 쉬워진다.

Promise Chain은 다양한 작업을 순차적으로 처리하면서도 중간에 새로운 작업을 쉽게 추가하거나 수정할 수 있다.

 

 

 

 

 

 

반응형