티스토리 뷰

 

[JS] 비동기 처리를 위한 Promise 객체

ES6에서 비동기 처리를 위한 패턴으로 Promise라는 객체가 등장했다. 현재는 Promise를 활용한 Fetch API나 Axios같은 비동기 통신 라이브러리를 통해 비동기 처리 시점이 명확해지며 ES8에 등장한 async/awa

tcitr-antoliny.tistory.com

콜백 패턴의 단점을 극복하기 위해 등장한 Promise객체와 ES8버전에서 등장한 async/await 구문으로 인하여

비동기처리와 관련된 코드의 가독성이 뛰어나게 좋아졌다.

 

오늘은 비동기 처리 코드의 형식을 동기 형식과 유사하게 바꿔 한 단계 더 성장한 가독성을 자랑하는

async/await과 관련해서 포스팅을 작성하고자 한다.

 

동기 형식

 

일단 먼저 기존의 Promise객체를 사용한 비동기 통신 패턴을 살펴보면

then메서드의 인자로 후속처리를 할 콜백 함수를 보내는 형식이다.

fetch('https://jsonplaceholder.typicode.com/posts')
.then((posts) => console.log(posts))
.catch((error) => console.log(error))
.finally(()=> console.log("finish"))

만약 후속처리가 간단하지 않은 상황과 마주했을 때는 아래 코드와 같이 then메서드가 굉장히 많이 사용되어

복잡한 Promise Chain형식이 될 것이다.

fetch("someurl/path")
  .then((posts) => {
    ...
    return fetch("someurl/path");
  })
  .then((comments) => {
    ...
    return fetch("someurl/path");
  })
  .then((post) => {
    ...
    ...
  })
  .catch((error) => {
    ...
    ...
  })
  .finally(() => {
    ...
    ...
  })

콜백 패턴같은 경우는 비동기 통신의 결과를 다음 후속처리를 할 콜백 함수의 인자로 보냄으로써 중첩 함수의 형태지만

Promise객체를 통한 비동기 통신은 후속처리마다 Promise객체를 반환하기 때문에 

각 후속처리 단계를 분리시킬 수 있는 부분이 가독성면에서 큰 차이를 만들어냈다.

하지만 Promise객체도 결국은 콜백 함수를 인자로 받기 때문에 콜백 패턴이라고 할 수 있다.

결국 콜백 패턴은 매번 then메서드를 사용할 때마다 콜백 함수를 인자로 받기 때문에

위 사진과 같이 복잡한 비동기 처리를 콜백 패턴으로 구현하면 Promise객체가 존재하더라도

가독성이 기하급수적으로 떨어지는 건 여전하다.

 

 

async/await

 

ES8버전부터 async/await이라는 구문이 추가되었다.

어쩌면 다른 프로그래밍 언어에도 이미 존재하는 구문이기 때문에 몇몇 독자분들에겐 익숙하게 느껴지지 않을까 싶다.

 

async/await구문은 이전에 봤던 콜백 패턴과는 다르다.

콜백 패턴에서는 후속 처리를 위해 콜백 함수를 사용했다면 async/await구문은 후속 처리 자체가 필요 없다.

왜냐하면 비동기 처리의 결과를 리턴 받기 때문에 동기식처럼 사용하면 된다.

const url = 'https://jsonplaceholder.typicode.com';

const getPostsData = async(url) => {
  const postsRes = await fetch(url);
  return postsRes;
}

console.log(getPostsData(`${url}/posts`));

(※ jsonplaceholder서버가 제공하는 가짜 API를 사용했다.)

 

위 코드에서 getPostsData함수를 async로 선언했고 Promise객체와 관련된 코드 앞에 await키워드를 추가했다.

 

간단하게 async, await이 없다고 생각하고 fetch함수 또한 비동기 함수가 아닌 일반함수라고 가정해 보자

그렇다면 console.log에는 아마 일반함수의 반환값이 출력될 것이다.
(동기처럼 생각해 보자 )

놀랍게도 위 코드의 실행결과는 비동기 함수를 사용했음에도 불구하고 동기 방식처럼

fetch()함수의 결과인 Response객체가 출력된다.

 

이렇게 async/await 구문을 사용하면 비동기 통신의 결과를 리턴 받을 수 있다.

 

만약 비동기와 관련된 함수를 호출하지 않음에도 async함수로 선언하면 어떻게 될까?

function add(x, y) {
  return x + y;
};

