비동기처리 문법에 대한 고찰 ( callback - Promise - async/await )

UI SEOK YU·2023년 2월 21일
2
post-thumbnail

왜?

자바스크립트를 배우다 보면 프로미스를 왜 쓰는지도 모르고 비동기면 무작정 프로미스를 쓰게된다.
우리가 흔히 프로미스가 등장한 배경이 콜백지옥과 비동기처리라고 알고 있는데,
사실은 콜백도 비동기 처리가 가능한데다가 어떻게 쓰느냐에 따라 콜백지옥의 형태도 빠져나올 수 있다.
그럼 도대체 왜 프로미스가 나오게 되었는가?

그리고 promise.then().then() 형태에서 then()은 언제 실행되는가?
찾아본 설명의 대부분은 "앞에 것이 완료되면 then()에 있는 함수가 실행되요~" 이딴 무책임한 내용이라 짜증이 났다. 이건 그냥 처음보는 사람이 코드만 봐도 의미 상 짐작할 수 있다.
나는 프로미스의 실행, then()의 실행, then()으로 인해 생성되는 새로운 프로미스, 새로운 프로미스가 가진 콜백의 실행
어떤 순서대로 일어나는지, 각각의 실행시점이 모두 선명하게 정리되길 원했다.

그렇다면 async await은 왜 나왔지? 단순히 동기적으로 표현하기위해서?
'동기적으로 표현' 한다는 것은 어떤 의미인가? '동기적인 표현' 의 정의는 왜 안알려줌?
프로미스는 비동기를 처리하기 위함인데 왜 await을 붙여서 기다려야하지? 기다리지 않으려고 나온 문법 아닌가?
await을 쓰려면 함수에 async를 붙여야 하는 이유는? 그 async 함수의 활용목적은?
아무튼 궁금하고 정리되지 않는 것 투성이라 몇 주 간에 걸쳐 공부하고 정리했다.

생각의 순서

  1. 비동기는 무엇인가
  2. 콜백을 왜 쓰는가
  3. 콜백의 단점이 무엇인가
  4. 프로미스는 어떻게 동작하는가
  5. 프로미스의 한계
  6. async/await의 탄생
  7. async/await은 어떻게 동작하는가



1. 비동기는 무엇인가

1-1. 비동기 작업의 필요 이유

  • 자바스크립트는 싱글 스레드 이다.
  • 싱글스레드에서 오래걸리는 작업을 수행하면, 그 이후의 코드가 실행되지 못한 채 기다린다.
  • 파일 입출력, 네트워크 통신 등이 '오래걸리는 작업' 에 해당한다.
  • 이것을 따로 빼서 돌려두고, 다른작업을 수행한다. '따로 빼서 돌려둔다' 가 비동기작업이다.
  • 비동기작업은 스레드가 아닌 웹프라우저 또는 백그라운드의 도움을 받는다.

1-2. 비동기 작업과 그 이후

  • 비동기 작업은 싱글스레드의 콜스택에서 떠나 백그라운드에서 진행되는 작업이다.
  • 백그라운드에서 '어떤 오래걸리는 작업' 이 완료됨에 따라, 이것에 후행되는 작업을 이어가야할 것이다.
  • 콜스택에서 떠난 비동기 함수를 어떻게 통제할 것인가?
    오래걸리는 작업 이후에 일련의 '동작'이 어떻게 순서대로 이루어지도록 설계할 것인가?

동작의 순서를 통제하기 위해서 콜백을 사용한다.




2. 콜백을 왜 쓰는가

2-1. 콜백함수

function loadData(callback) {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", "https://jsonplaceholder.typicode.com/posts");
  xhr.onload = function () {
    if (xhr.status === 200) {
      const data = JSON.parse(xhr.responseText);
      callback(null, data);
    } else {
      callback(`Error loading data. Status code: ${xhr.status}`, null);
    }
  };
  xhr.send();
}

function printResult (err, data) {
  if (err) {
    console.error(err);
  } else {
    console.log(data);
  }
}

loadData(printResult);

위는 콜백함수를 활용하여 서버로부터 데이터를 로드하는 코드이다.
loadData 라는 함수에 printResult 함수를 넘김으로서,
loadData 함수가 작업을 하다가 콜백자리에 있는 함수가 호출될 때 비로소 printResult가 수행된다. 즉 loadData의 callback 자리에 뭐가 오든 상관 없이, callback이 실행할 시점이 되면 (그 코드가 읽히게 되면) 비로소 수행된다.

