[번역] JavaScript의 이벤트 루프를 다루기 위한 적절한 도구 선택하기

sejin kim·2024년 2월 10일
1

번역

목록 보기
5/9

이 글은 소프트웨어 엔지니어 Alex MacArthur님이 작성한 다음의 글을 한국어로 옮긴 것입니다 : Picking the Right Tool for Maneuvering JavaScript's Event Loop


대부분의 경우, JavaScript의 이벤트 루프에 대해서 깊이 고민하지 않더라도 별다른 문제가 없을 수 있습니다. 하지만 어떤 시점에 이르게 되면 (특히 렌더링 프로세스 또는 비동기 작업과 같은 작업에 많은 시간을 할애하기 시작하면) 이벤트 루프가 어떤 원리로 작동하는지뿐만 아니라 가장 효과적으로 다룰 수 있는 여러 도구를 알아두는 것이 유용할 수 있습니다.

이때 '다룬다'는 것은, '코드를 이벤트 루프 반복의 일부 또는 완전히 다른 반복에서 실행되도록 스케줄하는 것'을 의미합니다. 상황에 따라 어떤 도구를 선택하느냐에 따라 성능과 사용자 경험에 큰 영향을 미칠 수 있습니다.



간략한 복기

이벤트 루프에 대한 간략한 복기 : 브라우저의 메인 스레드에서 실행되는 모든 작업들의 시기를 조정하는 메커니즘입니다. 페이지가 로드되면 루프가 지속적으로 돌면서 브라우저의 다른 부분에서 실행할 작업이 있는지 확인합니다. 실행할 작업이 있다면 해당 부분은 임시 제어권을 얻고 가능한 작업을 실행합니다. 이러한 작업들에는 사용자 입력, 렌더링, 네트워크 요청 등 거의 모든 것이 포함됩니다.

이를 머릿속에 시각화하는 방법은 다음과 같습니다.



이벤트 루프가 도는 동안, 각각 다른 큐에는 할 일들이 차곡차곡 쌓이면서 실행을 위해 call stack으로 이동할 차례를 대기하고 있습니다. 이때 여기서 가장 많이 언급되는 것은 두 가지입니다. 바로 task queuemicrotask queue 입니다.



Task Queue

작업(또는 callback 또는 macrotask)이란 많은 브라우저 API들의 콜백을 보관하는 기본(primary) 큐를 말합니다. 예를 들어 addEventListener()를 사용할 때마다 브라우저는 이벤트가 트리거되는 즉시 task queue에 콜백을 던지고, 이벤트 루프가 큐로 돌아오면 해당 작업은 실행을 위해 call stack으로 이동합니다.


const clickedCallback = () => {
	// 버튼을 클릭한 이후 task queue에 추가됩니다.
	console.log("clicked!");
};

buttonNode.addEventListener('click', clickedCallback);

이 큐는 이벤트 루프의 각 루프마다 실행할 작업이 있는지 확인합니다. 무언가가 발견되면 가장 오래된 작업(FIFO - '선입선출')을 실행한 다음, 다음 작업으로 넘어갑니다.

이외에도 더 많은 내용들이 있지만, 조금 더 깊이 파고들고자 한다면 task queue에 관한 다양한 자료들을 살펴볼 수 있습니다. 제가 보았던 최고의 강연 중 몇 가지는 Philip RobertsJake Archibald의 강연이었습니다.



Microtask Queue

call stack의 모든 작업들이 실행되어도(나중에 실행될 작업을 큐에 추가하는 것까지 포함할 수 있습니다) 제어권은 아직 이벤트 루프에게 돌아가지 않습니다. 대신 microtask queue에서 제어권을 확보할 기회가 주어집니다.

아마도 이미 이러한 큐와 상호작용한 적이 있었을 것입니다. 해결된(resolved) Promise.then()에서 발생하는 모든 것들은 바로 이 큐에서 실행됩니다. 예를 들면:


Promise.resolve().then(() => {
    console.log('Fired from the microtask queue!');
});

setTimeout(() => {
    console.log('Fired from the task queue!');
}, 1000);

microtask queue의 특별한 점은 큐가 완전히 비워질 때까지 제어권이 이벤트 루프에게 반환되지 않는다는 것입니다. 이는 microtask queue가 자체적으로 더 많은 콜백을 큐에 적재할 수 있다는 점에서 문제의 소지가 있습니다. 그 흐름은 다음과 같습니다:



유의하지 않으면 이벤트 루프가 UI 업데이트나 사용자 입력 처리 등 다른 작업을 수행하지 못하도록 스레드를 차단하는 심각한 지연이 발생할 수 있습니다. 더 자세하게 설명해 보자면, 아래 CodePen은 두 가지 유형의 연속적인 루프를 5초 동안 실행합니다. 첫 번째는 setTimeout()을 사용하여 반복해서 호출합니다. 두 번째는 queueMicrotask()를 사용하여 microtask queue가 비워지고 5초가 다 지날 때까지는 이벤트 루프가 다른 작업을 수행하지 못하도록 합니다.

이러한 루프 중 하나를 트리거한 다음 'Increment' 버튼을 클릭해 보세요.



클릭해 보았다면 setTimeout()이 실행되는 동안에도 카운트를 계속 증가시킬 수 있다는 것을 알 수 있었을 것입니다. 이는 예상된 동작입니다. 각 실행 사이에도 이벤트 루프는 여전히 루프를 돌면서 UI 업데이트와 같은 다른 작업을 처리할 수 있습니다. 하지만 queueMicrotask()가 실행되는 동안에는 그럴 수 없었습니다. 모든 것이 멈췄습니다.



'도구'들

이러한 맥락에서, 브라우저에는 이벤트 루프가 도는 동안 코드를 실행할 수 있는 일련의 도구 세트가 함께 제공됩니다. 물론 모든 도구가 있는 것은 아니지만, 아마도 여러분이 무언가를 만드는 과정에서 가장 많이 보게 될(그리고 사용하게 될) 일반적인 도구들 중의 일부일 것입니다.


#1. setTimeout(() => {}, 0)

이벤트 루프에서 가능한 빨리 실행될 수 있도록 어떤 콜백을 큐에 대기시키고 싶을 때 이것을 사용할 수 있습니다. '가능한 빨리'라고 말한 이유는 기술적으로 다음 사이클에서 실행됨을 보장할 수 없기 때문입니다. 대신, 브라우저가 콜백을 실행하기 위해 큐에 얼마나 빨리 넣을 수 있는지에 따라 달라질 수는 있습니다. 지연 시간을 0으로 전달하더라도, 실제 최소 지연 시간은 사용 방법에 따라 0에서 4 밀리초 사이로 다를 수 있습니다.

이런 식으로 작업을 큐에 추가하는 일반적인 이유는, 하나의 큰 작업으로 인해 이벤트 루프가 지연되는 것을 방지하기 위해서입니다. 이벤트 루프가 돌아가는 동안 동일한 스레드에서는 많은 작업들이 실행됩니다. 이때 하나의 작업이 다른 모든 작업을 너무 오래 지연시키면 브라우저의 속도가 느려집니다. 여러 차례에 걸쳐 실행되도록 작업을 예약하면 하나의 큰 작업이 진행되는 동안 다른 중요한 작업을 처리할 수 있습니다.

각 아이템마다 많은 비용이 드는 프로세스를 거쳐야 하는 큰 리스트에서 작업하고 있다고 가정해 보겠습니다. 전체 리스트를 동기식으로 처리할 수 있을 것입니다:


function doExpensiveProcess(item) {
    console.log('PROCESSING:', item);
}

function processItems(items) {
    const item = items.shift();

    doExpensiveProcess(item);

    if (items.length) {
        processItems(items);
    }
}

