자바스크립트 비동기 처리 - Callback, Promise, async & await

dahyeon·2022년 8월 18일
0

자바스크립트

목록 보기
2/7

동기(synchronous) vs. 비동기(asynchronous)

  • 동기 프로그래밍에서는 한 번에 하나의 작업만 수행된다. 작업이 수행될 동안 다른 작업이 수행되지 않으므로, 작업이 순차적으로 실행된다.
  • 비동기 프로그래밍에서는 두 개 이상의 작업이 동시에 수행될 수 있다. 동기 프로그래밍과 달리 하나의 작업이 끝날 때까지 기다리지 않고 다른 작업을 수행할 수 있다.

아래 코드는 동기적으로 실행된다.

console.log('A');
console.log('B');
console.log('C');
 
Result : 
A
B
C

위와 같이 가벼운 작업으로 이루어진 프로그램이라면 동기적으로 실행한다고 하더라도 비효율적인 것 같진 않다. 하나의 작업을 수행하는 시간이 짧기 때문이다.

하지만 서버에서 데이터를 요청해서 받아와야 하는 상황이라면 어떨까? 데이터 양이 어마어마할 경우 전부 로딩될 때까지 다른 작업이 수행되는 것을 중단하기보다는 다른 작업을 함께 수행하는 것이 효율적이다. 이 때 필요한 것이 비동기 처리이다.

다음의 코드를 보자.

console.log('A');
setTimeout(()=>{
    console.log('B');
}, 3000);
console.log('C');

Result : 
A
C
B

위 코드에서 setTimeout 함수는 3초 후에 ‘B’를 출력한다. 따라서 A, C가 각각 먼저 출력되고 3초 후에 B가 출력된다.

여기서 setTimeout 부분이 비동기 처리가 된 것으로, B가 출력될 때까지 기다리지 않고 그 아래 작업이 먼저 실행되었다!

서버에서 데이터를 요청해서 받아오는 것도 마찬가지로 자바스크립트 내장 함수를 통해 비동기적으로 처리할 수 있다.

하지만 만약 데이터를 받아온 후에 이 데이터를 처리하고 싶다면 어떨까? 예를 들어 수행하고 싶은 작업 A, B, C가 있다. A가 처리된 다음에 C가 처리되게 하고 싶은데, A가 처리되는 동안에는 B를 처리하고 싶다… A를 비동기 처리해주면 A와 B를 동시에 수행할 수 있겠지만, C가 처리되는 순서는 어떻게 보장할까?

아래 예시를 보자.

let data;
setTimeout(()=>{
    data = "processed!";
}, 3000);
console.log(data);

Result : 
undefined

위 코드에서 setTimeout 함수는 3초 후에 data라는 변수에 “processed!”를 할당한다. 이 함수는 비동기 처리되므로 실행될 동안 아래 작업도 동시에 실행된다. 따라서 data에 “processed!”가 할당되기 전에 출력되어버리기 때문에, 결과가 undefined가 나오는 것이다. 우리가 하고 싶은 것은 data에 값이 할당된 후 이 값을 사용하는 것이다.

본 포스팅에서는 비동기 처리를 하면서도 코드의 실행 순서를 조절할 수 있는 요소들에 대해 알아보도록 하겠다.


1. Callback 함수

함수 A 다음에 C를 실행시키고 싶다면, 가장 간단한 방법은 A 안에서 C를 실행시키는 것이다.

다른 함수에 인수로 넘겨지는 함수를 콜백함수라고 한다.

let data;
setTimeout(function f1(){
    data = "processed!";
    f2(data);
}, 3000);

function f2(data){
    console.log(data);
}

위 예시에서

  • f1은 setTimeout의 콜백 함수이다. setTimeout 함수는 3초가 지난 후에 f1을 실행한다.
  • f2는 f1 안에서 실행되는 콜백함수인데, f1 안에서 f2가 실행되기 때문에 실행 순서가 f1 → f2로 보장된다.

그러나 함수의 실행 순서가 더 길어지고 복잡해진다면, 콜백 함수 안에 콜백 함수, 그 콜백 함수 안에 또 콜백 함수를 씀으로써 코드는 더욱 복잡해지고 에러를 핸들링하기가 어려워진다.

