본문 바로가기

비동기와 Promise

현업에서 웹 개발자로 일한지 어느덧 2년이 다 되가는 시점에서 비동기라는 개념을 애매하게 이해하고 있다고 생각되었습니다.

 

최근 봤던 저의 지식 밑천이 탈탈 털린(...) 면접에서 비동기에 대해 물었을 때 정말 자신없게 대답해서 너무 부끄러웠습니다ㅠ

 

이제는 비동기에 대한 개념을 제 것으로 확실하게 만들고 싶습니다.

 

1. 비동기/동기의 차이점

 

코어 자바스크립트에서 설명한 '비동기'는 A함수에서 B함수로 제어권을 넘겨 A함수는 B함수를 실행하고 바로 다음 작업을 처리하는 동시에 B함수에서 작업이 완료되면 A함수로 그 결과값을 리턴합니다.

 

A함수는 그동안 신나게 B와 관련없는 작업들을 하고 있다가 B함수로부터 결과값을 받으면 그제서야 B함수 다음으로 해야 할 작업을 실행합니다.

 

스타벅스 온 김에 real world에서의 상황을 예로 들어보자면

 

- 동기

 

직원: 커피기계로 커피 내려야지

(커피 내리는 중 ...)

직원: (커피 다 내릴 때까지 기다리는 중....)

(커피 내리기 완료)

직원: 커피 다 내렸으니까 우유 스팀 시작해야지

(우유 스팀하는 중...)

직원: (또 기다리는 중)

(우유 스팀 완료)

직원: 이제 라떼 만들어야지

(라떼 만드는 중)

(라떼 만들기 완료)

직원: 이제 케이크 꺼내야지

(케이크 꺼내는 중)

(케이크 꺼내기 완료)

직원: 이제 세팅해야지

(세팅 중)

(세팅 완료)

직원: 주문하신 음료 나왔습니다!

 

이렇게 일하면 짤리겠죠?

 

- 비동기

 

직원: 커피기계로 커피 내릴 동안 우유 스팀도 켜두고, 케이크도 꺼내놔야지

(커피 내리는 중...)

(우유 스팀 중...)

(케이크 꺼내는 중...)

(케이크 꺼내기 완료)

직원: 미리 세팅하고 있어야지

(세팅 중)

(커피 내리기 완료)

(우유 스팀 완료)

직원: 어 다 내렸네, 이제 라떼 만들어야지

(라떼 만드는 중)

(라떼 만들기 완료)

(세팅 완료)

직원: 주문하신 음료 나왔습니다!

 

 

따라서 B함수에서 어떤 결과값을 받기 전까지는 비동기와 관련없는 다른 Task를 처리하고 있다가 비동기 작업이 완료되면 다음 Task를 수행하는 방식이 곧 비동기식 처리입니다.

 

2. Promise

 

Promise는 비동기 처리에 사용되는 객체로, 비동기 작업을 완료 또는 실패하는 결과값을 반환하여 그 결과값을 받아 다음 Task를 실행할 수 있는 방식으로 사용됩니다. 상태에 따라 pending, fullfilled, rejected로 구분합니다.

 

또한 Promise는 다음 그림처럼 여러 개의 Promise를 연결하여 실행하는 체인 구조를 가집니다.

 

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

 

pending: Promise를 호출하고 아직 결과값을 반환하지 않은 상태

1
const result = new Promise();

 

fullfilled: 비동기 작업이 완료된 상태. then()으로 결과값을 받아 다음 작업을 처리할 수 있는 상태

 

1
2
3
const result = new Promise(function (resolve, reject){
  resolve(true);
});
 

 

rejected: 비동기 작업이 실패한 상태. catch()로 실패한 결과값을 받을 수 있습니다.

 

1
2
3
4
5
const result = new Promise(function (resolve, reject) {
  reject(false);
}).catch(function (e) {
  console.log(e);
});
 

 

2-1. Promise 에러 핸들링

 

Promise로 에러를 처리하는 방법은 두 가지가 있습니다.

 

- catch로 받기

 

위의 rejected상태에서 볼 수 있듯 catch()로 실패 결과값을 받아서 처리할 수 있습니다.

 

catch()는 reject로 명시적인 에러 반환 뿐만 아니라 코드에서 발생할 수 있는 모든 에러 및 throw로 넘긴 에러 또한 처리합니다.

 

.- then()의 두번째 인자로 에러 핸들링

 

then()에서 에러를 받아서 처리할 수도 있습니다.

 

then()의 두번째 인자의 콜백함수가 곧 에러 핸들링 함수입니다.

 

1
2
3
4
5
6
7
const result = new Promise(function (resolve, reject) {
  reject(false);
}).then(function(res) {
  console.log("res: ", res);
}, function(error) {
  console.log("error: ", error);
});
 

 

Q. 둘 중에 어떤 에러 핸들링을 지향해야 할까?

 

가급적 catch()를 통해 에러 핸들링할 것을 권장합니다.

 

다음 Promise에서 에러를 핸들링하는 상황을 가정합니다.

 

then에서 throw한 에러는 어디에서 처리될까요?

1
2
3
4
5
6
7
8
9
10
const promise1 = new Promise(function(resolve, reject) {
  resolve("yes");
}).then(function(res){
  console.log(res);
  throw new Error("error in then");
}, function(e){
  console.log("catch error in then(): ", e)
}).catch(function(e) {
  console.log("catch error in catch(): ", e);
});
 

 

정답은 catch문에서 처리됩니다.

 

즉 then에서 발생한 에러를 잡지 못하는 문제가 있습니다.

 

다음과 같이 23~28번째 줄에서 내가 예상한 건 "This is Resolved"인데 Promise에서 에러를 처리하지 못해 스크립트가 중단되고, 브라우저 콘솔창에 에러가 찍힙니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function task1 (fn) {
 var result = fn();
 
 return new Promise(function(resolve, reject){
   if (result) resolve(result);
   else reject({ message: "This is Rejected" });
 });
}
 
function closure (a, b) {
 return function() {
   return a > b;
 }
}
 
task1(closure(12)).then(function(res){
  if (res) 
    throw new Error("This is Resolved");
}, function(err){ 
  console.log(err.message);
}); // This is Rejected
 
task1(closure(102)).then(function(res) {
  if (res) 
    throw new Error("This is Resolved");
}, function(err){ 
  console.log(err.message);
}) // Uncaught (in promise) Error: This is Resolved

 

catch로 에러를 처리할 경우에는 Promise 체인의 어떤 위치에서 발생한 에러든 잡을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function task1 (fn) {
 var result = fn();
 
 return new Promise(function(resolve, reject){
   if (result) resolve(result);
   else reject({ message: "This is Rejected" });
 });
}
 
function closure (a, b) {
 return function() {
   return a > b;
 }
}
 
task1(closure(12)).then(function(res) {
  if (res) 
    throw new Error({ message: "This is Resolved" });
}).catch(function(err) {
  console.log(err.message);
}); // This is Rejected
 
task1(closure(102)).then(function(res) {
  if (res) 
    throw new Error("This is Resolved");
}).catch(function(err) {
  console.log(err.message);
}); // This is Resolved

 

Q. 그렇다면 catch() 다음의 then()은 어떻게 실행될까?

 

아래의 결과를 예측해봅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new Promise((resolve, reject) => {
    console.log('Initial');
 
    resolve();
})
.then(() => {
    throw new Error('Something failed');
        
    console.log('Do this');
})
.catch(() => {
    console.error('Do that');
})
.then(() => {
    console.log('Do this, no matter what happened before');
});

 

Promise 체이닝에서는 catch()문또한 정상적으로 종료되었다고 판단될 경우 다음 then으로 제어 흐름이 넘어가 에러 핸들링 이후의 Task를 계속 실행할 수 있습니다.

 

따라서 결과값은 다음과 같습니다.

 

"Initial"

"Do that"

"Do this, no matter what happend before"

 

It's possible to chain after a failure, i.e. a catch, which is useful to accomplish new actions even after an action failed in the chain. Read the following example:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises

 

만약 catch()에서 에러를 해결하지 못한 경우에는 다음으로 가까운 에러 핸들러로 에러를 throw할수도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 실행 순서: catch -> catch -> then
new Promise((resolve, reject) => {
 
  throw new Error("에러 발생!");
 
}).catch(function(error) { // (*)
 
  if (error instanceof URIError) {
    // 에러 처리
  } else {
    alert("처리할 수 없는 에러");
 
    throw error; // 에러 다시 던지기
  }
 
}).then(function() {
  /* 여기는 실행되지 않습니다. */
}).catch(error => { // (**)
 
  alert(`알 수 없는 에러가 발생함: ${error}`);
  // 반환값이 없음 => 실행이 계속됨
 
});
 