const asyncFunction = async(a, b) => {
  const response = add(a, b);
  console.log(response);
  return response;
}

console.log(asyncFunction(10, 20));

비동기 함수 내에서 실행한 add함수의 반환값은 30이고 해당 값을 response변수에 할당했다.

그리고 async함수 내부에서 response변수를 출력해 보면 30이 출력되고

async함수에서 return을 한 뒤 출력해 보면 Promise객체가 출력되는 걸 확인할 수 있다.

 

이렇게 async로 선언된 함수의 리턴값은 언제나 Promise이다.

 

이번에는 Promise와 관련된 코드에 await을 사용하지 않으면 어떻게 되는지 확인해 보겠다.

const url = 'https://jsonplaceholder.typicode.com';

const getPostsData = async(url) => {
  1. const postsRes = await fetch(url);
  2. const postsRes = fetch(url);
  console.log(postsRes);
}

getPostsData(`${url}/posts`);

1번 코드는 await을 사용했고 2번 코드는 await을 사용하지 않았다.

await을 사용했을 때는 Promise의 Result값을 얻었고

await을 사용하지 않았을 때는 Promise객체 자체를 받는다.

 

응답에 대한 결과는 Promise객체의 내부 슬롯인 [[PromiseResult]]에 담겨있는데

(※ 오른쪽 사진 Promise객체 마지막 부분을 확인하자)

내부 슬롯 특성상 직접적인 접근이 불가능하다.

그렇기 때문에 간접적인 접근방식이 제공되어야 해당 값을 가져올 수 있는데

우린 이미 해당 값을 가져와본적이 많기 때문에 간접적인 접근 방식을 이미 알고 있다.

 

Promise객체의 then메서드나 await 키워드는 Promise객체의 내부 슬롯인 PromiseResult의 값에

접근해서 해당 값을 가져오는 역할을 한다.

하지만 위와 같이 내부 슬롯의 값을 가져오는 건 await에게 있어서 부가적인 기능일 뿐이다.

 

await의 역할 중 가장 중요한 부분은 "await"이라는 단어의 의미 그대로 "기다리는"것이다.

const url = 'https://jsonplaceholder.typicode.com';

const getPostsData = async(url) => {
  const response1 = fetch(url);
  console.log(response1);
  const response2 = fetch(`${url}/1`);
  console.log(response2);
}

getPostsData(`${url}/posts`);
console.log(1);

전역 코드에서 getPostsData 비동기 함수를 먼저 호출하고

마지막에 console.log를 출력한다.

 

그리고 getPostsData 비동기 함수에는 await구문이 존재하지 않는다.

 

위와 같은 코드를 실행해 보면

전역 코드에 있는 console.log(1)이 마지막에 수행되는 걸 알 수 있다.

 

이렇게 비동기함수이지만 await키워드로 분기점을 설정해 주지 않으면

비동기가 아닌 동기적으로 실행되며 getPostsData함수가 전부 다 실행될 때까지 대기하는 블로킹이 발생하게 된다.

 

만약 fetch API를 사용한 HTTP 통신에서 많은 시간이 소요된다고 가정해 보면

해당 코드는 통신에서 응답을 기다리느라 다른 작업은 아무것도 할 수 없는 최악의 상황과 마주하게 된다.

이번에는 await 키워드를 사용해 보자

이번에는 전역에 있는 마지막 코드 console.log(1)이 가장 먼저 실행된 걸 확인할 수 있다.


위와 같은 동작이 가능한 이유를 알려면 비동기에 대한 이해가 필요하다.

 

async/await과 관련된 포스팅이기 때문에 짧게 설명하자면

브라우저 환경에는 태스크 큐, 마이크로 태스크 큐와 이벤트 루프라는 게 존재하는데

위 예제처럼 async/await 구문에서는 fetch()같은 Web API는 자바스크립트 엔진이 아닌 브라우저가 실행하게 되고

Promise를 반환하게 되는데 앞에 await키워드가 존재하면 해당 실행 컨텍스트를 일시 중지하고 마이크로 태스크큐에 보내버린다.

 

그렇게 콜 스택에서 async함수의 실행이 미뤄져서 빠지게 되고 전역에 있는 console.log를 먼저 실행된 뒤

콜 스택이 완전히 비었을 때 이벤트 루프를 통하여 마이크로 태스크에 큐에 있던 getPostData를 콜 스택에 다시 푸시하여 실행된다.

 