그래서 콜백함수를 쓴다. 함수가 정의된 순서에 맞춰서 실행을 시키려고.


3. 콜백의 단점이 무엇인가

콜백함수만 사용하여 비동기작업을 처리할 때의 단점은 여러가지가 있다.

3-1. 흐름제어가 어렵다

getData(url1, function (data1) {
    processData(data1, function (result1) {
        console.log(result1);
    });
});

getData(url2, function (data2) {
    processData(data2, function (result2) {
        console.log(result2);
    });
});

콜백은 자체적으로 제어 흐름 관리를 제공하지 않기 때문에 여러 병렬 비동기 연산이나 조건부 비동기 연산과 같은 복잡한 제어 흐름 시나리오를 처리하기 어렵다.

위 코드는 2개의 비동기 작업을 실행하고 있다.
url1 에서 받아오는 작업을 url2보다 먼저 실행했다고 해서 url1이 url2보다 빠르게 완성이 된다고 보장 할 수 있는가? 그렇지 않다.

하지만 이것을 함수에 콜백으로 넣어서 실행하면 순서를 지킬 수 있을 것 같다.



3-2. 콜백 지옥이 발생한다

getData(url1, function (data1) {
    processData(data1, function (result1) {
        console.log(result1);
        getData(url2, function (data2) {
            processData(data2, function (result2) {
                console.log(result2);
            });
        });
    });
});

그랬더니 그 유명한 콜백지옥의 형태가 되어버렸다.
콜백만 사용하면 깊게 중첩되고 읽기 어려운 코드인 콜백지옥이 발생할 수 있다.
이로 인해 프로그램의 흐름을 이해하기 어렵고 디버깅이 더 어려워진다.

function handleData1(data1) {
    processData(data1, handleResult1);
}

function handleResult1(result1) {
    console.log(result1);
    getData(url2, handleData2);
}

function handleData2(data2) {
    processData(data2, handleResult2);
}

function handleResult2(result2) {
    console.log(result2);
}

getData(url1, handleData1);

이렇게 작성할 수도 있다.
분리가 되어서 외관상으로는 깨끗해 졌지만,
결국 함수가 활용한 콜백이 어떻게 동작하는지 찾아가려면 해당함수를 참조하도록 찾아나가야 한다.
이렇게 해도 가독성이 증가하지는 않는 것 같다.

근데 데이터를 불러오는 과정에서 에러가 날 수 있지 않은가?
에러처리도 해 주어야 할 것 같다.



3-3. 오류 처리가 복잡하다

getData(url1, function (data1, error1) {
    if (error1) {
        console.log("Error from " + url1 + ": " + error1);
    } else {
        processData(data1, function (result1, error2) {
            if (error2) {
                console.log("Error from " + url1 + ": " + error2);
            } else {
                console.log(result1);
                getData(url2, function (data2, error3) {
                    if (error3) {
                        console.log("Error from " + url2 + ": " + error3);
                    } else {
                        processData(data2, function (result2, error4) {
                            if (error4) {
                                console.log("Error from " + url2 + ": " + error4);
                            } else {
                                console.log(result2);
                            }
                        });
                    }
                });
            }
        });
    }
});

그랬더니 아주 아름다운 코드가 되어버렸다..
이런 형태는 오류의 원인을 추적하기가 더 어려워진다.



3-4. 새로운 문법의 필요성

위 예시들에서 알 수 있듯이, 비동기 작업을 처리하기 위해 콜백만으로 핸들링하는 것은
코드의 유지보수를 어렵게 만든다.
따라서 함수의 실행순서 및 에러처리 등을 더 효율적으로 다루는 새로운 문법의 필요성이 대두될 수 밖에.




4. 프로미스는 어떻게 동작하는가

4-1. 프로미스 객체

new Promise() 에 의해 프로미스 객체는 생성된다.
프로미스 객체는 여러가지 들고 있는 게 많다.

  • executer 는 생성되자마자 즉시실행

  • state 는 객체의 executor 이행상태이며, resolve() 또는 reject() 에 의해 변경된다.

  • result는 객체가 리턴할 결과이며, resolve() 또는 reject()에 전달된 값이다.

  • executer안의 resolve() 또는 reject() 들은 이 프로미스 객체 자신을
    (1) state가 pending -> fulfilled 또는 rejected로 변하게 만들고
    (2) promise 객체의 result 값을 메서드가 받은 인자로 변경시킨다.

  • then() 은 새로운 프로미스 객체를 낳는 메서드 이다.
    이렇게 생성된 새로운 프로미스는 자신의 선행된 promise를 로깅하고(지켜보고) 있다.

