Promise & async 못다한 이야기

조 은길·2022년 1월 28일
3

Javascript 정리

목록 보기
30/48
post-thumbnail

이번 시간에는 우리는 콜백 지옥에서 벗어나게 해줄 대안들에 대해서 알아보자!!

왜 Promise 객체를 쓰는 걸까??

Promise는 언제 쓰이는 걸까??

용도는 생각보다 명확하다.

Promise는는 JS에서 제공하는 객체로 비동기 작업을 하는 함수에 return 타입으로 사용한다.

promise가 표준이 되기 전에는 라이브러리마다 처리방식이 달라서, 프로그래머 입장에서는 일일히 작동 방식을 찾아봐야 했다.
그래서, 공통된 인터페이스를 만들어서 쓰기 시작했으니 그게 promise이다.

ex)

fetch("api.whatever.com");
// promise를 return 한다.

요청을 보내고, 받는 과정에서는 불가피하게 딜레이가 발생할 수 밖에 없다.
그래서 이 fetch 함수는 당연하게도 promise 객체를 반환한다.

즉, 무언가 비동기 작업을 하는 함수이다??
자동적으로 Promise 객체를 반환하겠구나!! 하고 생각하면 된다.


1. 상태 ( state )

프로세스가 기능 수행을 다 완료해서, 성공했는지 실패했는지

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

대기(pending) : 이행하지도, 거부하지도 않은 초기 상태.
이행(resolved or fulfilled) : 연산이 성공적으로 완료됨.
거부(rejected) : 연산이 실패함.

state : pending -> resolved or rejected

2. 프로듀서와 컨슈머의 차이

우리가 원하는 데이터는 제공하는 사람과 이 제공된 데이터를 쓰는 사람의 다른 견해를 이해하자

  1. Producer 네트워크 데이터 제공자 (서버 쪽 컴퓨터)

promise의 생성자 안에는 엑시큐터(executor)라는 콜백함수를 전달해줘야 한다.
엑시큐터라는 콜백함수에는 또다른 2가지의 콜백함수를 받는다.

  • resolve : 기능을 정상적으로 수행해서 최종적으로 데이터를 전달함
  • reject : 기능을 수행하다가 중간에 문제가 생기면, 호출하게 됨

무언가 큰 데이터를 받아오는 것은 시간이 걸린다. 그런 작업을 동기적으로 하게 되면, 네트워크에서 데이터를 받아오는 동안, 그 다음 라인의 코드가 실행되지 않는다.
그렇기 때문에, 데이터를 받아오는 동안 놀고 있는 우리의 CPU가 다른 일을 하도록, 논 블로킹 I/O 방식으로 만들어 주는 게 좋다.
바로 그 작업이 Promise를 만들어서 하는 것이다.

const promise = new Promise((resolve, reject) => {
  // 새로운 promise를 만드는 순간 엑시큐터라는 콜백 함수가 자동적으로 실행된다.
  // 바로 이런 점 때문에, 클릭 시에 네트워크에서 데이터를 받아와야 되는 상황이라면, 
  // 필요하지도 않은 네트워크를 받아오지 않게끔 주의해야 한다.

  console.log("doing something...");
  setTimeout(() => {
    resolve("gil");
    // reject는 Error라는 JS에서 제공되는 객체를 통해서 값을 전달한다.
    reject(new Error("no network"));
  }, 2000);
});
// promise라는 producer를 만들었다
  1. consumers (서버 쪽에서 데이터를 주면, 받아쓰는 우리들)
    then, catch, finally를 이용해서 값을 받아올 수있다.
    thenpromise가 정상적으로 수행이 되서, 최종적으로 resolve라는 콜백함수의 값이 value로 전달된다.
    catchpromise가 수행하는데 문제가 생겼을 경우, 최종적으로 reject라는 콜백함수의 값이 value로 전달된다.
    resolve이든 reject든 둘 중에 하나가 실행이 되면, 나머지 코드는 실행되지 않는다.
