Don’t block the event loop! 매끄러운 경험을 위한 JavaScript 비동기 처리

본 글은 강재석 님이 JSConf Korea 2019에서 발표한 ‘JavaScript Async for Effortless UX’ 세션 내용을 각색하여 옮긴 글입니다(발표 영상, 발표 자료).

 

들어가며

웹 서비스를 사용하다 보면 갑자기 화면이 멈추거나, 타이핑한 게 화면에 바로바로 반영되지 않는다거나, 애니메이션이 매끄럽게 동작하지 않는 등의 현상을 종종 마주할 수 있습니다. 사용자 경험을 해치는 대표적인 예라고 할 수 있습니다. 이러한 문제를 야기하는 원인에는 여러 가지가 있을 수 있지만, 그중에서 JavaScript 언어의 특징 중 하나인 run-to-completion, JavaScript 코드가 구동되는 JavaScript 엔진, 콜 스택(call stack), 이벤트 루프(event loop), 태스크 큐(task queue) 등을 중점적으로 살피며 원인을 분석해 보고, 분석한 원인을 바탕으로 몇 가지 해결 방법도 제시해 보겠습니다.

 

사용자 경험

사용자 경험이란 무엇일까요? 여러 가지로 정의할 수 있겠지만, 저는 ‘사용자가 어떤 프로덕트나 시스템, 서비스에 대해 느끼는 감정’이라는 말에 가장 공감합니다.

그런데 아래와 같은 문제들이 발생하면 사용자 경험에 부정적인 영향을 끼치게 됩니다. 

이런 문제들의 공통점이 무엇일까요? 페이지 로딩, 타이핑, 애니메이션과 같이 사용자가 의도한 상호 작용에 대한 반응이 제때 나타나지 않았다는 점입니다. 상호 작용에 적절한 반응이 없다는 건 사용자 경험 관점에서 봤을 때 부정적인 요소입니다. 그렇다면 무엇이 이러한 문제를 만들었을까요? 의도한 동작이 제때 보이지 않았다면, 무언가가 해당 동작을 막거나 방해하고 있다고 의심해 볼 수 있습니다. 이런 의심을 해소하기 위해 JavaScript 코드가 내부적으로 어떻게 동작하는지 한번 살펴보도록 하겠습니다.

 

Run-to-completion

Run-to-completion이란, 하나의 메시지 처리가 시작되면 이 메시지의 처리가 끝날 때까지는 다른 어떤 작업도 중간에 끼어들지 못한다는 의미입니다. 아래는 run-to-completion의 예제입니다.

 위 왼쪽 예제 코드를 실행하면 오른쪽과 같은 결과를 확인할 수 있습니다(브라우저 프로세스가 먹통이 되어 어쩔 수 없이 강제 종료시켜야 할 수도 있습니다). 그럼 이러한 run-to-completion 방식의 동작 원리는 무엇일까요?

 

콜 스택

JavaScript 엔진에는 코드가 실행될 때 그 위치를 나타내는 커서(cursor) 역할을 하는 콜 스택이라는 곳이 있습니다. 요청이 들어올 때마다 해당 요청을 순차적으로 콜 스택에 담아 처리하게 되는데요. 예를 들어, 현재 어떤 함수가 호출되어서 동작하고 있는지, 다음에 어떤 함수가 호출되어야 하는지 등을 제어합니다. 아래 예제 코드를 보면 JavaScript 코드가 수행될 때 콜 스택에서 어떤 일이 일어나는지 확인할 수 있습니다. ‘hello’라는 메시지를 출력하는 코드를 갖고 있는 hello 함수와, 그 hello 함수를 호출하고 ‘JSConfKorea’라는 메시지를 출력하는 코드를 갖고 있는 helloJsConf 함수를 정의한 뒤, 마지막에 helloJsConf 함수를 호출하는 코드입니다.

