[JavaScript] Promise, async function, await

참고 문서

테스트 환경 정보

Promise:

  • ES2015에서 최초 정의
  • Chrome 32, Edge 12, FireFox 29, Opera 19, safari 8 이상에서 지원
  • IE에서 사용 불가

async function, await:

  • ES2017에서 최초 정의
  • Chrome 55, Edge 15, FireFox 52, Opera 42, Safari 10.1 이상에서 지원
  • IE에서 사용 불가

개요

ECMAScript의 Promise, async function, await 사용법 정리.

Promise

Promise는 비동기 작업의 완료(혹은 실패)와 결과 값을 나타내는 객체다. jQuery의 Deferred Object와 비슷하다.

Promise 객체는 요딴 상태 중 하나를 가진다:

  • pending: initial state, neither fulfilled nor rejected.
  • fulfilled: meaning that the operation was completed successfully.
  • rejected: meaning that the operation failed.

상태에 대한 정의는 여기에서 확인.

new Promise( executor )
new Promise( function ( resolve, reject ) { ... } )
  • resolve: Promise의 상태를 fulfilled로 변경하고 resolve 메시지를 전달하는 함수. 함수라서 명시적으로 호출해야 함. 이렇게 전달되는 값은 resolved value 혹은 fullfilled value라고도 한다.
  • reject: Promise의 상태를 rejected로 변경하고 reject 메시지를 전달하는 함수. 이것도 함수다.

Promise() 생성자 함수는 executor를 실행하고 Promise 객체를 반환한다.

* Promise는 웹 워커에서도 사용할 수 있다고 한다.

Promise.prototype.then()

promise.then( onFulfilled, onRejected )
  • onFulfilled: resolve 때 실행할 함수
  • onRejected: reject 때 실행할 함수

Promise.prototype.then()은 파라미터로 resolve 혹은 reject를 처리할 핸들러 함수를 받는다.

생성자 함수와 .then()은 Promise를 반환한다(나중에 설명할 .catch().finally()도 마찬가지). 따라서 메서드 체이닝 패턴으로 작성해야 함:

var willBeSuccess = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('success message(or fulfillment value)');
  }, 1000);
});

willBeSuccess.then((message) => {
  console.log(message); // 1초 후 'success message(or fulfillment value)' 출력
});

var willBeFail = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject('rejection reason');
  }, 1500);
});

willBeFail.then(() => {
  console.log('do nothing'); // 실행 안됨
}, (reason) => {
  console.log(reason); // 1.5초 후 'rejection reason' 출력
});

.then() 하나로 예외 처리 코드까지 구현하면 가독성이 별로다. onRejected만 전담하는 .catch()를 써보자:

Promise.prototype.catch()

promise.catch( onRejected )
  • onRejected: reject 때 실행할 함수

Promise.prototype.catch()는 reject된 경우에 실행할 함수 하나만 받는다. 내부에서 promise.then(undefined, onRejected)를 호출한다고 함.

var willBeFail2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject('rejection reason');
  }, 1500);
});

willBeFail2.catch((reason) => {
  console.log(reason); // 1.5초 후 'rejection reason' 출력
});

.catch() 후의 상태

아래 예시를 보면 Promise의 상태가 onRejected 호출 후 fulfilled로 바뀐다:

var pr3 = new Promise((resolve, reject) => {
  reject('rejection reason');
})
var pr4 = pr3.then(msg => {
  console.log('pr3-msg:', msg); // 실행 안됨
})
var pr5 = pr4.catch(msg => {
  console.log('pr4-msg:', msg); // pr4-msg: rejection reason
});
var pr6 = pr5.then(msg => {
  console.log('pr5-msg:', msg); // pr5-msg: undefined
});

console.log(pr3); // Promise { <state>: "rejected", <reason>: "rejection reason" }
console.log(pr4); // Promise { <state>: "rejected", <reason>: "rejection reason" }
console.log(pr5); // Promise { <state>: "fulfilled", <value>: undefined }
console.log(pr6); // Promise { <state>: "fulfilled", <value>: undefined }

주의: pr3, pr4, pr5, pr6은 다 다른 인스턴스다.

에러 처리

메서드 체인 상의 에러는 .catch()가 받아준다:

new Promise((resolve, reject) => {
  resolve();
}).then((msg) => {
  throw new Error('I am error'); // 이 코드를 두 줄 위로 올려도 결과는 같음
  console.log('moo');
}).catch((reason) => {
  console.log('ya');
}).then((msg) => {
  console.log('ho');
});
// catch에서 'ya' 출력
// 두 번째 then에서 'ho' 출력

만약 .catch()가 없으면?

try {
  new Promise((resolve, reject) => {
    throw new Error('I am error');
    resolve();
  }).then((msg) => {
    console.log('moo ya ho');
  });
} catch (e) {
  console.error('Are you error?'); // 실행 안됨
}
// Uncaught (in promise) Error: I am error

'I am error'만 출력되는데 아무래도 Promise 내부에 try-catch가 있다고 봐야할 것 같음.

Promise.prototype.finally()

promise.finally( onFinally )

Promise.prototype.finally()는 Promise가 이행만 되면 resolve/reject에 상관없이 무조건 실행하는 함수를 받는다. onFinally는 인자도 없고 Promise의 결과 값에 영향을 주지도 않는다:

var pr7 = new Promise((resolve, reject) => {
  resolve('me is result value'); // 여기가 reject()여도 결과는 같음
}).finally(() => {
  console.log('알파카로 만든 파카는 알파카파카');
});
// '알파카로 만든 파카는 알파카파카'

.finally()는 척 노리스처럼 강력해서 에러 따윈 신경쓰지 않는다:

new Promise((resolve, reject) => {
  throw new Error('What?');
}).finally(() => {
  console.log('엄마랑 아들이 택견을 하면 모자이크');
});
// '엄마랑 아들이 택견을 하면 모자이크'
// Uncaught (in promise) Error: What?

setTimeout()을 Promise로 감싸기

비동기 함수이지만 Promise를 반환하지 않는 API를 감싸는 방법이다:

var wait = ms => new Promise(resolve => setTimeout(resolve, ms));
wait(1000)
  .then(() => {console.log('a second has passed')})
  .catch(() => {console.log('something went wrong')});

원 소스 출처

Promise.resolve(), Promise.reject()

Promise.resolve(value)
Promise.reject(value)

각각 value가 결과 값이며 상태가 fulfilled 혹은 rejected인 Promise를 반환한다:

Promise.resolve(1).then(console.log); // 1
Promise.reject(2).catch(console.log); // 2

async function

async function fn() {}

async 키워드를 사용해 선언하는 함수. 함수가 실제로 어떤 값을 반환하는지 여부에 관계없이 항상 Promise를 반환한다.

return 키워드로 반환한 값은 Promise의 숨겨진 프로퍼티에 저장되기 때문에 꺼내려면 .then()이나 await이 필요하다.

var hello = async () => {
  return 'Hello!';
};
hello().then(console.log); // Hello!
// 아래와 같음
// hello().then((msg) => { 
//   console.log(msg) 
// });

Promise 래핑

async 함수의 반환값이 명시적인 Promise가 아니라면 자동으로 Promise로 감싸진다:

async function () {
  return 1;
}

이 코드는 아래와 비슷하다:

function () {
  return Promise.resolve(1);
}

같은게 아니라 비슷한 이유는 async 함수가 Promise로 감싸진 것처럼 작동하지만 완전히 동일하진 않기 때문이다.

만약 반환하려는 참조가 Promise라면 async 함수는 새 참조를 반환하지만 Promise.resolve()는 일치하는 참조를 반환한다:

var p = new Promise(res => {res(1)});

async function asyncReturn() {
  return p;
}
console.log(p === asyncReturn()); // false

function basicReturn() {
  return Promise.resolve(p);
}
console.log(p === basicReturn()); // true

await

await expression

await은 async 함수 내부에서만 사용할 수 있는 연산자로, Promise를 기다릴 때 사용한다. 더 정확히 말하자면 await 연산자 다음의 Promise가 fulfilled 될 때까지 함수의 실행을 일시 정지시킨다:

var wait = ms => new Promise(resolve => setTimeout(resolve, ms));
var fn = async () => {
  await wait(2000);
  console.log('done');
};
fn();
console.log('아재개그는 아주 재밌는 개그');
// '아재개그는 아주 재밌는 개그'
// 2초 후 'done' 출력

