[Typescript] Promise 객체를 이용한 비동기(Asynchronous)적 처리
1. 서두
이번 포스팅에선 JavaScript의 기본 객체 중, Promise를 이용한 비동기(Asynchronous)적 처리에 대해 정리했다.
만약 비동기적 처리와 JavaScript의 비동기적 처리 방법에 대해 모르고 있다면 필자의 이전 글을 먼저 읽고오길 바란다.
2021.12.05 - [SW/Typescript] - [Typescript] JavaScript의 비동기(Asynchronous)적 처리
JavaScript는 비동기적 처리를 위해 CallBack 패턴이란 것을 사용한다. 하지만 전통적인 CallBack 패턴은 CallBack Hell로 인해 가독성이 떨어지고 비동기 작업 처리중 발생한 오류를 핸들링하기 어렵고, 동시에 여러 비동기 처리를 한번에 처리하는 데도 한계가 있는 패턴이다.
이로 인해 JavaScript의 표준 중, 2015년에 공개된 ES6(ECMAScript 2015)에서부터 이러한 CallBack Hell을 유발 할 수 있는 패턴을 대체하기 위해 Promise를 표준으로 도입했다. Promise는 JavaScript에서 비동기적 처리에 사용되는 객체이다. Promise는 기존 CallBack 패턴의 문제점을 보완했는데,
기존 CallBack 패턴은 크게 "CallBack Hell"과 "오류처리의 한계" 라는 문제점을 가지고 있었다.
2. CallBack Hell
기존 CallBack 패턴이 갖는 첫 번째 문제점, "CallBack Hell"이다.
프로그래머 커뮤니티에서 JavaScript와 관련된 meme 중에 심심치 않게 볼 수 있는 주제인데,
어떤 함수에 CallBack 함수를 붙이고 그 CallBack 함수에 CallBack 함수를 붙이고 그리고 또 그 CallBack 함수에 CallBack 함수를 붙이는 아주 악날한 패턴이다.
이러한 CallBack Hell의 문제점은 딱 봐도 알겠지만 특유의 패턴으로 인해 코드의 복잡도에 매우 상승하여 가독성이 매우 나빠지게 된다는 것이다.
그럼 어째서 ES6 이전에는 이런 가독성이 나쁜 패턴을 울며 겨자먹기 식으로 사용을 했던 것일까?
그것은 바로 CallBack 함수의 실행 순서를 보장하기 위함이었다.
이를 이해하기 위해선 우선 동기적 처리모델과 비동기적 처리모델에 대해 알아야 할 필요가 있다.
동기적 처리 모델(Synchronous Proccessing Model)은 직렬적으로 Task(작업)를 처리하는 것을 말한다. 직렬적이라는 것은 Task를 순차적으로 처리하며 어떤 임의의 Task가 처리중인 상태에선 다음 Task는 대기를 하게 된다는 것이다. 이때 이러한 대기를 Blocking 이라고 한다.
비동기적 처리 모델(Asynchronous Proccessing Model)은 병렬적으로 Task를 처리하는 것을 말한다. 병렬적이라는 것은 어떤 임의의 Task의 처리가 완료되지 않은 상태이더라도 다음 Task를 Blocking하지 않고 즉시 처리한다는 것이다.
JavaScript의 대부분의 DOM 이벤트와 Timer 관련 함수, AJAX 요청은 이러한 비동기적 처리 모델을 통해 동작하게 된다.
비동기적 처리 모델은 JavaScript에서 빈번하게 사용되며 Task를 병렬로 처리하기 때문에 다른 Task가 Blocking되지 않는 다는 장점이 있다. 하지만 비동기적으로 요청된 Task는 Blocking되지 않고 즉시 처리되기 때문에 만약 여러 비동기 함수들을 순차적으로 병렬처리해야 할 필요가 있는 경우, 이러한 순차적 실행을 보장받을 수 없다.
그래서 ES6 이전에는 비동기적으로 Task를 처리하지만 Task의 처리 순서를 보장받기 위해 CallBack 함수에 CallBack 함수를 Nesting(중첩)하고 Nesting한 CallBack 함수에 또 CallBack 함수를 Nesting하는 이른바, CallBack Hell을 사용하게 된 것이다.
이러한 CallBack Hell은 코드의 가독성이 떨어지고 복잡도가 상승하여 프로그래머의 실수를 유발하도록 하는 직접적인 원인이 되었다.
3. CallBack 패턴의 오류처리 한계
CallBack 패턴이 갖는 두 번째 문제점, "오류처리의 한계"이다. 사실 이 문제가 가장 심각하다.
이를 이해하기 위해선 우선 호출자(Caller)라는 것에 대해 알아야 한다.
호출자(Caller)란, 어떤 함수를 호출한 객체를 말한다.
예를 들어 아래 코드를 보자
1
2
3
4
5
6
7
|
function main() {
Hello();
}
function Hello() {
console.log(`caller: ${arguments.callee.caller}`);
}
|
cs |
위 코드는 main 함수가 Hello 함수를 호출하고 Hello 함수는 자신을 호출한 호출자를 출력하는 코드인데,
여기서 arguments 객체는 현재 함수에 전달 받은 매개변수를 담은 객체를 말하고, 그 하위 맴버로 callee라는 현재 함수객체를 나타내는 맴버가 있다. arguments.callee을 console.log()를 통해 출력해보면
위와 같이 현재 함수객체를 그대로 출력하는 것을 볼 수 있다. 그리고 이 arguments.callee의 하위 맴버로 caller라는 객체가 있는데, 이게 바로 현재 함수의 호출자를 나타내는 맴버이다. arguments.callee.caller를 출력하게 해보면
위와 같이 Hello 함수의 호출자를 main 함수로 지목하는 것을 알 수 있다. 만약 Hello 함수를 main 함수 안에 넣지 않고 직접 실행하게 되면
위와 같이 Hello 함수의 호출자가 존재하지 않는다는 것을 알 수 있다.
근데 이 호출자라는 것이 왜 중요하냐 하면, 예외(Exception)는 호출자의 방향으로 전파되기 때문이다.
이를 직접 확인 해보고 싶다면 아래 코드를 브라우저 개발자도구 콘솔창에서 실행해보면 된다.
a 함수가 b 함수를 호출하고 b 함수가 c 함수를 호출하고 c 함수는 예외를 던지도록 하는 코드인데, a 함수의 실행 결과를 보면 예외 발생 결과로 c, b, a, null 순으로 전파가 된 과정을 확인할 수 있다. 이런식으로 예외는 호출자의 방향으로 전파가 된다.
그럼 이번엔 아래 코드를 보자
1
2
3
4
5
6
|
try {
setTimeout(() => { throw new Error('Error');}, 1000);
} catch (e) {
console.log('try 내부의 setTimeout()의 CallBack 함수에서 던진 오류의 캐치가 안됨');
console.log(e);
}
|
cs |
위 코드를 실행시켜보면 catch 블록이 실행되지 않는다는 것을 볼 수 있다.
분명 try 블록에서 setTimeout을 이용해 예외를 던지는 CallBack 함수를 1초 뒤 실행되도록 했음에도 불구하고 어째서 catch 블럭에서 예외가 catch되지 않은 것일까?
이게 바로 CallBack 패턴의 오류처리의 한계인데,
이 원인은 예외를 던진 CallBack 함수의 호출자가 존재하지 않기 때문이다.
그런데 분명 코드상에 setTimeout이 버젓이 있는데 CallBack 함수의 호출자가 존재하지 않는다는게 무슨 말일까?
호출자가 존재한다는 의미는 호출자 함수가 Call Stack 안에 존재한다는 것을 말하는데,
JavaScript에서 setTimeout같은 비동기 함수가 호출이 되면 해당 함수가 Call Stack에 쌓이게 되고 JavaScript Engine이 해당 함수를 처리할때는 해당 함수와 해당 함수의 CallBack 함수를 Web API로 비동기 작업요청으로 전달하고 해당 함수를 "Call Stack에서 제거"하게 된다. 문제는 여기서 발생된 것이다.
Web API로 작업요청을 전달한 후 비동기 함수는 Call Stack에서 제거되기 때문에 그 비동기 함수를 통해 전달된 CallBack 함수의 호출자는 더이상 존재하지 않게 되는 것이다. 이로 인해 try 블럭 내 비동기함수의 CallBack 함수 내에서 예외를 던지더라도 이 예외가 catch 블럭에서 catch되지 않았던 것이다.
(JavaScript의 비동기적 처리 과정에 대해 잘 모르고 있다면 필자의 이전글을 꼭 읽고오길 바란다)
이러한 특성으로 인해 CallBack 패턴을 통해선 오류 처리가 어려운데, 이를 해결하기 위해 Promise가 제안되었고 Promise는 ES6에 정식 채택되어 IE를 제외한 대부분의 브라우저가 지원하고 있다.
4. Promise 란? 그리고 기본 사용법
Promise는 CallBack 패턴을 대신해 사용할 수 있는 비동기 처리 패턴을 위한 객체이다. 기존 CallBack 패턴이 가진 단점을 보완하고 비동기 처리 시점을 명확하게 표현할 수 있다는 장점이 있다.
Promise의 전체적인 사용 패턴은 Promise에 CallBack 함수를 전달하여 비동기 작업을 처리하고 처리 상태와 결과를 Promise 객체에 메서드 체이닝으로 호출된 후속 처리 매소드(then, catch)로 전달하여 이후의 처리를 진행하게 된다.
후속 처리 메소드 | 설명 |
then | then 메소드는 두 개의 CallBack 함수를 인자로 전달 받는다. 첫 번째 인자는 CallBack 함수는 Promise의 처리 상태가 Fulfilled 상태일 때 호출되고, 두 번째 함수는 Promise의 처리 상태가 Rejected 상태일 때 호출된다. (중요) then 메소드는 Promise를 반환한다. |
catch | Promise 객체에 전달된 CallBack 함수의 인수 중 reject 함수가 호출되거나, 예외(Exception)이 발생하면 호출된다. catch 메소드는 Promise를 반환한다. |
우선 Promise는 Promise 생성자를 통해 객체로 이용할 수 있다. Promise 생성자는 비동기 작업을 처리할 CallBack 함수를 인자로 전달받는데, 이 CallBack 함수는 resolve 함수와 reject 함수를 인자로 전달받는다.
1
2
3
4
5
6
7
8
9
10
11
12
|
// Promise 객체 생성
const promise = new Promise((resolve, rejesct) => {
// 비동기 작업 처리 코드가 들어가는 곳
if(/*비동기 작업 성공 조건*/) {
resolve(/*비동기 작업 처리 결과*/);
}
else {
rejesct(/*비동기 처리 실패 이유*/);
}
});
|
cs |
그리고 Promise는 비동기 작업 처리의 상태 정보를 갖는다.
상태 정보의 종류는 다음과 같다.
처리 상태 | 의미 | 설명 |
Pending | 비동기 작업이 아직 처리되지 않은 상태 | resolve 함수와 reject 함수가 호출되지 않은 상태 |
Fulfilled | 비동기 작업의 처리가 성공한 상태 | resolve 함수가 호출된 상태 |
Rejected | 비동기 작업의 처리가 실패한 상태 | reject 함수가 호출된 상태 |
Settled | 비동기 작업이 이미 처리 완료된 상태 | resolve 함수와 reject 함수 중 하나가 호출된 상태 |
Pending은 아래와 같이 new Promise( ) 메서드를 호출하면 Pending 상태가 된다.
1
2
3
4
5
6
|
// Promise 객체 생성
const promise = new Promise((resolve, rejesct) => {
// 비동기 작업 처리 코드가 들어가는 곳
});
|
cs |
Fulfilled는 CallBack 함수의 인자로 전달받은 resolve 함수를 아래와 같이 호출하면 Fulfilled 상태가 된다.
1
2
3
4
5
6
|
// Promise 객체 생성
const promise = new Promise((resolve, rejesct) => {
resolve();
});
|
cs |
그리고 Fulfilled 상태가 되면 아래와 같이 Promise 객체에 후속으로 체이닝된 then( )을 이용하여 처리 결과값을 받을 수 있다. 그리고 then( )을 호출하고 나면 새로운 Promise 객체가 반환된다.
1
2
3
4
5
6
7
8
9
10
|
const getResult = () => {
return new Promise((resolve, reject) => {
const result = 100;
resolve(result);
});
};
getResult().then((result) => {
console.log(result); // 출력값: 100
});
|
cs |
Rejected는 CallBack 함수의 인자로 전달받은 reject 함수를 아래와 같이 호출하면 Rejected 상태가 된다.
1
2
3
4
5
6
|
// Promise 객체 생성
const promise = new Promise(() => {
reject();
});
|
cs |
그리고 Rejected 상태가 되면 실패 메세지(실패처리의 결과 값)를 아래와 같이 Promise 객체에 후속으로 체이닝된 catch( )를 통해 받을 수 있다.
1
2
3
4
5
6
7
8
9
|
const getResult = () => {
return new Promise((resolve, reject) => {
resolve(new Error('Error'));
});
};
getResult().then().catch((err) => {
console.log(err);
});
|
cs |
이를 이용하여 예를 들어 어떤 API를 이용해 데이터를 가지고 온다고 할때,
Promise에 전달하는 CallBack 함수 내부에 API를 통해 데이터를 가져오는 코드를 구현하고 만약 성공적을 데이터를 가져오면 그 데이터를 인자로 resolve 함수를 호출하고 데이터를 가져오는데 실패했다면 실패 메세지나 Error 객체를 인자로 reject 함수를 호출하면 된다. 그럼 resolve 함수나 reject 함수를 호출 시 체이닝 된 then( ) 이나 catch( ) 로 분기하여 작업 결과 또는 오류를 처리할 수 있다.
5. Promise Chaining
그리고 Promise의 특별한 기능중 하나로, 여러개의 Promise를 연결하여 사용할 수 있다는 점이 있다.
앞서 살펴본 then( )는 호출이 되고 나면 새로운 Promise 객체를 반환한다고 했는데, 그로 인해 아래와 같이 사용할 수도 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
const getResult = () => {
return new Promise((resolve, reject) => {
// ...
});
};
getResult()
.then(() => {
// ...
})
.then(() => {
// ...
})
.then(() => {
// ...
});
|
cs |
이러한 Promise Chaining 덕분에 더이상 더러운(?) CallBack Hell 패턴을 사용하지 않아도 된다. 사용 예를 한번 보겠다.
아래 코드는 setTimeout( ) API를 이용해 2초뒤 1이라는 값을 resolve로 반환하고, 체이닝된 then( )을 통하여 전달 받은 값을 출력하고 그 값에 숫자를 추가하여 더하는 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
const getResult = () => {
return new Promise((resolve, reject) => {
resolve(1);
});
};
getResult()
.then((result) => {
console.log(result); // 출력값: 1
return result + 1;
})
.then((result) => {
console.log(result); // 출력값: 2
return result + 1;
})
.then((result) => {
console.log(result); // 출력값: 3
});
|
cs |
6. Promise 오류 처리
Promise를 통해 비동기 작업을 처리하는 과정에서 오류가 발생 할 수 있는데 이러한 Promise에서의 오류를 처리하는 방법은 크게 2가지가 있다.
첫 번째로 then( )의 두 번째 인자에 CallBack 함수를 전달해 처리하는 방법이 있다.
Promise 객체에서 reject 함수가 호출되면 then( )의 두 번째 인자로 전달된 CallBack 함수를 통해 오류 메세지 또는 Error 객체를 받을 수 있다.
1
2
3
4
5
6
|
getResult()
.then((result) => {
// ...
}, (err) => {
// ...
});
|
cs |
두 번째 방법으로는 catch( )를 사용하는 방법이 있다.
Promise 객체에서 reject 함수가 호출되면 체이닝된 catch( )를 통해 오류 메세지 또는 Error 객체를 받을 수 있다.
1
2
3
4
5
6
7
|
getResult()
.then((result) => {
// ...
})
.catch((err) => {
// ...
});
|
cs |
위 두가지 방법을 상황이나 코딩스타일에 맞춰 적절히 사용하면 된다. 그치만 가급적이면 두 번째 방법을 이용하여 오류를 처리하는게 효율적이다.
아래 코드를 한 번 보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 1번 방법
getResult()
.then((result) => {
throw new Error('Error');
}, (err) => {
console.log(err); // 실행이 안됨
});
// 2번 방법
getResult()
.then((result) => {
throw new Error('Error');
})
.catch((err) => {
console.log(err); //
});
|
cs |
1번 방법을 이용할 때 만약 then( )의 첫 번째 인자로 전달된 CallBack 함수 내에서 Error가 던져졌을때, then( )의 두 번째 인자로 전달된 오류 처리 목적의 CallBack 함수가 호출되지 않는다.
하지만 2번 방법을 이용하면 then( )의 첫 번째 인자로 전달된 CallBack 함수 내에서 Error가 던져지면, 체이닝된 catch( )이 호출 되면서 오류 처리를 수행할 수 있다.
이러한 이유로 가급적이면 더 많은 예외 처리 상황을 위해 Promise 체인 끝에 catch( )를 붙이는 것을 권장한다.
7. Promise의 정적 메소드
보통 Promise는 생성자를 이용해 사용되지만 기본적으로 JavaScript에서 함수는 객체이기 때문에 메소드를 가질 수 있다. Promise는 4개의 정적 메소드를 가지고 있다.
7-1. Promise.resolve / Promise.reject
보통 Promise.resolve와 Promise.reject는 데이터 값을 Promise로 랩핑하기 위해 이용한다.
Promise.resolve 메소드는 인자로 전달된 값을 resolve 하는 Promise를 생성한다.
1
2
3
4
5
6
7
|
// 두 가지 방법 모두 동일하다
const promise = Promise.resolve(123);
promise.then(res => console.log(res)); // 출력값: 123
const promise = new Promise(resolve => resolve(123));
promise.then(res => console.log(res)); // 출력값: 123
|
cs |
Promise.reject 메소드는 인자로 전달된 값을 reject 하는 Promise를 생성한다.
1
2
3
4
5
6
7
|
// 두 가지 방법 모두 동일하다
const promise = Promise.reject(new Error('Error'));
promise.catch(err => console.log(err)); // 출력값: Error
const promise = new Promise(reject => reject(new Error('Error')));
promise.catch(err => console.log(err)); // 출력값: Error
|
cs |
7-2. Promise.all
Promise.all 메소드는 Promise로 구성된 배열을 인자로 전달 받는다. 전달 받은 Promise 배열 내부의 Promise를 병렬로 처리한 뒤, 그 결과를 resolve하거나 오류 발생 시 reject하는 새로운 Promise를 반환한다.
1
2
3
4
5
6
|
Promise.all([
new Promise(resolve => setTimeout(() => resolve(1), 3000)),
new Promise(resolve => setTimeout(() => resolve(2), 2000)),
new Promise(resolve => setTimeout(() => resolve(3), 1000))
]).then(res => console.log(res)) // [ 1, 2, 3 ]
.catch(err => console.log(err));
|
cs |
이때, 모든 Promise가 Fulfilled 상태이면 각각의 Promise가 resolve한 결과를 배열에 담아 resolve 하는 새로운 Promise를 반환하는데 결과 배열의 순서는 Promise.all에 전달한 Promise 배열의 순서와 동일하다 (처리 순서가 보장됨을 의미한다.)
그리고 Promise가 Rejected 상태이면 가장 먼저 실패한 Promise가 reject한 오류 메세지나 Error 객체를 reject하는 새로운 Promise를 반환한다.
1
2
3
4
5
6
|
Promise.all([
1, // Promise.resolve(1)
2, // Promise.resolve(2)
3 // Promise.resolve(3)
]).then(res => console.log(res)) // 출력값: [1, 2, 3]
.catch(err => console.log(err));
|
cs |
만약 Promise.all에 전달된 배열에 Promise가 아닌 요소가 있을 경우 해당 요소를 resolve하는 Promise로 랩핑되어 전달된다.
7-3. Promise.race
Promise.race는 Promise.all과 비슷하게 Promise로 구성된 배열을 전달받는데 차이점은 전달 받은 배열의 모든 Promise 요소를 병렬처리 한 뒤 각 결과값을 모두 전달 하는 것이 아닌, 가장 먼저 처리된 Promise가 resolve한 값만 반환하게 된다.
그리고 예외 발생이나 reject 함수가 호출될 경우, Promise.all과 동일하게 처리된다.
1
2
3
4
5
6
|
Promise.race([
new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1
new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2
new Promise(resolve => setTimeout(() => resolve(3), 1000)) // 3
]).then(res => console.log(res)) // 출력값: 3
.catch(err => console.log(err));
|
cs |
8. 출처
본 포스팅은 일부 내용이 아래 블로그 글들을 참고하여 작성되었다.
https://joshua1988.github.io/web-development/javascript/promise-for-beginners/
https://poiemaweb.com/es6-promise