치춘짱베리굿나이스

비동기 처리와 Promise 본문

Javascript + Typescript/이론과 문법

비동기 처리와 Promise

치춘 2022. 5. 13. 17:23

비동기 처리와 Promise

동기 (Synchronous) 와 비동기 (Asynchronous)

const [testValue, setTestValue] = useState(0);

  const handleOnClick = () => {
    setTestValue(testValue + 1);
    setTestValue(testValue + 1);
    setTestValue(testValue + 1);
    setTestValue(testValue + 1);
    setTestValue(testValue + 1);
  };

...
<div>{`${testValue}`}</div>
<div onClick={handleOnClick}>add 1</div>

앞에서 prevState에 관해 짧게 정리할 때 슬쩍 본 코드이다

testValue가 한번에 5씩 증가하지 않는 이유가 ‘비동기’ 적인 작동방식 때문이라고 했었는데, 상태값이야 prevState로 이전 값을 끌어다 씀으로써 보정할 수 있다지만, 다른 값들은 비슷한 이슈를 어떻게 처리할까?

짭짤감튀가 먹고 싶어서 맥도날드에 갔다고 생각해 보자 (내가 먹고싶은 건 아니고)

동기식 처리

void Mcdonald(void) {
    firstPerson();
    secondPerson();
    thirdPerson();
    fourthPerson();
    me();
}

...
first person got his hamburger set.
second person got her mcnugget.
third person got his bigmac set.
fourth person got her americano.
I finally got french fries.

동기식 처리의 경우에는 카운터가 하나밖에 없고, 나는 짭짤감튀를 먹기 위해서 앞의 모든 사람들의 결제 및 햄버거 수령이 끝날 때까지 기다려야 한다

내가 언제 짭짤감튀를 받아갈 수 있는지 그 순서는 명확하지만, 앞의 사람들의 처리가 느려지면 느려질 수록 나는 오늘 내로 짭짤감튀를 먹을 수 없을 지도 모른다

이처럼 동기식 처리는

  • 동시에 여러 작업을 (병렬로) 수행할 수 없다
  • 어느 함수 (코드) 가 언제 실행되는지, 그 순서가 알기 쉽다

비동기식 처리

const McDonald(void) {
    firstPerson();
    setTimeout(() => { // 대표적인 비동기 함수
        secondPerson();
        thirdPerson();
    }, 0);
    fourthPerson();
    me();
}

...
first person got his hamburger set.
fourth person got her americano.
I finally got french fries.
second person got her mcnugget.
third person got his bigmac set.

반면, 비동기식 처리는 맥도날드에 키오스크가 여러 대 있어서, 앞의 사람들이 결제를 끝냈는지 여부와 상관 없이 중간에 짭짤감튀를 먹을 수 있다

내가 언제 짭짤감튀를 받아갈 수 있는지 예측할 수 없다는 단점이 있지만, 키오스크 덕분에 여러 작업이 한번에 수행되어 처리 속도가 빨라진다

따라서 비동기식 처리는

  • 동시에 여러 작업을 병렬로 수행할 수 있다
  • 다만 어떤 함수 (코드) 가 언제 실행되는지 명확히 예측하기 어렵다

위의 예제에서 setTimeout() 은 대표적인 비동기 API이며, 동기적 코드인 firstPerson(), fourthPerson(), me() 가 먼저 실행된 후 비동기 API 내의 내용들이 실행된다

const McDonald(void) {
    setTimeout(firstPerson, 2000); // 2000ms
    setTimeout(secondPerson, 1000); // 1000ms
    setTimeout(thirdPerson, 1500); // 1500ms
    setTimeout(fourthPerson, 500); // 500ms
    setTimeout(me, 1000); // 1000ms
    console.log('Everyone arrived at the Kiosk.');
}

...
Everyone arrived at the Kiosk.
fourth person got her americano.
second person got her mcnugget.
I finally got french fries.
third person got his bigmac set.
first person got his hamburger set.

모든 코드를 비동기 함수 setTimeout() 에 넣고, 각자 계산 후 음식을 받기까지 걸리는 시간을 설정해 주자

  1. 비동기 함수 setTimeout()이 순서대로 실행되면서, 동작을 예약하고 종료된다
  2. 모든 setTimeout()이 동작 예약을 완료하면, console.log가 실행된다
  3. 예약했던 동작들이 병렬적으로 실행된다. 이때 대기 시간을 다르게 조정했으므로 각 setTimeout()은 다른 동작에 영향을 미치거나 영향을 받지 않고 독립적으로 해당 시간만큼 대기한다 ⇒ 대기 시간이 짧은 동작 먼저 수행된다

동작 예약?

이때 ‘동작을 예약한다' 라고 표현했는데, 각 함수가 실행될 때마다 코드가 Call Stack에 push되고, 스택에 푸시되었던 함수가 setTimeout() 을 지원하는 API에 요청을 보내면서 pop된다

