📌 동기와 비동기
- JavaScript는 동기 방식으로 처리되는 언어입니다.
- 이는 즉, 코드가 호이스팅 이후부터 우리가 작성한 순서에 따라 하나하나 동기적으로 실행되는 것을 의미합니다.
console.log('1');
console.log('2');
console.log('3');
1
2
3
- 비동기란 말그대로 코드가 언제 실행될지 예측할 수 없는 것을 말합니다.
- 한 가지 예로, setTimeout()이라는 WebAPI를 들 수 있는데, 이는 지정된 시간이 지나고 나서 우리가 전달한 함수를 자동으로 실행해주는 기능을 수행합니다.
- 콜백 함수란 쉽게 말해 "우리가 전달한 함수를 니가 불러줘"라는 개념으로, setTimeout()이라는 함수에는 handler라는 콜백 함수와, 어느 정도의 딜레이를 줄 것인지를 정하는 인자가 있습니다.
- 즉, 다음과 같이 사용할 수 있습니다.
console.log('1');
setTimeout(function() {
console.log('2');
}, 1000);
console.log('3');
1
3
2
- 실행 결과 위처럼 출력 순서가 변경된 것을 확인할 수 있습니다.
📌 콜백 헬
- 콜백 함수를 유용하게 쓰일 수도 있지만 자칫하면 콜백 헬에 빠질 수도 있습니다.
- 콜백 헬이란 콜백 함수들을 계속해서 묶어나가면서 콜백 함수 안에서 또다른 콜백 함수를 호출하고, 또 호출하고를 반복함으로써 발생하는 문제를 말합니다.
💬 콜백 헬 예제
- 콜백 헬에 대해 좀 더 알아보기 위해 간단한 예제를 작성해보겠습니다.
class UserStorage {
loginUser(id, password, onSuccess, onError) {
setTimeout(() => {
if (id === 'baegwon' && password === '1234') {
onSuccess(id);
} else {
onError(new Error('not found'));
}
, 2000);
}
getRoles(user, onSuccess, onError) {
setTimeout(() => {
if (user === 'baegwon') {
onSuccess({ name: 'baegwon', role: 'admin' });
} else {
onError(new Error('no access'));
}
}, 1000);
}
}
- 예제 수행 과정은 간단합니다.
- 사용자 아이디와 비밀번호를 받아 로그인을 수행합니다.
- 로그인에 성공하면 사용자 권한을 체크합니다.
- 이를 위해 다음과 같은 예제 코드를 작성해주었습니다.
const userStorage = new UserStorage();
const id = 'baegwon';
const password = '1234';
userStorage.loginUser(id, password,
(user) => {
userStorage.getRoles(user, (userWithRole) => {
console.log(userWithRole.name);
}, (error) => { console.log(error); });
},
(error) => {
console.log(error);
}
);
- 위 코드의 문제점은 무엇일까요?
- 바로 가독성이 떨어진다는 것입니다. 어디서부터 어디까지 연결되어 있는건지 한눈에 가늠하기도 어렵고, 비즈니스 로직을 이해하기도 힘듭니다.
- 또한 에러가 발생할 경우 어느 부분에서 발생한 에러인지를 확인하기가 까다롭습니다.
- 이제, 이렇게 콜백 헬에 빠진 비동기 코드를 수정하는 방법에 대해 알아보겠습니다.
📌 Promise
- Promise, 한국어로는 '약속'이라는 단어입니다.
- 프로미스는 JavaScript에서 제공하는, 비동기를 간편하게 처리할 수 있도록 도와주는 오브젝트 입니다.
- 프로미스는 정해진 기능을 수행하고 나서, 정상적으로 기능이 수행되어졌다면 성공 메시지와 함께 처리된 결과값을 제공해주며, 만약 기능을 수행하다가 문제가 발생했다면 에러를 전달해줍니다.
- 프로미스에는 두 가지 학습 포인트가 있습니다.
- 첫 번째는 상태(State)로, 프로세스가 현재 실행 중인지(pending), 성공했는지(fulfilled), 실패했는지(rejected)를 나타내는 상태에 대해 이해하는 것이 중요합니다.
- 두 번째는 정보를 제공하는 Producer와 정보를 소비하는 Consumer의 차이에 대해 이해하는 것입니다.
💬 프로미스 객체 만들기
- 먼저 우리가 원하는 기능을 비동기적으로 실행하는 프로미스 객체를 만들어줍니다.
const promise = new Promise((resolve, reject) => {
// doing some havy work (network, read files)
console.log('doing something...');
});
- 프로미스 객체를 만들기 위해서는 위와 같이 생성자에 executor라는 콜백 함수를 전달해주어야 하며, 해당 콜백 함수는 resolve와 reject라는 두 개의 콜백 함수를 전달받고 있습니다.
- 위처럼 작성하고 바로 실행해보면 콘솔 창에 'doing something...'이 출력되는 것을 확인할 수 있습니다.
- 이는 즉, 프로미스 객체를 만드는 순간 우리가 전달한 executor 콜백 함수가 실행된다는 것을 의미하기 때문에 때에 따라 주의해서 사용해야 합니다.
- 이제 위 프로미스 안에서 네트워크 통신을 하는 것처럼 setTimeout을 이용하여 시간 딜레이를 주도록 하겠습니다.
const promise = new Promise((resolve, reject) => {
// doing some havy work (network, read files)
console.log('doing something...');
setTimeout(() => {
resolve('baegwon');
}, 2000);
});
- 해당 프로미스는 어떤 일을 2초 정도 수행하다가 잘 마무리하여 resolve라는 콜백 함수를 호출하면서, 특정 값을 전달하는 객체입니다.
- 위 프로미스 객체는 정보를 제공하는 역할, 즉 Producer이며, 이제 이를 이용하는 Consumer를 만들어야 합니다.
💬 프로미스 객체 사용하기
- Consumer는 then, catch, fianally를 통해 값을 받아올 수 있습니다.
- 역시 예제를 통해 살펴보겠습니다.
promise.then((value) => {
console.log(value); // "baegwon"
});
- then은 프로미스가 정상적으로 실행되어 최종적으로 resolve라는 콜백 함수를 통해 전달된 값을 얻어올 수 있습니다.
const promise = new Promise((resolve, reject) => {
// doing some havy work (network, read files)
console.log('doing something...');
setTimeout(() => {
reject(new Error('no network'));
}, 2000);
});
- 코드를 조금 바꾸어 resolve 대신 reject 콜백 함수를 호출해보겠습니다.
- reject 콜백 함수는 프로미스가 정상적으로 수행되지 않은 경우를 말하며, 이를 처리하기 위해서는 catch를 사용해야 합니다.
promise.then((value) => {
console.log(value);
}).catch((error) => {
console.log(error); // "no network"
});
- finally는 프로미스 성공/실패 여부와 상관없이 마지막에 무조건 호출되어지는 코드 블록을 의미합니다.
promise.then((value) => {
console.log(value);
}).catch((error) => {
console.log(error); // "no network"
}).finally(() => {
console.log('finally'); // "finally"
});
💬 프로미스 체이닝
- 또한 프로미스는 다음과 같이 체이닝으로 연결하여 사용할 수도 있습니다.
const fetchNumber = new Promise((resolve, reject) => {
setTimeout(() => resolve(1), 1000);
});
fetchNumber.then(num => num * 2)
.then(num => num * 3)
.then(num => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(num - 1), 1000);
});
})
.then(num => console.log(num)); // "5"
- 프로미스 체이닝 시 오류를 처리하는 방법은 다음과 같습니다.
- 간단한 예제를 통해 살펴보겠습니다.
const getHen = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve('hen'), 1000);
});
const getEgg = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(`${hen} => egg`), 1000);
});
const cook = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(`${egg} => meal`), 1000);
});
getHen().then(hen => getEgg(hen))
.then(egg => cook(egg))
.then(meal => console.log(meal)); // "hen => egg => meal"
- 위 예제는 닭이 낳은 알을 요리하는 일련의 과정을 코드를 작성해본 것입니다.
const getHen = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve('hen'), 1000);
});
const getEgg = () =>
new Promise((resolve, reject) => {
setTimeout(() => reject(new Error(`error! ${hen} => egg`)), 1000);
});
const cook = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(`${egg} => meal`), 1000);
});
getHen().then(hen => getEgg(hen))
.then(egg => cook(egg))
.then(meal => console.log(meal)); // X
.catch(error => console.log(error)); // "error! hen => egg"
- 코드를 조금 바꾸어 중간에 문제가 발생하는 상황을 구현해보았습니다.
- 이렇게 될 경우 알을 찾지 못했기 때문에 요리를 중단해버리고 에러를 출력하게 되며, 이는 때에 따라 올바른 에러 처리 방법이 되지 못할 수도 있습니다.
- 따라서 만약 알을 찾지 못했을 경우, 다른 재료로 요리를 하도록 에러 처리를 하고 싶다면 다음과 같이 할 수 있습니다.
const getHen = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve('hen'), 1000);
});
const getEgg = () =>
new Promise((resolve, reject) => {
setTimeout(() => reject(new Error(`error! ${hen} => egg`)), 1000);
});
const cook = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(`${egg} => meal`), 1000);
});
getHen().then(hen => getEgg(hen))
.catch(error => { return 'bread'; })
.then(egg => cook(egg))
.then(meal => console.log(meal)); // X
.catch(error => console.log(error)); // "error! hen => egg"
💬 프로미스를 사용한 콜백 헬 수정
- 이제 본격적으로 프로미스를 사용하여 위에서 구현했던 콜백 헬 코드를 수정해보겠습니다.
class UserStorage {
loginUser(id, password) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 'baegwon' && password === '1234') {
resolve(id);
} else {
reject(new Error('not found'));
}
, 2000);
});
}
getRoles(user) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (user === 'baegwon') {
resolve({ name: 'baegwon', role: 'admin' });
} else {
reject(new Error('no access'));
}
}, 1000);
});
}
}
- 더 이상 성공/실패 처리를 위한 콜백 함수를 받아오지 않아도 되기 때문에 훨씬 간단해진 것을 확인할 수 있습니다.
- 이제 위 프로미스를 사용하는 Consumer는 다음과 같이 구현할 수 있습니다.
const userStorage = new UserStorage();
const id = 'baegwon';
const password = '1234';
userStorage.loginUser(id, password)
.then(user => userStorage.getRoles(user))
.then(user => console.log(`Hello ${user.name}, you have a ${user.role} role`)
.catch(error => console.log(error));
- 역시 훨씬 깔끔해진 것을 확인할 수 있습니다.
📌 async / await
- 이번에는 비동기의 꽃인 async와 await에 대해서 알아보도록 하겠습니다.
- async와 await은 프로미스를 조금 더 간결하고 간편하고 동기적으로 실행되는 것처럼 보이도록 만들어주는 요소입니다.
- 즉, 프로미스 체이닝을 사용하게 되면 코드가 난잡해질 수 있는데, async와 await을 사용하면 마치 동기식으로 코드를 작성하는 것처럼 간편하게 코드를 작성할 수 있게 됩니다.
- 다만 그렇다고 해서 무조건 async와 await을 사용하는 것이 프로미스만 사용하는 것보다 좋은 것은 아니기 때문에 상황에 맞게 사용하는 것이 중요합니다.
- 그럼 본격적으로 async와 await을 사용해보도록 하겠습니다.
function fetchUser() {
return new Promise((resolve, reject) => {
return 'baegwon';
});
}
const user = fetchUser();
user.then(user => console.log(user)); // "baegwon"
- 기존에 프로미스를 사용할 때에는 위처럼 사용해주어야 했습니다.
- 이제, 위 프로미스 객체 앞에 async 키워드를 붙여주겠습니다.
async function fetchUser() {
return 'baegwon';
}
const user = fetchUser();
user.then(user => console.log(user)); // "baegwon"
- 위 두 코드는 동일하게 동작합니다. 즉, async라는 키워드를 함수 앞에 쓰면 해당 코드 블록이 자동으로 프로미스 객체가 되는 것입니다.
- 프로미스 객체를 훨씬 간편하게 만들 수 있는 것이죠.
- 다음으로는 이보다 더 유용한 await에 대해 알아보겠습니다.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function getApple() {
await delay(3000);
return 'apple';
}
async function getBanana() {
await delay(3000);
return 'banana';
}
- await은 위처럼 async가 붙어 있는 함수 안에서만 사용할 수 있습니다.
- delay 함수는 전달된 시간 만큼 딜레이를 주고 resolve 콜백 함수를 반환하는 함수이며, getApple과 getBanana 함수는 각각 await을 통해 3초 정도 기다렸다가 프로미스 객체를 반환하는 함수입니다.
- 추가로 사과와 바나나를 한 번에 가져오는 함수를 구현해보겠습니다.
function pickFruits() {
return getApple().then(apple => {
return getBanana().then(banana => `${apple} + ${banana}`);
});
}
pickFruits().then(console.log); // "apple + banana"
- 위 코드를 보면 프로미스를 사용했음에도 불구하고 콜백 헬에 빠지게 된 것을 확인할 수 있습니다.
- 프로미스 역시 중첩적으로 체이닝을 하게 되면 콜백 헬과 비슷한 문제가 발생하게 됩니다.
- 이제 위 코드를 async와 await을 통해 간단하게 수정해보겠습니다.
async function pickFruits() {
const apple = await getApple();
const banana = await getBanana();
return `${apple} + ${banana}`;
}
pickFruits().then(console.log); // "apple + banana"
- 콜백 헬 문제를 간단하게 해결하였습니다.
- 그런데 여기서도 한 가지 문제점이 있습니다.
- await를 통해 getApple, getBanana를 각각 호출하고 있는데, 사실 두 함수는 실행하는데 서로 전혀 연관이 없기 때문에 굳이 서로를 기다릴 필요가 딱히 없습니다.
- 따라서 이를 개선하기 위해서는 프로미스가 제공하는 API를 사용할 수 있습니다.
function pickAllFruits() {
return Promise().all([getApple(), getBanana()])
.then(fruits => fruits.join(' + '));
}
pickAllFruits().then(console.log); // "apple + banana"
- Promise.all은 배열로 전달받은 모든 프로미스들이 병렬적으로 다 받아질 때까지 기다렸다가 모아주는 기능을 수행합니다. 반환값은 보시다시피 배열입니다.
- 추가로, 만약 가장 처음에 완료되는 프로미스만 가져오고 싶다면 다음과 같이 Promise.race를 사용할 수 있습니다.
function pickOnlyOne() {
return Promise.race([getApple(), getBanana()]);
}
pickOnlyOne().then(console.log);
'🍦 JavaScript > JavaScript 기초' 카테고리의 다른 글
[JavaScript 기초] 4. JavaScript 최신 문법(ES6, ES11) (0) | 2023.08.27 |
---|---|
[JavaScript 기초] 2. Variable (0) | 2023.08.26 |
[JavaScript 기초] 1. async와 defer (0) | 2023.08.26 |
[JavaScript 기초] 0. JavaScript의 역사와 현재 (0) | 2023.08.25 |