🍦 JavaScript/JavaScript 기초

[JavaScript 기초] 3. 비동기 처리(Callback, Promise, async/await)

Baeg-won 2023. 8. 26. 14:26

📌 동기와 비동기

  • 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);
    }
}
  • 예제 수행 과정은 간단합니다.
    1. 사용자 아이디와 비밀번호를 받아 로그인을 수행합니다.
    2. 로그인에 성공하면 사용자 권한을 체크합니다.
  • 이를 위해 다음과 같은 예제 코드를 작성해주었습니다.
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);