m1ndy5's coding blog

자바스크립트와 비동기, Callback, Promise, Async/Await 본문

프론트엔드/java script

자바스크립트와 비동기, Callback, Promise, Async/Await

정민됴 2023. 5. 28. 15:15

자바스크립트의 특징

자바스크립트는 자바스크립트 엔진에서 실행이 되는데,
이 자바스크립트 엔진은 한 번에 하나의 태스크만 실행할 수 있는 싱글 스레드 방식으로 동작한다.
이를 동기적으로 작동한다 라고 얘기하는데

const one = () => console.log("1");
const two = () => console.log("2");
const three = () => console.log("3");

one();
two();
three();

위와 같은 예제를 실행하게 되면 1 2 3이 차례로 찍히게 된다.
(one 함수 실행 끝 -> two 함수 실행 끝 -> three 함수 실행 끝)

 

동기 실행의 단점

하지만 이처럼 동기적으로만 실행하게 된다면 굉장히 많은 문제를 겪게된다.
예를 들어 유튜브 영상의 로딩을 3분 기다려야된다고 생각해보자
그 기다리는 시간동안 댓글을 먼저 읽는다던지 추천 동영상 목록을 훑어보는 경우가 많을 것이다.
하지만 유튜브 영상 로딩 할 동안 댓글과 추천 동영상 목록이 뜨지 않는다면??? 아주 심심할 것이다.
이는 간단한 예시지만 동기적으로 실행될 때는 다른 작업이 차단되는 블로킹, 응답이 늦어지는 지연과 같은 문제가 발생할 것이다.

 

오늘 날의 웹은 더 복잡하게 생겼고 더 나은 UX을 위해 발전하기 때문에 비동기적으로 실행하는 모습을 자주 볼 수 있다.
그렇다면 싱글 스레드인 자바스크립트 엔진에서 어떻게 이게 가능한 것일까?!

 

자바스크립트와 비동기

바로 자바스크립트가 구동되는 환경인 자바스크립트 런타임(브라우저, NodeJS)와 같은 환경이 멀티스레드기 때문이다.
비동기 처리의 요청을 자바스크립트에서 처리하는 것이 아닌, 자바스크립트 런타임에게 이를 보냄으로써 해결 가능하다.

위 그림은 브라우저 환경의 모습이다.
DOM, AJAX, Timer과 같은 비동기 처리가 필요한 함수들은 JS 엔진이 아닌 브라우저에게 보내고
콜스택이 비게 되면 이벤트 루프가 이를 캐치해 콜백 큐에서 콜스택으로 밀어넣게 된다.

이 코드의 실행 결과값은 어떻게 될까?
4초 뒤에 getBeef, 3초 뒤에 sliceBeef, ... , 맨 마지막에 한우 스테이크 나왔습니다 가 뜰줄 알았겠지만
결과는 정 반대로 한우 스테이크 나왔습니다가 먼저 뜨고 맨 마지막에 getBeef가 실행된다.


이 이유는 앞서 말했던 것처럼 setTimeout과 같은 비동기처리가 필요한 함수는 브라우저한테 보내고,
console.log()와 같은 자바스크립트 함수를 먼저 실행하고 그 다음에 먼저 실행될 준비를 마친(1초, 2초, 3초, 4초) 순서대로
이벤트 루프가 콜스택에 밀어넣기 때문이다.

 

비동기 처리의 장단점

  • 비동기 처리는 동시요청이 가능해 앞서 말했던 동기적으로 작동했을 때의 단점을 보완 가능
  • 그렇지만 개발자들이 코드를 짜거나 유지보수를 할 때 어떤 함수가 먼저 실행되는지 한눈에 확인이 어려움.

이러한 단점을 보완하기 위해 콜백 함수, Promise, async/await을 사용하여 비동기 처리를 진행한다.

콜백함수

  • 콜백함수는 다른 코드의 파라미터로 넘겨주는 함수

하지만 콜백 함수라고 어?! 얘 콜백이니까 비동기네? 라고 생각하면 절대 안된다!!
그저 비동기 처리를 위해 콜백으로 구현할 수 있다는 말이다.

콜백 함수의 단점

콜백 지옥

하지만 콜백 함수 안에서 또다른 콜백 함수를 부르는 작업을 계속해서 이어나가다보면
이렇게 들여쓰기가 과도하게 발생하는 콜백 지옥을 볼 수가 있다.
비동기 처리의 실행과정을 이해하고 싶어 사용했다가 더 이해 할 수 없게 된다.

 

try/catch로 에러 못잡음

위의 예시를 보면 일부러 setTimeout 함수로 에러를 던지지만 에러를 캐치하지 못하고 abc가 찍히는 것을 확인할 수 있다.

 

Promise

기존 Callback의 단점을 보완하기 위해 Promise가 등장!

Promise는 비동기 처리를 수행하는 함수인 executor 함수를 가지고 있는데 이 함수는,

  • 비동기 처리가 성공했을 때 수행할 함수인 resolve
  • 비동기 처리가 실패했을 때 수행할 함수인 reject함수를 가지고 있음

 

Promise의 3가지 상태

Promise는 총 3가지의 상태를 가지는데

  • 처음 막 생성됐을 때 결과값을 기다리는 상태인 Pending,
  • 처리가 정상적으로 이루어졌을 때 resolve함수의 value 값을 리턴한 상태인 Fulfilled
  • 처리가 실패했을 때 reject함수의 error 값을 리턴한 상태인 Rejected상태

 

Promise의 후속 처리 메서드

