저번에 ajax에 대한 블로깅을 하면서,
Promise와 Callback에 대해 잠깐 다루긴 했지만 이 두 가지의 차이점까지는 다루지 못했었다

💡 우선 PromiseCallback 의 공통점은 무엇일까?

 공통점은 비동기처리가 동기적으로 처리되게끔 도와주는 것이다
비동기와 동기? 가끔마다 말장난같이 들리는 개념이다…


동기와 비동기는?

우선 동기(synchronous)는 한 작업이 끝날 떄까지 다음 작업을 기다리는 방식으로,
순차적으로 작업을 처리하기 떄문에 한 작업이 끝나야 다음 작업을 실행할 수 있다

반면 비동기(Asynchronous)는 한 작업의 완료 여부와 상관없이 다음 작업을 실행하는 방식으로,
한 번에 여러 작업을 처리할 수 있다


쉽게 생각해서 카페에서 커피주문을 할때
동기적 주문의 경우 직원 한명이 줄을 선 손님의 주문을 순차적으로 받고
한명의 주문을 받고 커피가 나와야만 다음 손님의 주문을 받는 것이고,

비동기적 주문의 경우 손님 인원만큼의 직원들이 손님들의 주문을 동시에 받아 손님들에게 진동벨을 주는 것이다


동기

function syncFunction() {
  console.log("B");
}

console.log("A");
syncFunction();
console.log("C");
// A B C

비동기

const start = Date.now();
setTimeout(() => {console.log(Date.now() - start + 'ms')}, 5000);
setTimeout(() => {console.log(Date.now() - start + 'ms')}, 3000);
setTimeout(() => {console.log(Date.now() - start + 'ms')}, 1000);
// 1000ms
// 3000ms
// 5000ms

✔️ setTimeout은 자바스크립트의 비동기 내장함수이다

 따라서, setTimeout 함수들은 거의 동시에 호출되기 때문에 호출되는 시점은 모두 거의 같다
 가장 길게 설정된 콜백 함수의 타이머가 5초이기 때문에
 출력결과를 보면 모든 작업을 처리하는 데 걸린 시간은 5초인 것을 알 수 있다


더 이해하기

그럼 동기작업과 비동기작업이 섞이게 될 경우엔 어떻게 될까?

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 0);
const baz = () => console.log("Third");

bar();
foo();
baz();

// First
// Third
// Second

bar()함수 내부에 있는 setTimeout 함수가 호출될거고
콜백 함수의 타이머가 0초로 설정되어 있기 때문에 즉시실행 돼서
bar()함수 내부의 코드보다 빠르게 출력되지않을까?

⚠️ 하지만 출력결과를 보면, 뒤늦게 호출된 함수들 내부의 코드가 출력되었다

 즉, setTimeout 함수가 호출되는 시점과 콜백함수가 실행되는 시점이 다르다는 것을 알 수 있다
 그 이유는 자바스크립트가 싱글 스레드인것과 관련이 있다

💡 그럼 여기서 의문이 하나 생긴다

 싱글 스레드이면 한번에 하나의 작업만 수행이 가능할텐데,
 어떻게 비동기적으로 동작시킬 수 있을까?


브라우저 그리고 이벤트 루프


Event Loop

자바스크립트가 싱글 스레드를 가지면서도 비동기로 동작할 수 있는 원리는 바로 브라우저에 있다
브라우저에 있는 Web API가 멀티스레드로 구현되어 있는데,
메인 스레드(Call Stack)가 작업을 Web API에 요청하여 Web API가 대신 실행하고,
그 작업이 완료되면 이벤트나 콜백 함수를 받아 결과를 실행하는 방식으로 비동기 동작이 가능해진다

이런식으로 싱글 스레드인 자바스크립트의 작업을 멀티 스레드로 돌려 작업을 동시에 처리시키게 한다던지,
여러 작업 중 어떤 작업을 우선으로 동작시킬 것인지 결정하는 컨트롤을 위해 존재하는 것이 이벤트 루프이다
브라우저 내부의 Call Stack, Callback Queue, Web APIs 등의 요소들을
이벤트 루프가 모니터링하면서 비동기적으로 실행되는 작업들을 관리하고,
이를 순서대로 처리하여 프로그램의 실행 흐름을 제어하는 관리자라고 보면 된다

