[모던 자바스크립트 Deep Dive] - 45~46장

Lee Jeong Min·2021년 10월 26일
0
post-thumbnail

이 글은 책 모던 자바스크립트 45장 ~ 46장을 읽고 정리한 글입니다.

45장 - 프로미스

JS는 비동기 처리를 위해 콜백 함수를 사용하지만 이 콜백 패턴은 다음과 같은 문제점이 있다.

  • 콜백 헬로 인한 가독성이 나쁘다.
  • 비동기 처리 중 발생하는 에러의 처리가 곤란하다.
  • 여러 개의 비동기 처리를 한번에 처리하는데 한계가 있다.

이러한 비동기 처리를 위해 ES6에서는 프로미스를 도입하여 위 단점들을 보완하고 비동기 처리 시점을 명확하게 표현한다.

비동기 처리를 위한 콜백 패턴의 단점

콜백 헬

const get = url => {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', url);
  xhr.send();

  xhr.onload = () => {
    if (xhr.status === 200) {
      console.log(JSON.parse(xhr.response));
      return JSON.parse(xhr.response); // 기대한 대로 동작 X
    } else {
      console.error(`${xhr.status} ${xhr.statusText}`);
    }
  };
};


const response = get('https://jsonplaceholder.typicode.com/posts/1');
console.log(response); // undefined

onload라는 이벤트 핸들러가 비동기로 동작하기 때문에 get 함수는 비동기 함수이다.

위와 같은 get 함수에서 onload 이벤트의 결과로 출력되는 JSON.parse(xhr.response))를 콘솔에 출력이 아닌, 결과로 반환하려면 어떻게 해야 하는가?

위 함수는 비동기 함수이기 때문에 비동기 함수가 종료된 이후에 완료되므로 함수 내부의 비동기로 동작하는 코드에서 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당하면 기대한 대로 동작하지 않는다.

위 상황에서 get 함수의 반환문은 생략되었으므로 암묵적으로 undefined를 반환한다. onload 이벤트 핸들러는 get 함수가 호출하지 않기때문에 이벤트 핸들러의 반환값을 get 함수가 캐치할 수 없고, 상위스코프를 사용한다 하더라도 이벤트 핸들러 프로퍼티에 바인딩한 이벤트 핸들러는 전역코드의 실행이 끝난다음 실행되기 때문에 확인할 수 없다.

이처럼 비동기 함수는 비동기 처리를 외부에 반환할수 없고, 상위 스코프의 변수에 할당할 수 없어서 비동기 함수의 처리 결과에 대한 후속 처리는 비동기 함수 내부에서 수행해야 한다. --> 콜백함수를 이용

const get = (url, successCallback, failureCallback) => { ... }

get('url', console.log, console.error);

그러나 이 방법은 비동기 함수가 비동기 처리 결과를 가지고 비동기 함수를 호출하여 콜백 함수 호출이 중첩되어 복잡도가 높아지는 현상을 발생시킬 수 있다. --> 콜백 헬

get('url', a => {
  get(`url/${a}`, b => {
    get(`url/${b}`, c => {
      get(`url/${c}`, d => {
        get(`url/${d}`, e => {
  
        })
      })
    })
  })
})

에러 처리의 한계

try {
  setTimeout(() => { throw new Error('hi!'); }, 1000);
} catch (e) {
  console.error('에러', e);
}

위 예제에서는 setTimeout 함수가 실행 컨텍스트 스택에 들어가 호스트에게 호출 스케줄링을 부탁하고 바로 종료되기 때문에 실행 컨텍스트 스택에서 제거된 상태이므로 캐치 블록에서 잡히지 않는다.
에러는 호출자 방향으로 전파되어 콜 스택의 아래 방향으로 전파되는데, 저 함수가 호출될때의 콜 스택에는 아무것도 없기 때문에 에러를 잡을 수가 없다.

try...catch...finally 문은 try 코드 블록이 실행되고 그 중 에러가 발생하면 catch 문의 err 변수로 전달되어 catch 블록이 실행된다. finally 블록은 에러 발생과 상관없이 반드시 한 번 실행된다. 이렇게 에러를 처리하면 프로그램이 강제 종료 되지 않는다.