function pyramidOfDoom() {
  setTimeout(() => {
    console.log(1)
    setTimeout(() => {
      console.log(2)
      setTimeout(() => {
        console.log(3)
      }, 500)
    }, 2000)
  }, 1000)
}

이러한 상황을 'pyramid of doom'이나 'callback hell'이라고 부르기도 한다.


2. Promise

Promise 객체를 사용하여 f1→f2의 순서로 실행하되, f1은 비동기적으로 실행해보도록 하자. Promise 객체는 f1, f2를 연결해주는 역할을 한다.

Promise를 사용하려면 우선 promise 객체를 생성해야 한다. Promise는 executor라는 함수(f1)를 인수로 받는다. 이 함수에 비동기 처리로 실행하고자 하는 코드를 작성한다.

let promise = new Promise(function(resolve, reject) {
  // executor
});

✔ 새로운 promise 객체가 생성되면 executor는 자동적으로 실행된다.

우리가 하고자 하는 것은 비동기 처리가 끝난 후에 다음 처리를 실행하는 것이다. 그러면 executor 함수의 처리가 끝났다는 것은 어떻게 알 수 있을까?

Executor 함수 안에서, resolve 함수를 호출하면 된다.

let promise = new Promise(function(resolve, reject) {
  let result = "hello";
	resolve(result);
});
promise.then((result)=>{
	console.log(result);
});

f1→f2의 순서로 실행하고, f1에서 처리가 끝난 데이터(result)를 f2에 넘겨주고 싶은 상황.

1) executor=f1 안에서 resolve 호출(resolve(result)) = f1의 처리가 잘 끝났다는 뜻.

2) resolve를 호출할 때 다음 처리에 사용할 데이터(result)를 인수로 넘긴다.

3) promise.then(f2)와 같이 작성하면, f1에서 resolve가 호출되었을 때 f2가 실행된다. f2는 f1에서 resolve에 넘긴 인수(result)를 그대로 인수로 받는다.

즉! f1→f2 순서로 실행하기 위해 promise 객체를 생성해서 executor로 f1을 설정하고, promise.then의 인수로 f2를 설정했다. 이 경우 f1에서 resolve 함수가 호출되면 f2가 실행된다. f1→f2 실행 순서가 보장되는 것이다. 데이터를 넘기고 싶다면 resolve 함수 안에 인수로 넘겨주면 된다.

위는 전체적인 흐름을 설명한 것이고, 이제 차근차근 하나씩 살펴보도록 하자.

🔶 Executor가 인수로 받는 resolve, reject는 각각 다음과 같다.

  • resolve: 함수 안의 처리가 끝났을 때 호출해야 하는 콜백 함수. resolve 함수에 넘긴 값은 다음 처리를 실행하는 함수에 전달된다.
  • reject: 함수 안의 처리가 실패했을 때 호출해야 하는 콜백 함수. 대부분의 경우 오류 메시지 문자열을 인수로 넘긴다.

두 함수는 자바스크립트 엔진에서 이미 정의된 함수로 직접 생성할 필요가 없다.

Promise 객체에는 PromiseState와, PromiseResult라는 프로퍼티가 들어있다.

우선 promise state에 대해 살펴보자.

🔶 Promise는 다음의 세 가지 상태를 가질 수 있다.

  • pending: Promise 객체의 초기 상태. 아직 resolve/reject가 호출되지 않은 상황.
  • fulfilled: resolve가 호출되었을 때
  • rejected: reject가 호출되었을 때

✔ Result는 resolve, reject에 인수로 넘긴 값이 된다. 즉, resolve(data)를 했다면 promise 객체의 result는 data가 된다.

  • 초기 상태에서는 아직 resolve, reject가 호출되지 않은 상태이기 때문에 state는 “pending”이고, result는 undefined이다.

❗ PromiseState, PromiseResult 는 internal property이므로 직접 접근할 수는 없다! Promise 객체를 출력해보면 PromiseState, PromiseResult는 다음과 같이 [[]] 에 둘러싸인 internal property로 나온다.

Promise {[[PromiseState]]: 'rejected', [[PromiseResult]]: "error", ...}