이 코드를 실행하는 동안 콜 스택에선 아래와 같은 동작이 진행됩니다.

  1. 전체 main 코드 블록이 스택에 쌓인다.
  2. helloJsConf 함수가 호출되어 스택에 쌓인다.
  3. hello 함수가 호출되어 스택에 쌓인다.
  4. console.log('hello')가 스택에 쌓인다.
  5. ‘hello’를 콘솔에 출력함으로써 console.log('hello')는 스택에서 제거된다.
  6. hello 함수가 스택에서 제거된다.
  7. console.log('JSConfKorea')가 스택에 쌓인다.
  8. ‘JSConfKorea’를 콘솔에 출력함으로써 console.log('JSConfKorea')는 스택에서 제거된다.
  9. helloJSConf 함수도 일을 모두 마쳤으니 스택에서 제거된다.
  10. main 코드 블록이 스택에서 제거된다.

이와 같이 JavaScript는 콜 스택 구조와 함께 run-to-completion 방식으로 동작합니다.

그렇다면 만약 같은 상황에서 요청을 차례로 처리하다가 시간이 다소 오래 걸리는 작업을 만나면 어떻게 될까요? 아래 예제를 살펴보겠습니다.

이전 예제와 같이 동작을 하다가 someExpensive 함수와 같이 처리하는 데 오래 걸리는 요청을 만나면 ‘hello’ 나 ‘jsConfKorea’ 메시지를 출력하는 일에 지연이 발생할 것입니다.

그렇다면 여기서 한 가지 의문이 생깁니다. JavaScript가 단일 콜 스택 구조로 작업을 처리한다고 했는데요. 우리가 웹 서비스를 이용할 때를 생각해 봅시다. 클릭하고 스크롤하고 타이핑하는 와중에 데이터를 호출하여 화면에 보여주고… 이러한 작업들이 정말 순차적으로 차례차례 기다리면서 처리되고 있는 걸까요? 실제로는 그렇지 않습니다. 브라우저와 JavaScript 엔진은 이러한 동시성 문제를 해결해주는 웹 API(setTimeoutPromise 등..)와 이벤트 루프를 제공하고 있습니다.

 

이벤트 루프

MDN에서는 아래와 같은 코드로 이벤트 루프를 설명하고 있습니다. 

// https://developer.mozilla.org/ko/docs/Web/JavaScript/EventLoop#Event_loop
 
while(queue.waitForMessage()) {
  queue.processNextMessage()
}

위 코드를 해석해 보겠습니다. waitForMessage 함수가 동기적으로 동작한다고 가정했을 때, 무한 루프를 실행하면서 메시지를 기다리고, 메시지가 있다면 다음 메시지를 처리한다는 의미입니다. 즉 이벤트 루프는 JavaScript의 엔진의 구성 요소는 아니지만, 구동되는 환경(브라우저나 Node.js와 같은 런타임 환경)에서 콜 스택에 어떤 작업을 쌓을지 관장하는 역할을 합니다.

이벤트 루프가 어떻게 동작하는지 간단하게 살펴보면 다음과 같습니다.

  1. 처리할 작업이 있다면 그중 가장 오래된 작업을 실행한다.
  2. 처리할 작업이 없다면 다음 작업을 기다린다.
  3. 다시 처리할 작업이 있다면 1번으로 돌아가 반복한다.

아래 예제를 통해 좀 더 자세히 살펴보겠습니다.

setTimeout은 타이머 이벤트를 생성해 인자로 넘겨준 시간만큼 기다렸다가 수행하는 기능을 하는데요. 예제 코드와 같이 인자가 없는 경우에는 기본값인 0을 넘겨줍니다. 타이머 시간을 0으로 주었기 때문에 바로 실행되어야 할 것 같지만 실제론 그렇지 않습니다. 왜 그런지는 이후에 코드가 어떻게 동작하는지 하나하나 따라가면서 알아보도록 하겠습니다. Promise는 비동기 작업이 처리되었을 미래 시점의 완료 또는 실패의 상황을 다루는데 사용하는 API입니다. 위 코드에서는 resolve 메서드를 통해 빈 값으로 이행하는 Promise를 반환하고 then 메서드를 통해 이행 완료하였을 때의 콜백을 넘겨줍니다.