console.logCall Stack에 등록되지마자 콘솔에 내용을 출력하고 동작이 끝나기 때문에 바로 pop되지만, setTimeout()은 별도의 API에 동작을 예약한 후 해당 API가 병렬로 동작을 수행한다

그렇다는 것은 자바스크립트 / 타입스크립트의 모든 함수가 비동기식인 것이 아니라, 몇몇 함수들이 실행 환경 (node.js, 브라우저) 의 도움을 받아 비동기식으로 동작하는 것 뿐이다

주로 사용되는 대표적인 비동기 함수는 서버에서 데이터를 받아오는 fetching 함수 (fetch, axios) 와 파일을 읽어들이는 함수가 있다

콜백 함수

const foo = () => {
    console.log('hello?');
}

const bar = (str, callback) => {
    console.log(str);
    callback();
}

bar('byebye', foo);

const handleButtonClick = () => {
    console.log('clicked!');
}

...
<button onClick={handleButtonClick}>클릭</button>

다른 함수의 인자로 받아지는 함수와, 어떠한 이벤트에 의해 호출되어지는 함수를 콜백 함수라 한다

예시에서 barcallback 함수를 인자로 받아와 내부에서 실행하므로, foo는 콜백 함수라고 할 수 있다

또한 handleButtonClick은 ‘클릭 이벤트' 가 발생했을 때 호출되므로, handleButtonClick도 콜백 함수라고 할 수 있다

쉽게 말하면 코드에서 바로 호출되는 함수가 아니라, 어딘가에 함수를 등록해 놓고 특정 이벤트나 시점에 실행되는 함수가 콜백 함수인 것이다

말이 어려워서 그렇지 콜백 함수 자체는 어렵지 않다

비동기에서의 콜백 지옥

foo(() => {
    bar(() => {
        aaa(() => {
            bbb(() => {
                ccc(() => {
                    ddd(() => {
                        eee(() => {
                            fff(() => {
                                run('hello');
                            }).bind(this);
                        }).bind(this);
                    }).bind(this);
                }).bind(this);
            }).bind(this);
        }).bind(this);
    }).bind(this);
}).bind(this);

콜백 함수에 대해 알아본 이유는 콜백 지옥 때문이었다

비동기 함수들을 순차적으로 실행시키고 싶을 때, 보통은 어떠한 작업이 끝나고 나야 함수가 동작하도록 콜백 함수로 등록하여 사용하는데, 이 방식을 사용하면 작업량이 많아질 수록 콜백을 잔뜩 중첩해야 하기 때문에 (특정 작업이 끝났을 때 다른 작업을 수행하기 위해서) 콜백 지옥에 빠져버린다

이 콜백 지옥을 타파하기 위해 나온 것이 Promise이다

Promise

비동기 작업을 간단하게 처리하도록 도와주는 객체이다

쉽게 말하면, 비동기 작업을 수행한 후 작업 성공 시와 실패 시에 전달 값을 다르게 하여 서로 다른 작업을 가능케 한다

순차적으로 수행되지 않는 비동기 함수의 실행 순서를 사용자가 제어할 수 있도록 도와주고, 콜백 지옥에 비해 가독성 또한 뛰어나다

Promise 객체를 반환하는 대표적인 라이브러리로 Axios, Fetch가 있다

Promise로 비동기 작업 관리하기

const promise = new Promise((resolve, reject) => {
    // 수행할 비동기 작업
});

/*
    (resolve, reject) => {} 함수가 executor이다
*/

Promise 객체는 위와 같이 생성할 수 있다

  • Promise() 클래스를 통해 객체를 만들며, 인자로 함수를 전달받는다
  • 이 함수는 executor라고 불리며, 인자 2개를 받는다
  • 첫 번째 인자는 resolve로, executor 내에서 호출할 수 있는 또 하나의 함수이다. resolve는 비동기 작업이 성공했을 때 호출한다
  • 두 번째 인자는 reject로, executor 내에서 호출할 수 있는 또 하나의 함수이다. reject는 비동기 작업이 실패했을 때 호출한다

Promise 객체는 new Promise() 코드에 도달하자마자 내부에 선언된 비동기 작업을 시작한다

이 비동기 작업이 언제 끝날지는 경찰도 모르기 때문에 일단 실행부터 시켜놓고, 성공했을 때 (resolve가 호출되었을 때)와 실패했을 때 (reject가 호출되었을 때) thencatch 메소드를 통해 각각 다른 처리를 수행할 수 있도록 미리 지정해놓는 것이 일반적인 사용밥이다

then-catch 를 어디선가 많이 봤다 했는데 Axios에서도 데이터를 받아올 때 then-catch 메소드으로 에러 처리를 한다

그 이유야 당연히 AxiosPromise 객체를 반환하기 때문이지

Promise로 후속 동작 정의

const promise = new Promise((resolve, reject) => {
    // 수행할 비동기 작업
});

promise
    .then(() => {
        console.log('성공했대요');
    })
    .catch((e) => {
        console.log(e);
    });

이렇게 promise 객체의 then, catch 메소드를 이용하면 비동기 작업의 결과에 따른 후속 작업을 정의할 수 있다

