Promise(), async, await

jude·2022년 2월 21일
0

javascript

목록 보기
4/4
post-thumbnail

Promise(), async, await

Promiseasync, await는 비동기 로직 호출 함수의 실행 순서를 보장해주기 위해서 만들어졌다.

Promise는 Ecmascript2015(ES6)에 나온 기술이고 async, await는 Ecmascript2017(ES8)에 나온 기술이다.

브라우저의 지원 범위에 따른 각각의 사용법을 알아둘 필요가 있다.

동기와 비동기

일반적인 자바스크립트 코드는 위에서 아래로 읽어내려가면서 처리되는 방식(인터프리터 언어)이다.
자바스크립트는 위에서부터 순차적으로 코드를 실행하며, 이것은 다른말로 동기방식 이라고 할 수 있다.

그런데 이런방식은 웹 페이지를 읽어들일 때, 서버로부터 처리할 양이 많은 데이터를 받아올 때 문제가 생긴다.

순차적으로 코드를 읽어내려오다가 웹 페이지를 렌더링 하는 것을 멈추고 서버에서 데이터를 모두 받을 때까지 기다린다면 화면 렌더링은 매우 느려질 것이다.

그래서 웹 페이지를 렌더링할 때는 html, css 순으로 렌더링 후, 이미지, 폰트 등과 같은 용량이 큰 데이터는 병렬로 받아와서 페이지에 나중에 렌더링된다.

자바스크립트는 코드 자체적으로는 동기적(순서대로)으로 실행되지만, 브라우저에서 읽어들일 때는 비동기(병렬처리) 방식으로 읽어들인다.

그리고 서버에서 데이터를 받아올 땐 XMLHttpRequest()를 사용하는데, XMLHttpRequest를 이용하는 방식이 복잡하기 때문에 jquery의 ajax()가 생겼고, 이러한 개선 기능을 브라우저 내장 API에서도 지원하기 위해 Promise()ES6, async, awaitES8에 구현되었고, Promise 기반으로 만들어진 axios()라는 라이브러리도 등장하게 되었다.


아래와 같이 코드를 실행하면 a함수를 먼저 호출했음에도 불구하고 b함수가 먼저 실행이 된다.
여기서 a함수는 네트워크 상에 데이터를 호출하는 비동기 함수 원리와 비슷하기 때문에 예로 들었다.

다른 로직들은(b함수) 빠르게 먼저 실행되고, 서버 데이터 요청과 같이 시간이 꽤 걸리는 코드는 별도(병렬)로 작동된다.
때문에 서버에서 데이터를 가져와서 화면에 그리는 작업을 해주기 위해선 a함수와 같은 비동기 호출 함수의 결과를 기다렸다가 데이터를 조작하는 코드를 순서대로 작동하게 하는 보장 방법이 필요하다.

function a() {
  setTimeout(() => {
    console.log( 'A' );
  }, 1000)
}
function b() {
  console.log( 'B' );
}

a();
b();

// B
// A

a 함수가 호출되고 난 뒤에 b 함수가 호출되기 위해선 어떻게 해야 할까?

콜백지옥

Promiseasync, await를 이해하기 전에 콜백지옥에 대해 알아야 한다.

function a(callback) {
  setTimeout(() => {
    console.log( 'A' );
    callback();
  }, 1000)
}
function b(callback) {
  setTimeout(() => {
    console.log( 'B' );
    callback();
  }, 1000)
}
function c(callback) {
  setTimeout(() => {
    console.log( 'C' );
    callback();
  }, 1000)
}
function d(callback) {
  setTimeout(() => {
    console.log( 'D' );
    callback();
  }, 1000)
}

a(function() {
  b(function() {
    c(function() {
      d(function() {
        console.log( 'Done!' );
      });
    })
  })
})
// a
// b
// c
// d
// Done!

// a, b, c가 1초 마다 차례 대로 콘솔에 출력되고 c가 호출된 뒤 1초 뒤에 d와 Done! 이 동시에 출력된다.

각 함수의 순서는 보장되지만 이런 로직들이 많으면 많아질수록 점점 뎁스가 깊어지고, 가독성이 떨어지고, 유지보수하기 힘들어지기 때문에 이러한 코드들을 개미지옥처럼 빠져나오기 힘들다는 뜻의 콜백지옥이라고 한다.

그래서 이러한 단점을 보완하기 위해서 Promise(es6)와 async, await(es8)가 구현됐다.

Promise()

Promise()ES6에 구현된 비동기 로직 처리용 객체다.
new Promise()로 생성자 함수를 호출하면 promise 객체가 반환된다.
promise 생성자 함수의 인자로 콜백함수를 넣을 수 있는데, 그 안에 비동기 로직을 작성하고, 콜백함수엔 매개변수 2개를 받을 수 있다.