🔶 f1→f2에서 f2 실행시키기: then, catch, finally

  • then
    promise.then(
      function(result) { /* handle a successful result */ },
      function(error) { /* handle an error */ }
    );
    • then 메서드는 두 개의 인수를 받는데, 첫 번째 인수는 promise가 resolve 되었을 때 실행되는 함수로 resolve를 호출할 때 넘겨준 인수를 인수로 받는다.

    • 두 번째 인수는 promise가 reject 되었을 때 실행되는 함수로, 마찬가지로 reject를 호출할 때 넘겨준 인수(error)를 인수로 받는다.

      에러 처리가 필요없다면 promise.then(f2) 처럼 resolve되었을 때 실행할 콜백 함수만 등록해도 된다.

  • catch 에러 처리만 담당하는 메서드로, promise가 reject 되었을 때 실행할 함수를 인수로 받는다. .catch(f2).then(null, f2)와 같다고 생각하면 된다.
  • finally promise가 resolve 또는 reject 되었을 때 실행할 함수를 인수로 받는다. .finally(f2).then(f2, f2) 와 비슷하지만 다른 점은 finally 안에 등록된 함수는 인수를 받지 않는다는 것이다. 즉 .then이나 .catch와는 달리 resolve 또는 reject를 호출하면서 넘겨준 데이터나 에러를 인수로 받지 않는다. 대신 다음에 나오는 .then이나 .catch와 같은 handler에게 처리를 맡긴다.
    new Promise((resolve, reject) => {
      setTimeout(() => resolve("value"), 2000);
    })
      .finally(() => alert("Promise ready")) // triggers first
      .then(result => alert(result)); // <-- .then shows "value"

🤔 공부하면서 헷갈렸던 것:

Q. 비동기 처리를 위해 함수를 promise로 감쌀 때, 왜 promise 객체 자체를 할당하지 않고 함수 안에서 promise 객체를 반환하도록 만드는 것일까?

let delay1 = ()=>{
    return new Promise((resolve)=>{
        setTimeout(()=>{
            resolve();
            console.log("delay1 executed!");
        }, 1000);
    })
}

let delay2 = new Promise((resolve)=>{
    setTimeout(()=>{
        console.log("delay2 executed!");
        resolve();
    }, 1000);
})

위 코드에서 delay1, delay2는 똑같이 setTimeout이란 함수를 promise 객체로 감싼 것이다. delay1은 promise 객체를 반환하도록 했고, delay2에는 바로 promise 객체를 할당했다.

앞서 ✔ 새로운 promise 객체가 생성되면 executor는 자동적으로 실행된다. 라고 언급한 바 있다. 따라서 위 코드에서 delay2는 바로 실행된다! 반면 delay1은 호출하기 전까지는 실행되지 않는다. 함수처럼 원하는 시점에 호출하고 재사용하고 싶다면 delay1 처럼 promise 객체를 반환하게 만들어야 한다.

🔶 비동기 처리 여러 개를 병렬로 실행하기

f1, f2, f3를 비동기 처리로 한꺼번에 실행하고 싶다면?

let delay1 = ()=>{
    return new Promise((resolve)=>{
        setTimeout(()=>{
            resolve("1");
            console.log("delay1 executed!");
        }, 1000);
    })
}

let delay2 = ()=>{
    return new Promise((resolve)=>{
        setTimeout(()=>{
            resolve("2");
            console.log("delay2 executed!");
        }, 1000);
    })
}

let delay3 = ()=>{
    return new Promise((resolve)=>{
        setTimeout(()=>{
            resolve("3");
            console.log("delay3 executed!");
        }, 1000);
    })
}

delay1();
delay2();
delay3();

Result : (1초 후)
delay1 executed!
delay2 executed!
delay3 executed!

위 코드와 같이 promise 안에 executor 함수에 등록해서 비동기 처리로 실행되게끔 만든 다음에 호출하면 된다. 이 경우 delay1, delay2, delay3이 차례대로 동기적으로 실행되는 것이 아니라 병렬로 동시에 실행이 된다. 따라서 1초 후에 Result: 에 적힌 것처럼 결과가 한꺼번에 출력이 된다.

