비동기: callback, promise, async

Dasol Kim·2022년 3월 8일
0
post-thumbnail

본 포스팅은 유튜브 드림코딩의 강좌를 수강한 후 필자가 홀로 복습한 내용과 함께 재구성하여 작성되었다.

어느 프로그래밍 언어에서나 비동기적으로 메소드를 호출한 후 다음 작업을 위해 이것의 결과, 즉 메소드의 반환값이나 에러 이유를 받아오는 일은 중요하다. 이러한 작업을 일반적으로 비동기 처리라고 일컫는데, 이번 포스팅에서는 javascript에서 어떻게 비동기 처리를 구현할 수 있는지 알아보고자 한다.


Callback 함수를 왜 사용하는가?

일반적으로 콜백 함수는 higher-order function의 매개변수 자리에 정의되어 함수의 body안에서 내부적으로 호출된다. 그렇다면 왜 function declaration을 정의해서 직접 호출하지 않고 왜 콜백 함수를 사용하는가? 이는 콜백 함수의 호출 시점을 개발자가 정의하기 위함이라고 볼 수 있다.(혹은 api로서 미리 정의되어 있을 수 있다.) 콜백 함수가 임의적으로 호출되는 시점 예시 상황으로는 어댑터가 관리하는 특정 리스트 아이템이 클릭되었을 때, higher-order function을 통해 함께 받아온 매개변수의 값이 특정 조건을 만족할 때, 데이터베이스에 대해 쿼리를 요청할 때 등이 있다.

콜백 함수는 synchronous callback과 asynchronous callback으로 나뉘어진다. synchronous callback은 콜백 함수를 사용하는 higher-order function의 실행을 blocking한다.

✏️ higher-order function 호출 > callback function 호출 > callback function 실행완료(blocking) > higher-order function 실행 완료

이와 달리, asynchronous callback은 higher-order function의 실행을 non-blocking 한다.

✏️ higher-order function 호출 > callback function 호출 > higher-order function 실행 완료 > callback function 실행 완료

asynchronous operation을 지원하는 higher-order function은 보통 api로 미리 정의되어 있다. 예를 들면 setTimeOut(callbackFunc, msc)이나 db.query(sql, function(error, result) { ... })등이 있다. 위에서 요약한 asynchronous callback 함수의 실행 과정을 구체적으로 설명하기 위해 자바스크립트 런타임 환경 내부적으로 일어나는 동작 과정을 살펴보자. 자바스크립트 언어 자체는 멀티 스레딩을 지원하지 않는 대신 자바스크립트 런타임 환경이 non-blocking I/O과 event driven을 지원한다. 따라서 자바스크립트 런타임 환경이 콜백함수를 요청받고 이것의 작업이 완료되면 Task Queue에 대기시키고, 이벤트 루프가 싱글 스레드에서 동작하는 Call Stack이 비는 순간 여기에 등록한다.

🪄 Javascript run-time environment:
non-blocking I/O + event driven

한편, 어떤 경우에는 비동기적으로 실행되는 콜백 함수가 실행을 완료할 때까지 기다려 결과를 받아오고 싶을 때가 있다. 구체적으로, 어떤 요청을 해서 성공을 하면 그 결과를 받아오고 실패를 하면 에러 이유를 받아와야 한다. Javascript의 경우 이러한 비동기 처리 작업을 위한 Promise 객체를 지원한다.

✏️ MDN: A Promise is a proxy for a value not necessarily known when the promise is created. It allows you to associate handlers with an asynchronous action's eventual success value or failure reason. This lets asynchronous methods return values like synchronous methods: instead of immediately returning the final value, the asynchronous method returns a promise to supply the value at some point in the future.



Callback과 함께 사용되는 유용한 객체 Promise

Promise의 특성을 다음과 같이 정리할 수 있다.

  • 코드상으로 Promise 객체를 생성하는 Producer와 연산에 대한 결과/에러/뒷처리 작업을 하는 Consumer로 나눌 수 있다.
  • Promise의 세 가지 인스턴스 메소드, 즉 위에서 설명한 연산에 대한 결과를 처리하는 then(), 에러를 처리하는 catch(), 뒷처리 작업을 처리하는 finally()를 통해 chaining을 구현할 수 있다. then()을 통해 받아온 결과값을 통해 다른 Promise 객체를 다시 반환할 수 있다. 이때 에러 핸들링을 위한 순서도 고려해야 한다.
// Promise is a JavaScript object for asynchronous operation.
// State: pending -> fulfilled or rejected
// Producer vs Consumer

// 1. Producer
// when new Promise is created, the executor runs automatically.
const promise = new Promise((resolve, reject) => {
  // doing some heavy work (network, read files)
  console.log('doing something...');
  setTimeout(() => {
    resolve('dasol');
    // reject(new Error('no network'));
  }, 2000);
});

// 2. Consumers: then, catch, finally
promise //
  .then(value => {
    console.log(value);
  })
  .catch(error => {
    console.log(error);
  })
  .finally(() => {
    console.log('finally');
  });

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