processItems([1, 2, 3]);

그러나 이는 전체 작업이 완료될 때까지는 브라우저에서 다른 일이 발생할 수 없음을 의미합니다. 사용자 이벤트는 어떤 응답도 생성하지 않습니다. 애니메이션 GIF가 멈췄습니다. 답답할 것입니다.

그래서, 대신 각 아이템을 별도의 작업으로 처리할 수 있도록 큐에 추가해 보겠습니다:


function doExpensiveProcess(item) {
    console.log('PROCESSING:', item);
}

function processItems(items) {
+   setTimeout(() => {
        const item = items.shift();

		doExpensiveProcess(item);

        if (items.length) {
            processItems(items);
        }
+   }); <-- `0` by default
}

processItems([1, 2, 3]);

이번에는 리스트가 처리되는 동안 이벤트 루프가 다른 작업을 확인할 기회를 갖게 되므로 사용자 경험이 조금 더 매끄러워집니다.

이전과 비슷한 예를 들어 이를 설명할 수 있습니다. 두 개의 버튼이 있습니다. 하나는 리스트를 동기식으로 처리하고, 다른 하나는 비동기식으로 처리합니다.



예상대로, 동기식 루프는 전체 작업이 완료될 때까지 모든 작업을 차단합니다. 그러나 비동기식 루프는 큰 작업을 분할하므로 사용자 경험이 그다지 손상되지 않습니다. 여전히 약간의 지연은 있긴 하지만 완전한 데드락은 발생하지 않습니다.

같은 맥락에서: MessageChannel()

만약 어떤 이유로 인해 setTimeout()이 적합하지 않다면, 가능한 대안이 있습니다. 바로 MessageChannel() 입니다.


const channel = new MessageChannel();
channel.port1.onmessage = () => {
    console.log("Fired on next event loop cycle!");   
};
channel.port2.postMessage(null);

물론 이러한 선택의 이점은 다소 모호합니다만, MessageChannel()은 브라우저에 의해 관리되는 타이머를 큐에 넣을 필요가 없다는 점에서 두 가지 방법 중 더 효율적일 가능성이 있다는 몇 가지 의견을 접했습니다. 다만 그 이상은 더 이상 말씀드릴 수 없습니다.


#2. queueMicrotask(() => {}, 0)

현재 작업이 완료되기 전에 코드를 실행하고, 제어가 다른 어떤 일을 위해 이벤트 루프로 다시 넘어가기 전에 약간의 코드를 실행하고 싶을 때가 있을 것입니다. 이것이 바로 queueMicrotask()의 역할입니다. 이벤트 루프의 동일한 반복에서 더 중요할 수 있는 작업이 마무리된 이후 '딱 한 가지만 더'를 수행하는 데에 훌륭한 도구로, 이벤트 루프의 동일한 반복에서 모든 작업을 수행할 수 있습니다.

실제 사용 사례를 많이 접하지는 못했습니다만 몇 가지의 인위적인 예시를 생각해 보았습니다. 이것은...

...일련의 복잡한 로직 이후에 어떤 마지막 작업을 추가적으로 수행하는 데 유용할 수 있습니다.

예를 들어 누군가 버튼을 클릭하면 일련의 로그를 작성하여 logs 배열이 채워지도록 해야 한다고 가정해 보겠습니다. 콜백에는 조기에 반환하는 코드 경로를 포함해 몇 가지 복잡한 로직이 포함되어 있습니다. microtask queue에 콜백을 던지면 이러한 다양한 경로에서 로깅을 깔끔하게 제거할 수 있어 방해받지 않는 느낌을 줄 수 있습니다.


let logs = [];

function firstThing() {
    logs.push('log #1');
}

function secondThing() {
    logs.push('log #2');
}

function thirdThing() {
    logs.push('log #3');
}

function emitLogs() {
    console.log('Logs:', logs);
    logs = [];
}