프로미스의 생성

new 연산자와 함께 Promise를 호출하여 생성하고 이는 ECMAScript 사양에 정의된 표준 빌트인 객체이다.
resolve, reject함수를 인수로 받는다.

const promiseGet = url =>
  new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.send();

    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(JSON.parse(xhr.response));
      } else {
        reject(new Error(xhr.status));
      }
    };
  });

promiseGet('https://jsonplaceholder.typicode.com/posts/1');

아까 위에서 작성했던 코드를 promise를 사용하면 다음과 같이 작성할 수 있다. 달라진 부분은 반환하는 것이 new Promise 이며 인자로 resolve, reject를 사용하여 성공 시, resolve 함수를, 실패 시 reject 함수를 호출한다. 둘다 프로미스를 반환한다.

프로미스의 상태 정보

프로미스 상태 정보의미상태 변경 조건
pending비동기 처리가 아직 수행되지 않은 상태프로미스가 생성된 직후 기본 상태
fulfilled비동기 처리가 수행된 상태(성공)resolve 함수 호출
rejected비동기 처리가 수행된 상태(실패)reject 함수 호출

생성된 직후의 프로미스는 기본적으로 pending 상태. 비동기 처리 결과에 따라 프로미스의 상태가 변경된다.

  • 비동기 처리 성공: resolve 함수를 호출해 프로미스를 fulfilled 상태로 변경
  • 비동기 처리 실패: reject 함수를 호출해 프로미스를 rejected 상태로 변경

비동기 처리가 수행된 상태를 settled 상태라고 한다.(다른 상태로 변화 불가능)

PromiseStatus = 비동기 처리 상태 정보
PromiseValue = 비동기 처리 결과 정보
프로미스는 비동기 처리 상태와 결과를 관리하는 객체이다.

프로미스의 후속 처리 메서드

then, catch, finally를 제공한다. 프로미스의 비동기 처리 상태가 변화하면 후속 처리 메서드에 인수로 전달한 콜백 함수가 선택적으로 호출된다. 모든 후속 처리 메서드는 프로미스를 반환하고 비동기로 동작한다.

Promise.prototype.then

두 개의 콜백함수를 인수로 전달받는다.

  • 첫 번째 콜백 함수는 프로미스가 fulfilled 상태가 되면 호출된다. -> 콜백함수는 프로미스의 비동기 처리 결과를 인수로 전달받음
  • 두 번째 콜백 함수는 프로미스가 rejected 상태가 되면 호출된다. -> 이때 콜백함수는 프로미스의 에러를 인수로 전달받는다.
new Promise(resolve => resolve('fulfilled')).then(
  v => console.log(v),
  e => console.error(e)
);

new Promise((_, reject) => reject(new Error('rejected'))).then(
  v => console.log(v),
  e => console.error(e)
);

언제나 프로미스를 반환하고, 콜백 함수가 프로미스 반환 시 그대로 반환하고 아닌 값을 반환하면 그 값을 암묵적으로 resolve, reject 하여 프로미스를 생성해 반환

Promise.prototype.catch

한 개의 콜백 함수를 인수로 전달받는다. 프로미스가 rejected 상태인 경우만 호출된다.

new Promise((_, reject) => reject(new Error('rejected'))).catch(e =>
  console.log(e)
);

언제나 프로미스 반환

Promise.prototype.finally

한 개의 콜백 함수를 인수로 전달받고, 프로미스의 성공 또는 실패와 상관없이 무조건 한 번 호출된다. --> 공통적으로 수행해야 할 처리 내용이 있을 때 유용하며 언제나 프로미스를 반환한다.

new Promise(resolve => resolve('fulfilled'))
  .then(
    v => console.log(v),
    e => console.error(e)
  )
  .finally(() => console.log('finally'));
promiseGet('https://jsonplaceholder.typicode.com/posts/1')
  .then(res => console.log(res))
  .catch(e => console.error(e))
  .finally(() => console.log('end'));

