ES6
이전까지는 비동기 처리에서 작업 순서를 보장하기 위해 error-first callback 패턴을 사용했다.
node.js
에서 fs
모듈을 사용하여 파일을 읽을 때 사용하는 readFile()
함수가 error-first callback 패턴을 사용하는 좋은 예다.
하지만 지속해서 사용하다 보니 여러 단점이 발견되었고, 이런 단점을 해결하기 위해 Promise가 탄생했다.
과연 어떤 문제가 있는지, 또 error-first callback 패턴을 어떻게 Promise 패턴으로 변경하는지 알아보도록 하자!🙂
callback hell 은 비동기 처리가 필요한 작업의 순서를 보장하려고 할 때 생긴다.
예를 들어 text_1.txt -> text_2.txt -> text_3.txt 순서대로 파일의 내용을 출력한다고 해보자.
const fs = require('fs');
fs.readFile('./text_1.txt', (err, data) => {
if(err) {
console.error(err);
}
console.log(data.toString());
});
fs.readFile('./text_2.txt', (err, data) => {
if(err) {
console.error(err);
}
console.log(data.toString());
});
fs.readFile('./text_3.txt', (err, data) => {
if(err) {
console.error(err);
}
console.log(data.toString());
});
실행 결과
1. text_1.txt content!
2. text_3.txt content!
3. text_2.txt content!
동기식 흐름을 따르지 않는 비동기 처리의 특성상, 비동기 작업의 실행 순서가 보장되지 않는다..😥
만약 파일의 용량이 각자 다르다면 더욱 예상하지 못한 결과를 얻을 수도 있다.
이번엔 순서대로 실행될 수 있도록 코드를 고쳐보자!
const fs = require('fs');
fs.readFile('./text_1.txt', (err, data) => {
if(err) {
console.error(err);
}
console.log(data.toString());
fs.readFile('./text_2.txt', (err, data) => {
if(err) {
console.error(err);
}
console.log(data.toString());
fs.readFile('./text_3.txt', (err, data) => {
if(err) {
console.error(err);
}
console.log(data.toString());
});
});
});
실행 결과
1. text_1.txt content!
2. text_2.txt content!
3. text_3.txt content!
위처럼 코드를 고치면 비동기 작업의 순서를 보장할 수 있다. 하지만 순서대로 실행하려는 파일의 개수가 10개가 넘어간다면?🤔
이처럼 error-first callback 패턴을 사용하면 코드의 indent
가 깊어지고, 지금은 데이터를 출력하는 간단한 작업이지만 가져온 데이터를 분기별로 다르게 조작하려 한다면 더욱 가독성이 떨어질 수밖에 없다.
이 외에도 예외처리를 위해 try...catch
문법을 callback 함수마다 달아줘야 하는 등, 여러 가지 단점이 존재하고 이를 해결하기 위해 Promise가 등장하게 된다.
Promise는 error-first callback 패턴의 여러 단점을 보완하기 위해서 생긴 기능이다.
그렇다고 callback을 아예 사용하지 않는 것은 아니고, 비동기 처리에 사용되는 callback 에서 일어날 수 있는 여러 가지 단점들을 보완해주는 기능이다.
Promise의 특징은 비동기 처리를 실행하긴 하지만, 결과를 바로 callback 함수로 건네주는 것이 아니라 나중에 처리할 수 있다는 것이다.
또한 Promise는 new Promise()
생성자 함수를 통해 Promise 객체를 만들어서 처리하기 때문에 다른 함수에게 Promise 객체를 넘겨서 처리하는 것도 가능하다.
그러면 이제 Promise에 대해 자세히 알아보도록 하자!😆
Promise는 아래처럼 세 가지 상태를 가진다.
pending
Promise가 생성되고 나서부터
fulfilled
나rejected
상태가 되기 전 까지의 상태
fulfilled
Promise에서
resolve()
함수가 호출되면fulfilled
상태가 된다.
rejected
Promise에서
reject()
함수가 호출되면rejected
상태가 된다.
그리고 resolve()
혹은 reject()
함수가 호출되면 해당 Promise를 settled Promise라고 하고, settled Promise는 resolve()
나 reject()
를 다시 호출한다고 해도 상태가 변하지 않는다.
한 마디로 Promise는 최종적으로 fulfilled
혹은 rejected
중 하나의 상태만을 가진다.
Promise가 등장한 이후로 error-first callback 패턴을 이용하던 함수들은 Promise를 지원하도록 바뀌었다.
하지만 Promise 객체를 반환하지 않는 함수들도 new Promise()
생성자 함수를 이용해서 Promise 객체를 사용할 수 있으니 걱정하지 않아도 된다.
fs.readFile()
함수를 Promise 객체로 변환하는 예제를 통해 Promise의 사용법을 알아보자!🙂
const fs = require('fs');
getText('./text_1.txt')
.then(data => console.log(data)) // 파일 내용이 있을 경우 실행
.catch(err => console.error(err)); // 파일 내용이 없거나 파일을 불러오지 못 한 경우 실행
function getText(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if(err || data.toString() === '')
reject(new Error('no contents'));
resolve(data.toString());
})
});
}
(원래 fs
모듈은 Promise를 지원하지만, error-first callback 패턴 함수를 Promise 객체로 만드는 예제를 보여주기 위해 일부러 이렇게 작성하였다.)
이제 getText()
함수를 호출하면 Promise 객체를 반환하게 되는데, Promise 객체의 매개변수를 보면 resolve
와 reject
를 가지는 callback 함수로 비동기 처리할 작업을 감싸고 있는 것을 볼 수 있다.
resolve
와 reject
또한 callback 함수이며, 함수가 정상적으로 동작한다면 resolve()
를 호출, 실패했다면 reject()
를 조건에 따라 호출하면 된다.
그리고 반환된 Promise 객체의 then()
메서드로 resolve()
에 대한 처리, catch()
메서드로 reject()
에 대한 처리를 진행할 수 있다.
then()
메서드의 두 번째 매개변수로도 reject()
처리가 가능하지만, 그렇게 되면 then()
의 callback 에서 발생한 예외는 잡아내지 못하므로, catch()
메서드를 사용하도록 하자!😣
이렇게만 해도 callback이 두 번 호출되는 경우를 방지할 수 있는 장점이 있지만, Promise의 진가는 체이닝에 있다.
만약 Promise chaining이 없다고 가정해보고 Promise를 활용해서 callback hell이 일어날 수 있는 코드를 작성해보자.
비교를 위해 callback hell 에서 사용했던 예제를 똑같이 사용하겠다!
const fs = require('fs');
getText('./text_1.txt').then(data => {
console.log(data);
getText('./text_2.txt').then(data => {
console.log(data);
getText('./text_3.txt').then(data => {
console.log(data);
}, (err) => console.error(err))
}, (err) => console.error(err))
}, (err) => console.error(err));
function getText(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if(data.toString() === '') reject(new Error('err'));
resolve(data.toString());
})
});
}
Promise를 사용했는데도 불구하고 indent
가 error-first callback 패턴을 사용한 것과 큰 차이가 없어 보인다...😥
하지만 Promise chaining 을 활용하면 아주 깔끔한 코드를 작성할 수 있다.
const fs = require('fs');
getText('./text_1.txt')
.then(data => { console.log(data); return getText('./text_2.txt');})
.then(data => { console.log(data); return getText('./text_3.txt');})
.then(data => console.log(data))
.catch(err => console.error(err));
function getText(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if(data.toString() === '') reject(new Error('err'));
resolve(data.toString());
})
});
}
Promise 또한 객체이기 때문에 then()
함수의 반환 값으로 또 다른 Promise 객체를 반환해서 코드를 간결하게 만들었다.
그리고 여러 개의 비동기 작업에 대한 reject()
처리도 Promise chaining을 이용하면 하나의 catch()
만으로도 처리가 가능하다!
이처럼 Promise를 활용하면 error-first callback 패턴의 여러 단점을 해결할 수 있다.
JavaScript
의 특성상 비동기 처리는 거의 필수적이고, error-first callback 기반으로 만들어진 많은 API
들이 Promise 기반으로 재구성되었기 때문에, Promise 객체에 대한 정확한 이해가 필요하다고 생각해서 정리해 보았습니다.
혹시 부족한 내용이나 틀린 부분이 있다면 지적해주시면 감사하겠습니다!!🤗
참고 자료
Using promises - JavaScript | MDN
https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Using_promises
Promise() 생성자 - JavaScript | MDN
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise
Promise - JavaScript | MDN
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise