본문 바로가기
Javascript

이벤트 루프

by 긍고 2022. 8. 7.
반응형

개요


최근 회사에서 차세대 프로젝트 진행 중 문자 도메인 프론트엔드 작업을 주로 하게되면서 기존에 접하지 못했던 js 코드를 많이 접하고 있다. 개발 일정이 있다보니 일정에 쫓겨 모든 코드를 이해한 뒤 사용하지 못하는 경우가 종종 생기고 있었는데, 이러한 부분에서 이슈가 생겨서 관련 내용을 공부 후 정리하고자 한다.

setTimeout(function() {
    console.log('world')
}, 0)

console.log('hello')

// 결과
// hello
// world

위의 js 코드는 그동안 제대로 이해하지 못했던 코드 중 대표적인 코드이다. setTimeout 은 일정 시간을 대기 후 콜백함수를 실행하게 해주는 함수인걸로 알고 있었고, 대기 기간을 0초로 설정한다면 함수 사용의 의미가 없다고 생각했다. 앞의 가정과 함께 예상한 결과는 world → hello 순서로 출력되는 것이었지만, 실제 결과는 내가 생각했던 결과와 반대로 출력이 된다.

 

결과가 위와 같이 나타나는 이유는 단순히 setTimeout 함수만의 어떤 특징으로 인한 것이 아닌 자바스크립트 엔진, 나아가서 이벤트 루프와 연관이 있으며 이번 글에서는 이에 대해 정리한다.

 

Event Loop


결과 예측해보기

function delay() {
    for (var i = 0; i < 100000; i ++);
}

function jayG() {
    delay();
    prodo();
    console.log('jayG!'); // (3)
}

function prodo() {
    delay();
    console.log('prodo!'); // (2)
}

function lion() {
    console.log('lion!'); // (4)
}

setTimeout(lion, 10); // (1)
jayG();

위 예제의 결과를 예측해보자. 정답은 prodo! -> jayG! -> lion! 순서로 출력된다. 만약 delay 함수의 지연 시간이 lion 함수를 기다리는 10ms보다 더 길다면 jayG! 보다 lion!이 먼저 출력된다고 생각할 수 있는데, delay 함수 내부의 수에 몇을 곱해도 결과는 같다. 자바스크립트 엔진과 이벤트 루프 작동 원리를 통해 왜 이러한 결과가 나왔는지 알아보자.


자바스크립트 엔진

그림1. 자바스크립트 엔진

흔히 알려진대로 자바스크립트는 Single Thread 기반의 언어이다. 실제 자바스크립트 엔진(Webkit, V8 등)의 내부를 보면 위와 같으며, 메모리 할당에 쓰이는 힙(Heap)과 호출 스택(Call Stack)으로 구성된다. 요청 처리 시 단일 호출 스택(Call Stack)을 사용하며 어떠한 요청이 들어오면 그 때마다 순차적으로 해당 요청을 순차적으로 스택에 담아 처리한다. 위의 예제 코드가 자바스크립트의 Call Stack 내에서 동작하는 바를 모식도로 정리하면 아래와 같다.

그림2. 예제 콜스택 흐름

전역 환경에서 실행되는 코드는 별도로 묶여있지는 않지만 가상의 익명함수로 감싸져 있으며, 이를 (anonymouns) 로 표기하였다. Call Stack 흐름을 보면, 맨 처음 실행되는 setTimeout 함수는 어딘가로 10ms 타이머 이벤트를 요청한 후 바로 스택에거 제거된다. 이후 바로 밑의 jayG 함수가 스택에 추가되고 해당 함수 내부의 prodo 함수가 추가된다. console.log를 통해 prodo! 를 출력하고 해당 함수는 스택에서 사라지며, jayG 함수 내부에 있던 console.log를 통해 jayG! 가 출력된다.

 

이 때, 전체 코드 실행이 끝났으므로 (anonymous)도 함께 스택에서 제거되며, 마지막으로 아까 setTimeout에 콜백인자로 넣어주었던 lion 함수가 실행되어 그 안에 있는 console.log를 통해 lion! 을 출력 후 프로그램이 끝나게 된다.


자바스크립트 런타임

위에서 자바스크립트는 싱글 쓰레드 기반이어서 한 번에 하나의 동작만 동기로 처리할 수 있다고 했지만 setTimeout 함수는 어딘가로 타이머 요청을 보냈고, 그에 대한 처리가 비동기로 이루어졌다. 해당 함수 뿐만 아니라 웹 개발 시 ajax 호출 등 비동기 함수를 사용할 때가 많은데 이러한 비동기 요청에 대한 처리는 자바스크립트 엔진이 아닌 브라우저나 node.js와 같은 js 런타임(실행 환경)에서 지원한다. 아래 사진은 브라우저, node.js 내부 구조를 모식화한 사진이다.

