JS #15. 이벤트 루프 (Event Loop)
Prog. Langs & Tools/JavaScript

JS #15. 이벤트 루프 (Event Loop)

자바스크립트의 이벤트 루프에 대해서 정리해 보고자 한다.

 

단일 스레드 언어 자바스크립트

자바스크립트는 '단일 스레드' 언어이다. 하지만 실제로 우리는 자바스크립트로 여러가지 작업을 동시에 처리할 수 있다. 예를 들면 Node.js 웹서버에서 동시에 여러 개의 HTTP 요청을 처리하는 식으로 말이다. 스레드가 하나인데 자바스크립트는 과연 어떻게 동시성을 지원하는 것일까?

정답은 이벤트 루프이다. 자바스크립트는 이벤트 루프를 이용해 비동기적으로 동시성을 지원한다. 정확하게 말하면 자바스크립트 엔진(V8)에서는 이벤트 루프가 존재하지 않으며 단일 콜 스택(Call Stack)을 사용하며, 브라우저나 Node.js 환경에서 비동기 처리를 담당한다. 예를 들면 브라우저에서 비동기 호출을 위해 사용하는 setTimeout이나 XMLHttpRequest와 같은 WEB API는 자바스크립트 엔진 바깥에 정의되어 있다. 

브라우저 환경
Node.js 환경

따라서 자바스크립트가 단일 스레드 기반으로 단일 콜 스택을 사용하는 것은 맞지만 자바스크립트가 구동되는 환경(브라우저, Node.js 등)에서는 여러 개의 스레드가 사용될 수 있다. 이러한 환경에서 단일 콜 스택을 사용하는 자바스크립트 엔진과 연동하기 위해 사용하는 장치가 이벤트 루프인 것이다.

 

단일 호출 스택

자바스크립트 언어의 특징을 조금 더 살펴보면 자바스크립트 함수가 실행되는 방식은 Run to completion 이다. 이 말은 하나의 함수가 실행(run)되면 이 함수가 끝날 때(completion)까지는 다른 작업이 중간에 끼어들지 못한다는 의미이다. 자바스크립트 엔진은 하나의 호출 스택을 사용하는데, 현재 스택에 쌓여있는 모든 함수들이 실행을 마치고 스택에서 제거되기 전까지는 다른 어떠한 함수도 실행될 수 없다. 

function delay() {
    for (var i = 0; i < 1000000; i++);
}
function foo() {
    delay();
    bar();
    console.log('foo!');
}
function bar() {
    delay();
    console.log('bar!');
}
function baz() {
    console.log('baz!');
}

setTimeout(baz, 0);
foo();

// 출력결과
// bar!
// foo!
// baz!

 

함수가 실행되는 순서대로 콜 스택을 그려보았다. 4번에서 bar! 가, 5번에서 foo!가, 그리고 6번에서 baz!가 출력이 될 것이다. 그리고 전역으로 실행되는 코드는 한 단위의 코드 블록으로써 가상의 익명 함수로 감싸져 있다고 생각하면 좋다. 따라서 첫 줄이 실행될 때 콜 스택 맨 아래에 익명 함수가 하나 추가되고, 마지막 라인까지 실행되면 그 익명함수는 제거된다.

콜스택 안에서 많은 처리시간이 걸리는 함수가 들어갈 수 있다면 어떻게 대응해야 할까? 예를 들어 브라우저에서는 콜스택에 실행할 함수가 있는 동안은 block 상태를 유지한다. 이 시간동안 브라우저는 렌더링을 할 수도, 다른 코드를 수행할 수도 없다. 이러한 문제에 대한 해결방법을 알기 위해서는 이벤트 루프의 동작 원리를 이해해야 한다.

 

태스크 큐와 이벤트 루프