promise가 객체임을 인지하고 봐야 promise를 이해하는데 도움이 된다.
프로미스 객체는 결국 실행해야할 함수와 그 결과값을 들고 있는 틀에 불과하다.



4-2. 프로미스 순서

< 정리해야할 순서 >

  • promise가 완료되는 시점
  • then() 함수가 실행되는 시점
  • then()의 콜백이 microtaskQueue에 넘어가는 시점
  1. 프로미스 객체는 새로운 Promise() 생성자를 사용하여 생성되며,
    그 안에 있는 executor 는 호출 스택에서 즉시 실행된다.

  2. then() 메서드는 promise 객체에서 호출되어 새로운 프라미스를 반환한다.
    then() 이 가진 콜백은 새로 생성된 promise에 내장되어 있으며, 실행되지 않고 일단 기다린다.
    또한 then()을 실행한 프로미스객체(부모 프로미스)는 자신의 [[PromiseFulfillReactions]] 슬롯에 then으로 생성한 자식 프로미스를 저장한다.

  3. executor가 resolve 또는 reject 를 실행하게 되면,
    프로미스의 상태는 각각 "fulfilled" 또는 "rejected"으로 설정된다.
    부모 프로미스는 [[PromiseFulfillReactions]]에 등록한 리액션 들을 차례대로 실행한다.
    이때 이 프로미스의 완료를 기다리는 자식 프로미스는 micro-task Queue에 자신의 콜백을 전달한다.

  4. 2,3번은 여러번 반복될 수 있다. 이것을 프로미스 체이닝이라고 한다.
    만일 에러가 발생할 경우, 에러 객체는 프로미스들을 따라 전파되고, 보통 가장 마지막에 위치한 catch문에 의해 핸들링된다.

  5. 콜스택에 있는 작업이 모두 수행되어 콜스택에 비워질 때,
    이벤트 루프는 micro-task Queue에 있는 작업을 실행시킨다.
    이때, 프로미스의 콜백이 resolve 될 수 있으며, 그 즉시 이 프로미스가 완료되길 기다리던 연결된 프로미스 객체가 자신의 콜백을 micro-task Queue에 전달한다.

  6. 콜스택이 비워지고, micro-task Queue 의 작업이 (프로미스의 콜백이) 콜스택으로 올라와 순차적으로 수행되는 과정이 반복된다.

  7. micro-task Queue에 있는 모든 작업이 완료되고, 코드실행이 종료된다.



4-3. 프로미스 동작

많은 서적과 레퍼런스 들을 참고하여 내가 이해한 대로 정리

const p = new Promise((res, rej) => {
  setTimeout(() => res("result"), 1000);
}).then(r => console.log(r))
  .then(() => console.log("finish"));
  1. new Promise에 의해 새로운 프로미스 객체가 생성되고, 그것의 콜백인
    (res,rej)=>{setTimeout(res()),1000)} 가 즉시 실행된다.
    setTimeout 메서드가 있기 때문에, 해당함수는 백그라운드에서 1초동안 대기하다가, res()함수가 macrotaskQueue 에 추가될 것이다.

  2. 맨 앞의 promise가 끝나진 않았지만, 맨 앞의 프로미스의 메서드인 then() 이 실행되었으므로 새로운 프로미스 객체가 생성된다. 이 새로운 프로미스 객체는 r=>console.log(r)라는 콜백을 가지고 있으며, 아직 실행되지는 못했다.

  3. then()이 한번 더 호출되었다. 2번에서 생성한 프로미스의 메서드이므로, 호출된 순간 프로미스객체 하나가 더 생성된다. 이로서 위 코드에서는 프로미스 객체가 총 3개 생성되었다. 하지만 아직 완료된 부분이 없으므로, 3개의 프로미스 객체는 모두 pending 상태이다.

  4. 콜스택에서 실행될 다른 모든 코드가 실행이 완료되었다고 가정할 때, 비로소 setTimeout이 1초뒤에 반환하여 macrotaskQueue에 들어있던 res()가 이벤트루프에 의해 콜스택으로 올라와 실행된다.

  5. res()가 실행되어, 비로소 첫 promise객체의 상태가 fulfilled 되었으며, 첫번째 프로미스객체의 결과값은 "result"로 변경되었다. 또한 콜스택은 다시 비워진다.

  6. 첫번째 프로미스의 상태를 지켜보던(로깅하던) 두 번째 프로미스 객체는, 자바스크립트 엔진에 의해 자신이 가진 콜백의 매개변수에 앞선 프로미스의 결과값인 "result"를 반영하여 자신의 콜백을 microtaskQueue에 추가한다. 현재 콜 스택이 비워져 있으므로, 이벤트루프에 의해 콜스택에 올라간다.

  7. 두번째 프로미스 객체가 들고 있던 콜백이 실행되었으므로, 두번째 프로미스의 상태도 완료되었다. 이로서 세번째 프로미스는 자신의 콜백을 microtaskQueue 에 추가할 수 있다.

  8. microtaskQueue 에 추가된 3번째 프로미스객체의 콜백은 이벤트루프에 의해 콜 스택으로 올라오게 되며, 실행되어 "finish"라는 문구가 출력된다.


