이번 시간에는 우리는 콜백 지옥에서 벗어나게 해줄 대안들에 대해서 알아보자!!
Promise는 언제 쓰이는 걸까??
용도는 생각보다 명확하다.
Promise는는 JS에서 제공하는 객체로 비동기 작업을 하는 함수에 return 타입으로 사용한다.
promise
가 표준이 되기 전에는 라이브러리마다 처리방식이 달라서, 프로그래머 입장에서는 일일히 작동 방식을 찾아봐야 했다.
그래서, 공통된 인터페이스를 만들어서 쓰기 시작했으니 그게 promise
이다.
ex)
fetch("api.whatever.com");
// promise를 return 한다.
요청을 보내고, 받는 과정에서는 불가피하게 딜레이가 발생할 수 밖에 없다.
그래서 이 fetch
함수는 당연하게도 promise
객체를 반환한다.
즉, 무언가 비동기 작업을 하는 함수이다??
자동적으로 Promise
객체를 반환하겠구나!! 하고 생각하면 된다.
프로세스가 기능 수행을 다 완료해서, 성공했는지 실패했는지
Promise는 다음 중 하나의 상태를 가진다.
대기(pending) : 이행하지도, 거부하지도 않은 초기 상태.
이행(resolved or fulfilled) : 연산이 성공적으로 완료됨.
거부(rejected) : 연산이 실패함.
state : pending -> resolved or rejected
우리가 원하는 데이터는 제공하는 사람과 이 제공된 데이터를 쓰는 사람의 다른 견해를 이해하자
promise의 생성자 안에는 엑시큐터(executor)라는 콜백함수를 전달해줘야 한다.
엑시큐터라는 콜백함수에는 또다른 2가지의 콜백함수를 받는다.
- resolve : 기능을 정상적으로 수행해서 최종적으로 데이터를 전달함
- reject : 기능을 수행하다가 중간에 문제가 생기면, 호출하게 됨
무언가 큰 데이터를 받아오는 것은 시간이 걸린다. 그런 작업을 동기적으로 하게 되면, 네트워크에서 데이터를 받아오는 동안, 그 다음 라인의 코드가 실행되지 않는다.
그렇기 때문에, 데이터를 받아오는 동안 놀고 있는 우리의 CPU가 다른 일을 하도록, 논 블로킹 I/O
방식으로 만들어 주는 게 좋다.
바로 그 작업이 Promise
를 만들어서 하는 것이다.
const promise = new Promise((resolve, reject) => {
// 새로운 promise를 만드는 순간 엑시큐터라는 콜백 함수가 자동적으로 실행된다.
// 바로 이런 점 때문에, 클릭 시에 네트워크에서 데이터를 받아와야 되는 상황이라면,
// 필요하지도 않은 네트워크를 받아오지 않게끔 주의해야 한다.
console.log("doing something...");
setTimeout(() => {
resolve("gil");
// reject는 Error라는 JS에서 제공되는 객체를 통해서 값을 전달한다.
reject(new Error("no network"));
}, 2000);
});
// promise라는 producer를 만들었다
then
, catch
, finally
를 이용해서 값을 받아올 수있다.then
은 promise
가 정상적으로 수행이 되서, 최종적으로 resolve
라는 콜백함수의 값이 value로 전달된다.catch
는 promise
가 수행하는데 문제가 생겼을 경우, 최종적으로 reject
라는 콜백함수의 값이 value로 전달된다.resolve
이든 reject
든 둘 중에 하나가 실행이 되면, 나머지 코드는 실행되지 않는다. let Data = new Promise();
Data.then(function(){
}).catch(function(){
});
=> new Promise()
문법으로 Data
라는 변수 오브젝트를 하나 생성하시면 Promise
제작 끝이다. 그럼 이제 Data
라는 변수에다가 then()
을 붙여서 실행가능하다.
코드가 실행이 실패했을 경우엔 catch()
함수 내의 코드를 실행시켜준다.
(물론, 지금은 프로미스 안에 코드가 암것도 없다)
이런 식으로 코드를 차례로 실행할 수 있게 도와주는 디자인 패턴이 바로 Promise
이다.
Promise
가 콜백함수보다 좋다고 하는 이유
1. 콜백함수와는 다르게 순차적으로 뭔가를 실행할 때 코드가 옆으로 길어지지 않는다.
then
함수를 붙여서 순차적으로 실행하니까...
2. 콜백함수는 순차적으로 뭔가를 실행시킬 때, 콜백함수마다 일일히 에러 처리 코드를 붙여줘야 한다. 그러나,promise
는catch
구문 한 줄로 모든 에러 처리를 다 커버한다.
=> 비동기인 애들은 특히나!! 실패할 가능성을 염두해둬야 한다.
지금까지 정리한 내용으로 코드로 풀어보면 다음과 같다.
promise
.then((value) => {
console.log(value); // gil이 찍힌다.
})
.catch((error) => {
// 그렇다면, reject의 값은 어디로 전달되는가??
// catch 구문을 쓰지 않으면, 에러가 uncaught "잡히지 않은 에러"라고 뜬다.
console.log(error);
})
.finally(() => {
// 최근에 추가된 인자이다.
// 성공하든 실패하든 무조건 마지막에 호출 되어진다.
console.log('finally');
});
const fetchNumber = new Promise( (resolve, reject) => {
setTimeout( () => resolve(1), 1000 );
} );
// num 에는 1이 들어가서 2가 곱해지고 .. 쭉 가는 것을 알 수 있다.
fetchNumber
.then(num => num * 2)
.then(num => num * 3)
.then(num => {
// 추가로 then은 값을 바로 전달해도 되고, 또다른 promise를 전달해도 된다.
return new Promise( (resolve, reject) => {
setTimeout( () => resolve(num -1), 1000 );
})
})
.then(num => console.log(num))
.catch((error) => {
// catch 구문을 쓰지 않으면, 에러가 uncaught "잡히지 않은 에러"라고 뜬다.
console.log(error);
});
Promise의 특징
1. 일단new Promise()
로 생성된 변수를 콘솔창에 출력해보면, 현재 상태를 알 수 있다.
성공/실패 판정 전에는<pending>
이라고 나오며,
성공 후엔<resolved>
실패 후엔<rejected>
이런 식으로 나온다.
이렇게 프로미스 오브젝트들은 3개 상태가 있다.
그리고 성공을 실패나 대기상태로 다시 되돌릴 순 없다.
2.Promise
는 동기를 비동기로 만들어주는 코드가 아니다.
Promise
는 비동기적 실행과 전혀 상관이 없다.
그냥 코딩을 예쁘게 할 수 있는 일종의 디자인 패턴이다.
예를 들면..Promise
안에 10초 걸리는 어려운 연산을 시키면, 10초동안 브라우저가 멈춘다.
10초 걸리는 연산을 해결될 때까지 대기실에 제껴두고 그런거 아니다.
(그냥 원래 자바스크립트는 평상시엔 동기적으로 실행이 되며 비동기 실행을 지원하는 특수한 함수들 덕분에 가끔 비동기적 실행이 될 뿐이다.)
=> clear style of using promise
async
와 await
은 promise
를 좀 더 간결하게 만들어주는 역할이다.
체이닝을 계속하면, 조금 코드가 난잡해질 수 있기 때문에 좀 더 간단한 API로 async
와 await
을 사용하면, 우리가 그냥 동기식으로 코드를 순서대로 작성하는 것처럼 할 수 있다.
그러나, 무조건 async
와 await
을 쓰는 것이 맞는 것은 아니다.
promise
를 써야지 맞는 경우도 있고, async
를 써야 맞는 경우도 있게 때문에 상황에 맞게 맞춰서 쓰자.
async & await
은 promise
를 더 간편하게 쓰기 위한 syntactic sugar
이다.
- syntactic sugar (신테틱 슈거)
=> 기존에 존재하는 API 위에 조금 더 간편하게 쓸 수 있는 API를 제공하는 것
ex)class
,async & await
등
async
와await
의 특징
async
키워드를 쓰면Promise
오브젝트가 절로 생성된다.async
키워드를 쓴 함수 안에서는await
을 사용가능하다.await
은 그냥프로미스.then()
대체품으로 생각하면 된다.
하지만then
보다 훨씬 문법이 간단합니다.
// promise 버전
function pickFruits() {
// promise도 너무 길게 체이닝을 하면, 콜백 지옥과 비슷한 결과가 나온다.
return getApple().then((apple) => {
return getBanana().then((banana) => `${apple} + ${banana}`);
});
}
// 똑같은 버전을 async로 만들어보기
// try , catch를 이용한 에러 처리
async function pickFruits() {
try{
const apple = await getApple();
const banana = await getBanana();
return `${apple} + ${banana}`;
} catch(err) {
console.log(err);
}
}
- 비동기식처리되는 코드를 담는다면,
await
기다리는 동안 브라우저가 잠깐 멈출 수 있다.await
은 실패하면 에러가 나고, 코드가 멈춘다.
- 그것을 예방하기 위해서,
try & catch
구문 사용해서 에러 처리를 해줄 수 있다.
// 콜백 디자인 패턴
const addSum = (a, b, callback) => {
setTimeout(() => {
if (typeof a !== "number" || typeof b !== "number") {
callback("a, b must be numbers");
}
callback(undefined, a + b);
}, 3000);
};
addSum(10, 20, (error, sum) => {
if (error) return console.log({ error });
// sum을 받지 않아야 자동으로 undefined가 뜨는구나
console.log({ sum });
// 여기서 addSum()을 한 번 더 하고 싶다면??
addSum(sum, 15, (error1, sum1) => {
if (error1) return console.log({ error1 });
// sum을 받지 않아야 자동으로 undefined가 뜨는구나
console.log({ sum1 });
});
});
// 콜백으로 에러 처리시에는 if 절로 처리해줘야했지만,
// promise는 catch 구문을 통해 더 쉽게 처리가 가능하다.
// 연쇄작용을 할 때, 콜백을 호출할 때마다, 에러처리를 일일히 해줘야 한다.
// 연쇄 작용을 더 하면, 할 수록 코드가 더욱 nesting 되기 때문에, 복잡해진다.
// 이 똑같은 것을 promise는??
// promise 디자인
const addSum = (a, b) => {
return new Promise((resolve, reject) => {
// resolve 나 reject 둘 중에 하나가 실행이 되면,
// 나머지 코드는 실행되지 않고 종료된다.
setTimeout(() => {
if (typeof a !== "number" || typeof b !== "number") {
reject("a,b must be number");
}
resolve(a + b);
}, 1000);
});
};
addSum(10, 20)
.then((sum) => {
console.log({ sum });
return addSum(sum, 15);
})
.then((sum1) => {
console.log({ sum1 });
return addSum(sum1, "kor");
})
.catch((error) => console.log({ error }));
// 에러 처리도 몇 번을 연쇄작용을 해도, 딱 한 번만 해주면 된다.
// 몇 개를 달아도 코드가 너무나 간단해진다.
// 콜백을 여전히 많이 쓰지만, Promise로 많이 옮겨가는 추세이다.
// 하지만, then 체인도 길어지면, 약간 콜백 지옥 냄새가 날 수 있다.
// 이거를 좀 더 이쁘게 사용할 수 있는 문법이 있다.
// 그냥 문법만 좀 더 간결하게 바꿔주는 거!!
// promise 디자인
const addSum = (a, b) => {
return new Promise((resolve, reject) => {
// resolve 나 reject 둘 중에 하나가 실행이 되면,
// 나머지 코드는 실행되지 않고 종료된다.
setTimeout(() => {
if (typeof a !== "number" || typeof b !== "number") {
reject("a,b must be number");
}
resolve(a + b);
}, 1000);
});
};
const totalSum = async () => {
try {
let sum = await addSum(10, 10);
console.log({ sum });
const sum2 = await addSum(sum, 27);
console.log({ sum2 });
const final = await addSum(sum, sum2);
console.log({ final });
} catch (err) {
// 이것도 한 번도 처리해주면 된다.
if (err) console.log(err);
}
};
console.log(totalSum());
// async의 에러 처리는 try & catch 구문으로 해줘야 한다.
// 실무에서는 논블로킹 I/O 작업을 이런 식으로 한다.
async function pickFruits() {
const apple = await getApple();
const banana = await getBanana();
return `${apple} + ${banana}`;
}
위의 코드에서 Banana
를 받아오는데, Apple
을 먼저 받아올 필요가 없다면 이런 식으로 코드를 짜는 것은 상당히 비효율적이다.
Apple
를 받아오는데 2초가 걸리고, Banana
를 받아오는데 2초가 걸린다면, pickFruits()
은 실행되는데 4초의 시간이 걸린다.
그렇다면, 어떻게 더 효율적으로 코드를 짤 수 있을까??
async function pickFruits() {
// 이 코드가 나오는 순간, promise가 곧바로 실행된다.
const applePromise = getApple();
const bananaPromise = getBanana();
// 이렇게 실행하면, 2개의 프로미스가 동시에 받아와지기 때문에,
// 2초만에 모든게 다 받아와진다.
const apple = await applePromise;
const banana = await bananaPromise;
// 최종 return 까지는 2초밖에 걸리지 않음
return `${apple} + ${banana}`;
}
그러나, 이렇게 동시다발적으로 실행이 가능한 경우 (= 병렬적으로 실행가능한 경우)에는
접근법 1
같이 더럽게 코드를 작성하지 않는다.
접근법 2 => Promise.all
Promise.all()
모든 Promise
들이 병렬적으로 다 받아질 때까지 모아주는 API이다.
Promise.all
은 배열 형태로 받아온다.
function pickAllFruits() {
return Promise.all([getApple(), getBanana()])
.then(fruits => fruits.join(' + '));
}
pickAllFruits().then(console.log);
어떤 것이든 상관없고, 먼저 받아지는 첫 번째 과일만 받아오고 싶다면??
promise.race()
function pickOnlyOne() {
// race()는 Promise의 내장 API이다.
return Promise.race([getApple(), getBanana()]);
}
// 둘 중 먼저 받아오는 것만 출력 되는 것을 확인할 수 있다.
pickOnlyOne().then(console.log);
물론, 이외에도
Promise
객체에서 지원하는 다양한 메소드가 존재한다.
상황에 맞게 짧은 response time을 낼 수 있는 메소드를 사용하자!!
MDN 프로미스 - 정적 메소드에서 다양한 메소드를 확인하자!!
짧은 response time이 좋은 UX ( 사용자 경험 )를 만든다!!!
async
, await
을 사용하려면, promise
지원이 필수적이다.
그런데 왜 그렇게 async , await
이 좋다고 하는 걸까??
그냥 " 동기적인 코드가 친숙하고 직관적이다 " 라고만 하기에는 애매모호하다.
이 말은 동기적인 코드가 비동기적인 코드보다 더 좋다는 것을 가정하는 있는 것인데... 그런가??
=> 위의 코드에서 promise
로 작성된 hello
변수는 쓰임과 동시에 생명주기가 끝난다. 그러나, async
함수에서의 hello
변수는 계속 살아있다.
이런 경우, 이미 사용이 끝난 변수를 다시 쓸 수도 있는 위험부담을 갖고 있다는 측면에서 promise
가 더 좋다고 볼 수 있다.
그런데도 많은 프로그래머들은 async
방식을 더 선호한다.
Why??
promise chaining
과 async & await
의 코드 스타일은 확실하게 다르다.
기존 동기적인 코드 스타일과 async & await
의 방식이 좀 더 유사한 것도 사실이다.
우리가 일반적으로 코드를 작성할 때는 논리적으로 동기와 비동기 코드를 명확하게 구분하기 않을 때가 많다고 생각한다.
그리고 비동기함수를 맞이하게 될 때에는 보통 논리적인 이유보다는 시스템적인 제약 때문일 경우가 많다.
그런데, 이 시스템적인 이유 때문에 갑자기 사용하던 코드 스타일을 바꾸자면, 불편할 것이다. 그래서 async & await
을 더 선호하지 않나 싶다. ( 뇌피셜 임다 )
예를 들어, axios
라이브러리는 promise
를 기반으로 하는 라이브러리이다.
다음 수도 코드들에 논 블로킹으로 작성해야 되는 코드가 4개라고 치자
1. 유저 불러오기
2. 블로그 생성
3. 유저 업데이트
4. Log 서비스에 API로 외부에 호출하기
=> 다 처리가 되면 "success"
빨간색 부분은 논 블로킹 작업을 가리킨다. => 외부에서 일어나는 작업
파란색 부분(블로킹 작업)은 실제로 Data를 받았을 때, 파싱을 한다든지 가공해서 보내고 받는 작업을 가리킨다.
Node에서는 비동기이기 때문에, 아래와 같이 호출된다.
첫번째 호출이 끝나지도 않았는데, 나머지 작업들이 호출되고 있다.
빨간색 구간에 있을 때는 Node가 놀고 있는게 아니라, 파란색 작업을 한다.
Node.js에서 1번처럼 호출하고 싶다면??
callback
, then
, async
를 사용해서, 동기코드처럼 똑같이 실행해줄 수있다.
(주의사항) 무조건
await
를 하는게 좋은 것은 아니다.
물론, 빨간색 부분에서는 Node가 다른 일을 할 수 있으니까, 서버는 효율적으로 돌아가고 있다.
그러나, 가로 부분의 길이를 줄이는 것이 UX(사용자 경험)에서는 더 짧은 response time이 나오기 때문에 더 좋다.
존재하는 유저인지 확인하고 => 블로그를 생성해야 하니까 순차적으로 실행해줘야한다.
그러나, 블로그 생성이랑 유저 업데이트는 같이해도 문제가 없다.
Promise.all()
을 적절하게 사용해서 response time을 줄이자!!