코드는 아래 순서로 동작합니다.

  1. setTimeout을 호출한다.
  2. 콜백을 태스크 대기 열에 담아둔다.
  3. Promise를 호출한다.
  4. then에 콜백으로 넘어온 부분을 ‘마이크로 태스크’ 대기 열에 담아둔다.

 

마이크로 태스크

여기서 ‘마이크로 태스크(micro task)’에 대해 잠깐 짚고 넘어가겠습니다. ES2015에서는 동시성을 다루기 위한 Promise와 같은 API들이 추가되었는데요. 이들은 일반 태스크와는 조금 다른, 마이크로 태스크를 다루게 됩니다. 태스크는 브라우저 혹은 그 외 구동 환경에서 순차적으로 실행되어야 하는 작업을 의미합니다. 단순히 스크립트를 실행하거나, setTimeout이나 UI 이벤트 발생으로 인한 콜백 등이 그 대상이 됩니다. 마이크로 태스크는 현재 실행되고 있는 작업 바로 다음으로 실행되어야 할 비동기 작업을 뜻합니다. 즉 마이크로 태스크는 일반 태스크보다 높은 우선순위를 갖는다고 볼 수 있습니다. 예제에 사용된 Promise나 Observer API, NodeJS의 process.nextTick 등이 그 대상이 됩니다.

앞서 설명한 이벤트 루프의 동작 순서에 마이크로 태스크 개념을 포함하면 다음과 같습니다.

  1. 마이크로 태스크가 있는지 먼저 확인하고, 있다면 모든 마이크로 태스크를 먼저 수행한다.
  2. 처리할 태스크가 있다면 가장 오래된 태스크를 실행한다.
  3. 처리할 태스크가 없다면 다음 태스크를 기다린다.
  4. 다시 처리할 작업이 있다면 1번으로 돌아가 반복한다.

태스크를 기다리기 전에 마이크로 태스크가 있는지를 먼저 확인하고, 마이크로 태스크가 있다면 먼저 모두 수행하고 나서 태스크를 수행합니다.

그럼 아까 예제 코드로 다시 돌아오면, 드디어 이벤트 루프가 하는 일을 확인할 수 있습니다. Promise의 then 메서드로 넘겨준 콜백이 마이크로 태스크로써 이벤트 루프를 통해 콜 스택으로 투입된 뒤 실행됩니다. 그다음엔 ‘hello’를 출력하는 태스크를 수행합니다.

그렇다면 이벤트 루프에 대한 이해를 기반으로 비동기를 다루는 웹 API를 활용하면 모든 문제를 다 해결할 수 있는 걸까요? 아쉽게도 그렇진 않습니다. 여전히 앞선 태스크 때문에 다음 태스크 실행이 가로막힐 수 있는 가능성이 남아 있습니다. 아래 예제를 보면, 코드가 차례로 수행되다가 고비용 연산 작업으로 가정한 someExpensive 함수를 먼저 콜 스택으로 밀어 넣는데요. 이 때문에 ‘hello’를 출력하는 태스크는 이벤트 루프에 막혀 버립니다. 해당 작업이 완료되고 나서야 실행될 수 있겠죠.

참고: 이벤트 루프에 대한 HTML 스펙 문서

정리하자면, 태스크는 항상 이벤트 루프를 통해 순차적으로 실행되기 때문에 임의의 태스크가 완료되기 전까지는 다른 태스크가 실행될 수 없고, 마이크로 태스크 대기 열은 일반 태스크 대기 열보다 우선순위가 높기 때문에 마이크로 태스크 대기 열이 모두 비워지기 전까진 UI 이벤트가 실행될 수 없습니다. 즉 CPU에서 고 비용 연산을 포함한 태스크나 마이크로 태스크가 실행되고 있다면, UI와 직결된 클릭, 텍스트 입력, 렌더링과 같은 이벤트가 가로막힐 수 있고, 이것은 곧 사용자 경험을 해치는 요소가 될 수 있다는 것입니다.

 