브라우저의 내부를 구성하는 각 부품의 역할은 이렇다

🔎 Heap : 동적으로 생성된 자바스크립트 객체가 저장되는 공간

🔎 Call Stack : 자바스크립트 엔진이 코드 실행을 위해 사용하는 메모리 구조

🔎 Web APIs: 브라우저에서 제공하는 API 모음으로, 비동기적으로 실행되는 작업들을 전담하여 처리한다
 (AJAX 호출, 타이머 함수, DOM 조작 등)

🔎 Callback Queue : 비동기적 작업이 완료되면 실행되는 함수들이 대기하는 공간

🔎 Event Loop : 비동기 함수들을 적절한 시점에 실행시키는 관리자

💡 동작흐름을 살펴보면,

 자바스크립트 엔진의 콜 스택에 실행될 함수들이 쌓이게 되는데
비동기 작업이 실행되어야 할 때는 Web API를 호출하게 된다

 Web API에서는 콜백 함수를 콜백 큐에 추가하게 되고
 이벤트 루프는 콜백 큐와 콜 스택을 보면서 콜 스택이 비면 콜백 큐의 함수를 꺼내 콜 스택에 넣어 실행시킨다
 따라서 콜 스택이 하나여도 비동기 동작이 가능한 것이다


아까 코드의 실행순서를 다시 살펴보면, 아래와 같다

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 0);
const baz = () => console.log("Third");

bar();
foo();
baz();

// First
// Third
// Second

1) bar가 호출되면 setTimeout을 반환하고 콜스택에 추가 된다
 미래에 실행될 것을 약속한 후에 setTimeout의 콜백 함수가 Web API에 추가되어 비동기적으로 실행 된다

2) Web API에서 콜백함수의 타이머가 실행되는 동안 foo가 호출되어 콜스택에 추가된다. 실행 완료 후 콜스택을 빠져나가며 First를 출력한다

3) baz가 호출되어 콜스택에 추가된다. 실행 완료 후 콜스택을 빠져나가며 Second를 출력한다

4) foo와 baz가 실행되는 동안 콜백함수의 타이머가 완료되면 콜백함수는 콜백 큐에 들어가서 콜스택이 비워질 때까지 기다린다
 이 때, 이벤트 루프가 콜스택과 콜백 큐의 상태를 확인하며 콜스택이 비워지면 콜백 함수를 콜스택으로 이동시킨다
 이렇게 해서 콜백함수의 실행이 완료되면 콜스택을 빠져나간다


비동기의 필요성

예를 들어 웹 애플리케이션에서 데이터베이스 쿼리를 수행하는 작업이 있다고 가정해보자
만약 이 작업을 동기적으로 수행하면, 데이터베이스에서 응답이 올 때까지 기다려야 한다
그러면 이 때 웹 애플리케이션은 다른 요청을 처리하지 못하므로,
대규모 트래픽이 발생할 경우 웹 애플리케이션의 성능이 저하될 수 있다

비동기 방식으로 데이터베이스 쿼리를 수행하면,
데이터베이스에서 응답이 올 때까지 기다리는 동안에도 다른 요청을 처리할 수 있게 된다
결과가 주어지는데 시간이 걸리더라도 그 시간 동안 다른 작업을 할 수 있으므로 자원을 효율적으로 사용할 수 있는 것이다
이렇게 비동기 방식을 사용하면, 대규모 트래픽에서도 안정적으로 동작할 수 있는 웹 애플리케이션을 만들 수 있다

또 파일 입출력이나 키보드 타이핑 하는 이벤트 동작일 경우라면 어떨까?
비동기가 없다면 파일을 다운 받거나 키보드를 타이핑 하는 동안에는 동안 웹사이트는 멈추게 되어 아무것도 못하게 될 것이다


비동기의 문제점

⚠️ 비동기는 요청한 작업의 완료여부와 상관없이 다음 작업을 실행한다고 했다
 그런데 만약 다음 실행할 작업이 이전에 요청한 작업의 결과가 반드시 필요할 경우 문제가 생긴다
 왜냐하면 비동기 방식은 요청과 응답의 순서를 보장하지 않기 때문이다

그렇다면 해결방법은??


콜백 함수

자바스크립트에서 함수는 일급 객체 이다