fetchNumber
  .then(num => num * 2)
  .then(num => num * 3)
  .then(num => {
    return new Promise((resolve, reject) => {
      setTimeout(() => resolve(num - 1), 1000);
    });
  })
  .then(num => console.log(num)); // 1초후에 5가 출력됨

// 4. Error Handling
const getHen = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve('🐓'), 1000);
  });
const getEgg = hen =>
  new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error(`error! ${hen} => 🥚`)), 1000);
  });
const cook = egg =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(`${egg} => 🍳`), 1000);
  });

getHen() //
  .then(getEgg)
  .then(cook)
  .then(console.log)
  .catch(console.log);

Promise 객체를 생성하는 동시에, 이것의 매개 변수인 콜백 함수인 executor는 바로 실행된다. 위에 코드에서 보여주듯 executor는 resolve와 reject 메소드를 받아오는 콜백 함수이다. 이는 Promise의 조상인 constructor function이 매개 변수로 받아온 함수를 바로 호출한 것으로 추론할 수 있다.(이해하기 어렵다면 skip) 그리고 2초후에 resolve 메소드를 통해 얻은 value가 Promise 객체의 result value가 되며 이는 .then()을 통해 불러올 수 있다. 만일 resolve 메소드 대신 reject 메소드를 호출한다면 promise chaing에서 .catch()가 에러를 콘솔에 출력할 것이다. finally()는 성공/실패 여부와 상관없이 마지막에 무조건적으로 실행된다.

만일 .then()을 통해 받아온 인자와 반환되는 함수의 매개변수가 동일하다면(예를 들어, .then(egg => console.log(egg))), 다음과 같이 간편하게 쓸 수 있다.
.then(console.log)

또한 아래와 같이, getEgg가 저장한 Promise가 반환하는 Error에 대해 사전에 미리 핸들링을 하여 다음 remaining promise chain을 이어나가고 싶을 때 다음과 같이 에러 핸들링을 할 수 있다.

getHen() //
  .then(getEgg)
  .catch(error => '🥐')
  .then(cook)
  .then(console.log)
  .catch(console.log);

이렇게 수정하면 콘솔에 출력되는 결과는 🥐 => 🍳 이다.

참고로, Promise를 통해 연산을 수행할 때에는 pending 상태, 이것의 연산이 성공적으로 수행되면(resolve가 호출되면) fulfilled 상태, 실패하면(reject가 호출되면) rejected 상태이다. 이와 같은 PromiseState는 Promise 객체 안에 제 상태에 따라 PromiseResult와 함께 기록된다.




async, promise의 synctatic sugar ✨

async는 function 키워드 앞에 쓰여 Promise 객체를 직접 반환하지 않고도 return 값을 통해 암묵적으로 Promise를 반환하게끔 해주는 syntatic sugar로서 새로운 개념이 아니다.

function fetchUser() {
    return new Promise((resolve) => resolve("dasol"));
}

const user = fetchUser();
user.then(console.log); // dasol
console.log(user); // Promise {<fulfilled>: 'dasol' ... }

// async 사용하여 바꾼 예제(여기서부터 위의 코드는 무시한다고 가정)
async function fetchUser() {
    // do network reqeust in 10 secs....
    return 'dasol';
  }

const user = fetchUser();
user.then(console.log); // dasol
console.log(user); // Promise {<fulfilled>: 'dasol' ... }

두 예제를 비교했을 때, async 키워드를 사용하면 코드가 더 간결해질 수 있음을 확인할 수 있다. async를 사용함으로써 얻을 수 있는 코드의 간결성은 Promise 객체를 직접 사용하고 난 뒤 promise chaining이 길어져서 복잡해질 때 더욱 빛을 발휘할 수 있다.

참고로 async 키워드는 function declaration 외에도 function expression 앞에 붙어 변수에 할당될 때에도 사용될 수 있고 이를 간결화한 arrow function 앞에도 붙어 사용될 수도 있다. async 함수가 변수에 할당되어 사용되는 경우에는 함수가 즉시 호출이 되기 때문에 함수의 body는 즉시 호출이 된다. 해당 포스팅에서는 이러한 async 함수가 바로 호출되지 않도록 function declaration을 사용하여 Promise.all(...), Promise.race(...) 와 같은 api를 추후에 사용한 테스트 결과를 보이도록 할 것이다.


promise chaining을 버리고 async과 함께 await 사용하기

await는 async 함수 안에 사용될 수 있는 키워드로, async 함수에서 return 문으로 넘긴 값을 또 다른 async 함수 안에 있는 await가 붙은 expression에서, then()을 호출하지 않고도, 받아올 수 있다. 에러 처리의 경우, catch()을 호출하는 대신, try-catch 구문을 활용할 수 있다. 예시를 보며 차근차근 이해해보도록 하자.