then, catch, finally를 이용한 후속 처리 부분

프로미스의 에러 처리

아까 위에서 적은 promiseGet의 resolve, reject는 프로미스를 반환하므로 then, catch, finally등의 후속 처리메서드를 사용할 수 있다.

에러를 처리하는 경우 then 메서드의 두 번째 콜백 함수로 처리하거나 catch를 사용해 처리할 수 있는데, catch 메서드는 내부적으로 then(undefined, onRejcted)를 호출하는 것과 같다.

그러나 아래의 상황 때문에 then 메서드보다 catch 메서드를 사용하는 것이 가독성이 좋고 명확하다.

promiseGet('https://jsonplaceholder.typicode.com/todos/1').then(
  res => console.xxx(res), 
  err => console.error(err)
);
promiseGet('https://jsonplaceholder.typicode.com/todos/1')
  .then(res => console.xxx(res))
  .catch(err => console.error(err));

위 코드의 각각의 결과는 위 사진과 같다. then 메서드 사용시 첫번째 콜백 함수에서 발생한 에러를 캐치하지 못해 Uncaught ~ 에러가 발생하며, catch 메서드를 사용 시 비동기 처리에서 발생한 에러 뿐만 아니라 then 메서드 내부에서 발생한 에러까지 모두 캐치가 가능하다. (TypeError 발생)

프로미스 체이닝

then, catch, finally 후속 처리 메서드는 언제나 프로미스를 반환하기 때문에 연속적으로 호출할수 있고 이를 프로미스 체이닝 이라고 한다.

이 프로미스 체이닝을 통해 비동기 처리 결과를 받아 후속 처리를 하므로 콜백 헬이 발생하지 않는다. 그러나 결국 promise도 콜백 패턴을 사용하므로 콜백함수를 사용하지 않는 것은 아니다.

콜백 패턴은 가독성이 좋지 않은데 이 문제를 async/await를 통해 해결할 수 있다. --> 동기 처리처럼 프로미스가 처리 결과를 반환하도록 함.

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

(async () => {
  const { userId } = await promiseGet(`${url}/posts/1`);

  const userInfo = await promiseGet(`${url}/users/${userId}`);

  console.log(userInfo);
})();

프로미스의 정적 메서드

Promise는 주로 생성자 함수로 사용되지만 함수도 객체이므로 메서드를 가지며 5가지 정적 메서드를 제공한다.

Promise.resolve / Promise.reject

resolve와 reject 메서드는 이미 존재하는 값을 래핑하여 프로미스를 생성하기 위해 사용한다.

인수로 전달받은 값 resolve하는 프로미스 생성

Promise.resolve([1, 2, 3]) === new Promise(resolve => resolve([1, 2, 3])));

뒤에 then 후속 처리 메서드 붙여서 console.log 찍으면 [1, 2, 3] 나옴

reject도 마찬가지로 인수로 전달 받은 값을 reject 하는 프로미스를 생성한다.

Promise.all

여러 개의 비동기 처리를 모두 병렬 처리할때 사용한다.

Promise.all([requestData1(), requestData2(), requestData3()])
  .then(console.log)
  .catch(console.error);

이 메서드는 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달 받는다. 모든 promise가 fulfilled 상태가 되면 모든 처리 결과를 배열에 저장해 새로운 프로미스를 반환한다.

이 메서드가 종료되는 시간은 가장 늦게 fulfilled 상태가 되는 프로미스의 처리 시간보다 약간 더 김(가장 긴 시간이 3초라면 3초 + 알파가 모든 처리에 걸리는 시간)

이 메서드는 첫 번째 프로미스가 resolve한 처리 결과부터 차례대로 배열에 저장하기 때문에 처리 순서가 보장되며 하나라도 rejected 상태가 되면 그 즉시 종료한다.

인수로 전달받은 요소가 프로미스가 아닌 경우 resolve 메서드를 통해 프로미스로 래핑함

Promise.race

Promise.all 메서드 처럼 동일하게 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받는다.