위와 같은 이유로 전역에 있는 console.log가 가장 먼저 실행된 것이다.

 

⭐️🎀 JavaScript Visualized: Promises & Async/Await

Ever had to deal with JS code that just... didn't run the way you expected it to? Maybe it seemed lik...

dev.to

(※ 비동기 작업과 관련된 자세한 내용은 위 포스팅을 참고하기 바란다.)


await키워드를 만나면 해당 비동기 함수가 일시 중지된다는 걸 알게 됐다.

어떻게 하면 실행되고 있는 함수를 일시 중지 시킬 수 있는 것일까?

 

 

Generator(제너레이터)

 

ES6에 추가된 Generator는 함수의 실행을 일시 중지했다가 필요한 시점에 다시 실행시킬 수 있다.

function* generatorFunction() {
  console.log("hello");
  yield 1;
  console.log("world");
  yield 2;
  console.log("!");
  yield "generator function";
}

위 코드와 같이 Generator함수는 기존함수 키워드에 *을 붙이면 된다.

그리고 yield라는 키워드를 사용할 수 있는데

yield에 관해서는 해당 Generator를 생성한 뒤 실행해 보면서 알아보자.

const generator = generatorFunction();

console.log(generator); // Object [Generator] {}

만약 generatorFunction이 일반 함수였다고 가정하면

위와 같은 코드에서 해당 함수의 구문이 실행되고 리턴값이 generator변수에 할당될 것이다.

 

하지만 Generator함수는 마치 생성자처럼 리턴값으로 Generator객체를 반환하기 때문에

generator변수에 Generator객체가 할당된 걸 확인할 수 있다.

 

처음 보는 이미 형용화된 Generator객체는 어떠한 속성을 가지고 있을까?

console.dir을 통해 해당 객체를 출력해 보았다.

 아마 Iterable한 객체를 아는 사람이라면 눈에 띄는 메서드 하나가 보이지 않을까 싶다.

그건 바로 next메서드인데 Generator객체는 next메서드를 상속받은 형태다.

 

Generator Prototype은 next메서드가 구현되어 있고 Generator가 상속받은 Object에는

Symbol.iterator메서드가 구현된 Iterable 한 객체다.

 

그렇기 때문에 Generator객체는 Symbol.iterator메서드도 상속받고 next메서드 또한 상속받은 형태인

Iterable이면서 Iterator인 객체이다.

그렇다면 Generator객체도 Iterator이기 때문에

Iterator가 사용할 수 있는 구문인 Spread문법과 for ... of 문에서도 동작할까?

function* generatorFunction() {
  console.log("hello");
  yield 1;
  console.log("world");
  yield 2;
  console.log("!");
  yield "generator function";
}

const generator1 = generatorFunction();

for (let value of generator1) {
  console.log(value); // hello 1 world 2 ! generator function
}

const generator2 = generatorFunction();

console.log([...generator2]); // hello world ! [1, 2 'generator function']

위와 같이 잘 동작하는 걸 확인할 수 있다.

 

이번에는 Generator객체에 next메서드를 사용해 보겠다.

function* generatorFunction() {
  console.log("hello");
  yield 1;
  console.log("world");
  yield 2;
  console.log("!");
  yield "generator function";
}

const generator = generatorFunction();

console.log(generator.next()); // hello {value: 1, done: false}
console.log(generator.next()); // world {value: 2, done: false}
console.log(generator.next()); // ! {value: 'generator function', done: false}
console.log(generator.next()); // {value: undefined, done: true}

 

첫 next메서드를 사용했을 때 출력결과를 확인해 보면 console.log("hello")가 실행되고

value, done 프로퍼티를 갖는 Iterator Result 객체를 반환한다.

 

계속해서 next를 사용할 때마다 Generator객체의 생성자 함수 코드에서 yield부분까지만 실행되는 걸 확인할 수 있다.

이렇게 Generator객체는 yield와 Iterator의 특징을 통해 실행을 중지시킬 수 있고 또다시 실행할 수 있다.

즉 함수의 제어권이 사용자에게 주어진 형태다.

 

그리고 Generator객체의 next메서드는 Iterator가 가진 next메서드와 달리

next메서드에 인수를 전달할 수 있다.

(※ Python의 Coroutine과 동일하다.)

 

즉 사용자가 Generator를 다시 가동할 때 값을 전달할 수 있다.