5. 프로미스의 한계

  • 프로미스를 생성하고 .then().then().then().then().. 으로 이어나간다.
  • 하나의 일련과정이 종료되기까지, 한 줄로 쭉 이어서 코드 작성을 해야한다.
  • 프로미스의 분기를 나누지 않으면 여러번 활용이 불가능하다.

6. async/await 의 탄생

6-1. async/await 과 프로미스의 비교

  • 프로미스 체이닝은 then에 의해 각각의 프로미스 객체들이 먼저 모두 생성됨

  • 앞의 프로미스가 완료되면, 바로 뒤 프로미스가 뒤 이어서 실행됨
    즉 각각의 프로미스는 선행되어야할 프로미스를 기다리는 상태임

  • 이것을 체이닝형태로 된 비동기 꼴을 우리가 평소 사용하는 형태로 바꾸어보자
    프로미스객체 앞에 await을 붙여서 각각의 프로미스가 완료되면 (일단) 그 값을 받아온다고 생각해보자.
    프로미스 체이닝에서도 프로미스는 앞선 프로미스가 완료되서 값을 전달해 주기까지 기다리는데,
    async 내부에서도 마찬가지로 await 키워드를 통해 프로미스의 결과값를 하나하나 기다린다.

  • 즉, 둘 다 프로미스의 결과값을 활용하는데 공통점이 있다.
    프로미스 체이닝은 추출된 프로미스의 결과값이 바로 그 다음 프로미스의 input으로 들어가는 반면에,

  • await은 해당 추출된 값을 일반 값으로 (프로미스가 아닌 값으로.. 이래서 await이 프로미스를 벗겨낸다고 표현함) 다룰 수 있도록 만드는 것이다.

  • 그렇다면 프로미스 체이닝은 바로 다음 프로미스로 넘어가 버리는 반면에 await을 통해 값을 추출하면 더 다양하고 명료하게 쓸 수 있다는 것이다.

이렇게 await 키워드의 의미와 사용을 이해해야 하는데,
강의나 책에서는 단순히 'await을 붙이면 프로미스가 실행되는 것을 기다려요~'
라고 설명헌다.
이런 표현은 오해를 불러일으킬 뿐만 아니라, 명확히는 틀린내용이다.
async 함수 내부에서 작동하는 원리를 보면 이런 단순화 된 설명이 왜 잘못된 것인지 이해할 수 있다.
=> 6-2에 설명

  • 결과적으로 async 내부에서는 프로미스 체이닝에서 일어나는 것 처럼 프로미스 객체의 실행완료된 값을 뽑아 사용하는 것처럼 보인다.

  • async 함수는 호출되는 즉시, 내부를 동작시키는(프로미스 체이닝처럼 동작이 일어나는)
    일련의 과정을 품은 프로미스를 그 즉시 반환한다.

  • 여기서 async 함수의 최종 리턴값이 resolve 값이 된다.
    결국 async 함수 자체도 프로미스를 반환하므로 뒤이어 then을 붙여 체이닝이 가능하다.

6-2. async/await 의 동작원리 ⭐️

onsole.log("1")