let Data = new Promise();

Data.then(function(){

}).catch(function(){

});

=> new Promise() 문법으로 Data라는 변수 오브젝트를 하나 생성하시면 Promise 제작 끝이다. 그럼 이제 Data라는 변수에다가 then()을 붙여서 실행가능하다.
코드가 실행이 실패했을 경우엔 catch() 함수 내의 코드를 실행시켜준다.
(물론, 지금은 프로미스 안에 코드가 암것도 없다)

이런 식으로 코드를 차례로 실행할 수 있게 도와주는 디자인 패턴이 바로 Promise이다.

Promise가 콜백함수보다 좋다고 하는 이유

1. 콜백함수와는 다르게 순차적으로 뭔가를 실행할 때 코드가 옆으로 길어지지 않는다.
then 함수를 붙여서 순차적으로 실행하니까...

2. 콜백함수는 순차적으로 뭔가를 실행시킬 때, 콜백함수마다 일일히 에러 처리 코드를 붙여줘야 한다. 그러나, promisecatch구문 한 줄로 모든 에러 처리를 다 커버한다.
=> 비동기인 애들은 특히나!! 실패할 가능성을 염두해둬야 한다.

지금까지 정리한 내용으로 코드로 풀어보면 다음과 같다.

promise
  .then((value) => {
    console.log(value); // gil이 찍힌다.
  })
  .catch((error) => {
    // 그렇다면, reject의 값은 어디로 전달되는가??
    // catch 구문을 쓰지 않으면, 에러가 uncaught "잡히지 않은 에러"라고 뜬다.
    console.log(error);
  })
  .finally(() => {
    // 최근에 추가된 인자이다.
    // 성공하든 실패하든 무조건 마지막에 호출 되어진다.
    console.log('finally');
  });

3. promise chaining

const fetchNumber = new Promise( (resolve, reject) => {
    setTimeout( () => resolve(1), 1000 );
  } );

  // num 에는 1이 들어가서 2가 곱해지고 .. 쭉 가는 것을 알 수 있다.
  fetchNumber
  .then(num => num * 2)
  .then(num => num * 3)
  .then(num => {
    // 추가로 then은 값을 바로 전달해도 되고, 또다른 promise를 전달해도 된다.
    return new Promise( (resolve, reject) => {
      setTimeout( () => resolve(num -1), 1000 );
    })
  })
  .then(num => console.log(num))
  .catch((error) => {
    // catch 구문을 쓰지 않으면, 에러가 uncaught "잡히지 않은 에러"라고 뜬다.
    console.log(error);
  });

Promise의 특징

1. 일단 new Promise()로 생성된 변수를 콘솔창에 출력해보면, 현재 상태를 알 수 있다.
성공/실패 판정 전에는 <pending> 이라고 나오며,
성공 후엔 <resolved>
실패 후엔 <rejected> 이런 식으로 나온다.
이렇게 프로미스 오브젝트들은 3개 상태가 있다.
그리고 성공을 실패나 대기상태로 다시 되돌릴 순 없다.

2. Promise는 동기를 비동기로 만들어주는 코드가 아니다.
Promise는 비동기적 실행과 전혀 상관이 없다.
그냥 코딩을 예쁘게 할 수 있는 일종의 디자인 패턴이다.
예를 들면.. Promise 안에 10초 걸리는 어려운 연산을 시키면, 10초동안 브라우저가 멈춘다.
10초 걸리는 연산을 해결될 때까지 대기실에 제껴두고 그런거 아니다.

(그냥 원래 자바스크립트는 평상시엔 동기적으로 실행이 되며 비동기 실행을 지원하는 특수한 함수들 덕분에 가끔 비동기적 실행이 될 뿐이다.)


async와 await