function* generatorFunction() {
  
  const x = yield 1;
  console.log(x);
  const y = yield 2;
  console.log(y);
  const z = yield 3;
  console.log(z);
  
}

이번 generatorFunction의 yield구문이 있는 부분을 보면 변수에 값을 할당하는 형태로 되어있는데

기존 함수의 동작처럼 yield구문의 평가 결과가 x에 할당되는 게 아닌

next()메서드로 보낸 인자값이 해당 변수에 할당된다.

function* generatorFunction() {
  
  const x = yield 1;
  console.log(x);
  const y = yield 2;
  console.log(y);
  const z = yield 3;
  console.log(z);
  
}

const generator = generatorFunction();

console.log(generator.next(10)); // { value: 1, done: false }
console.log(generator.next(20)); // 20 { value: 2, done: false }
console.log(generator.next(30)); // 30 { value: 3, done: false }
console.log(generator.next(40)); // 40 { value: undefined, done: true }

처음 next메서드를 호출했을 때 인자로 10을 전달했고 변수 x에 10이 할당될 것이라고 예상했지만

두 번째 next메서드를 호출했을 때의 인자인 20이 할당되어 출력된 걸 확인할 수 있다.

 

Generator에서 next메서드를 호출했을 때는 언제나 yield 표현식까지만 진행된다.

그렇기 때문에 항상 처음으로 호출하는 next메서드의 인자로 어떤 값을 전달하더라도 의미 없는 값이 된다.


위 사진은 Python에서의 Generator이다.

send메서드는 Python Generator에서 값을 전달하는 방식이고 JS에서 next메서드에 인수를 전달해서 호출하는 것과 동작이 같다.

(※ 문법적으로 차이가 있을 뿐 동작은 거의 같다. Generator에 값을 전달했을 때 흐름을 이해하기 위해 첨부했다.)


 

 

async/await의 원리

 

이제 Generator에 대해 어느정도 알았으니

async/await에서 어떻게 함수의 실행을 중지시키고 재개하는지에 대한 의문이 풀렸다.

 

Generator로 비동기를 구현하면 async/await처럼 비동기 로직을 동기 형식으로 작성이 가능하다.

즉 async/await은 Generator와 Promise기반의 "Syntax Suger"인 것이다.

 

만약 async/awiat키워드를 사용한 아래와 같은 코드를 ES6버전으로 변환해야 한다고 생각해 보자

const url = 'https://jsonplaceholder.typicode.com';

const getPostsData = async(url) => {
  const response = await fetch(url);
  const posts = await response.json();
  console.log(posts);
}

getPostsData(`${url}/posts`);

ES6버전에는 async와 await 키워드가 존재하지 않는다.

그렇기 때문에 async를 Generator함수로 바꾸고 await키워드를 yield로 바꾸기만 하면 된다.

const url = 'https://jsonplaceholder.typicode.com';

function* getPostsData() {
  const response = yield fetch(`${url}/posts`);
  const posts = yield response.json();
  console.log(posts);
}

위와 같은 코드가 동작하려면 getPostsData 함수를 통해 Generator 객체를 생성하고

Generator 객체에 처음 next()메서드를 호출한 결괏값이 다음 next()메서드를 호출할 때 인자로 전달되어야 동작이 가능하다.

 

심지어 일반적인 인자로 전달되어서도 안된다.

만약 response에 fetch API의 결과인 Promise객체가 할당된다고 생각해 보자

하지만 그다음 yield구문인 response.json()함수를 호출하려면 해당 객체가 Promise의 [[PromiseResult]] 내부 슬롯의 값인 Response객체여야 해당 메서드가 호출 가능하다.

즉 Promise객체를 바로 인자로 전달하면 안 되고 해당 Promise객체의 내부 슬롯 [[PromiseReulst]]의 값을 전달해야 한다.

( 혹시 await은 Promise객체의 내부 슬롯인 [[PromiseResult]]에 접근하는 역할도 한다는 걸 기억하나? )

 

Promise객체의 내부 슬롯인 [[PromiseResult]]에 접근하는 방법은 간단하다.

Promise객체에 then메서드를 사용할 때 해당 메서드의 인자로 전달되는 콜백 함수의 첫 번째 인자는

Promise객체의 내부 슬롯인 [[PromiseResult]]이다.

 

지금까지 설명한 내용을 가능하게 하기 위해 Generator 객체를 async/await처럼 동작할 수 있도록 하는 async함수를 만들었다.