document.getElementById('button').addEventListener('click', () => {
    // 다양한 코드 경로를 추적할 필요가 없습니다.
    queueMicrotask(() => {
        emitLogs();
    });

    firstThing();

    if (someCondition()) {
        secondThing();
        return;
	}
    
    thirdThing();
});

이러한 경우 로직이 꽤 무거워지더라도 모든 가능한 경로를 거친 이후에 코드를 깔끔하게 실행할 수 있을 것입니다.

...모든 이벤트 리스너가 안전하게 연결된 후에만 이벤트를 신뢰성 있게 처리(dispatch)할 수 있습니다.

한 페이지에 복잡한 이벤트 리스너 세트를 처리해야 하는 경우가 있을 수 있습니다. queueMicrotask()를 사용하면 UI가 준비되었음을 보다 확실하게 알릴 수 있습니다. 다음과 같이 상상해 보세요:


queueMicrotask(() => {
    // UI가 준비된 이후 이벤트 발생(emit)
    document.body.dispatchEvent(new CustomEvent('ui:ready'));
});

document
    .getElementById('button')
    .addEventListener('click', () => console.log('button clicked!'));

document
    .getElementById('box')
    .addEventListener('mouseover', () => console.log('hover!'));

// ... 더 많은 이벤트 리스너 및 기타 UI 설정

물론 이벤트 리스너가 연결된 후에 동일한 이벤트가 처리될 수 있지만, 그렇게 하려면 애플리케이션이 보다 규범적인 방식으로 설계되어야 하며, 이벤트가 발생한 후에도 실수로 다른 UI 설정이 배치되지 않는다고 가정해야 합니다. 다시 말해, 이러한 작업은 같은 차례에서 발생하기 때문에 브라우저의 다른 부분에서 다른 작업이 들어와 지연을 유발할 위험이 없어지고 예측 가능성이 높아집니다.

...우선 순위가 더 높은 작업이 수행된 후에 작업을 수행합니다.

특히 성능에 대한 우려가 있는 작업을 할 때, queueMicrotask()를 사용하면 이벤트 루프의 각 차례에서 가장 중요한 작업에 우선순위를 부여할 수 있습니다. 중요한 작업을 수행하는 일련의 함수를 실행하고 있다고 가정해 보겠습니다. 각 함수는 트리거될 때마다 로깅되어야 하지만, 이러한 작업으로 인해 주요 작업의 속도가 느려지는 것은 원치 않을 것입니다. 이러한 로깅 작업을 microtask queue에 넣으면 가장 중요한 작업에 방해가 되지 않도록 할 수 있습니다:


function firstThing() {
    console.log('first very important thing.');

    queueMicrotask(() => {
        console.log('send log');
    });
}

function secondThing() {
    console.log('second very important thing.');

    queueMicrotask(() => {
        console.log('send another log');
    });
}

firstThing();
secondThing();

// 출력:
// first very important thing.
// second very important thing.
// send log
// send another log

특히 이러한 콜백은 기본 작업을 중단시키지 않으면서도 이벤트 루프가 완료된 후 발생할 수 있는 다른 작업에 의해 차단될 위험이 없으며, 모두 동일한 반복 내에서 실행됩니다.


#3. requestAnimationFrame(()=> {});

이는 브라우저의 리페인트 주기에 맞춰 코드를 실행해야 할 때 유용합니다. 이벤트 루프는 작업을 실행할 수 있는 속도로 루프를 돌지만, 대부분의 디바이스들은 초당 60회 정도로 업데이트하면서 화면을 페인트합니다.

requestAnimationFrame()의 가장 확실한 장점은 부드러운 애니메이션을 조율할 수 있는 능력입니다. 예를 들어 무한히 회전하는 애니메이션을 만들기 위해 setTimeout() 또는 setInterval()을 호출하면 '작동'은 하지만 브라우저가 사용자에게 표시되는 내용을 업데이트하는 방식과는 무관하게 동작하기 때문에 프레임 손실과 함께 애니메이션이 버벅일 수 있습니다. 다음은 두 개의 회전하는 사물이 있는 예시입니다. 하나는 setTimeout()을 사용하고 다른 하나는 requestAnimationFrame()을 사용합니다:



자세히 보면 왼쪽의 애니메이션에서 약간 이상한 점을 발견할 수 있습니다. 리페인트 주기를 고려하면서 회전이 발생하지 않습니다. 그냥 프로그래밍된 대로 진행되기 때문에 약간의 버벅거림이 발생합니다. 그러나 두 번째는 브라우저가 페인트 작업을 수행하려고 할 때에만 DOM을 수정하므로, 프레임이 화면에 표시되는 내용과 조화를 이루며 업데이트되어 애니메이션이 더 부드러워집니다.

이는 CSS 전환을 HTML 요소에서 더 정교하게 처리하는 등 다른 용도로도 유용합니다. 내부의 콘텐츠의 규모를 알 수 없는 박스를 슬라이드로 열고 싶다고 가정해 보겠습니다. 과거에는 박스의 max-height를 상자의 실제 높이보다 높은 값으로 설정하는 트릭을 사용한 적이 있었을 것입니다:


<style>
    .box {
        /* box의 다른 스타일들... */
        
        transition: max-height 0.5s;
        max-height: 0;
	}

    .is-open {
        // box가 500px 보다 클 것입니다!
        max-height: 500px;
    }
</style>

<button id="button">Open Box</button>

<div class="box">
    An unknown amount of content.
</div>

<script>
    document.getElementById('button').addEventListener('click', () => {
        box.classList.add('is-open');
    });
</script>

박스가 슬라이드되며 열리겠지만 이는 약간의 추측이 필요한 게임이기도 합니다. 값이 너무 낮으면 박스는 완전히 열리지 않을 것입니다. 반면 값이 너무 크면 애니메이션이 낭비되어 불필요하게 오래 지속될 것입니다. 그러나 requestAnimationFrame()을 사용한다면 단번에 더 정밀한 애니메이션을 구현할 수 있습니다:


  1. 박스를 완전히 확장합니다.
  2. 렌더링된 높이를 측정합니다.
  3. 브라우저에서 다음 리페인트 이후에 계산된 값으로 height 변경을 예약하여 애니메이션을 트리거합니다.

<style>
    .box {
        /* box의 다른 스타일들... */
        
        display: none;
	}
</style>

<!-- box의 HTML은 여기로 이동합니다. -->

<script>
    document.getElementById('button').addEventListener('click', () => {
        const box = document.getElementById('box');

        // box를 렌더합니다.
        box.style.display = '';

        // 실제 높이를 측정합니다.
        const height = `${box.clientHeight}px`;

        // 시작 높이를 0px로 설정합니다.
        box.style.height = '0px';

        // 다음 리페인트 전
        requestAnimationFrame(() => {
            // 다음 리페인트 후
            requestAnimationFrame(() => {
                box.style.height = height;
            });
        });
    });
</script>

중첩된 requestAnimationFrame()이 중요합니다. 애니메이션이 정상적으로 호출되려면 브라우저가 초기 0px 높이값을 설정한 다음 리페인트 작업의 기회를 얻은 이후에 업데이트된 height 값을 적용해야 합니다. 그렇지 않으면 두 DOM 변경 사항이 일괄 처리되면서 열린 상자가 화면에 '튀어나오게(pop)' 됩니다.

다음은 requestAnimationFrame()이 어떻게 원하는 대로 애니메이션을 적용하는지에 대한 간단한 예제입니다.



이 특별한 도구는 그 용도에 계속 놀라게 되는 도구입니다. 개인적으로 브라우저의 리페인트 주기에 맞춰 작업을 예약하는 데 얼마나 유용한지 놀라울 정도입니다.


#4. requestIdleCallback(() => {})