아래 예시는 async를 사용하기 전과 후로 나누어, 받아오는 데 어느 정도 시간이 소요되는 사과 이모지와 바나나 emoji를 순차적으로 호출하여 콘솔에 출력해서 보여준다.

async function getApple() {
	// do heavy work...
	return '🍎';
}

async function getBanana() {
	// do heavy work...
	return '🍌';
}

getApple() //
	.then((apple) =>
		getBanana().then((banana) => console.log(`${apple} + ${banana}`))
	);

pickFruits() // 🍎 + 🍌

// await를 사용하여 바꾼 예제(여기서부터 위의 코드는 무시한다고 가정)
async function getApple() {
	// do heavy work...
	return '🍎';
}

async function getBanana() {
	// do heavy work...
	return '🍌';
}

async function pickFruits() {
    const apple = await getApple()
    const banana = await getBanana()
    console.log(`${apple} + ${banana}`)
}

pickFruits() // 🍎 + 🍌

두 예제의 pickFruits 메소드를 비교해보면 promise chaining을 사용했을 때보다 await를 사용했을 때 코드가 훨씬 간결한 것을 확인할 수 있다. 코드의 간결성과 더불어 asyncawait의 조합을 통해 비동기 메소드를 병렬적으로 처리할 수 있다. 위의 코드는 async 함수를 호출한 코드 바로 앞에 await를 붙임으로써 반환된 결과가 apple에 할당될 때까지 기다려 사과, 바나나 순으로 순차적으로 비동기 메소드를 처리하였다.

async function pickFruits() {
	const applePromise = getApple();
	const bananaPromise = getBanana();
	const apple = await applePromise;
	const banana = await bananaPromise;
	return `${apple} + ${banana}`;
}

pickFruits().then(console.log);

비동기 메소드를 병렬적으로 처리하여 최종 결과를 반환하는 위의 코드는
프로미스를 반환하는 getApple()과 getBanana()를 await 키워드 없이 호출하여 동시에 실행되게끔 한다. 만일 🍎를 받아오는데 1000ms, 🍌를 받아오는데에도 1000ms가 소요된다면 1초후에 pickFruits 메소드에 대한 결과가 콘솔창에 출력될 것이다. 같은 가정하에서 순차적 처리를 했을 경우에는 2000ms가 소요될 것이다.

function delay(ms) {
	return new Promise((resolve) => setTimeout(resolve, ms));
}

async function getApple() {
	await delay(2000);
	return '🍎';
}

async function getBanana() {
	await delay(1000);
	return '🍌';
}

async function pickFruits() {
	const applePromise = getApple();
	const bananaPromise = getBanana();
	const apple = await applePromise;
	const banana = await bananaPromise;
	return `${apple} + ${banana}`;
}

pickFruits().then(console.log);

delay(ms) 메소드를 사용하여 사과와 바나나가 반환되는 시간을 코드상으로 조정해주었다. 이 경우 사과는 2초, 바나나는 1초가 소요되어 최종 결과가 반환되기 까지에는 2초가 걸린다.

🪄 내가 정리해 본 Promise 대신 async+await를 사용하는 경우: 1. promise chaining이 복잡할 때 2. 서로 연관된 비동기 메소드를 병렬적으로 처리하고 싶을 때



Promise APIs: Promise.all(...), Promise.race(...)

Promise.all(프로미스 객체를 반환하는 메소드가 담긴 배열)은 배열 안에 담긴 모든 프로미스가 각자의 resolve()를 호출할 때 자신의 resolve()가 최종적으로 호출되는 Promise 객체를 생성한다. 이는 곧 배열 안에 담긴 어떠한 하나의 프로미스가 reject()를 호출한다면 해당 Promise 객체는 reject()를 호출하게 된다는 의미이다. 한편, Promise.race(프로미스 객체를 반환하는 메소드가 담긴 배열) 배열 안에 담긴 어떤 프로미스가 resolve()혹은 reject()를 호출한다면 Promise 객체 역시 동일한 콜백 함수를 호출하게 된다.

✏️ Promise.all(iterable): Wait for all promises to be resolved, or for any to be rejected. If the returned promise resolves, it is resolved with an aggregating array of the values from the resolved promises, in the same order as defined in the iterable of multiple promises.

✏️ Promise.race(iterable): Wait until any of the promises is fulfilled or rejected. If the returned promise resolves, it is resolved with the value of the first promise in the iterable that resolved.

사과를 얻어오는데 1초, 바나나를 얻어오는데 2초가 걸린다고 가정하자. 아래 코드의 실행 결과는 주석을 확인하길 바란다.

function pickAllFruits() {
	return Promise.all([getApple(), getBanana()]).then((fruits) =>
		fruits.join(' + ') // 🍎 + 🍌 (after 2000ms)
	);
}
pickAllFruits().then(console.log);

function pickOnlyOne() {
	return Promise.race([getApple(), getBanana()]);
}

pickOnlyOne().then(console.log); // 🍎 (after 1000ms)

0개의 댓글