성공하면 ‘성공했대요' 라는 문구가 콘솔에 출력될 것이고, 실패하면... 에러 메시지 (e) 가 출력될 것이다

const promise = new Promise((resolve, reject) => {
    resolve();
});

Promise 선언 시에 내부에서 아예 resolve() 함수를 실행하도록 선언해 버리면, 실패 없이 무조건 성공으로 간주한다

따라서 then으로 지정한 동작이 실행된다

const promise = new Promise((resolve, reject) => {
    reject();
});

반대로 Promise 선언 시에 내부에서 reject() 함수를 실행하도록 선언해 버리면, 무조건 실패로 간주한다

따라서 catch로 지정한 동작이 실행된다

결론은 성공 케이스와 실패 케이스를 분리하고, 성공할 경우 resolve(), 실패할 경우 reject() 를 실행할 수 있도록 executor를 구성하면 된다

Promise 재사용으로 같은 작업 반복하기

const promiseFunc = (boolVar) => {
    return new Promise((resolve, reject) => {
        if (boolVar) resolve();
        else reject();
    });
}

new Promise() 부분을 아예 별개의 함수로 빼 보자

언제 resolve가 호출되고 언제 reject가 호출되는지도 정의하였다

const promise1 = promiseFunc(true);
promise1
    .then(() => {
        console.log('성공!');
    })
    .catch(() => {
        console.log('실패!');
    });

// 성공!

이렇게 만들면 promiseFunc를 호출할 때 비동기 작업이 시작되므로, 함수의 동작 시점을 조절할 수 있다

AxiosFetch의 반환값을 변수에 넣어 사용하는 것도 이러한 맥락이라 할 수 있겠다

작업 결과 전달하기

const promiseFunc = (boolVar) => {
    return new Promise((resolve, reject) => {
        if (boolVar) resolve('great');
        else reject('error');
    });
}

const promise1 = promiseFunc(true);
promise1
    .then(() => {
        console.log('성공!');
    })
    .catch(() => {
        console.log('실패!');
    });

// great!
// 성공!

성공했을 때와 실패했을 때 문구를 출력하기 위해 값을 인자로 전달해줄 수 있다

그 외

const throwError = (boolVar) => {
    return new Promise((resolve, reject) => {
        throw Error('error!');
    });
}

const promise1 = promiseFunc(true);
promise1
    .then(() => {
        console.log('성공!');
    })
    .catch(() => {
        console.log('실패!');
    }); 

// 실패!

executor 내에서 에러 발생 시 (에러가 throw될 시) 자동으로 reject() 가 수행된다

const promiseFunc = (boolVar) => {
    return new Promise((resolve, reject) => {
        resolve();
        reject();
    });
}

const promise1 = promiseFunc(true);
promise1
    .then(() => {
        console.log('성공!');
    })
    .catch(() => {
        console.log('실패!');
    });

// 성공!

executor 내에서 resolve와 reject가 순서대로 적혀 있다면, 첫 번째 함수만 유효하다

위의 예시에선 resolve()reject() 보다 먼저 호출되므로, 동작 성공으로 판단하여 항상 then으로 넘어간다

const promiseFunc = (boolVar) => {
    return new Promise((resolve, reject) => {
        resolve();
        return (true);
    });
}

const promise1 = promiseFunc(true);
promise1
    .then(() => {
        console.log('성공!');
    })
    .catch(() => {
        console.log('실패!');
    });

// 성공!

executor의 반환값은 무시된다

결론

Promise는 약속한 동작이 성공했을 때 fullfilled로 판단하여 then에 등록한 동작을 수행하고, 실패했을 때 rejected로 판단하여 catch에 등록한 동작을 수행한다

그리고 executor에 등록한 비동기 동작이 아직 수행 대기 중일 땐 pending 상태로 판단한다

이처럼 비동기 작업을 수행하고 후속 작업을 등록할 때 비교적 간결하게 설정할 수 있어 콜백 지옥에서도 벗어나고 유연한 설계가 가능하다

Promise와 관련된 문법으로 async / await가 있는데, 정리글이 너무 길어져 다른 포스팅에서 다뤄보도록 할 것

후.... C만 주구장창 파다가 자바스크립트 본격적으로 파보려 하니 비동기가 의외로 발목을 잡는다

예상치 못한 동작이 심심찮게 일어나서 항상 내 머리를 아프게 만든다

참고자료

JavaScript 비동기 핵심 Event Loop 정리

콜백 함수(Callback)의 정확한 의미는 무엇일까?

[Javascript] 비동기, Promise, async, await 확실하게 이해하기

[javascript]비동기처리와 Promise/axios/fetch

 

'Javascript + Typescript > 이론과 문법' 카테고리의 다른 글

require, import, export  (0) 2022.07.25
Throttle & Debounce  (0) 2022.05.18
[Typescript] Type vs Interface  (0) 2022.05.09
spread, rest  (0) 2022.04.12
비구조화 할당 (구조분해 할당)  (0) 2022.04.12
Comments