🔎 일급객체란?

 변수에 할당이 가능한,
 함수의 매개변수로 전달이 가능한,
 함수의 반환값으로 사용가능한,
 객체의 프로퍼티로 사용가능한
특성을 가진 객체를 의미하다

💡 일급 객체 특성을 이용해 함수의 매개변수에 함수 자체를 넘겨,
함수 내에서 매개변수 함수를 실행하는 기법콜백함수라고 하는데,
 비동기의 문제점으로 응답의 처리 결과에 의존하는 경우
 콜백 함수를 이용해서 작업 순서를 간접적으로 끼워 맞출 수 있다

function asyncFunc(callback) {
  console.log("비동기 작업 시작");
  setTimeout(function () {
    console.log("비동기 작업 완료");
    const result = 2;
    callback(result); 
  }, 1000);
}

function finalResult(finalResult) {
  console.log("결과:", finalResult);
}

asyncFunc(function (result) {
  finalResult(result * 2)
});
// 비동기 작업 시작
// 비동기 작업 완료
// 최종 결과: 4


콜백 지옥

콜백 함수는 비동기 함수에서 작업 결과를 전달받아 처리하는데 사용하면서 작업 순서를 맞출수 있다는 장점이 있지만,
비동기적인 작업을 처리할 때 콜백 함수를 중첩해서 사용하다 보면
콜백 지옥으로 인해 코드가 복잡하고 가독성이 떨어질 수 있는 단점도 존재한다

function stepOne(callback) {
  setTimeout(function () {
    const result = 2;
    callback(result);
  }, 1000);
}

function stepTwo(prevResult, callback) {
  setTimeout(function () {
    const result = prevResult + 3;
    callback(result);
  }, 1000);
}

function stepThree(prevResult, callback) {
  setTimeout(function () {
    const result = prevResult + 5;
    callback(result);
  }, 1000);
}

function finalStep(prevResult) {
  console.log("결과값:", prevResult);
}

stepOne(function (result1) {
  stepTwo(result1, function (result2) {
    stepThree(result2, function (result3) {
      finalStep(result3);
    });
  });
});
// 결과값: 10


Promise

콜백 함수의 콜백지옥과 같은 한계점을 극복하기위해 Promise 라는 비동기 처리를 위한 전용객체가 등장했다
사실 콜백 함수는 비동기를 동기적으로 처리하기 위한 일종의 “편법” 같은 것이지 정식으로 지원하는 비동기 전용 함수가 아니다
Promise는 비동기 작업의 성공 또는 실패와 그 결과값을 나타내는 객체로
Promise를 사용하면 비동기 작업을 쉽고 깔끔하게 연결할 수 있게 된다

Promise는 생성자 함수를 호출할 때 함수 인자로 콜백함수를 선언할 수 있다
콜백 함수는 resolvereject 두 개의 함수를 인자로 받는데
resolve 메서드는 Promise 객체가 성공 상태가 될 때 호출되며,
reject 메서드는 Promise 객체가 실패 상태가 될 때 호출된다

cosnt a = new Promise(function(resolve, reject) {

});

🔎 프로미스를 생성하고 종료될 때 까지 총 3가지 상태를 갖는다

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

💡 그럼 어느시점에서 어떤상태를 나타낼까?

 new Promise() 메서드를 호출하면 대기(pending) 상태가 된다
 Promise의 결과는 then 메서드와 catch 메소드로 받을 수 있는데
 Fulfilled 가 되었을 때 then()을 이용하면 처리 결과 값을 받을 수 있고
 Rejected가 되면 실패한 이유를 catch()를 통해서 받을 수 있다

resolve 메서드가 호출되면, Promise 객체는 이행(Fulfilled) 상태가 되고 then 메서드가 자동으로 호출된다
reject 메서드가 호출되거나 통신에 문제가 발생하면, Promise 객체는 실패(Rejected) 상태가 되고 catch 메서드가 자동으로 호출된다


function stepOne() {
  return new Promise(function (resolve) {
    setTimeout(function () {
      const result = 2;
      resolve(result);
    }, 1000);
  });
}

function stepTwo(prevResult) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      const result = prevResult + 3;
      resolve(result);
    }, 1000);
  });
}

function stepThree(prevResult) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      const result = prevResult + 5;
      resolve(result);
    }, 1000);
  });
}