이 방법은 브라우저가 '유휴(idle)' 상태로 간주되거나, MDN에서 설명하는 것처럼 '여유가 있다고 판단될 때' 이벤트 루프의 어떤 순간에서도 낮은 우선순위의 코드를 실행하는 데 가장 적합합니다.

이는 콜백이 이벤트 루프의 어느 순간에서 실행될지 알 수 없다는 점에서 다른 도구와 차별됩니다. 이를 사용하면 더 중요한 다른 작업에 우선순위를 양보할 수 있습니다. 가장 간단한 형태로, requestIdleCallback()에 일부 작업을 던지면 브라우저에서 여유가 있을 때마다 실행을 위해 큐에 추가합니다:


requestIdleCallback(() => {
    console.log("low priority stuff.")
});

미세 조정(fine-tuning)을 위한 추가 도구도 제공합니다. 콜백은 현재 유휴 시간에서 남은 시간을 대략적으로 나타내는 IdleDeadline 객체를 받습니다. 이는 여러 유휴 시간에 걸쳐 분할해야 하는 대규모 작업을 예약하는 데 유용할 수 있습니다.

예를 들어 애플리케이션에서 스레드가 유휴 상태일 때마다 전송하려는 메시지의 컬렉션을 작성했다고 가정해 보겠습니다. 제공된 IdleDeadline을 사용하면 브라우저의 유휴 시간 동안 가능한 한 많은 메시지를 처리한 다음, 남은 메시지를 다음 유휴 시간까지 미룰 수 있습니다:


const messages = ['first', 'second', 'third'];

function processMessage(message) {
    console.log('processing:', message);
}

function processMessages(deadline) {
    // 처리할 메시지와 시간이 남아 있습니다.
    while (deadline.timeRemaining() > 0 && messages.length) {
        const message = messages.shift();

        processMessage(message);
    }

    // 시간이 부족합니다. 남은 메시지는 다음에 예약합니다.
    if (messages.length) {
        requestIdleCallback(processMessages);
    }
}

requestIdleCallback(processMessages);

다만 한 가지 주의할 점은 현재 Safari에서는 지원되지 않는다는 것입니다. 하지만 fallback/polyfill을 통해 간단히 대체할 수 있으므로, 대부분의 사용자에게는 혼잡하지 않은 이벤트 루프의 반복에서 낮은 우선순위 작업을 실행하는 이점을 얻을 수 있습니다.



TL;DR

많은 것을 설명했습니다. 아래는 작업을 스케줄하는 시기에 따라 이러한 도구를 언제 사용하는 것이 적절한 것인지를 요약한 것입니다.


  • setTimeout(() => {}, 0) - 높은 우선순위의 작업을 여러 이벤트 루프 차례에 걸쳐 분산시켜, 메인 스레드에서 다른 모든 작업들이 처리되는 상황을 피하려는 경우 사용합니다.

  • queueMicrotask(() => {}) - 현재 call stack에 있는 작업보다 상대적으로 우선순위는 낮은 작업이지만, 이벤트 루프에서 다른 작업이 실행되기 전에 완료되기 원하는 경우 사용합니다.

  • requestAnimationFrame(() => {}) - 리페인트 주기와 조화를 이루도록 작업을 수행하고 싶을 때 사용합니다. 일반적으로 리페인트가 발생한 직후나 그 직전에 사용됩니다.

  • requestIdleCallback(() => {}) - 낮은 우선순위로 완료해야 할 작업이 있지만, 이벤트 루프에 유휴 시간이 있을 때 수행되어도 무방한 경우 사용합니다.



무엇이 빠졌나요?

이 글에서 설명한 방식으로 이벤트 루프를 탐색할 수 있는 다른 도구가 여러 개 존재하며, 제가 언급하지 않은 다른 중요한 고려 사항도 있을 수 있습니다. 소개되지 않은 다른 유용한 도구가 있다면 주저하지 마시고 공유해 주세요!

profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

0개의 댓글