[JS] 프로미스
2023-02-16

자바스크립트는 비동기 처리를 위해 콜백 함수를 사용한다.

ES6에서는 비동기 처리를 위한 또 다른 패턴으로 프로미스를 다루고 있다.

프로미스는 전통적인 콜백 패턴이 가진 단점을 보완하고, 비동기 처리 시점을 명확하게 표현한다.


콜백 패턴의 단점

<

let todos; const get = url => { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.send(); xhr.onload = () => { if (xhr.status === 200) { todos = JSON.parse(xhr.response) } else { console.log(`${xhr.status} ${xhr.statusText}`); } } } get('https://api.com/posts/1'); console.log(todos); // undefined

위 함수는 비동기 함수이다. onload 가 비동기로 동작하기 때문.

get이 호출되면, GET 요청을 전송하고, onload를 등록하고, undefined를 반환한 후 즉시 종료된다.

비동기 함수인 get 내부의 onload 는 get이 종료된 후 실행되는 것이다.

따라서 get 함수의 onload의 결과를 반환한대도 원하는대로 동기적으로 동작하지 않는다.


비동기 함수 get이 호출되면, 함수 코드를 평가하면서 get 함수의 실행 컨텍스트가 생성된다.

실행 컨텍스트 스택에 푸시되고, 코드 실행 과정에서 xhr.onload 이벤트 핸들러 프로퍼티에 이벤트 핸들러가 바인딩 된다.

get이 종료되면, get함수의 실행 컨텍스트가 스택에서 팝 되어 제거되고, console.log 가 호출된다.

console.log의 실행 컨텍스트가 생성되어 스택에 푸쉬된다.

만약 console.log가 호출되기 전에 load가 발생했더라도, xhr.onload 이벤트 핸들러 프로퍼티에 바인딩한 이벤트 핸들러는 절대 console.log보다 먼저 실행되지 않는다.


서버로부터 응답이 와야 xhr 객체에서 load 이벤트가 발생한다. 그리고 나서야 xhr.onload 이벤트 핸들러가 태스크 큐에 저장되어 대기하게 되고, 콜스택이 비어졌을 때(전역 실행 컨텍스트조차 없을때), 이벤트 루프에 의해 콜 스택으로 푸시되어 실행이 되는 것이다.


이벤트 핸들러 또한 함수이므로, 이벤트 핸들러의 평가 > 이벤트 핸들러의 실행 컨텍스트 생성 > 콜 스택에 푸쉬 > 이벤트 핸들러 실행 과정을 거치는 것이다.


따라서 이벤트 핸들러가 실행되려면 콜 스택이 빈 상태여야 하므로 console.log 는 이미 종료된 시점이다.


즉, 비동기 함수는 비동기 처리 결과를 외부에 반환할 수 없고, 상위 스코프의 변수에 할당할 수도 없다.

따라서 비동기 함수의 처리 결과에 대한 처리는 비동기 함수 내부에서 수행해야 한다.

그래서 보통 이 처리를 해줄 함수를 콜백함수로 사용하는 방법을 쓴다.


const get = (url, successCallback, failureCallback) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.send(); xhr.onload = () => { if (xhr.status === 200) { successCallback(JSON.parse(xhr.response)); } else { failureCallback(xhr.status); } }; }; get(url, console.log, console.error);