"처리할 수 없는 에러"
"알수 없는 에러가 발생함: ..."

 

 

Q. Promise Chain에서 각각 다른 에러를 처리하고 싶은 경우

 

위의 catch()로 에러를 처리하는 외에 then()에서 발생하는 에러를 각각 핸들링하고 싶은 경우도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const task = new Promise(function (resolve, reject) {
  resolve(1);
});
 
task.then(function(res) {
  if (res >= 1return res+1;
  else {
    throw new Error(res)
  }
}).then(function(res){
  if (res >= 3return res+1;
  else {
    throw new Error("last");
  }
}).catch(function(e) {
  if (e.message == "last") {
    console.log("마지막에서 실패함");
  } else if (e.message < 1) {
    console.log("중간에서 실패함");
  }
});
 

만약 then이 엄~청 많고 에러도 각각 다르게 처리해야 한다면 catch()에서 조건을 무한정 늘릴수도 없는 노릇입니다.

 

 

따라서 then()의 각 콜백함수에서 try catch문으로 에러를 핸들링할 수 있습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const task = new Promise(function (resolve, reject) {
  resolve(1);
});
 
task.then(function(res) {
  try {
    if (res >= 1return res+1;
    else {
      throw new Error(res)
    }
  } catch(e) {
    console.log("중간에서 실패함");
    throw e; // throw 하지 않으면 정상 종료로 판단하고, 다음 then이 실행됨
  }
}).then(function(res){
  try {
    if (res >= 3return res+1;
    else {
      throw new Error("last");
    }
  } catch (e) {
    console.log("마지막에서 실패함");
    throw e; // throw 하지 않으면 정상 종료로 판단하고, 다음 then이 실행됨
  }
}).catch(function(e) {
  console.log(e.message);
});
 

 

또한 일반적으로 try catch에서 발생한 에러를 해당 catch에서 해결할 수 없다고 판단할 경우, 다음 에러 핸들러로 에러를 던지는 것처럼 Promise의 catch또한 다음 catch로 에러를 throw할 수 있습니다.

 

 

Q. Promise의 마지막에 catch를 하지 않으면 어떻게 될까?

 

Promise에 catch문으로 에러를 핸들링하지 않으면 Promise에서 에러가 발생할 때 그 에러는 갇히게 되고, 결국 스크립트가 비정상적으로 종료되고 콘솔에 에러가 출력됩니다.

 

전역적으로 Promise에러를 잡는 방법으로 unhandledrejection 이벤트를 지원합니다.

 

1
2
3
window.addEventListener("unhandledrejection"event => {
  console.warn(`UNHANDLED PROMISE REJECTION: ${event.reason}`);
});
 

콜백함수의 event 인자는 두 속성을 가집니다.

 

  • event.promise: reject된 promise객체
  • evnet.reason: reject된 promise에서 리턴한 에러 객체

또한 브라우저에서 해당 에러 로그를 출력하게 하고 싶지 않다면 event.preventDefault();로 이벤트를 취소할 수 있습니다.


문제 1. 

 

다음의 상황에서 catch()문은 에러를 처리할 수 있을까요? 정답은 '더보기'에 있습니다.

 

1
2
3
4
5
6
7
new Promise(function(resolve, reject) {
  setTimeout(_ => {
    throw new Error("에러 발생!");
  }, 1000);
}).catch(function(e){
  console.log(e.message);
});

 

더보기

정답.

 

처리할 수 없습니다.

 

콜스택에서 이미 Promise 콜백함수가 pop된 후 setTimeout 콜백함수가 콜스택에 쌓여 실행되기 때문에 catch문이 트리거될 수 없습니다.

 

이벤트 루프를 익히고 난 후 더욱 쉽게 이해할 수 있습니다.

 

 

마지막으로 Promise 이해를 돕는 문제를 풀 수 있는 사이트들입니다.

 

https://danlevy.net/javascript-promises-quiz/

 

JavaScript Promises: 9 Questions - Dan Levy's Programming Blog

Come for the JavaScript, stay for the cat memes.

danlevy.net

codingame.com/playgrounds/347/javascript-promises-mastering-the-asynchronous/its-quiz-time

 

It's quiz time - JavaScript promises, mastering the asynchronous

Explore this playground and try new concepts right into your browser

www.codingame.com:443

 

 

[참고한 자료]

https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event

https://ko.javascript.info/promise-error-handling

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

https://joshua1988.github.io/web-development/javascript/promise-for-beginners/