#event-loop, #javascript

이벤트 루프, 넌 누구냐

최근 requestAnimationFrame(이하 rAF)과 setTimeout을 공부하면서 브라우저의 싱글 스레드 동작이 정확히 어떻게 이루어지는지 잘 모른다는 생각이 들었다. 이번 기회에 이벤트 루프의 동작을 공부하고 추가로 rAF와 setTimeout의 차이점까지 알아보자.

들어가기 전에

들어가기 전에 용어 정리를 해두면 좋을 것 같다.

  • 프로세스

    • 운영체제가 프로그램의 실행을 위해 프로그램에 메모리를 할당하는 단위
  • 스레드

    • 프로세스가 할당받은 메모리를 실행하는 단위. 하나의 프로세스가 여러 스레드로 나뉠 수 있다.
  • task queue

    • 콜 스택에 들어가기 전에 setTimeout, 사용자 이벤트 콜백 등이 저장되는 큐
  • microtask queue

    • Promise.then 콜백이 저장되는 큐

이벤트 루프, 넌 누구냐

브라우저 메인 스레드 동작 타이밍을 관리하는 관리자라고 할 수 있다. 여기서 메인 스레드란 자바스크립트 코드 실행이나 브라우저 렌더링을 맡는 등 브라우저의 주된 동작이 수행되는 곳이다.

이벤트 루프가 왜 중요한가요?

관리자라고 하니까 중요한 것 같기는 한데 감이 오지 않을 수 있다. 브라우저 동작 특징을 알아보면서 이벤트 루프가 중요한 이유를 알아보자.

1. 브라우저 동작의 대부분이 메인 스레드에서 싱글 스레드로 실행된다.

웹 api에서 제공하는 비동기 함수들(ex. fetch, setTimeout)과 워커 종류를 제외한 대부분의 자바스크립트 코드가 메인 스레드라는 곳에서 실행된다. 또한 브라우저 화면을 그리는 렌더링 작업도 이곳에서 실행된다. 이처럼 브라우저의 주요한 동작들이 메인 스레드라는 하나의 싱글 스레드로 동작한다.

여기서 메인 스레드가 싱글 스레드로 동작하는 것이 중요한 이유는 싱글 스레드에서 하나의 작업을 하고 있다면 다른 작업은 지연시키기 때문이다. 예를 들어 싱글 스레드의 동작은 한 사람이 여러 가지 일을 동시에 처리할 수 없는 것과 비슷하다. 게임식사라는 2개의 작업이 수행되어야 할 때 게임을 하고 있다면 식사 작업은 지연될 수밖에 없다. 둘 중 하나의 작업이 굉장히 길어진다면 다른 작업은 시작조차 못 할 것이다.

메인 스레드에서도 마찬가지로, 개발자가 만약 자바스크립트 코드에 무한 루프 코드를 작성한다면 해당 작업이 다른 모든 작업을 지연시키고 메인 스레드에서 무한히 동작할 것이다. 결국에는 브라우저가 먹통이 되는 현상이 발생한다. 사용자 이벤트도 메인 스레드에서 동작하기 때문에 키보드 입력이나 마우스 클릭도 동작하지 않는다. 그러므로 싱글 스레드의 작업 관리는 매우 중요하다고 할 수 있다.

image

F12로 개발자 도구를 열어 while(1) {} 을 입력해보자. 브라우저가 먹통이 되는 현상을 경험할 수 있다.(주의 - 프로세스 강제종료 해야 합니다.) 하지만 다른 브라우저 탭은 잘 동작함을 알 수 있는데 이는 탭마다 다른 프로세스로 동작하기 때문이다.

2. 메인 스레드는 이벤트 루프에 의해 관리된다.

앞서 언급한 것처럼, 메인 스레드와 같은 싱글 스레드에서 하나의 작업이 오랫동안 실행되어서도 안 되고, 여러 작업 중 어떤 작업을 우선으로 동작시킬 것인가 결정하는 것도 매우 중요하다. 또한, 작업 간 전환 속도를 빠르게 하여 한 번에 하나의 작업씩 수행하지만 마치 동시에 수행하는 것처럼 동작해야 한다. (참고 - 동시성과 병렬성)

이러한 섬세한 컨트롤을 누군가 해주어야 할 텐데 누가 이런 역할을 할까? 바로 이벤트 루프가 메인 스레드의 동작을 관리한다. 그러므로 이벤트 루프의 동작을 정확히 이해하는 것이 브라우저 실행 전반을 이해하는 데 있어 매우 중요한 것이다.

이벤트 루프의 동작

이벤트 루프가 왜 중요한지는 알 것 같다. 그럼 이벤트 루프는 어떠한 우선순위로 task를 실행할까? 이벤트 루프의 동작을 같이 알아보자.

image (그림 1 - Jake Archibald: 루프 속 - JSConf.Asia)

  • 그림 상

    • T : task queue
    • rAF : requestAnimationFrame
    • S : Style (렌더 트리 생성)
    • L : Layout
    • P : Paint