콜백지옥
get('/step1', a => { get(`/step2/${a}`, b => { get(`/step3/${b}`, c => { get(`/step4/${c}`, d => { console.log(d); } } } }

에러처리의 한계
try { setTimeout(() => { throw new Error('Error!');}, 1000); } catch (e) { console.error(e); }

try 코드 블록 내에서 setTimeout 함수를 호출하면, 1초 후에 콜백함수가 실행되도록 타이머 설정한다.

이후 콜백 함수는 에러를 일으킨다.


비동기 함수인 setTimeout이 호출되면 setTimeout 함수의 실행 컨텍스트는 콜 스택에 푸쉬된다.

setTimeout은 비동기 함수이므로 자신이 실행된 후, 콜백함수가 실행되는 것을 기다리지 않고 종료되고, 콜 스택에서 제거된다.

이후 타이머가 만료되면 setTimeout의 콜백 함수는 태스크 큐로 푸시되고, 콜 스택이 비어졌을 때에 이벤트 루프에 의해 콜 스택으로 푸시된다.


setTimeout의 콜백함수가 실행될 때 setTimeout은 콜스택에서 제거된 상태이다. 즉, 콜백함수의 호출자는 setTimeout이 아니라는 뜻이기도 하다.

에러는 호출자 방향으로 전파가 되므로, 이 에러는 catch 블럭에서 캐치되지 않는 것이다.


이러한 한계를 극복하기 위해 프로미스가 도입되었다.


프로미스

Promise 생성자 함수를 new 연산자와 함께 호출하면 프로미스 객체를 생성한다.

Promise는 ES6에서 도입된, 표준 빌트인 객체이다.


Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수를 인수로 받으며

이 콜백 함수는 resolve 와 reject 함수를 인수로 전달받는다.


const promise = new Promise((resolve, reject) => { if (성공) { resolve('result') } else { reject('fail') } });

Promise 생성자가 인수로 전달받은 콜백 함수 내부에서 비동기 처리를 수행한다.

비동기 처리 성공하면 resolve, 실패하면 reject 를 호출한다.


const promiseGet = url => { return 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(url);

비동기 함수인 promiseGet은 함수 안에서 프로미스를 생성하고 반환한다.

비동기 처리는 Promise생성자 함수가 인수로 전달받은 콜백함수 내부에서 수행한다.

비동기 처리 성공 시, 비동기 처리 결과를 resolve 에 전달해 호출하고,

실패 시, 에러를 reject에 전달해 호출한다.


프로미스 객체는 비동기 처리 상태에 대한 정보를 갖고 있다.

수행 안된걸 pending

수행 완료되어 fulfilled or rejected 인 상태를 settled 라고 함


이러한 비동기 처리 결과에 따라서 후속 작업을 하기 위한 메서드도 제공된다.

(then, catch, finally)


프로미스의 비동기 처리 상태가 변화하면 후속 처리 메서드에 인수로 전달한 콜백함수가

모든 후속 처리 메서드는 프로미스를 반환하고, 비동기로 동작한다.


Promise.prototype.then

then은 인수로 콜백함수 2개 받는다.

  • fullfilled 일 때 호출될 콜백 함수 (인자로 프로미스 비동기 처리 결과 받음)
  • rejected 일 때 호출될 콜백 함수 (인자로 프로미스 에러 받음)

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

    then은 항상 프로미스를 반환한다.


    Promise.prototype.catch

    catch 메서드는 인자로 콜백함수 1개 받는다.

    프로미스 rejected일 때만 호출됨

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

    catch 메서드도 항상 프로미스 반환함


    Promise.prototype.finally

    finally 메서드는 인자로 콜백함수 1개 받는다.

    fulfilled, rejected 상관없이 항상 한 번 호출된다.

    공통적으로 처리할 것 있을 때 유용

    얘도 항상 프로미스 반환

    new Promise(() => {}).finally(() => console.log('finally')); // finally

    프로미스 에러 처리

    비동기 처리 중 발생한 에러는 then메서드의 두번째 콜백함수로 처리할수도, catch 메서드로 처리할 수도 있다.

    catch 호출 시, 내부적으로 then(undefined, onRejected) 를 호출한다.

    즉, 아래와 같다.

    promiseGet(url) .then(res => console.log(res)) .then(undefined, err => console.error(err)); // == catch

    그러나 then의 두번째 콜백함수는 첫번째 콜백함수에서 발생하는 에러는 캐치하지 못한다.

    따라서 catch를 맨 마직에 호출해서 비동기 처리의 에러(rejected) 뿐 아니라 then 메서드 내부의 에러까지 모두 캐치하면 된다.


    프로미스 체이닝

    then, catch, finally 는 모두 프로미스를 반환하므로 연속적으로 호출할 수 있다.

    이를 프로미스 체이닝이라 함


    만약 프로미스가 아닌 undefined 이런 애를 반환한다고 해도, 암묵적으로 resolve or reject 호출해서 프로미스로 만들어서 반환하는 것이다.


    프로미스도 콜백 패턴을 사용하기 때문에 가독성이 떨어지는 문제는 동일하게 발생한다.

    이를 해결하기 위해 async/await 문을 사용한다.

    마치 동기 처리 되는 것처럼 프로미스의 처리 결과를 반환하도록 구현할 수 있다.


    프로미스의 정적 메서드

    Promise.resolve

    Promise.reject

    이미 존재하는 값을 래핑해 프로미스를 생성하기 위함

    Promise.resolve


    Promise.reject


    Promise.all


    const promiseGet = (url) => { return new Proimise((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)); } }; }); }; const githubIds = ["jeresig", "ahejlsberg", "ungmo2"]; Promise.all( githubIds.map((id) => promiseGet(`https://api.github.com/users/${id}`)) ) .then((users) => users.map((user) => user.name)) .then(console.log) .catch(console.error);

    promise 1, 2, 3 의 수행 완료 시점과 상관없이 1,2,3 순대로 반환해줘서 처리 순서를 보장해준다.


    Promise.race


    Promise.allSettled

    반환하는 객체는 프로미스 처리 결과를 담고 있으며,

    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

    fetch는 HTTP 요청 전송 기능을 제공하는 클라이언트 사이드 Web API 이다.

    const promise = fetch(url, [, options])

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


    따라서 프로미스 후속 처리 메서드를 사용할 수 있다.

    Response 객체는 HTTP 응답을 나타내는 다양한 프로퍼티를 제공한다.

    fetch('urlname').then(response => console.log(response));

    response는 해당 HTTP 응답 나타내는 Response 객체를 래핑한 프로미스가 resolve되어 Response 객체가 튀어나온 것을 받는다.


    fetch: POST
    fetch(url, { method: 'POST', headers: { 'content-Type': 'application/json'}, body: JSON.stringify(payload) });