=> clear style of using promise
asyncawaitpromise를 좀 더 간결하게 만들어주는 역할이다.
체이닝을 계속하면, 조금 코드가 난잡해질 수 있기 때문에 좀 더 간단한 API로 asyncawait을 사용하면, 우리가 그냥 동기식으로 코드를 순서대로 작성하는 것처럼 할 수 있다.

그러나, 무조건 asyncawait을 쓰는 것이 맞는 것은 아니다.
promise를 써야지 맞는 경우도 있고, async를 써야 맞는 경우도 있게 때문에 상황에 맞게 맞춰서 쓰자.

async & awaitpromise를 더 간편하게 쓰기 위한 syntactic sugar이다.

  • syntactic sugar (신테틱 슈거)
    => 기존에 존재하는 API 위에 조금 더 간편하게 쓸 수 있는 API를 제공하는 것
    ex) class, async & await
  • asyncawait 의 특징
    • async 키워드를 쓰면 Promise 오브젝트가 절로 생성된다.
    • async 키워드를 쓴 함수 안에서는 await을 사용가능하다.
    • await은 그냥 프로미스.then() 대체품으로 생각하면 된다.
      하지만 then보다 훨씬 문법이 간단합니다.
// promise 버전
function pickFruits() {
  
  // promise도 너무 길게 체이닝을 하면, 콜백 지옥과 비슷한 결과가 나온다.
  return getApple().then((apple) => {
    return getBanana().then((banana) => `${apple} + ${banana}`);
    
  });
}

// 똑같은 버전을 async로 만들어보기
// try , catch를 이용한 에러 처리
async function pickFruits() {
  try{
  
  const apple = await getApple();
  const banana = await getBanana();
  return `${apple} + ${banana}`;
    
  } catch(err) {
    console.log(err);
  }
}
  • 비동기식처리되는 코드를 담는다면, await 기다리는 동안 브라우저가 잠깐 멈출 수 있다.
  • await은 실패하면 에러가 나고, 코드가 멈춘다.
    • 그것을 예방하기 위해서, try & catch 구문 사용해서 에러 처리를 해줄 수 있다.

코드 예제

똑같은 코드를 콜백, promise, async로 만들어보자!!

솔직히, 이론 몇 번 보는 것보다, 이렇게 만들어보니까 한 눈에 쏙 들어왔다.

콜백

// 콜백 디자인 패턴
const addSum = (a, b, callback) => {
  setTimeout(() => {
    if (typeof a !== "number" || typeof b !== "number") {
      callback("a, b must be numbers");
    }
    callback(undefined, a + b);
  }, 3000);
};

addSum(10, 20, (error, sum) => {
  if (error) return console.log({ error });
  // sum을 받지 않아야 자동으로 undefined가 뜨는구나
  console.log({ sum });
  // 여기서 addSum()을 한 번 더 하고 싶다면??
  addSum(sum, 15, (error1, sum1) => {
    if (error1) return console.log({ error1 });
    // sum을 받지 않아야 자동으로 undefined가 뜨는구나
    console.log({ sum1 });
  });
});
// 콜백으로 에러 처리시에는 if 절로 처리해줘야했지만, 
// promise는 catch 구문을 통해 더 쉽게 처리가 가능하다.
// 연쇄작용을 할 때, 콜백을 호출할 때마다, 에러처리를 일일히 해줘야 한다.

// 연쇄 작용을 더 하면, 할 수록 코드가 더욱 nesting 되기 때문에, 복잡해진다.
// 이 똑같은 것을 promise는??

promise

// promise 디자인
const addSum = (a, b) => {
  return new Promise((resolve, reject) => {
    // resolve 나 reject 둘 중에 하나가 실행이 되면,
    // 나머지 코드는 실행되지 않고 종료된다.
    setTimeout(() => {
      if (typeof a !== "number" || typeof b !== "number") {
        reject("a,b must be number");
      }
      resolve(a + b);
    }, 1000);
  });
};

addSum(10, 20)
  .then((sum) => {
    console.log({ sum });
    return addSum(sum, 15);
  })
  .then((sum1) => {
    console.log({ sum1 });
    return addSum(sum1, "kor");
  })
  .catch((error) => console.log({ error }));