가장 먼저 fulfilled 상태가 된 프로미스의 처리 결과를 resolve하는 새로운 프로미스를 반환한다. 그러나 하나라도 rejected 상태가 되면 에러를 reject하는 새로운 프로미스를 즉시 반환한다.(promise.all처럼)

Promise.allSettled

프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달받고 전달받은 프로미스가 모두 settled 상태가 되면 처리 결과를 배열로 반환한다.

  • 프로미스가 fulfilled 상태인 경우 비동기 처리상태를 나타내는 status 프로퍼티와 처리 결과를 나타내는 value 프로퍼티를 갖는다
  • 프로미스가 rejected 상태인 경우 비동기 처리 상태를 나타내는 status 프로퍼티와 에러를 나타내는 reason 프로퍼티를 갖는다.

마이크로태스크 큐

setTimeout(() => console.log(1), 0);

Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));

다음과 같은 코드에서 프로미스의 후속 처리 메서드의 콜백 함수는 태스크 큐가 아닌 마이크로 태스크 큐에 저장되어 2 -> 3 -> 1의 순서로 출력된다.

마이크로태스크 큐와 태스트 큐와는 별도의 큐인데 안에 저장되는 것들이 다르다

마이크로태스크 큐 -> 프로미스의 후속 처리 메서드의 콜백 함수
태스크 큐 -> 비동기 함수의 콜백함수나 이벤트 핸들러

마이크로태스크 큐가 태스크 큐보다 우선순위가 높다.(콜 스택이 비면 마이크로태스크 큐먼저 확인 후 태스크 큐 진행)

fetch

XMLHttpRequest 객체와 마찬가지로 HTTP 요청 전송 기능을 제공하는 클라이언트 사이드 API 이다. 사용법이 간단하고 프로미스를 지원하여 콜백 패턴의 단점에서 자유롭다.

fetch 함수는 HTTP 응답을 나타내는 Response 객체를 래핑한 Promise 객체를 반환한다.

response객체는 HTTP 응답을 나타내는 다양한 프로퍼티와 메서드를 제공하는데, MIME 타입이 application/json인 응답 몸체를 취득하려면 Response.prototype.json 메서드를 사용한다.(역 직렬화를 위해)

get요청, post요청, patch요청, DELETE요청 해보기!


46장 - 제너레이터와 async/await

제너레이터란?

ES6에서 도입되었으며 코드 블록의 실행을 일시 중지했다가 필요한 시점에 재개할 수 있는 특수한 함수

특징

  1. 함수 호출자에게 함수 실행의 제어권을 양도 가능
    함수 호출자가 함수 실행을 일시 중지시키거나 재개시킬 수 있다.

  2. 함수 호출자와 함수의 상태를 주고받을 수 있다
    함수의 호출자와 양방향으로 함수의 상태를 주고받을 수 있다.

  3. 제너레이터 함수 호출시, 제너레이터 객체를 반환한다
    이터러블이면서 동시에 이터레이터인 객체를 반환

제너레이터 함수의 정의

function* 키워드로 선언하며 하나 이상의 yield 표현식을 포함한다.

일관성 유지를 위해 function 키워드 바로 뒤에 붙이는 것을 권장한다.

function* genFunc() { yield 1; }

제너레이터 함수는 화살표 함수로 정의할 수 없고, new 연산자와 함께 생성자 함수로 호출할 수 없다.

제너레이터 객체

제너레이터 함수 호출 시, 함수 코드블록 실행이 아닌 제너레이터 객체를 생성해 반환한다. 제너레이터 객체는 이터러블이면서 동시에 이터레이터이다.

제너레이터 객체는 next 메서드를 갖는 이터레이터이면서 이터레이터에는 없는 return, throw 메서드를 갖는다.

  • next 메서드 호출 시, 제너레이터 함수의 yield 표현식까지 코드 블록 실행하고 yield된 값을 value 프로퍼티 값으로, false를 done 프로퍼티 값으로 갖는 이터레이터 리절트 객체를 반환한다.

  • throw 메서드 호출 시, 인수로 전달받은 에러를 발생시키고 undefined를 value 프로퍼티 값으로, true를 done 프로퍼티 값으로 갖는 이터레이터 객체를 반환한다.

  • return 메서드 호출 시, 인수로 전달받은 값을 value 프로퍼티 값으로, true를 done 프로퍼티 값으로 갖는 이터레이터 객체를 반환한다.

