자바스크립트는 single-threaded 프로그래밍 언어이다. 그렇기 때문에 동기적이다. 동기적 synchronous이란 의미는 다음의 동치와 같다.
synchronous === one call stack === one piece of code at a time
하지만 우리는 이를 비동기적으로도 작동시킬 수 있다.
이 포스트에서 자바스크립트에서 코드를 비동기적(asynchronous)으로 작성하기 위한 3가지 방법을 학습 및 리뷰할 것이다: 콜백, Promise 객체, async&await
- 콜스택(Call Stack) is basically a data structure which records where in the program we are.
우선 콜 스택은 우리가 브라우저 등에서 '실행'시킨 즉 함수의 호출, 'invoke'관점에서 작동할 기능들이 들어오는 '스택'이다. 스타벅스에서 주문 후 "음료 제조 중"에 들어간 것이다.
If we step into a func, we push sth on the stack,
return from a func, we pop off the top of the stack
- 'maximum call stack size exceeded': 코드를 작성하면서 이와 같은 에러메시지를 본 적이 있다. 콜백 안의 콜백으로 마치 엘레베이터 내 양쪽벽면이 거울로 둘러쌓여 무한대의 거울상이 생기는 것과 같다. 브라우저는 사용자에게 당신이 같은 콜백을 수천번, 수만번을 호출했을 리가 없으니 이를 끝내겠다고 오류로 뱉는 것이다.
- blocking vs Non-blocking: 브라우저가 느려지는 이유는?
만약 동기적으로 작동하는 코드베이스로 인해 콜 스택에 들어온 함수가 내부 콜백에 setTimeout으로 타이머가 걸려있어, 우리는 브라우저의 다른 부분을 클릭하거나 해당 페이지로 넘어가거나 등등 다른 수행을 할 수 없는 이러한 경우 콜 스택이 blocking 된 것이다.
리액트 등으로 만드는 컴포넌트형 웹앱이 대세라고 하지만? 아직까지 한국에서는 페이지 전체를 reRender 하는 형태가 꽤나 남아있다.
🔗 관련 글: 지메일이 핫메일을 이긴 진짜 이유 (Ajax가 가져온 유저 인터페이스의 혁신)
개발자로서 코드의 최적화를 위한 사고는 "브라우저가 왜 느려질까?"라는 질문부터 시작된다.
*setTimeout에서 걸어주는 타이머는 minimum을 걸어준 것이지, 0으로 한다고 바로 실행된다, 이런 의미로 활용하는 것은 아니다.
call stack ⇪(실행) | |
Event Loop 🔄 | 콜스택이 비었을 때 task queue에 대기하고 있던 실행할 내용을 콜스택으로 넘겨줌 |
task queue↩︎ |
💡 Question
Node.js는 single-thread인가?
그렇다면 Event Loop는 왜 필요한가?
자바스크립트에서 함수는 객체이다,
함수는 변수를 담은 채로 함수를 리턴할 수 있고(closure),
리턴할 함수를 인자로서 받아 다른 함수에 전달할 수도 있고,
변수에 할당할 수 있다.
함수가 인자로 콜백함수를 받아서 들어오는 배열의 각 요소나 객체의 key/value에 함수를 적용하여 그 결과를 성공 시 또는 실패 시(error) 결과를 되돌려 리턴해주는 것이라 'callback'이란 네이밍의 의미를 따져볼 수 있다.
따라서 콜백함수 안에 콜백함수, 또 그 안에 콜백함수..가 있을 수 있다.
callback hell case 🧻
예제로 다음과 같은 과정의 코드가 있을 경우,
1. 사용자에게 id, pw 받아오기
2. 로그인 시도
3. 로그인 성공 시 id 받아오고
4. 역할 받아오기(admin 등)
5. 성공적으로 받아온다면 사용자의 object를 갖게 되는 것// class객체는 생략함 const userStorage = new UserStorage(); // class 만들고 서버와 통신 const id = prompt('enter your id'); // 사용자가 입력 const password = prompt('enter your pssword'); // 사용자가 입력 userStorage.loginUser(id, password, (user) => { // ⓐ콜백 사용자가 입력한 user정보를 다음 콜백에 넘김 userStorage.getRoles(user, (userWithRole) => { // ⓑ콜백 이 콜백은 사용자의 Role정보를 받아오는 함수 alert(`Hello ${userWithRole.name}, you have a ${userWithRole.role} role`) }, (error) => { // 에러가 날 경우 에러 핸들링(ⓑ콜백) console.log(error) }) }, (error) => { // 에러 핸들링(ⓐ콜백) console.log(error) })
드림코딩by엘리 유튜브 무료강의 예제 참고
예제는 콜백 안에서 콜백을 한번 더 전달하는 두 겹의 중첩뿐이긴 하지만,
이렇게 콜백 안에서 콜백을 전달하고, 또 전달, 또 전달하게.. 되면 콜백지옥이 되는데, 이러한 코드 문제점: 가독성 현저히 낮고, 로직 파악이 불편하며 디버깅도 어려움, 유지보수에 꽝이다.
따라서 병렬적으로 작성할 수 있고, 네트워크와 효율적으로 통신할 수 있도록 Promise객체와 async & await를 사용하여 비동기 코드를 작성한다. (곧 비동기적으로 코드를 작성해야 하는 이유)
콜백함수를 인자로 받는 map, filter, forEach 등 배열 메소드
아래 gif 이미지에서 forEach를 async하게 만들어 실행하는 경우를 보여주고 있다.
앞서 말한 blocking 현상과 non-blocking을 여기서 확인할 수 있다.
비동기적으로 코드를 작성하면, 코드가 실행되는 사이사이마다 브라우저는 render할 수 있다.(render queue가 초록색으로 깜빡이는 것)
Promise는 비동기를 간편하게 처리할 수 있도록 도와주는 "object"이다.
☝🏻 예를 들어 오픈될 수강에 대한 이메일 공지 알림받고싶어 이메일 구독 신청하는 경우, 뒤늦게 사전공지창을 발견했을 때 이메일 등록 시 수업은 이미 오픈되었으니 기다리지 않아도 바로 메일로 공지가 옴 : 이미 성공적으로 처리된 promise인 것!
promise 객체는 promise객체를 리턴한다. (처음 배울 때 코드를 직접 작성하는 기술,적인 면에서는 중요한 포인트라고 생각한다.)
const getDataPromise = path => {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf8", (err, data) => {
if(err) {
reject(err);
}
resolve(data);
})
})
};
수행 결과 값이 있으면 resolve
콜백인자로 넘겨주고, .then 메소드로 resolve 콜백인자 또는 리턴 값을 콜백에 또 넘긴다.
콜백 결과 리턴에 실패 시, 즉 에러가 있으면 에러를 reject
콜백인자로 넘겨준다.
*.try/ .catch 메소드로 에러를 잡음.
*여기서 말하는 콜백은 콜백함수와 같이 작동하는 기능을 말함.
프로미스는 콜백을 쓰지 않고 비동기로 깔끔한 코드를 작성하도록 하는 객체이다.
Promise is a JavaScript object for asynchronous operation.
❗️ Two points
- 상태 state* : pending -> fulfilled (수행 성공 완료) or rejected
:프로세스가 operation을 수행하고 있는 중인지 / 기능을 다 수행하여 성공했는지 실패했는지
(정해진 장시간의 기능을 수행 후 기능이 정상적으로 수행되었으면 성공 메시지 + 처리된 결과값 전달하고(resolve), 수행 중 에러가 발생하면 에러를 전달(reject)한다.) *resolve 와 reject는 executor라고 부른다.
리턴하는 것이 없으면 'pending' 상태- producer(정보 제공) vs consumer(소비)
: promise객체를 만든다. 이것이 producer이고, 이 promise 인스턴스를 사용하는 .then/ .catch/ .finally 메소드를 consumer로 본다.
.then 등의 메소드를 통해 여러 다른 비동기를 묶어서 병렬적으로 처리가 가능한 것이다.
예제
// 1. producer
const promise = new Promise((resolve, reject) => { // 두가지 콜백이 또 인자로 들어옴 (resolve, reject : executor)
// 파일을 읽거나(fs.readfile) 네트워크와 통신하는 등의 시간이 걸리는 작업 → 비동기적으로 처리(곧 비동기가 필요한 이유!)
console.log('doing something...');
setTimeout(() => {
resolve('success'); // 수행 성공 시 resolve 콜백 호출(, 인자 전달)
// reject(new Error('no network'))
}, 2000)
})
// 2. consumer
promise
.then((value) => { // promise 값이 잘 수행되었으면 어떤 값(value)을 받아와서 (인자를 넘겨줘서) 콜백을 수행할 것
// 여기서 value는 위 producer 코드의 resolve콜백을 통해 전달한 'success'
console.log(value);
}) // ! then은 성공한 수행결과값을 전달하거나, promise를 전달할 수 있음, 여기서는 똑같은 promise를 리턴함
.catch(error => { // ! 그 리턴된 promise에 catch를 또 호출할 수 있음
console.log(error);
})
.finally(() => { // 성공실패 여부 상관없이 마지막에 호출 가능. 인자 따로 받지 않아도 ok
console.log('finally');
});
// 이렇게 consumer(메소드)를 사용하여 promise를 chaining
드림코딩by엘리 유튜브 무료강의 참고
const getHen = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve('🐓'), 1000);
});
const getEgg = hen =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(`${hen} => 🥚`), 1000); // ⓐ
// setTimeout(() => reject(new Error(`${hen} => 🥚`)), 1000); ⓑ
});
const cook = egg =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(`${egg} => 🍳`), 1000);
});
return
하는 것을 지정해주면 메소드로 chaining 가능 getHen() //
.then(getEgg)
.catch(error => { // !직전에서 발생한 에러를 캐치하고 싶을 때는 바로 다음에 catch
return '🥖 '
}) // ! getEgg가 성공하지 않아도, return값을 지정하여 대신 전달해줘서 실패하지 않음(error뜨지 않음)
// 위 producer 코드에서 ⓐ를 주석 처리, ⓑ를 주석해제한 다음
// 이 consumer코드 실행 시 콘솔 출력값은 " 🥖 => 🍳 "
.then(cook)
.then(console.log)
// 에러 핸들링하고 있지 않기 때문에 마지막에 걸어줌
.catch(console.log)
// 콘솔 출력값 "🐓 => 🥚 => 🍳 "
드림코딩by엘리 유튜브 무료강의 참고
tip +
getHen() .then(hen => getEgg(hen)) // .then(getEgg)로 줄일 수 있음. 한 가지 인자만 받아오는 경우엔. .then(egg => cook(egg)) .then(meal => console.log(meal));
Promise의 경우에도 chaining이 길어지면 복잡하다.
Promise를 좀 더 간결하게, 비동기를 동기적으로 실행되는 것처럼 (코드를) 보이게 만들어주는 async&await 조합을 사용
async와 await는 synctatic sugar이자 유용한 API이다.
*경우에 따라 promise가 적합할 때가 있고, async & await를 써야할 때가 있다.
함수 앞에 async를 붙이고, async가 붙은 함수 내에서
await를 쓸 수 있다.
예제
function fetchUser() { // 서버에서 userdata를 받아오는 함수
// do network request in 10secs...
return new Promise((resolve, reject) => {
resolve('received');
})
}
// * async
async function fetchUser() {
return 'received';
}
const user = fetchUser(); // ! 함수 선언된 곳으로 함수의 블럭 수행 한줄씩.. 10초 동안 머무름
console.log(user); // 동기적이라 위 함수호출 수행 완료 후 넘어오는 것
// 비동기적인 처리를 안하면 사용자정보를 가져오는 데 10초가 걸리는데
// 이 이후 웹페이지에 표시되는 UI 를 로드하는 코드가 있다면
// 사용자는 10초 동안 텅텅 빈 브라우저 화면을 봐야함
// * await
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function getApple() {
await delay(1000); // delay(3000) 호출 ('3초가 지나는 수행') 이 끝날 때까지 기다려
// throw 'error'; // < catch
return '🍎';
}
async function getBanana() {
await delay(1000); // .then 역할
return '🍌';
}
async function pickFruits() {
const apple = await getApple(); // ! delay(1000)
const banana = await getBanana(); // ! delay(1000) 이 각 각 걸려있어(서로 상관없어 비동기적으로 이루어져도 됨)
return `${apple} + ${banana}`;
}
pickFruits().then(console.log)
드림코딩by엘리 유튜브 무료강의 참고
function pickAllFruits() {
return Promise.all([getApple(), getBanana()]) // 배열로
.then(fruits => fruits.join(' + '));
// ! 모든 프로미스들이 병렬적으로 다 받아질 때까지 기다림
// 배열 형태 등이 넘겨짐
}
→ Promise.all을 async & await와 함께 사용하여 효율적 코드 작성.
*Promise.race 도 있음, 가장먼저 수행결과값을 리턴하는 프로미스만 전달됨
💡 Question
다음 코드의 실행 순서는?const p = new Promise((resolve, reject) => { console.log("p"); setTimeout(() => { resolve("res"); console.log("res"); }, 2000); }); p.then((val) => { console.log(val + 'ⓐ'); // ⓐ }); console.log("here"); p.then((val) => { console.log(val + 'ⓑ'); // ⓑ }); /* 출력 값 p here res resⓐ resⓑ */
process⇪ | call stack | Task Queue | Web APIs |
---|---|---|---|
5 | console.log(val + 'ⓑ') | ||
4 | console.log(val + 'ⓐ') | ||
3 | console.log("res") | ||
anonymous*2000ms after | |||
p.then ⓑ | |||
2 | console.log("here") | ||
p.then ⓐ | |||
setTimeout(anonymous) | |||
1 | console.log("p") | ||
promise |
*중요한 것은 비동기는 동기가 끝나고 작동한다는 것