첫번째 매개변수(resolve)는 순서를 보장해주고 싶은 비동기 로직의 끝에 resolve()와 같이 호출해주고, 인자에 데이터를 넣어 Promise의 인스턴스로 반환할 수 있다.

두번째 매개변수(reject)는 잘못된 입력값을 받거나 에러가 발생했을 때 reject()를 호출해주면 에러를 발생시킬 수 있다.(임의의 에러 메세지나 에러 객체를 인자로 전달하는 것이 가능)
때문에 promise 생성자의 콜백함수 내부에서 if문 등으로, 에러 발생시 실행될 위치에 reject()를 작성해 준다.

여기서 중요한 것은 함수 호출시 promise 객체가 반환되며 resolve의 인자로 전달한 데이터는 then()의 콜백함수의 매개변수로 전달받을 수 있고, 혹시나 로직이 에러가 발생하여 reject가 호출됐을 경우 reject의 인자로 전달한 '에러 메세지' 또는 '에러 객체'를 catch()의 콜백함수의 매개변수로 받을 수 있다는 것이다.


아래는 비동기 로직의 순서를 보장해주는 then()을 사용한 호출 로직이다.

function a() {
  return new Promise(function(resolve) {
    setTimeout(() => {
      console.log( 'A' );
      resolve();
    }, 1000)
  })
}
function b() {
  return new Promise(function(resolve) {
    setTimeout(() => {
      console.log( 'B' );
      resolve();
    }, 1000)
  })
}
function c() {
  return new Promise(function(resolve) {
    setTimeout(() => {
      console.log( 'C' );
      resolve();
    }, 1000)
  })
}
function d() {
  return new Promise(function(resolve) {
    setTimeout(() => {
      console.log( 'D' );
      resolve('finish');
    }, 1000)
  })
}

a().then(() => {
  b().then(() => {
    c().then(() => {
      d().then((data) => {
		console.log(data); // 'finish'
        console.log( 'Done!' );
      })
    })
  })
})

then(), 다시 콜백지옥

위 코드를 보면 then()을 사용해도 콜백지옥 패턴과 다를게 없어보인다.
그럼 어떻게 개선을 할 수 있을까?

resolve()와 then() 체이닝

아래 코드와 같이 promise 객체를 다시 반환 시켜주면 then() 뒤에 다시 then()을 호출할 수 있고 코드는 좀 더 가독성 좋게 작성할 수 있다.

a()
  .then(() => {
    return b();
  })
  .then(() => {
    return c();
  })
  .then(() => {
    return d();
  })
  .then(() => {
    return console.log( 'Done!' );
  })

코드를 좀 더 다듬으면 더 짧게 정리가 된다.

a()
  .then(() => b())
  .then(() => c())
  .then(() => d())
  .then(() => {
    return console.log( 'Done!' );
  })

reject()와 catch() 에러 처리

reject()가 호출되면 catch() 함수를 호출하여 에러를 던질 수 있다.

function a(number) {
  return new Promise((resolve, reject) => {
    if (number > 4) {
      reject();
      return
    }
    setTimeout(() => {
      console.log( 'A' );
      resolve();
    }, 1000)
  })
}
function test() {
  a(7)
    .then(() => {
      console.log( 'resolve!' );
    })
    .catch(() => {
      console.log( 'reject!' );
    })
}
test(); // reject!

아래처럼 resolvethen()으로, reject()catch() 쪽으로 데이터를 보낼 수 있다.

에러 처리 대응을 그냥 에러 객체를 전달 하는 것이 아닌, 에러 객체의 message 프로퍼티로 대응하거나 임의의 에러 메세지 등으로 사용할 경우, catch()문 다음 로직이 실행되니 유의할 것.

function a(number) {
  return new Promise((resolve, reject) => {
    if (number > 4) {
      reject('에러 메세지');
      return
    }
    setTimeout(() => {
      console.log( 'A' );
      resolve('보낼 데이터');
    }, 1000)
  })
}
function test() {
  a(7)
    .then((res) => {
      console.log( res ); // '보낼 데이터'
      console.log( 'resolve!' );
    })
    .catch((message) => {
      console.log( message ); // '에러 메세지'
      console.log( 'reject!' );
    })
}
test();
// '에러 메세지'
// 'reject!'

axios 라이브러리 역시 Promise 객체가 반환되기 때문에, 내부적으로는 데이터 요청 실패시 reject의 인자로 에러 객체를 다양한 에러를 대응하는 코드들로 분기처리해서 catch 쪽으로 넘겨준다는 것을 추측할 수 있다.