// 에러 처리도 몇 번을 연쇄작용을 해도, 딱 한 번만 해주면 된다.
// 몇 개를 달아도 코드가 너무나 간단해진다.

// 콜백을 여전히 많이 쓰지만, Promise로 많이 옮겨가는 추세이다.
// 하지만, then 체인도 길어지면, 약간 콜백 지옥 냄새가 날 수 있다.
// 이거를 좀 더 이쁘게 사용할 수 있는 문법이 있다.
// 그냥 문법만 좀 더 간결하게 바꿔주는 거!!

async & await

// promise 디자인
const addSum = (a, b) => {
  return new Promise((resolve, reject) => {
    // resolve 나 reject 둘 중에 하나가 실행이 되면,
    // 나머지 코드는 실행되지 않고 종료된다.
    setTimeout(() => {
      if (typeof a !== "number" || typeof b !== "number") {
        reject("a,b must be number");
      }
      resolve(a + b);
    }, 1000);
  });
};

const totalSum = async () => {
  try {
    let sum = await addSum(10, 10);
    console.log({ sum });
    const sum2 = await addSum(sum, 27);
    console.log({ sum2 });
    const final = await addSum(sum, sum2);
    console.log({ final });
  } catch (err) {
    // 이것도 한 번도 처리해주면 된다.
    if (err) console.log(err);
  }
};

console.log(totalSum());

// async의 에러 처리는 try & catch 구문으로 해줘야 한다.
// 실무에서는 논블로킹 I/O 작업을 이런 식으로 한다.

병렬 처리

async function pickFruits() {

  const apple = await getApple();
  const banana = await getBanana();
  return `${apple} + ${banana}`;

}

위의 코드에서 Banana를 받아오는데, Apple을 먼저 받아올 필요가 없다면 이런 식으로 코드를 짜는 것은 상당히 비효율적이다.

Apple를 받아오는데 2초가 걸리고, Banana를 받아오는데 2초가 걸린다면, pickFruits()은 실행되는데 4초의 시간이 걸린다.

그렇다면, 어떻게 더 효율적으로 코드를 짤 수 있을까??

  • 접근법 1 => 지져분한 방법!!

async function pickFruits() {
  // 이 코드가 나오는 순간, promise가 곧바로 실행된다.
  const applePromise = getApple();
  const bananaPromise = getBanana();

  // 이렇게 실행하면, 2개의 프로미스가 동시에 받아와지기 때문에,
  // 2초만에 모든게 다 받아와진다.
  const apple = await applePromise;
  const banana = await bananaPromise;
  // 최종 return 까지는 2초밖에 걸리지 않음
  return `${apple} + ${banana}`;

}

그러나, 이렇게 동시다발적으로 실행이 가능한 경우 (= 병렬적으로 실행가능한 경우)에는
접근법 1 같이 더럽게 코드를 작성하지 않는다.

  • 접근법 2 => Promise.all

  • Promise.all()
    모든 Promise들이 병렬적으로 다 받아질 때까지 모아주는 API이다.
    Promise.all은 배열 형태로 받아온다.


function pickAllFruits() {
  return Promise.all([getApple(), getBanana()])
  .then(fruits => fruits.join(' + '));
}

pickAllFruits().then(console.log);

어떤 것이든 상관없고, 먼저 받아지는 첫 번째 과일만 받아오고 싶다면??

  • promise.race()
function pickOnlyOne() {
  // race()는 Promise의 내장 API이다.
  return Promise.race([getApple(), getBanana()]);
}

// 둘 중 먼저 받아오는 것만 출력 되는 것을 확인할 수 있다.
pickOnlyOne().then(console.log);