function* genFunc() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } catch (e) {
    console.error(e);
  }
}

const generator = genFunc();

console.log(generator.next()); // { value: 1, done: false }
console.log(generator.throw('Error')); // Error, { value: undefined, done: true }
console.log(generator.return('End!')); // { value: 'End!', done: true }

제너레이터의 일시 중지와 재개

제너레이터 객체의 next 메서드를 호출하면 함수의 코드 블록을 실행하는데, 모든 코드를 일괄 실행시키는 것이 아닌 yield 표현식까지만 실행한다.

yield 키워드는 제너레이터 함수의 실행을 일시 중지시키거나 yield 키워드 뒤에 오는 표현식의 평가 결과를 제너레이터 함수 호출자에게 반환한다.

function* genFunc() {
  yield 1;
  yield 2;
  yield 3;
}

const generator = genFunc();

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

제너레이터 함수가 끝까지 실행되면 value프로퍼티에 제너레이터 함수의 반환값 (현재는 undefined)가 할당되고 done 프로퍼티에는 제너레이터 함수가 끝까지 실행되었음을 알리는 true가 할당된다.

제너레이터 객체의 next 메서드에는 인수를 전달할 수 있다. --> 제너레이터 객체의 next 메서드에 전달한 인수는 제너레이터 함수의 yield 표현식을 할당받는 변수에 할당된다.

function* genFunc() {
  const x = yield 1;
  const y = yield x + 10;
  return x + y;
}

const generator = genFunc(0);

let res = generator.next();
console.log(res); // { value: 1, done: false }

res = generator.next(10);
console.log(res); // { value: 20, done: false }

res = generator.next(20);
console.log(res); // { value: 30, done: true }

처음 next 메서드 호출 시 yield 표현식 까지 실행후 일시 중지, 두 번째 next 메서드 때 전달한 인수 10은 x에 할당되고 마지막 20은 y에 할당되어 최종적으로 return 때 30이 반환된다.
이러한 제너레이터 특성을 활용하여 비동기 처리를 동기 처리처럼 구현할 수 있다.

제너레이터의 활용

이터러블의 구현

const infiniteFibonacci = (function* () {
  let [pre, cur] = [0, 1];

  while (true) {
    [pre, cur] = [cur, pre + cur];
    yield cur;
  }
})();

for (const num of infiniteFibonacci) {
  if (num > 10000) break;
  console.log(num);
}

이터레이션 프로토콜을 준수해 이터러블을 생성하는 방식보다 간단히 이터러블을 구현할 수 있다.

비동기 처리

const async = generatorFunc => {
  const generator = generatorFunc();

  const onResolved = arg => {
    const result = generator.next(arg);

    return result.done ? result.value : result.value.then(res => onResolved(res));
  };
  return onResolved;
};

async(function* fetchTodo() {
  const url = 'https://jsonplaceholder.typicode.com/todos/1';

  const response = yield fetch(url);
  const todo = yield response.json();
  console.log(todo);
})();

// =================================================== //

/** co version generator */

const co = require('co');

co(function* fetchTodo() {
  ....
});

비동기 처리와 관련하여 다음과 같이 제너레이터를 통해 구현할 수 있다. 처음 async의 fetchTodo를 호출하면서 generatorFunc()이 실행되면서 onResolved라는 generator를 자유변수로 갖는 클로저를 반환한다. 이것을 즉시 호출하여 next메서드를 실행하고, 첫 번째 yield문 까지 실행하여 done이 false면 yield된 fetch 함수가 반환한 promise가 resolve한 response 객체를 onResolved함수에 인수로 전달하면서 재귀호출 한다. 반복적으로 진행하다 done 프로퍼티값이 true가 되면 fetchTodo의 반환값인 undefined를 그대로 반환하고 처리를 종료한다.