How to handle this blocking?

그렇다면 어떻게 이러한 문제점을 해결할 수 있을까요? 간단한 데모를 통해 몇 가지 해결 방법을 짚어보려 합니다.

데모에 사용한 코드는 GitHub에 공개해 놓았습니다.

어떻게 동작하는 것인지 코드를 통해 살펴보면, 텍스트 입력 이벤트가 발생할 때마다 입력된 글자 수에 비례하는 개수의 랜덤 색상 엘리먼트가 추가되는 HTML을 생성하여 화면에 표시합니다. 포인트는 두 가지입니다. 많은 반복문을 실행하면서, DOM 변화 비용이 크다는 것인데요. 앞에서 설명한 블로킹 현상을 재현할 수 있는 고비용 연산을 구현한 코드입니다.

 

아래는 해당 데모를 크롬 개발자 도구를 사용하여 프로파일링한 결과입니다. 다소 복잡해 보이지만 여기서 크게 두 가지만 짚어보면 될 것 같습니다. 

일단 제일 상단 frames 영역을 보면 한 프레임을 표시하는데 무려 3초 가까이 소요된 것을 확인할 수 있습니다. 그다음 interactions 영역을 보면 사용자 상호작용과 관련하여 분석된 결과를 볼 수 있는데요. 주황색 블록의 빨간 밑줄은 사용자 상호작용을 처리하기 위해 메인 스레드를 기다린 시간을 의미합니다. 각 상호작용마다 상당한 시간이 소요된 것을 확인할 수 있습니다.

이런 시간을 최대한 줄이기 위해 제가 제안하는 방법은 크게 두 가지입니다. 하나는 단일 콜 스택 구조와 이벤트 루프 때문에 블록이 발생하고 있으니 다른 스레드에 작업을 위임하는 방법이고, 또 다른 하나는 블로킹의 원인이 되는 고비용 태스크를 적절하게 쪼개서 실행하는 방법입니다.

 

웹 워커

JavaScript에서 웹 워커(web worker)를 활용하면 멀티 스레딩이 가능합니다(참고). 웹 워커는 스크립트 수행을 메인 스레드가 아닌 별도 백그라운드 스레드에서 수행할 수 있게 해줍니다. 

메인 스레드에서 워커 객체를 생성하면 워커 스레드와 메시지 기반으로 통신이 가능합니다. 워커 스레드에게 postMessage를 통해 처리하는 데 오래 걸리는 작업의 실행을 요청하면 워커 스레드는 이를 실행합니다. 이를 통해 메인 스레드가 블록되는 것을 막을 수 있습니다. 워커 스레드는 작업이 완료되면 역시 postmessage를 통해 결과 완료 메시지를 전송하고, 메인 스레드에선 이를 통해 또 다른 작업을 할 수 있게 됩니다.

아래는 해당 방법을 적용하여 개선한 데모의 프로파일링 결과입니다. 프레임을 표시하고 사용자의 상호작용에 반응하는 시간이 상당 부분 줄어든 것을 확인할 수 있습니다. 

하지만 이 방법으로도 버벅거림을 전혀 느낄 수 없는 사용자 경험에까지 이르진 못했습니다. 일반적으로 60FPS, 즉 프레임 하나 처리하는데 걸리는 시간이 16ms 이하여야 매끄럽게 느껴지는데요. 그 기준에는 다소 미치지 못한 것으로 보입니다. 아마 일부 연산은 워커 스레드에 위임했지만 DOM 갱신과 같은 작업은 여전히 메인 스레드에서 수행하고 있기 때문에 그런 것 같습니다. 메인 스레드와 워커 스레드는 메시지 기반으로만 통신 가능하다는 것이 웹 워커의 한계입니다. 즉, 워커는 직접 DOM이나 메인 스레드의 콘텍스트에 접근할 수 없습니다.

 