axios.get(url)
  .then(() => {
  	// ...
  })
  .catch((err) => {
    console.lor(err);
  })

finally()

finally()는 비동기 요청이 성공하든, 실패하든 항상 호출되는 함수다.

function test() {
  a(7)
    .then(() => {
      console.log( 'resolve!' );
    })
    .catch(() => {
      console.log( 'reject!' );
    })
    .finally(() => {
      console.log( 'Done!' );
    })
}
test();
// 'reject!'
// 'Done!'

async, await

asyncawaitES8에 구현된 비동기 로직 처리용 키워드다.
Promise 생성자를 이용하는건 같지만, ES6에선 Promise 객체로 then()을 사용해서 비동기 로직의 순서를 보장해줬다면 ES8에서는 await 키워드를 통해서 then()과 같은 메서드 호출 없이 동기적인 로직처럼 직관적인 코드 실행 순서를 보장해줄 수 있다.

대신 비동기 로직 호출을 담고 있는 함수 앞에 async 키워드를 붙여주어야 그 안에 await 키워드를 작동시킬 수 있고, 아래 코드처럼 resolve의 인자로 받은 데이터는 await를 붙인 함수의 반환 값으로 해당 데이터를 받을 수 있다.

function a() {
  return new Promise((resolve, reject) => {
    // ...비동기 로직...
    console.log( 'A' );
    setTimeout(() => {
      resolve('보낼 데이터');
    }, 1000);
  })
}
function b() {
  console.log( 'B' );
}

async function test() {
  const res = await a()
  console.log( res );
  b();
}
test();
// A
// 보낼 데이터
// B

// A가 바로 출력되고 1초 뒤에 '보낼 데이터', 'B'가 동시에 출력됨.

동기적인 코드처럼 위에서 아래로 순차적으로 작성할 수 있어서 then()을 쓸 때보다 훨씬 직관적이다.

function a(){
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('A');
      resolve('Hello A');
    }, 1000)
  })
}
function b(){
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('B');
      resolve('Hello B');
    }, 1000)
  })
}
function c(){
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('C');
      resolve('Hello C');
    }, 1000)
  })
}
function d(){
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('D');
      resolve('Hello D');
    }, 1000)
  })
}

async function test() {
  const h1 = await a();
  const h2 = await b();
  const h3 = await c();
  const h4 = await d();
  console.log( 'Done!' );
  console.log( h1, h2, h3, h4 );
}
test();

// A
// B
// C
// D
// Done!
// Hello A Hello B Hello C Hello D

// A, B, C 각각 1초 뒤에 출력되고
// D, Done!, Hello A Hello B Hello C Hello D 는 한번에 출력됨

reject()와 catch() 에러 처리

async, await를 사용했을 때 Promise 객체를 반환하는 것은 똑같기 때문에 catch(), finally()와 같이 메서드 체이닝도 가능하지만 try, catch문을 사용하는 것을 권장한다.

function a(number) {
  return new Promise((resolve, reject) => {
    // ...비동기 로직...
    if (number > 4) {
      reject('에러 발생');
      return;
    }
    setTimeout(() => {
      console.log( 'A' );
      resolve('보낼 데이터');
    }, 1000);
  })
}
function b() {
  console.log( 'B' );
}
async function test() {
  try {
    const res = await a(7);
    console.log( res ); // '보낼 데이터
    b()
  } catch (error) {
    console.log('err', error); // '에러 발생'
  }
}
test();
// err 에러 발생
function a(number) {
  return new Promise((resolve, reject) => {
    // ...비동기 로직...
    if (number > 4) {
      reject('에러 발생');
      return;
    }
    setTimeout(() => {
      console.log( 'A' );
      resolve('보낼 데이터');
    }, 1000);
  })
}
function b() {
  console.log( 'B' );
}
async function test() {
  const res = await a(2)
    .then((res) => {
      console.log('then : ', res);
    })
    .catch((err) => {
      console.log('error : ', err);
    })
  console.log( 'returned res : ', res );
  b()
}
test();
// A
// then :  보낼 데이터
// returned res :  undefined
// B

위와 같이 await를 붙인 함수에 then(), catch()를 붙이면 작동은 하지만, 전달 받는 데이터는 then() 으로 들어가게 되고 변수 res로는 데이터를 반환받지 못한다.

finally

finally문은 try catch문에 이어서 작성하고 finally()와 마찬가지로 로직이 성공/실패에 상관 없이 finally문의 로직은 항상 실행된다.

async function test() {
  try {
    const res = await a(7);
    console.log( res ); // '보낼 데이터
    b()
  } catch (error) {
    console.log('err', error); // '에러 발생'
  } finally {
  	console.log( 'Done!' );
  }
}