function finalStep(prevResult) {
  console.log("결과값:", prevResult);
}

stepOne()
  .then(stepTwo)
  .then(stepThree)
  .then(finalStep)
  .catch(function (error) {
    console.error("에러 발생:", error);
  });

🔎 아까의 코드를 Promise 객체로 비동기처리를 해보자

 살펴보면 then 과 catch 가 메서드 체이닝을 이룬것을 볼 수 있다
 then 메서드와 catch 메서드는 다른 Promise 객체를 반환하기 때문에
 메서드 체이닝을 통해 여러 개의 비동기 작업을 순차적으로 처리할 수 있다
 즉, 작업이 순차적으로 실행되는 것이 보장되기때문에 콜백지옥의 문제가 해결된다


Promise의 문제점

마찬가지로 Promise 역시도 콜백 지옥과 같이
Promise 체인이 길어질 수록 코드가 복잡해짐에 따라 가독성이 떨어지고 유지보수가 어려워지는 상황이 발생할 수 있다
이를 Promise Hell 이라고 한다

function asyncFunc(value) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      if (value >= 1) {
        resolve("성공");
      } else {
        reject("실패");
      }
    }, 1000);
  });
}

asyncFunc(1)
  .then(function (result1) {
    console.log(result1);
    asyncFunc(2)
      .then(function (result2) {
        console.log(result2);
        asyncFunc(3)
          .then(function (result3) {
            console.log(result3);
          })
          .catch(function (error3) {
            console.error("에러 발생:", error3);
          });
      })
      .catch(function (error2) {
        console.error("에러 발생:", error2);
      });
  })
  .catch(function (error1) {
    console.error("에러 발생:", error1);
});
// 성공
// 성공
// 성공


async/await

Promise의 이러한 불편한 점들을 해결하기 위해 ES7(ES2017)에서 async/await 문법이 추가되었다
async/await 키워드를 사용하면 비동기 코드를 마치 동기 코드처럼 보이게 작성할 수 있다

function asyncFunc(value) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      if (value >= 1) {
        resolve("성공");
      } else {
        reject("실패");
      }
    }, 1000);
  });
}

async function handleAsyncTasks() {
  try {
    const result1 = await asyncFunc(1);
    console.log(result1);

    const result2 = await asyncFunc(2);
    console.log(result2);

    const result3 = await asyncFunc(3);
    console.log(result3);

  } catch (error) {
    console.error("에러 발생:", error);
  }
}

handleAsyncTasks();
// 성공
// 성공
// 성공


async

함수를 비동기 함수로 선언하는 역할로, await를 사용하기 위한 선언문이다
function 앞에 async을 붙여줌으로써, 함수내에서 await 키워드를 사용할 수 있게 된다
다시말해, await 키워드를 사용하기 위해선 반드시 async function 정의가 되어 있어야 한다

async function func() {
  return '반환';
}

const data = func();
console.log(data)

💡 그럼 async 키워드가 붙은 function은 어떤 역할을 할까?

 function에서 값을 리턴해본 결과
 이행(fulfilled) 상태의 프로미스 객체 형태로 반환되는 것을 확인할 수 있다
 이를 통해 async 리턴값은 Promise 객체로 감싸져 반환된다는 것을 알 수 있다


await

일반 비동기 처리처럼 바로 실행이 다음 라인으로 넘어가는 것이 아니라 결과값을 얻을 수 있을 때까지 기다린다
await는 Promise 의 then 메서드보다 심플하게 결과값을 얻을 수 있는 문법으로
비동기 함수 왼쪽에 await 만 명시해주고 결과값을 변수에 받도록 코드를 정의하면 끝이다

async function func() {
    const res = await fetch(url);
    const data = await res.json();
    console.log(data);
}
func()

💡 Promise 기반의 Web API인 fetch API를 통해 서버에서 데이터를 가져오는 경우를 생각해보자
 await 키워드를 사용하면 Promise가 처리될 때까지 코드 실행을 일시 중지하고,
 Promise가 처리되면 결과 값을 반환해서 변수에 할당한다
 따라서 일반적인 동기 코드 처리와 동일한 흐름으로 코드를 작성할 수 있기 때문에 코드를 읽기에도 수월하다


async / await 에러처리