스케줄링

또 다른 방법으로는 고비용 태스크를 여러 개로 쪼개 비동기적으로 적절히 실행시키는 방법이 있습니다. 처리하는 데 오래 걸리는 태스크 때문에 뒤의 태스크들이 블록되고 있다면, 이를 적절하게 쪼개서 태스크들 사이사이에서 실행될 수 있도록 만드는 것입니다.

스케줄링 구현은 GitHub에 공개해 놓았습니다.

스케줄링을 적용한 아래 코드를 살펴보겠습니다. 

기존 코드에서 입력(input) 이벤트가 트리거되었을 때 바로 작업을 수행하던 것과는 다르게, 위 코드에선 runChunks라는 인터페이스에 chunkGenerator를 넘겨주고 있습니다. chunkgenerator는 기존 반복문을 적절한 청크(chunk) 단위로 쪼개서 해당 단위마다 산출(yield)시키는 패턴을 가지고 있습니다. runChunks는 산출된 작업을 setTimeout과 같은 비동기 API를 통해 태스크 대기 열에 적재시키는 역할을 합니다.

위 방법을 적용하여 프로파일링 결과를 살펴보니, 역시나 상당 부분 개선된 것을 확인할 수 있습니다. 

하는 김에 좀 더 최적화를 해보자면, 데모는 텍스트 입력에 따라 DOM을 갱신하게 되는데요. 빠르게 타이핑하는 경우엔 입력에 따라 매번 DOM을 갱신하는 것이 아니라 마지막 순간에만 갱신해도 될 것 같습니다. 태스크가 큰 덩어리였다면 다음 태스크가 블록되므로 갱신할지 말지 판단하여 작업을 수행하는 게 불가능했을 텐데요. 태스크를 잘게 쪼개 놓은 덕분에 이러한 방법을 적용하는 게 가능합니다. 아래 코드와 같이 수행하는 태스크를 확인 후 진행 중이라면 해당 태스크를 취소하고 새로운 태스크를 수행하도록 합니다.

그 결과 아래와 같이 프로파일링 그래프도 상당히 깔끔해졌습니다. 프레임 처리 최대 소요 시간도 마지막에 DOM을 갱신하는 순간에만 100ms 정도이고 나머지 프레임은 준수한 편입니다. 상호작용 반응 시간도 눈에 띄게 짧아졌습니다.

 

정리

요약하자면, ‘만약 실행 시간이 긴 태스크 혹은 마이크로 태스크가 렌더링이나 클릭, 입력과 같은 이벤트를 블록한다면 사용자 경험을 해칠 수 있다. 이를 개선하기 위해 웹 워커와 같은 백그라운드 스레드에 처리하는 데 오래 걸리는 작업을 위임하거나, 작은 태스크로 쪼개서 적절하게 실행될 수 있도록 처리하여 중요한 UI 이벤트가 블록되지 않도록 조치해야 한다’로 정리할 수 있겠습니다.

사실 데모나 예시에서 가정한 상황은 일반적인 애플리케이션 코드에선 발생하기 힘든 다소 극단적인 상황이긴 합니다. 애초에 고비용 연산이나 과도한 DOM 갱신을 최소화하는 것이 더 좋은 방법일 수도 있습니다. 그러나 보다 매끄러운 사용자 경험을 구현하고 제공하기 위해 JavaScript 코드가 구동되는 환경, 그리고 그 원리에 대해 이해하고 문제를 해결해 보는 과정은 매우 즐거웠고, 스스로 성장하는데 도움이 될만한 경험이었다고 생각합니다. 여러분에게도 그 즐거움이 함께 전달되길 바라며 마무리하겠습니다.