비동기(데이터) 호출 함수 재활용 방법

아래와 같이 api를 호출하는 로직만 별도로 생성하여 재활용 하는 것을 권장함.

function fetchMovies(title) {
  const OMDB_API_KEY = '7035c60c'
  return new Promise(async (resolve, reject) => {
    const res = await axios.get(`https://omdbapi.com?apikey=${OMDB_API_KEY}&s=${title}`)
    resolve(res);
  })
}

async function frozenFetch() {
  const res = await fetchMovies('frozen')
  console.log(res);
}
frozenFetch()

function helloFetch() {
  fetchMovies('hello')
    .then(res => console.log(res))
}
helloFetch()

굳이 데이터 호출 함수를 별도로 만드는 이유는 뭘까?

  1. api 호출하는 코드는 해당 로직이 의미하는 것(영화의 정보를 가져오는)을 코드를 보고 직관적으로 파악하기 힘들기 때문에 fetchMovies와 같은 네이밍으로 함수를 만들어주면 어디에서 호출하든 '아, 이 코드는 영화 정보를 가져오는 코드구나' 라는 것을 알 수 있다.

  2. api 호출하는 곳마다 api key정보를 각각 선언해준다면 나중에 api key 정보를 수정할 때 어려움이 있기 때문에 key 정보 같은 필수 데이터 들을 하나의 함수에서 관리하기 쉽다.

  3. 별도로 생성한 api 호출 함수의 인자로 원하는 정보를 넣어서 다양한 용도로 사용하기 쉽다.(재활용하기 쉽다.)

  4. 에러 발생시, 아래 코드처럼 에러 대응 로직을 공통으로 관리하기 쉽다.

    function fetchMovies(title) {
      const OMDB_API_KEY = '7035c60c 에러가 발생하겠지'
      return new Promise(async (resolve, reject) => {
        try {
          const res = await axios.get(`https://omdbapi.com?apikey=${OMDB_API_KEY}&s=${title}`)
          resolve(res);
        } catch (err) {
          reject(err.message);
        }
      })
    }
    
    async function frozenFetch() {
      try {
        const res = await fetchMovies('frozen')
        console.log(res);
      } catch (err) {
        console.log(err); // Request failed with status code 401
    
      }
    }
    frozenFetch()
    
    function helloFetch() {
      fetchMovies('hello')
        .then((res) => console.log(res))
        .catch(err => console.log(err)) // Request failed with status code 401
    
    }
    helloFetch()

비동기 호출 에러 발생 주의사항

아래와 같이 api key만 요청하고 파라미터들을 요청하지 않을 경우 서버에서 에러가 발생해야 하는데 status code200으로 정상적으로 들어올 때가 있다.
이것은 서버에서 고쳐주는 것이 맞지만, 피치 못할 경우 프론트에서 처리할 수 있는 방법도 생각해놔야 한다.

function fetchMovies(title) {
  const OMDB_API_KEY = '7035c60c'
  return new Promise(async (resolve, reject) => {
    try {
      const res = await axios.get(`https://omdbapi.com?apikey=${OMDB_API_KEY}`)
      resolve(res);
    } catch (err) {
      reject(err.message);
    }
  })
}

데이터는 잘 들어오는데 data.Search로 들어오던 영화정보 배열 데이터가 없고, Error라는 객체 key에 에러 관련 문자 데이터가 들어 있는 것을 알 수 있다.

function fetchMovies(title) {
  const OMDB_API_KEY = '7035c60c'
  return new Promise(async (resolve, reject) => {
    try {
      const res = await axios.get(`https://omdbapi.com?apikey=${OMDB_API_KEY}`)
      if (res.data.Error) {
        reject(res.data.Error) // 또는 임의의 에러 메세지 등을 만들어서 전달 가능.
      }
      resolve(res);
    } catch (err) {
      reject(err.message);
    }
  })
}

해결 방법으로 위와 같이 데이터를 받아올 때 res.data.Error 객체가 존재할 경우 reject로 해당 에러 메세지를 바로 던져주거나, 임의의 메세지를 만들어서 전달하면 된다.

결론

Promise 객체를 이해하고 async, await에 대한 내용을 간단하게 정리한다는 것이 좀 양이 많아졌다. 하지만 비동기 호출 로직을 다루는 것은 프론트 개발에서 정말 중요한 부분이기 때문에 헷갈리지 않게, 확실히 이해하고 넘어가지 않으면 안된다고 생각한다.

참고 자료

profile
UI 화면 만드는걸 좋아하는 UI개발자입니다. 프론트엔드 개발 공부 중입니다. 공부한 부분을 블로그로 간략히 정리하는 편입니다.

0개의 댓글