--> 간략한 예제이므로 제너레이터 실행기가 필요하다면 co 라이브러리 사용을 권장한다.

async/await

ES8에서는 가독성 좋게 비동기 처리를 동기 처리처럼 동작할 수 있도록 async/await 가 도입되었다.

async/await로 다시 구현하면 다음과 같이 된다.

const fetch = require('node-fetch');

async function fetchTodo() {
  const url = 'https://jsonplaceholder.typicode/todos/1';

  const response = await fetch(url);
  const todo = await response.json();
  console.log(todo);
}

fetchTodo();

async 함수

await 키워드는 반드시 async 함수 내부에서 사용해야한다. 이 async 키워드를 사용한 함수는 언제나 프로미스를 반환하며, 명시적으로 프로미스를 반환하지 않더라도 암묵적으로 반환값을 resolve하는 프로미스를 반환한다.

async function foo(n) {
  return n;
}
foo(1).then(v => console.log(v));

const bar = async function (n) {
  return n;
};
bar(2).then(v => console.log(v));

const baz = async n => n;
baz(3).then(v => console.log(v));

클래스의 constructor 메서드는 항상 인스턴스를 반환해야하기 때문에 프로미스를 반환하는 async 함수를 사용할 수 없다. 그 외 객체의 메서드나, 클래스 메서드는 async 사용이 가능하다.

await 키워드

await 키워드는 프로미스가 settled 상태가 될 때까지 대기하다가 settled 상태가 되면 프로미스가 resolve 한 처리를 반환한다. 반드시 프로미스 앞에서 사용해야 한다.

const fetch = require('node-fetch');

const getGithubUserName = async id => {
  const res = await fetch(`https://api.github.com/users/${id}`);
  const { name } = await res.json();
  console.log(name);
};

getGithubUserName('hustle-dev');

await 뒤의 fetch함수가 실행되고나서 서버의 응답이 도착하여 settled 상태가 될때까지 기다렸다가 이후 프로미스가 settled 상태가 되면 프로미스가 resolve한 처리 결과가 res 변수에 할당된다.

모든 프로미스에 await 키워드를 사용하는 것은 주의해야 하며, 각각이 개별적으로 수행되는 비동기 처리인 경우 Promise.all 같은것으로 한번에 묶고 그 앞에 await를 사용하는 것이 좋다.

그러나 각각의 결과로 함수를 수행해야하는 경우 await를 모든 promise앞에 붙여서 순차적으로 실행 시켜야 한다.

에러 처리

asnyc/await에서 에러 처리는 try...catch 문을 사용할 수 있는데 콜백 함수를 인수로 전달받는 비동기 함수와 달리 프로미스를 반환하는 비동기 함수는 명시적으로 호출할 수 있기 때문이다.

// catch를 foo 함수 안에 써준 경우
const fetch = require('node=fetch');

const foo = async () => {
  try {
    const wrongUrl = 'https://wrong.url';

    const response = await fetch(wrongUrl); // fetch 를 사용하여 명시적으로 호출
    const data = await response.json();
    console.log(data);
  } catch (err) {
    console.error(err);
  }
};

foo();  // TypeError: failed to fetch

// -----------------------------------

// 후속 처리 메서드를 사용한 경우

const fetch = require('node=fetch');

const foo = async () => {

  const wrongUrl = 'https://wrong.url';

  const response = await fetch(wrongUrl);
  const data = await response.json();
  return data;
};

foo()
  .then(console.log)
  .catch(console.error); // TypeError: failed to fetch

foo 함수의 catch 문은 네트워크 에러뿐만아니라 try 코드 블록 내의 모든문에서 발생한 일반적인 에러까지 모두 캐치가능

async 함수 내에서 catch 문을 사용해서 에러 처리를 하지 않으면 발생한 에러를 reject 하는 프로미스를 반환하기 때문에 catch 후속 처리 메서드를 사용해 에러를 캐치할 수도 있다.

profile
It is possible for ordinary people to choose to be extraordinary.

0개의 댓글