Promise는 비동기 처리 완료 후의 결과값을 값 그대로 리턴하는 것이 아닌 Promise 객체로 감싸서 리턴하기 때문에
이를 처리해줄 후속 메서드가 필요하다.

  • then()은 Promise의 결과를 받아 처리하는 함수로 성공했을 때와 실패했을 때의 결과값을 둘다 처리 가능
  • catch()는 Promise으 결과가 실패일 때 오류를 받아 처리하는 함수
  • finally()는 Promise의 결과와 상관없이 무조건 실행되는 함수

 

error를 then과 catch 둘 다 잡을 수 있다면 언제 뭘 사용해서 잡지?

then은 이렇게 보다시피 두개의 콜백 함수를 실행시킬 수 있는데,
첫 번째는 성공했을 때, 두 번째는 실패했을 때이다.
두 번째 콜백함수는 Promise의 에러는 감지하지만 첫 번째 콜백에서 일어나는 에러는 감지하지 못한다.

그에 반해 catch는 Promise 처리에서 발생한 에러와 then 메서드 호출 이후의 에러까지 모두 감지할 수 있다.
따라서 요즘은 then에서 두가지 콜백을 쓰는 것이 복잡해 보이기 때문에
then에서는 성공 콜백, catch에서는 실패 콜백으로 처리하는 것이 보편적이다.

 

Promise Chaining

프로미스 체이닝이란 프로미스를 연속적으로 호출하는 것을 의미하는데
Promise안의 executor 함수의 결과값이 Promise 형태로 반환되기 때문에 이를 호출하는 것까지는 앞에서 말했으니 알 것이다.

  • 그렇다면 어떻게 연속적으로 후속처리메서드를 호출할 수 있을까?
    • 바로 후속처리메서드들 또한 반환값을 그냥 리턴하는 것이 아니라 Promise에 감싸서 리턴하기 때문이다.

 

Promise의 단점

하지만 이러한 Promise도 단점이 존재하니!

try/catch로 에러 못잡음

잘못된 url로 요청을 보냈기 때문에 fetch에서 오류가 나야하지만 pending 상태의 promise가 response에 들어가 있는 것을 확인할 수 있다.

 

Promise 지옥

Promise 또한 결국 후속처리 메서드를 통해 그 값을 처리해야하기 때문에
이렇게 후속처리 메서드 안에서 또 Promise를 불러오는 Promise 지옥이 발생한다.

 

Async/Await

이를 또 보완할 수 있는 async/await의 등장!
async/await을 한 문장으로 정의하자면
비동기 코드를 동기적인 방식으로 작성할 수 있도록 해주는 자바스크립트의 문법적 확장 이라고 할 수 있다.

 

async

async가 function 앞에 붙게되면 그 function이 프로미스가 아닌 일반 값을 리턴하더라도
암묵적으로 언제나 프로미스를 반환하게 되어있다.

이렇게 프로미스가 아니여도 되는 n의 값을 리턴했지만 Promise 후속 처리 메서드인 then을 사용해서 처리하는 모습을 볼 수 있는데
그 이유는 async function의 리턴값은 언제나 프로미스이기 때문!

 

await

await는 반드시 async 함수 내부에서 사용되어져야 하며 Promise 앞에서 사용해야한다.
await를 사용하면 Promise가 settled상태 즉 성공이든 실패든 결과가 난 상태까지 대기하고
이후에 처리 결과를 반환한다.

이렇게 그냥 비동기 함수 앞에 await를 쓰게 된다면 아무 소용이 없는 것을 확인 할 수 있다.

 

그렇다면 비동기 함수를 전부 await를 붙여서 동기처럼 작동하게 해야할까?

당연히 아니다.
이렇게 전부 await를 붙여서 동기처럼 작동하게 한다면 애초에 비동기를 쓸 이유가 없지 않은가!
이럴 때는 동시처리를 원하는 함수들을 Promise.all로 감싸고 그 앞에 await를 붙여서 동시 처리를 진행할 수 있다.

 

async/await의 에러 처리

try/catch in async function

async 함수 안에서 try/catch를 사용하여 에러를 잡은 코드이다.
앞선 callback과 Promise랑 다르게 잘 잡힌다.

 

후속메서드 catch

async 함수는 결국 Promise를 리턴하기 때문에 이렇게 후속처리메서드를 통해 처리를 할 수 있다.

 

최상위 레벨에서의 await 사용법

await를 사용할 때는 반드시 async 함수 안에서 사용해야한다는 것을 배웠다.
그렇다면 main 함수에서 await를 사용하고 싶을 때는 반드시 다른 함수로 빼서 그 앞에 async를 붙여야할까?

아니다! 이렇게 익명함수 형식으로 async 함수를 만들어서 호출해준다면 await를 최상위 레벨에서도 사용가능하다.

 

마지막 질문!

그렇다면 모든 promise에 항상 await을 붙여야할까??

NO!! 아니다!!

상황예시??

비동기 처리를 하고 끝나기까지 기다려야할 필요가 없는 동작은 await를 안써도 된다.
하지만 이럴 경우 호출한 비동기 함수는 반드시 에러 핸들링을 해야한다.

const onClick = () => {
    handleEventInsertion(body);  // await를 안쓴 비동기 함수
    alert("Thanks, we'll take it from here");
};

// 비동기 함수 안에서 에러 핸들링 하는 모습
const handleEventInsertion = async (body) => {
    try {
        const result = await performLengthyAsyncOperation(body);
        alert(`${result} inserted successfully`);
    } catch (error) {
        logger.error(error);
    }

    // or using the callbacks instead of try/await/catch:
    performLengthyAsyncOperation(body)
        .then(result => {
            alert(`${result} inserted successfully`);
        }).catch(error => {
            logger.error(error);
        });
};