티스토리 뷰

javascript에서 반복문 종류에 따라 비동기 처리의 실행 순서가 달라질 수 있다는 얘기를 듣고 구글링 하는데

아주 친절히 잘 정리 된 이 글을 발견했다. 😗👍

 

javascript의 비동기 처리는 Event Loop 이라는 것과 연관지어 이해하면 조금 더 처리 결과가 예측가능해 지는 것 같다.

그래서 조금 더 살을 붙여서 내방식 대로 답변을 작성해 보고자 한다.

 

 

 

 

 

Q. 1초 후 한번에 10개의 "result"가 출력되는 다음의 코드를 1초 마다 10번 출력되도록 바꾸기

function test() {
    const promiseFunction = () =>
        new Promise((resolve) => setTimeout(() => resolve("result"), 1000));
  
    Array(10)
        .fill(0)
        .forEach(async () => {
            const result = await promiseFunction();
            console.log(result);
        });
}

test();

 

 

 

 

 

 

 

일단 본론에 들어가기 전에 유념할 점

 

(1) 비동기 처리는 일하는 애가 따로있다!

자바스크립트는 싱글 스레드 방식으로 동작한다. 이때 싱글 스레드 방식으로 동작하는 것은 브라우저가 아니라 브라우저에 내장된 자바스크립트 엔진이라는 것에 주의하기 바란다.
만약 모든 자바스크립트 코드가 자바스크립트 엔진에서 싱글 스레드 방식으로 동작한다면 자바스크립트는 비동기로 동작할 수 없다. 
즉, 자바스크립트 엔진은 싱글 스레드로 동작하지만 브라우저 (혹은 노드)는 멀티 스레드로 동작한다. 
                                                                                      - [모던 자바스크립트 Deep Dive 815p]

자바스크립트 하면 "싱글스레드 논 블로킹 모델"이라는 것으로 유명하다.

 

근데 사실 v8 같은 자바스크립트 엔진만 보면 싱글 스레드가 맞지만, setTimeout이나 ajax 요청 보내고 응답을 받는 것 같은 비동기 처리는 이 js 엔진 대신 일해주는 애들이 따로 있다는 것!

(브라우저로는 Web API, 노드에서는 libuv라는 c++로 작성된 라이브러리 같은)

 

그러니 우리의 js엔진은 비동기로 처리해야 될 코드를 만나면 걔네보고 대신 처리해주라고 넘기고,

엔진이 처리할 수 있는 다음 코드들을 쭉쭉 진행한다.

 

이때 인자로 같이 넘긴 callback이나 프로미스 then의 callback 같은 것들은

대신 일해주는 애들이 해당 작업을 완수하면, 다시 우리의 js 엔진에서 처리해야 할 일들이다.

그러니 태스크 큐라는 곳에서 기다리다가 비동기 처리가 끝나면 js 엔진이 처리할 콜스택에 올려진다.

 

//sleep 함수는 js 엔진 내에서 처리
function sleep(delay) {
    var start = new Date().getTime();
    while (new Date().getTime() < start + delay);
}

let hi = function(i){
    sleep(1000);
    console.log(i , "hi");
};

[1,2,3,4,5].forEach(x => hi(x));

다음과 같이 setTimeout함수와 비슷한 작업을 하지만 js엔진 내에서 처리하는 sleep 이라는 함수를 작성해서 반복문 처리를 해봤다.  (반면 setTimeout은 timer API가 대신 스케줄링 해줌)

똑같이 forEach문을 썼지만 이번엔 원하는 대로 1초에 한번씩 "hi"를 출력한다.

 

 

👦1. js엔진이 직접 처리할 코드2. 다른 애들이 대신 처리해 줄 비동기 코드를 구분해서 생각해보자

 

 

 

(2) async/await 에서 await 키워드를 만나면 잠시 중단되는 단위는 async로 감싸진 함수다!

 

위에서 말한대로 js 엔진은 차례로 코드를 처리하다가

비동기 코드를 만나면 그에 대한 처리는 web api, libuv같은 애들한테 위임하고

이 비동기 처리가 끝난 후에나 실행될 callback 같은 코드들은 태스크 큐에서 대기하고 있게 하면서  ( 밑에서 설명하겠지만, 태스크큐는 macro, micro 로 분류 ) 

그 아래의 자기(js 엔진)가 처리할 수 있는 코드들을 계속해서 처리한다.

 

마찬가지로 await 키워드를 만나면 async가 붙은 함수 안의 코드가 microtask queue로 보내져 대기하게 되고

js 엔진은 다음 코드들을 계속 처리한다. (async 안의 코드들아 잠시 대기하렴 나는 내 일을 하련다.)

 

( 정확히는 콜백을 큐에 추가하거나 pop해서 콜 스택에 다시 올리는 일은 js엔진이 아닌 event loop이 담당... 인 듯?

그리고 이 큐에 추가되는 시점이 비동기 처리가 모두 끝난 후인지 (프로미스로 치자면 fulfilled / rejected 상태로 변한 이후) 아니면 미리 큐에 추가되어 있는 와중에 비동기 처리가 끝나길 기다리는 지 정확히 모르겠다.

큐의 특성을 고려한다면 전자가 타당해 보이지만. )                                         [ 나중에 좀더 찾아 볼 부분 ]

 

function test() {
    const promiseFunction = () =>
        new Promise((resolve) => setTimeout(() => resolve("result"), 1000));
  
    let arr = Array(10).fill(0);
    //for .. of
    for(el of arr){
        (async () => {
            const result = await promiseFunction();
            console.log(result);
        })();
    }

    //for도 마찬가지
    // for(i=0, len=arr.length; i<len; i++){
    //     (async () => {
    //         const result = await promiseFunction();
    //         console.log(result);
    //     })();   
    // }
}

test();

다음과 같이 forEach를 for 나 for of 문으로 바꾼다고 해도 반복문 전체를 포괄하는 async함수가 아니라 

각 반복문에서 수행할 단위씩을 감싸는 async함수들 이라면

달라진 것 없이 1초후 한꺼에 10개 "result"의 결과가 나타나게 된다.

 

여기서 forEach (넓게 보자면 map, reduce같은 것 도 포함)와 for, for in, for of 의 차이가 유래되는 것 같다.

 

forEach는 반복할 요소들을 하나씩 넣어 실행할 "함수"를 인자로 받기 때문에 async를 우리가 원하는대로 전체를 포괄하도록 감싸지 못하게 되는 것이다.

 

👧 await를 만났다고 멈추고 기다리는 것은 전체 코드가 아니라 async 함수 내부의 아이들이라는 것을 기억해보자.

 

 

 

 

 

 

 

 

 

Event Loop

 

공간적인 설명

 

브라우저

이미지 출처 : https://codenotcode.com/my-event-loop-beebef81cd46

node.js

이미지 출처 : https://medium.com/preezma/node-js-event-loop-architecture-go-deeper-node-core-c96b4cec7aa4

일반화

 

 

시간적인 설명 (Phase)

이미지 출처 : https://developer.ibm.com/tutorials/learn-nodejs-the-event-loop/

Each phase has a FIFO queue of callbacks that is executed during that phase.
The callbacks in the queue run until the queue is empty or the system-dependent limit is reached. 

각 phase에 해당하는 비동기 작업이 끝난 (이제 js 엔진에서 실행될 준비가 된 ) 콜백들이 대기하는 큐가 있다.

 

해당 phase가 되면 그 큐들이 빌때까지 (혹은 system이 정한 limit 까지) 콜백들이 js엔진의 콜 스택에 올려져 실행되고

만약 그 콜백 코드 안에 또 비동기 작업이 있다면,

그 작업이 위임되어 처리된 후에 만나는 해당 event loop phase 때 실행 될 것이다.

(예를들어 setTimeout에 설정한 delay시간이 길다면 event loop 이 몇 바퀴나 돌고서야 실행 될 것이다)

 

microtasks 

  • process.nextTick()
  • then() handlers for resolved or rejected Promises ( .then/catch/finally )
  • await

의 콜백으로 event loop의 각 phase 사이사이에서 실행된다.

위의 작업의 콜백들이 대기하는 큐를 micro 태스트큐, 나머지 큐를 (macro) 태스크 큐라는 용어로 부른다

 

(1) Timer Phase         : setInterval( ), setTimeout( ) 같은 함수의 콜백들이 실행되는 phase 

(2) Pending Phase

(3) Idle, Prepare Phase

(4) Poll Phase           : fs.readFile( ) 의 콜백 같은 I/O 콜백들이 실행 됨

(5) Check Phase        : setImmediate( ) 

(6) Close Phase        : socket.destroy() 처럼 'close'이벤트가 발생했을 때 등록된 콜백들이 이 phase에서 실행 됨.

 

* 일반적으로 2,3,6 등은 대부분 "only used internally"

 

setImmediate(() => {
    console.log("CHECK PHASE : triggered from main");
    process.nextTick(() => {
        console.log("MICROTASK : triggered from check");
    });
})

let p = async () => {
    setTimeout(()=>console.log("TIMER PHASE : triggered from main"), 1000);
    console.log("MAIN : async func");
    return 1;
}

let q = p().then((x)=>{
    console.log("MICROTASK : 1st then resolver",x);
    return new Promise(resolve => setTimeout(resolve, 2000, 2));
})

console.log("MAIN : hello");
q.then((res)=>console.log("MICROTASK : 2nd then resolver ",res));