물론, 이외에도 Promise 객체에서 지원하는 다양한 메소드가 존재한다.
상황에 맞게 짧은 response time을 낼 수 있는 메소드를 사용하자!!
MDN 프로미스 - 정적 메소드에서 다양한 메소드를 확인하자!!
짧은 response time이 좋은 UX ( 사용자 경험 )를 만든다!!!


async가 promise보다 좋은 이유??

async , await 을 사용하려면, promise 지원이 필수적이다.

그런데 왜 그렇게 async , await 이 좋다고 하는 걸까??

그냥 " 동기적인 코드가 친숙하고 직관적이다 " 라고만 하기에는 애매모호하다.

이 말은 동기적인 코드가 비동기적인 코드보다 더 좋다는 것을 가정하는 있는 것인데... 그런가??

=> 위의 코드에서 promise로 작성된 hello 변수는 쓰임과 동시에 생명주기가 끝난다. 그러나, async 함수에서의 hello 변수는 계속 살아있다.

이런 경우, 이미 사용이 끝난 변수를 다시 쓸 수도 있는 위험부담을 갖고 있다는 측면에서 promise 가 더 좋다고 볼 수 있다.

그런데도 많은 프로그래머들은 async 방식을 더 선호한다.

Why??

promise chainingasync & await의 코드 스타일은 확실하게 다르다.
기존 동기적인 코드 스타일과 async & await의 방식이 좀 더 유사한 것도 사실이다.

우리가 일반적으로 코드를 작성할 때는 논리적으로 동기와 비동기 코드를 명확하게 구분하기 않을 때가 많다고 생각한다.

그리고 비동기함수를 맞이하게 될 때에는 보통 논리적인 이유보다는 시스템적인 제약 때문일 경우가 많다.

그런데, 이 시스템적인 이유 때문에 갑자기 사용하던 코드 스타일을 바꾸자면, 불편할 것이다. 그래서 async & await을 더 선호하지 않나 싶다. ( 뇌피셜 임다 )


콜백을 써야하는 경우 vs Promise를 써야하는 경우


예를 들어, axios 라이브러리는 promise를 기반으로 하는 라이브러리이다.


총정리

다음 수도 코드들에 논 블로킹으로 작성해야 되는 코드가 4개라고 치자

1. 유저 불러오기
2. 블로그 생성
3. 유저 업데이트
4. Log 서비스에 API로 외부에 호출하기
=> 다 처리가 되면 "success"

빨간색 부분은 논 블로킹 작업을 가리킨다. => 외부에서 일어나는 작업
파란색 부분(블로킹 작업)은 실제로 Data를 받았을 때, 파싱을 한다든지 가공해서 보내고 받는 작업을 가리킨다.

1. 일반적인 동기 프로그램

2. Node.js에서 프로그램 동작

Node에서는 비동기이기 때문에, 아래와 같이 호출된다.
첫번째 호출이 끝나지도 않았는데, 나머지 작업들이 호출되고 있다.
빨간색 구간에 있을 때는 Node가 놀고 있는게 아니라, 파란색 작업을 한다.

3. callback, then, async를 이용한 동작

Node.js에서 1번처럼 호출하고 싶다면??
callback, then, async 를 사용해서, 동기코드처럼 똑같이 실행해줄 수있다.

(주의사항) 무조건 await를 하는게 좋은 것은 아니다.
물론, 빨간색 부분에서는 Node가 다른 일을 할 수 있으니까, 서버는 효율적으로 돌아가고 있다.
그러나, 가로 부분의 길이를 줄이는 것이 UX(사용자 경험)에서는 더 짧은 response time이 나오기 때문에 더 좋다.

4. 병렬 방식

존재하는 유저인지 확인하고 => 블로그를 생성해야 하니까 순차적으로 실행해줘야한다.
그러나, 블로그 생성이랑 유저 업데이트는 같이해도 문제가 없다.
Promise.all()을 적절하게 사용해서 response time을 줄이자!!


자료 출처 및 참고 자료

profile
좋은 길로만 가는 "조은길"입니다😁

0개의 댓글