그림3. 크롬 브라우저 구조
그림4. node.js 내부 구조

그림을 보면 브라우저와 node.js 모두 내부에 자바스크립트 엔진 외에 Event QueueEvent Loop를 가지고 있는것을 확인할 수 있는데, 자바스크립트의 비동기처리는 이것들을 통해 이루어진다.


Event Loop

브라우저를 기준으로 하면 브라우저는 크게 Javascript Engine, Web Api, Callback Queue(=Task Queue), Event Loop로 이루어진다. Javascript Engine은 앞서 설명한 내용과 같으며 Web api는 각 DOM, ajax 요청(XMLHttpRequest), Timeout 등의 비동기 함수로 구성된다.

 

Task Queue(= Callback Queue)는 비동기 함수에 전달된 callback 함수들이 저장되며(여러 callback queue의 집합), Event Loop는 이 Tack Queue를 감시하다가 Call Stask이 빈 상태가 되면 Task Queue의 callback 함수를 꺼내와 Call Stack에 올려준다 - Task Queue는 실제로 여러 Queue의 집합이기 때문에 이들 중 runnable 한 task가 있는 queue에서 함수를 꺼내온다-.

그림5. 예제 코드 실행 과정

예제 코드를 브라우저 구조에 대입하면 위와 같다(anonymous 함수 생략). setTimeout은 Web api에 Timer 요청을 하고 Call Stack과 별개로 Timer에서 시간을 센다. 설정한 시간이 지나면 callback(lion) 함수를 Tack Queue에 넣어준다. jayG 함수로부터 시작된 일련의 과정이 끝나고 Call Stack이 비게 되면 Event Loop가 Task Queue에서 lion 을 꺼내 Call Stack에 넣게 되고 해당 함수 실행 후 프로그램이 종료된다.

 

이벤트 루프를 통해 이해하기


비동기 API와 try-catch

$.('.btn').click(function() { // A
    try {
        $.getJson('/api/users', function(res) { // B
            console.log(res.resObj);
        });
    } catch(e) {
        console.log('Error: ' + e.message);
    }
})

웹 개발 시 많이 사용하는 비동기 함수로는 setTimeout외에도 XMLHttpRequest, addEventListener, I/O, UI rendering 등이 있는데, 위 코드는 그 중에서 빈도가 높은 XMLHttpRequest 사용시 발생할 수 있는 문제점이다. 언뜻 보면 $.getJson 함수 실행 중 에러가 발생하면 에러 내용이 출력될 것 같지만 실제로는 에러가 처리되지 않는다.

그림6. try-catch 예제 실행 과정

에러를 처리할 catch문은 A 함수 내부에 있지만 B 함수가 실행되는 시점은 A 함수가 Call Stack에서 지워진 시점 이후이다. 따라서 B 함수 내부에서 에러가 발생하더라도 이미 A 함수 실행 컨텍스트는 종료되었기 때문에 에러를 처리할 수 없다. 만약 에러를 처리하고 싶다면 B 함수 내부에 처리 로직을 넣어야 하며, 이렇게 한다 하더라도 네트워크 혹은 서버 에러는 잡을 수 없다.


setTimeout(fn, 0)

위 내용을 통해 setTimeout에 0을 넣더라도 실제로 바로 실행되지는 않는다는 것을 알게 되었다. 여기서 fn 함수는 바로 실행되지 않고 Task Queue에 들어가게 되는데, 이는 명시한 대기 시간이 실제 대기 시간이 아님을 의미한다(0으로 지정했음에도 0ms가 더 지난 이후에 실행될 수 있기 때문). 자바스크립트의 주 런타임인 브라우저는 HTML5 표준을 따르는데 해당 문서 내에는 아래와 같은 문구가 있다.

중첩 타이머 실행 시 다섯 번째 중첨 타이머 이후엔 대기 시간을 최소 4밀리 초 이상으로 강제해야 한다.

코드를 통해 브라우저 환경에서 setTimeout의 대기 시간을 테스트해보면 해당 표준이 지켜지고 있는것을 확인할 수 있다. 100ms동안 대기 시간이 0인 setTimeout을 반복 호출하는 코드를 작성해 보았다.

let start = Date.now();
let times = [];

setTimeout(function run() {
    times.push(Date.now() - start);

    if (start + 100 < Date.now()) alert(times);
    else setTimeout(run);
});

그림7. 타이머 코드 실행 결과

실행 결과를 보면 알 수 있듯이 초반에는 거의 바로 함수가 실행되지만, 5번째 부터는 스펙에 따라 최소 4ms 이상의 간격을 두고 실행됨을 알 수 있었다. 이는 setTimeout 뿐 아니라 setInterval 에도 동일하게 적용되며 해당 함수들을 사용할 때는 parameter가 실행보장 시간이 아닌, 실행에 필요한 최소 시간임을 알고 사용해야 한다.

 