Promise 는 catch 메서드를 통해 중간중간 명시함으로 써 에러를 받아야 했지만
async/await는 동기,비동기 구분없이 try/catch로 일관되게 예외 처리를 할 수 있다

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

}
func();

⚠️ 알아둘 것은 async/await가 Promise를 대체하기 위한 기능이 아니라는 것이다
 Promise를 더 편리하게 사용하기 위한 기능으로 내부적으로 Promise와 동일한 방식으로 동작한다
 즉, 코드 작성 부분을 프로그래머가 유지보수하게 편하게 보이는 문법만 다르게 해줄 뿐이다


질문

✨ promise와 callback 차이를 설명해주세요
Callback과 Promise 모두 비동기 처리를 위해 사용되는 패턴으로, Callback의 경우 함수의 처리 순서를 보장하기 위해 함수를 직접 중첩하여 사용하므로 콜백 지옥이 발생하는 단점과 에러 처리가 어려운 문제점이 존재합니다. 이러한 한계점을 극복하기 위해 등장한 것이 프로미스이며, Promise는 callback과 다르게 갖는 상태에 따라 then() 또는 catch() 메서드를 통해 작업이 순차적으로 실행되는 것이 보장됩니다. 또한 비동기 처리를 위한 전용 객체로서, callback과는 달리 Promise는 비동기 처리를 정식으로 지원하는 점에서 차이가 있습니다.
Callback을 사용하면 비동기 로직의 결과값은 콜백 함수 안에서만 처리되며, 콜백 함수 외부에서는 해당 결과값을 바로 알 수 없습니다. 그러나 Promise를 사용하면 비동기 작업의 결과값이 Promise 객체에 저장되어 바로 다룰 수 있어서 코드 작성이 편리해집니다. Promise를 사용하면 콜백 지옥을 피하고 비동기 처리가 간편해집니다.


✨ 콜백 지옥(callback hell)을 해결하는 방법을 말씀해주세요
Promise와 async/await를 사용합니다. Promise를 사용하면 비동기 작업을 체이닝하여 중첩을 피하고 코드를 간결하게 작성할 수 있습니다. async/await를 사용하면 비동기 코드를 동기식처럼 보이도록 만들어줍니다. async 함수 내에서 await 키워드를 사용하여 비동기 작업이 완료될 때까지 기다릴 수 있습니다. 이렇게 함으로써 콜백 지옥을 피하고 비동기 처리를 보다 효율적이고 가독성 좋게 구현할 수 있습니다.


✨ async, await 사용 방법을 설명해주세요
async/await는 JavaScript에서 비동기 처리를 더 간결하고 가독성 좋게 작성하기 위한 기능입니다. async/await을 사용하기 위해서 우선 비동기 처리할 함수 앞에 async 키워드를 붙이면, 해당 함수는 암묵적으로 Promise를 반환하게 됩니다. async 키워드가 붙은 함수 안에서 await 키워드를 사용해서 비동기 작업을 요청하면, 비동기 처리가 완료될 때까지 기다렸다가 성공 결과를 반환하게 됩니다. 또한, async/await는 try-catch 블록을 사용하여 비동기 작업 중 발생한 에러를 처리할 수 있습니다.


✨ promise 를 사용한 비동기 통신과 async, await 를 사용한 비동기 통신의 차이를 설명해주세요
Promise는 프로미스 객체의 then,catch,finally 메서드를 사용해서 비동기 결과를 처리하지만, async 함수 내에서 await 키워드로 비동기 함수의 처리가 완료될 때까지 기다렸다가, 성공 결과를 변수에 할당해서 사용할 수 있습니다. 이처럼 Promise와 다르게 후속메서드를 사용할 필요 없고, 좀 더 동기적인 형태로 비동기 처리를 구현할 수 있습니다. 따라서 async/await는 Promise를 더 편리하게 사용하기 위한 기능으로 키워드를 통해 코드를 선언적이고 직관적이게 작성하기 때문에 콜백과 프로미스의 단점인 콜백지옥과 프로미스 체이닝을 해소해줍니다. 또한 Promise 는 catch 메서드를 통해 중간중간 명시함으로써 에러를 받아야 했지만 async/await는 동기,비동기 구분없이 try/catch로 일관되게 예외 처리를 할 수 있습니다.



Reference

카테고리:

업데이트:

댓글남기기