여기서 setTimeout 함수의 콜백함수인 baz 함수가 어떻게 foo 함수가 끝나자 마자 실행이 되는지에 대해 좀 더 자세히 살펴보자. 여기에서 태스크 큐와 이벤트 루프가 등장하게 된다. 태스크 큐는 콜백 함수들이 대기하는 큐이고, 이벤트 루프는 호출 스택이 비워질 때마다 큐에서 콜백 함수를 꺼내와서 실행하는 역할을 한다. 위의 예제에서는 setTimeout 함수가 시간이 지나면 (예제는 0ms이긴 하지만) bar 함수를 태스크 큐에 추가한다. 그러면 이벤트 루프는 현재 콜스택에서 진행중인 태스크가 끝나자마자 태스크 큐에서 대기 중인 첫 번째 태스크를 꺼내서 실행하는 식인 것이다.

자바스크립트 엔진은 단순한 자바스크립트 코드에 대한 온디멘드 실행 환경이다. 각 이벤트 별로 스케줄링하는 것은 이를 둘러싸고 있는 환경이다. 예를 들어 서버에서 데이터를 가져오는 ajax 요청이 있다면 이는 자바스크립트 엔진이 호스팅 환경에게 "나는 코드 수행을 중지시키지만, 네트워크 요청이 모두 끝나면 가져온 데이터를 통해 이 함수를 다시 호출해 달라(callback)"라고 말하는 것과 같다. 

이벤트 루프와 태스크 큐를 그림으로 대략 표현하면 다음과 같다. Task sources는 setTimeout과 같은 비동기 함수가 받는 콜백 함수가 보내지는 백그라운드라고 이해하면 된다. 그리고 참고로 setTimeout 0ms도 기본적으로 4ms 정도의 지연시간을 가지고 있다고 보면 된다. 호출 스택에 너무 많은 함수들이 있다면 setTimeout의 초가 정확하게 동작하지 않을 수도 있다.

이벤트 루프의 구현 방식은 다음과 유사하다. queue.waitForMessage() 함수는 현재 아무 메시지도 없다면 새로운 메시지 도착을 동기적으로 기다린다.

while(queue.waitForMessage()){
    queue.processNextMessage();
}

 

비동기 API와 try/catch

브라우저의 여러 비동기 함수들(ex. addEventListener, XMLHttpRequest 등)이나 Node.js의 비동기 방식의 I/O 함수들은 이와 같이 이벤트 루프를 통해서 실행한다. 따라서 비동기 코드에서 try/catch 에러를 잡아낼 수 없는 경우가 종종 발생할 수 있다. 아래 예제를 한 번 보도록 하자.

$('.btn').click(function() { // (A)
    try {
        $.getJSON('/api/members', function (res) { // (B)
            // 에러 발생 코드
        });
    } catch (e) {
        console.log('Error : ' + e.message);
    }
});

버튼이 클릭되고 콜백 A가 실행될 때 $.getJSON 함수는 XMLHttpRequest API를 통해 서버에 비동기 요청을 보내고 실행을 마치면 콜 스택에서 사라진다. 이후 응답은 콜백 B를 태스크 큐에 추가하고 이벤트 루프를 통해 실행되어 콜 스택에 추가된다. 따라서 B는 A와 전혀 다른 독립적인 컨텍스트에서 실행이 되고 A 내부의 try/catch 문의 영향을 받지 않는다. 따라서 이러한 에러를 잡기 위해서는 콜백 B의 내부에서 try/catch 문을 작성해 주어야 한다.

$('.btn').click(function() { // (A)
    $.getJSON('/api/members', function (res) { // (B)
        try {
            // 에러 발생 코드
        } catch (e) {
            console.log('Error : ' + e.message);
        }
    });
});

 

콜백과 프로미스에 대한 추가적인 내용을 알고 싶다면 아래의 포스팅을 참고하기 바란다.

 

 

참고자료

  1. www.zerocho.com/category/JavaScript/post/597f34bbb428530018e8e6e2
  2. meetup.toast.com/posts/89
  3. developer.mozilla.org/ko/docs/Web/JavaScript/EventLoop
  4. 자바스크립트는 어떻게 동작하는가