위에서 언급한 제약은 브라우저에 국한되며 Node.js의 process.nextTick 혹은 setImmediate 를 사용하면 비동기 작업이라도 지연 없이 실행할 수 있다.

 

Promise와 이벤트 루프


Macro task, Micro task

자바스크립트는 기존에 비동기 처리를 위해 callback 함수를 사용해왔다. 이것을 통해 비동기 처리가 가능해졌지만 비동기 처리의 순차 처리가 필요한 경우 callback이 중첩되는 콜백지옥이 발생하기 쉬워졌고, 이는 예외 처리를 어렵게 하는 큰 요소이며 복잡도를 증가시켰다. ES6 이후부터는 이러한 문제를 해결하기 위해 Promise를 공식 지원하게 되었고, 이로 인해 비동기 처리에 또 하나의 요소가 추가된다.

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

Promise.resolve()
    .then(function() {
        console.log('promise1');
    })
    .then(function() {
        console.log('promise2');
    });

console.log('script end');

위 코드의 결과를 예상해보면 앞서 정리했던 내용을 바탕으로 script start → script end → setTimeout 순서로 출력되는 것은 예측해볼 수 있지만, Promise에 대한 코드는 어느 시점에 출력되는지 알기 어렵다. 아래 그림을 통해 런타임 구조가 어떻게 변경되었고 어떤 순서로 실행될 지 알 수 있다.

그림8. Micro task, Macro task Queue

위 그림을 보면 기존에는 Task Queue 하나였던 것이 MicroTask Queue(=Job Queue), MacroTask Queue 두 개로 분할된것을 알 수 있다. 기존에 언급되던 Callback Queue(=Task Queue)가 MacroTask Queue이며, Promise를 처리하기 위한 Queue가 MicroTask Queue이다. 두 Queue는 Call Stack에서 당장 처리되지 않는 callback 함수가 저장되는 Queue인것은 동일하지만, MicroTask Queue의 우선순위가 MacroTask Queue의 우선순위보다 높다. 또한 함수에 따라 callback 저장에 사용하는 Queue가 다른데 그 목록은 아래와 같다.

✔ Macrotask Queue 이용 함수
setTimeout(), setInterval(), setImmediate(), requestAnimationFrame, I/O, UI rendering
✔ Microtask Queue 이용 함수
process.nextTick(), Promise, queueMicrotask, async

MicroTask 우선순위 > MacroTask 우선순위 이므로 위의 예제의 출력 결과는 script start → script end → promise1 → promise2 → setTimeout 순서로 출력되게 된다. 우선순위의 차이 외에 두 Queue의 차이는 이벤트 루프가 Task를 꺼내어 실행시킬 때, MicroTask Queue의 task는 오래된 순서로 모두 꺼내어 실행시키고, MacroTask Queue의 task는 가장 오래된 한 개만 실행시킨다는 점이다.

그림9. micro, macro task에 대한 이벤트 루프 동작

이를 모식도로 나타내면 위 그림과 같은데 MicroTask는 바로 다음 MicroTask를 실행하므로 그 사이에는 UI 변화 or Network 통신(MacroTask)이 일어날 수 없으며, MicroTask Queue의 작업이 지연된다면 브라우저의 UI rendering 또한 지연될 수 있다.

 

정리


그동안 개발하며 이해하지 못하고 넘겼던 setTimeout 함수에서 시작해 자바스크립트, 자바스크립트 런타임 환경까지 공부하고 정리해 보았다. 아직 마주하지는 못한 이슈이지만 비동기 작업이 늦어진다면 화면 렌더링도 함께 늦어질 수 있으므로, 비동기 작업 시에 너무 오래 걸리게 된다면 setTimeout(callback, 0) 과 같은 방법을 통해 task를 분산할 수 있음을 알게 되었다.

 

이 글에서는 언급하지 않았지만 이벤트 루프는 꼭 하나만 있는것이 아니라 웹 워커에서는 여러 이벤트 루프를 사용하여 task를 컨트롤 하기도 한다. 또한 위에서 MicroTask Queue = Job Queue라고 언급을 하였지만, MicroTask Queue라는 개념은 HTML의 스펙, Job Queue라는 개념은 ES6 스펙에 정의된 개념이다. 이 두 개념의 연관관계가 명확하지 않아 완전히 같다라고는 할 수 없지만 이후로 논의가 많이 진전되어 현재는 두 개념을 비슷하게 보아도 무리가 없을것 같다고 한다.

 

기회가 된다면 현재 진행중인 프로젝트에서 이러한 개념들이 어떻게 적용되고 있는지 실제 예를 바탕으로 테스트, 확인하고 글로 정리해보려고 한다.

 

참조


 

setTimeout과 setInterval을 이용한 호출 스케줄링

 

ko.javascript.info

 

댓글