await 연산의 결과

await 연산자는 표현식을 Promise가 아니라 이행된 값으로 평가되도록 만든다.

가령 다음 예시에서:

(async () => {
  let result = await new Promise(resolve => {
    resolve('abc');
  });
  console.log('result:', result); // 'result: abc'
})();

result는 Promise가 아니라 resolve('abc')에 의해 넘겨진 abc다.

await도 한다! 래핑

만약 await 연산자 다음이 Promise가 아니면 해당 값은 resolved Promise로 변환된다.

예를 들면 이 코드는:

async () => {
  await 1234;
};

다음과 같다:

async () => {
  await Promise.resolve(1234);
};

async 함수를 비동기로 만드는 것은 await

async 함수의 본문은 0개 이상의 await으로 분할된다고 볼 수 있다(라는 MDN의 설명😇). 첫 번째 await을 만날때 까지 async 함수는 동기적으로 실행된다. 이 말은 await이 없는 async 함수는 일반 함수와 같다는 말이다:

var fn = async () => {
  console.log(1);
  console.log(2);
};
fn();
console.log('알파카파카파까?');
// 1
// 2
// '알파카파카파까?'

하지만 await을 만나면 그 부분부터 (async 함수를 호출한 코드의 관점에서) 비동기적으로 작동한다:

var fn2 = async () => {
  console.log('문제: 개성공단의 반대말은?');
  await 1; // 1은 아무 의미 없음
  console.log('🤣🤣🤣');
};
fn2();
console.log('답: 고양이실패단');
// '문제: 개성공단의 반대말은?'
// '답: 고양이실패단'
// '🤣🤣🤣'

await의 잘못된 사용

await을 적용할 대상은 Promise 객체여야 한다. 만약 문법적 실수로 Promise가 아닌 것을 지정했다면 의도와 다르게 작동하면서 버그가 만들어질 것이다. 아래 예시를 보자:

function promiseMe() {
  return new Promise((resolve) => {
    setTimeout(() => {
      let o = {
        data: 890604
      };
      resolve(o);
    }, 500);
  });
}

async function correctUsage() {
  // correct
  let p = await promiseMe();
  console.log('#1:', p.data);
}

async function incorrectUsage() {
  // wrong
  let data = await promiseMe().data;
  console.log('#2:', data);
}

correctUsage(); // #1: 890604
incorrectUsage(); // #2: undefined

문제의 incorrectUsage() 함수 내부를 보자.

let data = await promiseMe().data;

원래 의도는 Promise의 이행을 기다렸다가 data를 꺼내는 것이겠지만 그렇게 작동하지 않는다. 단계별로 나누어 설명하면, 우선 promiseMe().data undefined를 반환한다. promiseMe()가 Promise 객체를 반환했으나 data라는 프로퍼티가 없으니 .dataundefined를 반환하기 때문.

// wrong
let data = await undefined

await 다음에 위치한 undefined는 Promise 객체가 아니니 resolved Promise로 변환된다.

// wrong
let data = await Promise.resolve(undefined);

이제 우변은 await에 의해 Promise의 이행값 undefined가 된다.

// wrong
let data = undefined;

이 문제를 해소하려면, 아래처럼 await과 기다릴 대상을 괄호로 묶어줘야 한다:

// correct
let data = (await promiseMe()).data;

Promise의 병렬 처리

await이 여러 개 있을 때 주의할 점

Promise의 상태 변화를 기다리는 await의 동기적 특성은 아주 당연하게도 처리 속도에 악영향을 줄 수 있다:

var wait = ms => new Promise(resolve => setTimeout(resolve, ms));
var timeTest = async () => {
  let startTime = new Date();

  await wait(1000);
  await wait(1000);
  await wait(1000);

  let endTime = new Date();
  console.log(`It takes ${endTime.getTime() - startTime.getTime()} milliseconds.`);
};
timeTest(); // It takes 3028 milliseconds.

기다리는 것을 세 번 반복했더니 3초나 걸린다. 이런 경우엔 Promise를 반환하는 표현식 앞에 await을 걸지 말고, 일단 모두 실행하도록 한 다음 변수의 상태 변화를 기다리도록 하는게 좋다:

var wait = ms => new Promise(resolve => setTimeout(resolve, ms));
var timeTest2 = async () => {
  let startTime = new Date();

  let result = wait(1000);
  let result2 = wait(1000);
  let result3 = wait(1000);
  await result;
  await result2;
  await result3;

  let endTime = new Date();
  console.log(`It takes ${endTime.getTime() - startTime.getTime()} milliseconds.`);
};
timeTest2(); // It takes 1009 milliseconds.

거의 1초 정도에 작업이 완료된다.

Promise.all(), Promise.race(), Promise.any()

앞선 예시처럼 변수 여러 개에 await를 거는 방법은 코드가 예쁘지 않다. Promise가 제공하는 메서드를 써보자.

Promise.all(iterable)

Promise.all()은 여러 Promise의 결과를 집계할 때 사용한다. iterable에 Promise 객체 여럿을 배열로 던지면 됨:

var wait = ms => new Promise(resolve => setTimeout(() => {resolve(ms)}, ms));
var concurrent = async () => {
  return await Promise.all([wait(3000), wait(2000), wait(1000)]);
}
concurrent().then(console.log); // 'Array(3) [ 3000, 2000, 1000 ]'

가장 빠른놈만 하나 고르는 메서드도 있다.

Promise.race(iterable)

Promise.race()는 주어진 Promise들을 동시에 실행하되 가장 먼저 완료되는 것만 반환한다:

var wait = ms => new Promise(resolve => setTimeout(() => {resolve(ms)}, ms));
var pickOne = async () => {
  return await Promise.race([wait(3000), wait(2000), wait(1000)]);
}
pickOne().then(console.log); // 1000
Promise.any(iterable)

Promise.any()race()와 비슷하지만, 가장 먼저 '성공'한 Promise의 결과를 반환한다. 만약 모두 실패(거부)하면 AggregateError를 발생시킨다:

var doRresolve = ms => new Promise(resolve => setTimeout(() => {resolve(ms)}, ms));
var doReject = ms => new Promise((resolve, reject) => setTimeout(() => {reject(ms)}, ms));

(async () => {
  return await Promise.any([doRresolve(3000), doRresolve(2000), doRresolve(1000)]);
})().then(console.log); // 1000

(async () => {
  return await Promise.any([doRresolve(3000), doReject(2000), doReject(1000)]);
})().then(console.log); // 3000

(async () => {
  return await Promise.any([doReject(3000), doReject(2000), doReject(1000)]);
})().then(console.log); // AggregateError: No Promise in Promise.any was resolved

진짜 병렬 처리?

변수에 await를 걸든, Promise.all()를 쓰든 문제가 하나 있다. async 함수가 완료되려면 가장 느린 Promise의 작업이 끝날때까지 기다려야 한다는 것:

var wait = ms => new Promise(resolve => setTimeout(() => {resolve(ms)}, ms));
var concurrent = async () => {
  let startTime = new Date();

  await Promise.all([wait(5000), wait(3000), wait(1000)]).then(console.log);

  let endTime = new Date();
  console.log(`It takes ${endTime.getTime() - startTime.getTime()} milliseconds.`);
}
concurrent();
// It takes 5013 milliseconds.

가장 느린 Promise인 wait(5000) 때문에 총 실행시간은 약 5초다.

Promise.race()를 쓰면 가장 빠른 Promise만 기다리면 되긴 하지만, 느린 Promise의 실행 결과를 처리할 수 없다는 문제가 있다.

이 문제는 앞선 예시들의 구조 그대로 재사용하긴 힘들고 아래 방법처럼 .then()을 각각 호출하는게 대안이 될 수 있다:

var wait = ms => new Promise(resolve => setTimeout(() => {resolve(ms)}, ms));
var parallel = function () {
  wait(5000).then(console.log);
  wait(3000).then(console.log);
  wait(1000).then(console.log);
}
parallel();
// 1000
// 3000
// 5000

여담: 메서드 이름 짓기

만약 Promise를 반환하는 걸 메서드 이름으로 표현하기로 했다면 Node.js에서 동기 방식의 메서드에 Sync를 붙이는 것과 반대로 Async 혹은 Promise를 뒤에 덧붙이는 방법이 있다.

예시:

  • removeSomethingAsync()
  • drawGridPromise()

끝.