const async = (genFunc) => {
  const iterator = genFunc();

  function run(arg) {
    const result = iterator.next(arg);
    if (result.done) {
      return result.value
    } else {
      return Promise.resolve(result.value).then(run);
    }
  }
  return run();
}

async(getPostsData);

async함수는 인자로 Generator함수를 받는다. --> (genFunc);

그리고 해당 Generator함수의 객체를 생성한다. --> const iterator = genFunc();

run()을 반환하여 run함수를 실행하는데 run함수는 arg라는 인자를 받으며 해당 인자는 next메서드를 호출할 때 사용될 값이다.

하지만 처음 run을 호출할 때는 아무런 인자도 전달하지 않고 next메서드를 호출하여 Generator객체의 처음 yield문까지만 실행된다.

--> const result = iterator.next(arg);

const url = 'https://jsonplaceholder.typicode.com';

function* getPostsData() {
  const response = yield fetch(`${url}/posts`);
  ...
}

getPostsData함수를 예시로 들면 fetch(`${url}/posts`)가 실행되고 yield문을 만나 잠시 실행이 중단된다.

그리고 Generator객체는 Iterable이자 Iterator라는 걸 생각했을 때 어떠한 값이 반환될지 예상해 보면

일단 반환형태로 Iterator 리절트 형태인 value와 done프로퍼티를 가진 객체를 반환할 것이며

value에는 fetch의 결괏값인 Promise객체가 담길 것이고

done은 Iterator가 끝까지 진행됐는지의 여부이기 때문에 false가 반환될 것이다.

이제 run함수는 반환된 Iterator Result값을 통해 해당 Generator가 끝까지 실행됐는지 확인한다.

--> if (result.done) {return result.value}

만약 끝이 났다면 result객체의 value값을 반환한다.

반대로 끝이 나지 않았을 때는 result객체의 value를 Promise.resolve메서드를 통해 Promise객체로 변환한 뒤

then메서드의 콜백함수로 run을 전달하여 호출한다.

--> else {return Promise.resolve(result.value).then(run);}

 

여기서 Promise.resolve과정을 거치는 이유는 만약 value값이 Promise객체가 아닐 때를 대비한 것이고

then메서드를 호출할 때 전달한 콜백함수의 인자가 우리가 이전에 실행한 run이라는 걸 생각해 보자

  function run(arg) {
    const result = iterator.next(arg);
    if (result.done) {
      return result.value
    } else {
      return Promise.resolve(result.value).then(run);
    }
  }
  return run();
}

 

필자가 위에서 then메서드에 전달된 콜백 함수의 인자는

호출한 Promise객체의 내부 슬롯[[PromiseResult]]이 전달된다는 걸 기억하나?

 

즉 --> Promise.resolve(result.value).then(run)은 result.value를 Promise객체로 변환한 뒤

해당 객체의 then메서드를 호출했기 때문에 콜백함수로 사용된 run함수의 arg인자로 호출한

Promise객체의 [[PromiseResult]]가 전달되는 구조다.

function* getPostsData() {
  const response = yield fetch(`${url}/posts`);
  const posts = yield response.json();
  console.log(posts);
}

그렇기 때문에 다시 실행되는 iterator.next(arg);로

response변수에 fetch함수의 결괏값인 Promise객체의 [[PromiseResult]]가 담기게 되는 것이다.

 

그리고 다음 yield문까지 계속해서 위와 같은 과정을 반복해서 진행하는 구조다.

 

마지막으로 전체 코드를 실행해 보면

제너레이터 함수인 getPostsData와 async/await을 사용한 함수 getPostsData의 결괏값은 같다.

 

이제 async/await이 "Syntax Suger"라는 것에 어느 정도 감이 잡히지 않는가??

 

마지막으로..

 

async/await을 이해하기 위해서는 Generator에 대한 선수지식이 필요했다.

사실 JS말고도 다른 언어에서도 동일하게 나오는 개념이기 때문에 다른 언어를 접해본 사람들에겐 쉬운 내용이지 않았을까 싶다.

 

물론 이해하지 않아도 사용할 수 있긴 하다.

하지만 필자는 마술을 관람하는 관람객처럼 마술을 보며 신기함과 동시에 

"어떻게 한 거야"라는 생각이 머릿속을 지배하는 사람인가 보다.

 

그래서 그런지 인생이 좀 피곤하다.

댓글
공지사항