만약 f1, f2, f3를 병렬로 실행하되, 세 함수의 처리가 모두 끝나면 (모두 resolve 됐을 때) f4를 실행하고 싶다면 어떻게 해야 할까? 세 함수 중 어느 함수가 가장 마지막에 끝날 지 모르는 상황이라면 말이다. 이 때 사용할 수 있는 것이 Promise.all이다.

🔶 Promise.all

Promise.all(iterable);
  • 인수인 iterable은 Promise 객체가 요소로 들어 있는 반복 가능한 객체이다. 위 예시에서 [delay1(), delay2(), delay3()] 처럼 배열을 넘길 수 있다.
  • Promise.all 메서드는 그 안의 요소로 들어 있는 모든 promise 객체를 병렬로 실행한다.
  • 이후 모든 promise 객체가 resolve를 호출했을 때 then 메서드에 지정한 함수를 실행한다.
Promise.all([delay1(), delay2(), delay3()]).then(values => {
  console.log(values); // ["1", "2", "3"]
});
  • 이 때 then에 등록한 함수는 response라는 배열을 받는데, 해당 배열에는 각 promise 객체인 요소들이 resolve를 호출할 때 넘긴 인수가 들어있다.
  • 만약 실패한 객체가 하나라도 있다면, 가장 먼저 실패한 promise 객체에서 reject에 넘겨준 인수를 인수로 받는다.

🔶 Promise.allSettled()

  • 주어진 모든 promise가 resolve 또는 reject 될 때까지 기다린 후, 각 결과를 나타내는 배열을 반환한다.

🔶 Promise.any()

  • 주어진 promise 중 가장 먼저 resolve된 promise의 결과를 then에 등록된 함수의 인수로 넘겨준다.

3. Async & Await

async, await은 promise를 좀 더 간편하게 쓸 수 있도록 해주는 키워드이다.

async

async function foo() {
        return 1
    }
  • async 함수는 항상 promise를 반환한다. 만약 반환값이 명시적으로 promise가 아니라면 promise로 감싼 값을 반환한다. 즉, 위의 코드는 아래 코드와 같다.
      function foo() {
            return Promise.resolve(1)
        }

await

await는 async 키워드가 붙은 함수 안에서만 사용가능하며, promise 객체 앞에 붙어서 자바스크립트가 해당 promise가 처리될 때까지 기다리게끔 한다.

async function f() {

  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("완료!"), 1000)
  });

  let result = await promise; // 프라미스가 이행될 때까지 기다림 (*)

  alert(result); // "완료!"
}

f();

promise가 이행된 후 alert를 실행시키고자 하는 상황이라면, 앞서 했던 방법인 .then 메서드를 통해 연결해줄 수도 있다. 하지만 위 코드에서처럼 async 키워드가 붙은 함수 내에서 해당 promise 앞에 await를 붙여주면, 자바스크립트는 해당 promise가 이행될 때까지 기다린다. 즉, 꼭 .then으로 연결해주지 않아도 그 다음의 코드는 promise가 이행된 이후에 실행되는 것이다!

  • await 키워드는 promise가 처리될 때까지 기다리게끔 해주고,

  • 실행된 결과(resolve를 호출할 때 넘겨준 인수)를 반환한다.

  • promise가 reject 될 경우 throw 문을 작성한 것처럼 에러가 던져진다. 따라서 에러를 캐치하려면 try..catch 문을 사용해야 한다.

    async function f() {
    
      try {
        let response = await fetch('http://유효하지-않은-주소');
      } catch(err) {
        alert(err); // TypeError: failed to fetch
      }
    }
    
    f();

    ❗ async 키워드를 붙일 수 없는 최상위 레벨 코드에서는 await를 사용할 수 없다.
    → 이 경우 다음과 같이 async 함수로 코드를 감싸야 한다.

    (async () => {
      let response = await fetch('/article/promise-chaining/user.json');
      let user = await response.json();
      ...
    })();

혼자 공부하면서 정리한 자료이기에 틀린 부분이 있을 수도 있습니다. 정정이 필요한 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.


참고 자료

profile
https://github.com/dahyeon405

0개의 댓글