async function a() {
    console.log("2")
    const b = await new Promise((res) => {
        console.log("3")
        setTimeout(() => {
            console.log("4")
            res()
        }, 0)
    }).then(() => { return "5" })
    console.log(b)
}
a()
console.log("6")
// 출력 값
1
2
3 // async 함수를 실행하면, 처음 await에 붙은 프로미스 콜백까지 실행됨을 알 수 있다.
6 
4 // 백그라운드에서 돌아가는 등의 비동기적 작업은, async 함수 이후의 코드가 실행되고 나서 비로소 실행된다.
5 
  • async 함수는 실행되면 그 즉시 프로미스 객체를 반환하며, 이 프로미스의 콜백인 async 함수 내부는 즉시 실행된다.

  • async 내부를 실행하는 도중 await을 만나면, 해당 프로미스의 콜백을 실행 시키고 async 내부에서 아직 실행되지 않은 부분을 프로미스로 감싸 그 이후에 실행되도록 한다.

  • 그러면 현재는 await에 붙어있던 프로미스의 콜백 함수가 실행되고 있다.
    프로미스의 콜백이 완료되면, 그제서야 async 함수 뒤에 있던 코드들이 실행된다.

  • await에 붙어있던 프로미스의 콜백이 완료되어 그 프로미스가 resolve 되면, 아까 프로미스로 감싸두었던 async 함수 내부의 나머지가 microtaskQueue 추가되고 다른 함수들이 모두 완료되어 콜 스택이 비워지고 나서 마저 실행된다.

  • 이것이 async/await 함수의 동작 원리이며, await이 단순히 기다린다 의미로 단순화 하면 이해가 안되는 이유이다.

  • 이 내용이 이해가 되었다면 다음 코드의 출력이 이해가 될 것이다. 단순화된 설명으로는 a1 a2 b1 b2 로 출력되야할 것이지만, 그렇지 않다.

async function a() {
    await new Promise(resolve => { console.log("a1"); resolve(); })
    await new Promise(resolve => { console.log("a2"); resolve(); })
}

async function b() {
    await new Promise(resolve => { console.log("b1"); resolve(); })
    await new Promise(resolve => { console.log("b2"); resolve(); })
}

a()
b()
a1
b1
a2
b2

해설 (괄호 안은 microtaskQueue 의 실행 목록을 약식으로 표현)
1. a가 실행되고 즉시 프로미스 반환 []
2. a의 프로미스 콜백, 즉 a의 코드가 실행됨 []
3. a내부의 첫 프로미스 a1 가 생성되고 그 콜백이 실행됨 (a1 출력)[]
4. await으로 인하여 a 나머지 코드가 프로미스 형태로 생성됨. []
5. a1이 실행되어 resolve 되고, a의 나머지 코드가 microtaskQueue로 들어감. [a]
6. 콜스택의 함수 마저 진행 b() [a]
7. b가 실행되고 즉시 프로미스 반환 [a]
8. b는 1-5 단계의 a와 마찬가지로 b1을 실행하고 (b1출력) b의 나머지 부분이 프로미스로 생성되어, b1이 완료되는 순간 microtaskQueue로 추가됨.[a, b]
9. 함수가 모두 실행되고 콜스택이 비워졌으므로, microtaskQueue의 a 함수 내부 나머지부분이 실행됨 [b]
10. a2 프로미스가 생성되고, 그 콜백이 즉시 실행됨.(a2출력)[b]
11. await으로 인하여 a의 나머지부분을 a2의 완료를 기다리는 프로미스로 생성함.[b]
12. a2가 완료되고, a 나머지 부분이 microtaskQueue로 들어감.[b, a]
13. 콜스택이 비워졌으므로, microtaskQueue에서 b가 콜스택으로 올라오고 실행됨. [a]
14. b2 프로미스가 생성되고, 콜백이 즉시 실행됨. (b2출력)[a]
15. b 남은 부분이 b2를 기다리는 프로미스로 생성됨.[a,b]
16. 콜스택이 비워졌으므로, a의 나머지 부분이 실행되고, a는 종료됨 [b]
17. 콜스택이 비워졌으므로, b의 나머지 부분이 실행되고, b는 종료됨 []
18. 모든 함수가 완료되고 콜스택이 비워짐.

따라서 출력순서는 a1, b1, a2, b2 순.

6-3. 다른관점에서 async 함수

// 프로미스를 리턴하는 함수 형태
function fetchUser1(){
  return new Promise((res,rej)=>{
    resolve("user")
  })
}

// 그것을 async로 더 편리하게
async function fetchUser2{
	return "user"
}

async는 프로미스를 반환한다.
Promise 생성자를 return 으로 쓴 일반 함수와 같다.

0개의 댓글