process.nextTick(() => {
    console.log("MICROTASK : triggered from main");
});
console.log("MAIN : finish");

예시로 위 코드의 실행 결과는 다음과 같다.

 

 

[ 참고하면 좋은 글 ]

자바스크립트 런타임

How javascript runs

javascript visualized: Promises & Async/Await [강추⭐]

Introduction to the event loop in Node.js

 

 

 

 

 

 

 

 

 

Promise

비동기 처리를 위해 ES6부터 새로 도입된 패턴

 

전통적인 비동기 처리에서는 (1) 비동기 작업이 끝났는지 확인하고 (2) 그 결과값에 접근하고 싶으면

 

비동기 처리 함수에 인자로 callback을 달고 가게 해서 event loop이 비동기 작업 끝나면 결과를 callback의 인자로 패스해서 태스크 큐에 추가해주는 방식일 수 밖에 없었다.

그렇기 때문에 여러 단계를 거치는 비동기 작업이라면, 코드가 내부로, 내부로 깊어지는 일명 "콜백 헬"이 발생했던 것...

 

[ 👦 내부로 깊어지지 않아도 외부에서 추적 가능! ]

하지만 이제 Promise객체의 status, result프로퍼티를 통해 비동기 코드 외부에서도

(1) 비동기 작업이 완료되었는지 = status, (2) 그 결과값이 뭔지 = result 를 추적하는 것이 가능해졌다!

 

 

[ 👦 callback등록은 언제든지 가능 (꼭 비동기 코드 호출시 같이 넘겨주지 않아도 됨) ]

그리고 이제 callback도 비동기 코드의 인자로 주는게 아니라 프로미스 객체의 then 메소드를 통해 등록한다.

microtask 큐에서 기다리다가 status가 완료 (fulfilled/rejected) 상태가 되었을때 콜 스택에 추가되어 실행되는 방식이다.

 

그렇기 때문에 심지어 이미 promise 객체가 완료 상태가 바뀐 뒤라도, 언제든 then으로 등록 가능하다.

 

 

[ 나중에 더 알아볼 부분 2 ]

출처 : https://www.programmersought.com/article/49106265411/

진짜 promise 함수 소스 코드는 아니고, 저분이 설명하려고 직접 구현한 것 같은데

then의 콜백이 혼자 떨어져나와서 microtask 큐에서 기다리는데도 promise가 완료 상태가 됐는지 어쨌는지 추적가능한 것은 저렇게 callback 함수의 this = promise객체

로 바인딩 했기 때문에 가능한게 아닐까 싶다.

 

 

바뀐 비동기 처리 패러다임

 

 

promise 기본 문법

 

 

프로미스 체이닝

.then/catch/finally의 후속처리 메서드는 언제나 프로미스를 반환하므로 연속적으로 호출할 수 있다.
let p1 = Promise.resolve().then(()=>{});               //아무것도 리턴하지 않음
let p2 = Promise.resolve().then(()=>2);                //그냥 일반 값 리턴
let p3 = Promise.resolve().then(()=>Promise.resolve(3));         //프로미스 객체 리턴

let print = () => {
    [p1,p2,p3].forEach( (p , i)=> console.log(`p${i+1}`, p));
}

print();
console.log("--");
setTimeout(print, 3000);

실행결과

then의 callback에서 어떤 값을 리턴하든 then메소드를 실행하고 난 결과물은 새로운 프로미스 객체다!

 

state = 처음엔 pending이다가 then의 callback이 완료되고 나면 fulfilled or rejected

result = undefined / callback에서 리턴하는 일반 값 / Promise객체를 리턴한다면 그 객체의 result

                   [p1]                                [p2]                                                 [p3]

 

 

 

async / await

async, await과 쓸수 있는 자료형(?)과 리턴 값

 

await을 만나면 뒤가 비동기 코드 포함 안하더라도 무조건 일단 micro task 큐로 보내졌다가 후에 처리.

function 앞에 async를 붙이면 해당 함수는 항상 프라미스를 반환합니다.
프라미스가 아닌 값을 반환하더라도 이행 상태의 프라미스(resolved promise)로 값을 감싸 이행된 프라미스가 반환되도록 합니다.

( await키워드는 async 함수 안에서만 사용 가능하다! )
자바스크립트는 await 키워드를 만나면 프라미스가 처리(settled)될 때까지 기다립니다.
결과는 그 이후 반환됩니다.

await는 말 그대로 프라미스가 처리될 때까지 함수 실행을 기다리게 만듭니다.
프라미스가 처리되면 그 결과와 함께 실행이 재개되죠.
프라미스가 처리되길 기다리는 동안엔 엔진이 다른 일(다른 스크립트를 실행, 이벤트 처리 등)을 할 수 있기 때문에, CPU 리소스가 낭비되지 않습니다.                                     - [ javascript.info  async와 await ]

 

 

 

 

 

 

 

 

 