가장 잘 와 닿았던 그림이다. 중앙을 기준으로 왼쪽 path가 task queue, 오른쪽 path가 브라우저 렌더링이라고 생각하면 된다. (microtask는 그림 어디에서나 실행될 수 있다고 한다.) 이 그림을 보면서 이벤트 루프의 동작 과정을 따라가 보자.

1. 초기 콜 스택에 쌓여있는 task를 모두 처리

처음 html을 가져오고 script 태그를 만나는 순간을 생각해보자. 브라우저 렌더링 과정에 들어가기 전에 동작을 잠깐 멈추고 자바스크립트 코드를 읽기 시작한다. 자바스크립트 코드가 DOM 트리를 수정할 수 있기 때문이다. 이 과정에서 코드들이 콜 스택에 올라가 동작을 수행하게 되고 Promise나 setTimeout과 같은 비동기 관련 콜백들이 queue에 등록된다.

2. Promise.then 콜백이 microtask queue에 등록되어 있다면 실행

처음 콜 스택에 있는 코드들이 모두 실행되고 난 후부터는 Promise가 가장 높은 우선순위를 차지한다. Promise.then 콜백이 등록된 microtask queue는 특징이 하나 있는데, queue에 등록된 모든 콜백이 처리될 때까지 계속 수행한다는 것이다.

function loop() {
  function infinityThen() {
    Promise.resolve().then(infinityThen);
  }
  Promise.resolve().then(infinityThen);
}

loop();

궁금하다면 개발자 도구에 위 코드를 넣어보자. Promise가 resolve 될 때 Promise.then 콜백을 재귀적으로 등록하면 앞서 예시와 비슷하게 브라우저는 먹통이 되어버린다. Promise를 처리하느라 바쁜 나머지 다른 작업을 아무것도 못 하게 되는 것이다.

3. 화면 갱신이 필요하다면 렌더링 파이프라인으로 이동 (requestAnimationFrame(rAF))

화면 갱신이 필요하다면 이라고 했는데 이는 이벤트 루프가 판단하는 것이다. 사용자가 스크롤 이동을 했거나, 어떤 요소를 클릭했거나 등등 화면을 갱신해야 될 필요가 있다면 렌더링 파이프라인으로 이동한다. (그림 1에서 오른쪽 path)

여기서 자바스크립트 코드 상에 rAF api를 사용하면 이벤트 루프가 모니터 주사율(Hz)에 맞춰 렌더링 파이프라인으로 들어가려고 노력한다. (싱글 스레드이기 때문에 반드시 주기가 지켜지는 것은 아님) 보통은 모니터 주사율이 60Hz기 때문에 화면 갱신 속도도 60fps이고 이는 1프레임이 16ms 정도 나와야 함을 의미한다. 여기서 1프레임이란, 렌더링 파이프라인에 진입했을 때부터 다음 파이프라인 진입까지를 의미한다.

rAF를 동작시켰을 때 1프레임이 16ms로 지켜지는 모습.

4. task queue에 있는 콜백을 하나씩 실행. (setTimeout)

이제 드디어 setTimeout과 사용자 이벤트 콜백의 시간이다. 그런데 queue에 있는 콜백을 하나씩 실행한다는 것은 무슨 뜻일까. Promise와는 다르게 task queue는 콜백을 하나 실행하고 이벤트 루프를 놓아주어 다른 동작을 수행할 수 있도록 한다는 것이다. 앞서 Promise의 예시와 같이 재귀적으로 setTimeout 콜백을 task queue에 집어넣어도, 브라우저는 정상 작동한다.

requestAnimationFrame vs setTimeout

지금까지 이벤트 루프를 알아보았다. 생각보다 복잡한 일을 하는 녀석인데 마지막으로 rAF와 setTimeout의 동작 차이를 알아보며 마무리하도록 하자.

rAF와 setTimeout의 가장 큰 차이점은 1프레임 당 호출이 보장되느냐 되지 않느냐의 차이가 있다. 흔히 웹에서 애니메이션을 보여주기 위해 setTimeout 대신 rAF 사용을 권장한다. 그 이유는 애니메이션을 위해 setTimeout을 16ms마다 동작하도록 코드를 작성하여도, 다른 task에 의해서 지연될 가능성이 있어 1프레임 당 1번의 호출이 보장되지 않기 때문이다.

그림 1을 보면 setTimeout은 task queue에 올라가 동작하고, rAF는 렌더링 파이프라인과 붙어 동작하는 것을 확인할 수 있다. 이런 구조 상 rAF는 무조건 1프레임 당 1번의 호출이 보장되고 setTimeout은 지연되어 2프레임 당 1번, 또는 3프레임 당 1번 호출될 가능성도 있다. 이런 현상은 버벅거리는 애니메이션을 제공하여 사용자 경험을 떨어뜨린다.

참고