결론

 

기존 코드가 동작하는 방식

 

1)

각 스텝마다도 시간이 흐르고 있지만 1000ms에 비해 (저런 자잘한 작업에 대한 컴퓨터 처리 속도는) 매우 빠르므로 우리가 느끼기엔 거의 동시에 10개의 setTimeout이 API로 보내졌다가 1초뒤 한꺼번에 10개의 "result"가 출력되는 것 처럼 느껴짐.

 

2)

그리고 이건 나중에 더 보완해야할 부분인데, 아까 말했듯 아직 pending 상태인 callback이나

(await 때문에 block된) async함수도 micro 태스크큐 내에서 대기하는지,

아니면 event loop의 다른 자료구조에서 대기하고 있다가 settled 상태로 바뀌고 나서야 비로소 micro 태스크큐로 옮겨지는지 모르겠다. 

 

그래서 일단 그림을 전자인 것처럼 그리긴 했는데, 그래서 보통 각 phase 사이 사이 실행되는 micro 태스크가 macro 태스크 보다 더 먼저 실행되어야 하는데 그게 아닌 것처럼 오해의 소지가 있게 그려졌다.

(뭔가 후자가 논리적으로는 더 맞을거 같은 느낌....)

 

 

 

 

 

🎃 해결1)

함수를 인자로 받는 forEach 대신 실행문들을 브라켓으로 감싸는 for이나 for...of 문으로 교체한뒤

async 함수가 전체 반복문 과정을 포괄하도록 함수로 감싸면

원하는 대로 1초에 한번씩 10번 "result"가 출력된다.

 

function test() {
    const promiseFunction = () =>
        new Promise((resolve) => setTimeout(() => resolve("result"), 1000));

    let arr = Array(10).fill(0); 
    (async function(){
        for(el of arr){
            const result = await promiseFunction();
            console.log(result);
        }

        // 이 방법도 ok
        // for(i=0, len=arr.length; i<len; i++){
        //     const result = await promiseFunction();
        //     console.log(result);        
        // }
    })();    
}

test();

바꾼 코드의 동작 방식

 

( 참고로 작성해본 코드 )

해결2)

조잡하긴 하지만 async/await없이 프로미스만으로 작성하거나

function test() {
    const promiseFunction = () =>
        new Promise((resolve) => setTimeout(() => resolve("result"), 1000));

    let arr = Array(10).fill(0); 
    let p = promiseFunction();
    for(el of arr){
        p = p.then((res)=> {
            console.log(res);
            return promiseFunction();
        });
    }   
}

test();

해결3)

그마저도 없이 기존의 callback만 가지고 했던 비동기 처리방식

(callback에서 재귀적으로 자신 호출하는 방식으로)

let test = () => {
    function f (arr){
        setTimeout(()=>{
            console.log("result");
            if(arr.length==1) return;
            arr.pop();
            f(arr);
        }, 1000)
    } 
    f(Array(10).fill(0));
}

test();

으로도 똑같은 효과를 낼 수 있기는 하다.

 

 

 

 

 

 

 

 

 

부록 Promise.all

 

프로미스를 요소로 갖는 배열 (정확히는 이터러블)을 받아서 병렬적으로 실행시키고

각 프로미스의 result 들로 이루어진 배열을 result 값으로 가지는 새로운 프로미스 객체를 반환한다.

let p = Promise.all([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]);

console.log(p);
setTimeout(console.log, 3000, p);

state = 전체가 fulfilled 가 된 이후 fulfilled / 하나라도 reject되면 그 즉시 rejected

result = 각 프로미스의 result 값을 요소로 하는 배열

 

(1) 비동기 코드들은 병렬로 처리하고 싶지만,

(2) 이 비동기 뭉치들이 전부 종료 (fulfilled) 된 뒤 처리하고 싶은 것이 있을 때 (await을 쓰고 싶을 때 ) 

이 Promise.all을 사용하면 좋다.

이런 포맷으로

 

반면에 await 뒤에 forEach를 쓰면 프로미스 객체가 아니라서

(각 반복 요소들이 프로미스가 아니라고 말하는게 아니라, Promise.all은 실행결과가 또 하나의 프로미스인데 forEach는 아니라는 것을 말한다는 점!)

전체 요소들이 전부 fulfilled상태가 되었는지 상태 추적을 못하고 그냥 바로 다음라인으로 넘어가진다. (2) 조건을 충족할 수 없음.

 

(그냥 각 요소들이 콜스택에 올라왔다가 비동기 처리같은 것 하러 떠나면 fulfilled 됐는지 어쨌는지는 나는 모르는 일이고 콜스택 비었으니 다음 코드 올린다)

댓글
공지사항
최근에 올라온 글