이벤트 루프에 대해서

jj·2026년 9월 24일

https://www.youtube.com/watch?v=8aGhZQkoFbQ
What the heck is the event loop anyway? | Philip Roberts | JSConf EU

0.자바스크립트는 싱글 스레드

자바스크립트는 싱글 스레드이다.(브라우저는 멀티스레드.) 그런데 웹페이지에서는 한 번에 화면도 렌더링하고, 서버와 통신도 한다. 어떻게 이런 일이 가능할까?

🔍 왜 하필 싱글 스레드로 설계했나?

같은 공유 자원을 여러 스레드가 동시에 건드리면 충돌(Race Condition)이 발생한다. 이걸 막으려면 락(Lock)을 걸어야 하는데, 데드락 위험이 생긴다. 그래서 아예 한 번에 하나만 처리를 하자, 라는 단순한 방식을 선택한 것이다.

1.콜 스택(Call Stack):실행 컨텍스트(작업 환경)를 차곡차곡 쌓아 올려서, 전체 코드의 '실행 순서'를 보장해 주는 자료구조(순서가 중요함)

function multiply(a, b) {
    return a * b;
}

function square(n) {
    return multiply(n, n);
}

function printSquare(n) {
    var squared = square(n);
    console.log(squared);
}

printSquare(4);

스택은 후입선출이라 아래의 순서로 쌓이고 빠진다.
printSquare 호출 → [printSquare]
square 호출 → [printSquare, square]
multiply 호출 → [printSquare, square, multiply]
multiply 끝 → [printSquare, square]
square 끝 → [printSquare]
printSquare 끝 → []

2.블로킹(Blocking):시간이 오래 걸리는 작업이 콜 스택에서 실행되면 그 외의 작업을 실행하지 못하는 것.

// 가상의 동기적 네트워크 요청 함수
var foo = $.getSync('//foo.com');
var bar = $.getSync('//bar.com');
var qux = $.getSync('//qux.com');

console.log(foo);
console.log(bar);
console.log(qux);

동기적으로 서버와 통신하는 코드를 만든다고 가정해 보자. 이렇게 동기적으로 작동하는 코드는 동기작업을 처리하느라 화면 렌더링이 아예 멈추고 다른 작업을 할 수 없게 된다.

🔍 "그럼 브라우저에게서 받은 Web API 함수로 브라우저에게 위임해서 대신해달라고 하면 안 돼?"
Web API는 아무 코드나 대신 실행해주지 않는다. 타이머 재기, 네트워크 요청 기다리기, 이벤트 감지하기처럼 정해진 기다리는 일만 대신할 수 있다.

🔍 "그럼 setTimeout으로 감싸면?"
실행 시작 시점만 늦출 뿐, 일단 콜 스택에 올라가면 서버와 통신하는 연산이 끝나기 전에는 다른 작업을 할 수 없다. 게다가 한 번 시작되면 Run-to-completion(완전 실행 보장) 원칙 때문에 다른 작업이 끼어들지 못한다.

✅ 해결책: Web Worker
메인 스레드와 완전히 분리된 또 다른 스레드에서 코드를 돌리는 게 Web Worker이다.

  • 진짜 멀티스레드처럼 병렬 실행 가능
  • 멀티스레드와 달리 메모리를 공유 안 하고 메시지만 주고받아서(복사) 락이 필요 없음
  • 단, DOM 접근 불가, 데이터 복사 비용, Worker 생성 자체의 오버헤드라는 트레이드오프가 있어서 DOM조작이나 간단한 작업에는 사용하지 않는 것이 좋다.

3.Web API(브라우저가 제공하는 자바스크립트 엔진 외의 기능), 태스크 큐, 이벤트 루프

console.log('Hi');

setTimeout(function cb() {
    console.log('there');
}, 5000);

console.log('JSConfEU');

// 출력 순서:
// Hi
// JSConfEU
// (5초 후) there
  1. console.log('Hi'): 콜 스택에 올라가고 즉시 실행되어 Hi가 출력된 뒤 스택에서 내려온다.

  2. setTimeout(...) 실행: 콜 스택에 setTimeout 함수가 올라간다. 브라우저(Web API)에 해당함수를 보낸 뒤 실행이 종료되어 내려온다.

  3. console.log('JSConfEU'): 콜 스택에 올라가고 즉시 실행되어 JSConfEU가 출력된 뒤 스택에서 내려간다.

  4. 5초 후: Web API에서 5초가 지나면, 콜백 함수 cb가 태스크 큐(Task Queue)로 이동한다.

  5. 이벤트 루프(Event Loop): 이벤트 루프가 계속 감시하다가 콜 스택이 완전히 비어 있는 것을 확인하면, 태스크 큐에 있던 cb를 콜 스택으로 보낸다. 이것이 이벤트 루프의 역할이다. 5초가 아니라 0초여도 여기까지의 과정과 순서는 동일하다. 그래서 0초여도 바로 실행되지 않고, 동기 코드가 다 끝난 뒤 실행된다.

  6. cb() 실행: 콜 스택에 올라온 cb가 실행되면서 내부의 console.log('there')가 실행되어 there가 출력되고 종료된다.

브라우저                         [ Web API 함수 목록 ]
┌──────────────────────────────────────────────────────────────────────┐
│                                                                      │
│   ┌──────────────────┐                        ┌──────────────────┐    │
│   │                  │                        │                  │    │── setTimeout
│   │                  │                        │                  │    │   setInterval
│   │                  │                        └──────────────────┘    │
│   │                  │                            매크로 태스크 큐     │
│   │                  │                                                │
│   │                  │         ╭──────────╮     ┌──────────────────┐  │
│   │                  │         │          │     │                  │  │── Promise
│   │                  │         ╰──────────╯     └──────────────────┘  │   async/await
│   │                  │          이벤트 루프       마이크로태스크 큐    │
│   │                  │                                                │
│   │                  │                        ┌──────────────────┐    │
│   │                  │                        │                  │    │
│   └──────────────────┘                        └──────────────────┘    │
│    자바스크립트 엔진                            requestAnimationFrame  │
│    콜스택                                                              │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘
[1. 콜 스택 실행]  (동기 코드. 맨 처음엔 스크립트 전체가 하나의 task)
        │
        ▼ 스택이 완전히 비면
[2. 마이크로 태스크 큐 전부 비움]  (실행 중 새로 추가된 것까지)
        │
        ▼
[3. 지금이 렌더링 기회(그릴 타이밍)인가?]
        ├─ 예   → rAF 콜백 실행 → Style → Layout → Paint
        └─ 아니오 → 렌더링 단계 건너뜀
        │
        ▼
[4. 매크로 태스크 큐에서 딱 1개 실행]
        │
        └──────────────► 다시 1번으로 반복

4.동기 콜백과 비동기 콜백

// 1. 일반적인 동기적 forEach (실행되는 동안 화면 렌더링이 막힘)
[1, 2, 3, 4].forEach(function(i) {
    console.log(i);
    // 여기서 매우 무거운 작업을 한다고 가정 (delay() 등)
});

// 2. 비동기적 forEach 구현 (콜백을 큐에 나눠서 담아 렌더링을 방해하지 않음)
function asyncForEach(array, cb) {
    array.forEach(function(i) {
        setTimeout(function() {
            cb(i);
        }, 0);
    });
}

asyncForEach([1, 2, 3, 4], function(i) {
    console.log(i);
    // 무거운 작업을 쪼개서 실행
});

🔍 "저게 왜 콜백이지?"
"다른 함수에게 인자로 넘긴 함수"면 다 콜백. 동기/비동기와 무관하다.

🔍 "동기/비동기 차이는 뭐야?"
누가, 언제 호출하느냐의 차이.

  • 코드 1번(forEach만): 매 반복마다 콜백을 그 자리에서 직접, 동기적으로 호출 → 무거운 작업이 있으면 4번 다 끝날 때까지 스택이 안 비어서 렌더링이 막힘
  • 코드 2번(asyncForEach): 각 반복을 setTimeout(fn, 0)으로 감싸서 하나씩 태스크 큐를 거치게 만듦 → 각 콜백 사이사이에 콜 스택이 빌 틈이 생겨서 렌더링이 끼어들 수 있음

판별법: 콜백이 지금 바로 호출되면 동기, 큐를 거쳐 나중에 호출되면 비동기.

5. 렌더링과 이벤트 루프

브라우저는 16.6ms마다(60fps) 화면을 다시 그리려고 한다. 렌더링은 큐가 아니라 이벤트 루프 한 바퀴 중의 한 단계이고, 그릴 타이밍이 왔을 때만 실행된다. 다만 콜 스택이 비어야 이 단계로 넘어갈 수 있다. 그래서 오래 걸리는 동기 작업을 쭉 이어 돌리면 렌더링이 못 끼어들어 화면이 멈춘다. 해결책은 작업을 쪼개거나(청크), 디바운스를 쓰는 것.

디바운스란? 이벤트가 계속 발생해도, 마지막 이벤트로부터 일정 시간이 지나야 딱 한 번만 실행시키는 기법. 실행 횟수를 줄여서 메인 스레드가 할 일을 줄이는 기법이다. 할 일이 줄면 결과적으로 렌더링이 끼어들 여유가 생긴다. 검색창에 검색어를 쓰면 실시간으로 추천 검색어를 보여주는데, 타자를 칠때마다 보여주기보다 타자 치는 동작이 특정 시간동안 없어야 추천 검색어를 보여주는 것이다.

6.추가적인 의문

왜 이벤트 루프는 스택이 빌 때까지 기다리기만 할까

Run-to-completion(완전 실행 보장)규칙이 있다.
한 함수가 시작되면 끝날 때까지 다른 코드가 끼어들 수 없다. 데이터 안전성을 위해 생긴 규칙이다. 멀티스레드 언어는 락으로 이 문제를 풀지만, 자바스크립트는 완전 실행 보장 규칙으로 해결한다.

왜 콜백 여러 개는 정확한 시간에 안 맞을까

콜백 함수 자체는 Web API에서 여러 개 동시에 대기가 가능하다. 그러나 콜 스택은 싱글 스레드라 한 번에 하나씩만 처리하니, 큐에 동시에 도착한 콜백들도 결국 순서대로 밀려서 실행된다. 팝업샵 오픈런을 뛰어서 번호표를 받아도 앞번호 사람들이 샵에서 나오지 않으면 늦게 들어가게 되는 것과 같다.

추가적인 학습

이벤트 루프와 동시성

동시성(Concurrency)이란?

🔍 "동시성과 병렬성은 뭐가 달라?"
동시성: 여러 작업이 같은 시간 구간에 진행 중인 것처럼 다루는 구조. 한 번에 하나씩 처리하되 번갈아 가며 처리해도 된다.
병렬성: 여러 작업이 물리적으로 같은 순간에 실행되는 것. 멀티 코어나 Web Worker가 여기에 해당한다.

자바스크립트의 메인 스레드는 동시성은 있지만 병렬성은 없다. 타이머 대기, 네트워크 대기는 Web API가 따로 처리하고, 코드 실행은 콜 스택에서 하나씩만 처리하기 때문이다.

이벤트 루프와의 관계
이벤트 루프는 대기 중인 작업을 하나뿐인 콜 스택에 한 번에 하나씩 안전하게 넘긴다. 이것이 싱글 스레드로 동시성을 구현하는 방법이다.

Micro Task Queue / Macro Task Queue

Macro Task (= 그냥 Task)
이벤트 루프가 한 바퀴에 1개씩 꺼내 실행하는 작업 단위이다. setTimeout/setInterval 콜백, DOM 이벤트(클릭 등) 콜백, 네트워크 응답 처리 등이 여기에 들어간다.

Micro Task
현재 함수나 프로그램이 종료된 후, 실행 스택이 비어 있을 때, 이벤트 루프로 제어권을 돌려주기 전에 실행되는 짧은 함수이다. Promise의 .then/.catch/.finally 콜백, async/await에서 await 이후 코드, queueMicrotask(), MutationObserver 콜백 등이 여기에 들어간다. 우선순위가 Macro Task보다 높다.

🔍 "마이크로 태스크가 계속 새 마이크로 태스크를 만들면?"
마이크로 큐는 완전히 빌 때까지 비우기 때문에 렌더링과 macro task가 영영 실행되지 못해 화면이 멈춘다. 마이크로 태스크는 렌더링보다 우선순위가 높은 만큼 남용하면 위험하다.

rAF (requestAnimationFrame)

"화면을 새로 그리기 직전에 내가 맡긴 작업을 한 번 실행해 달라" 고 브라우저에 부탁하는 함수이다.

function animate(timestamp) {
  // 그리기 직전에 할 작업 (예: 공의 위치를 조금 옮기기)
  requestAnimationFrame(animate); // 다음 프레임도 하고 싶으면 스스로 다시 예약
}
requestAnimationFrame(animate); // 첫 예약

중요한 점은 한 번만 실행되는 예약이라는 점이다. 계속 움직이게 하려면 콜백 안에서 다시 예약해야 한다.

알아둘 것내용
일회성한 번 실행하고 끝난다. 계속하려면 콜백 안에서 다시 호출해야 한다
콜백에 전달되는 timestamp"이번 프레임이 시작된 시각"이다.
cancelAnimationFrame(id)예약 취소. requestAnimationFrame이 돌려준 ID를 넘기면 된다
다른 탭을 보고 있을 때어차피 안 보이니까 대부분의 브라우저가 멈춘다 (배터리 절약)

rAF와 setInterval의 차이

🔍 "setInterval(fn, 16) 하면 60fps 아니야?"
아니다. setInterval은 화면 갱신 타이밍을 모르기 때문이다.

setInterval(fn, 16)requestAnimationFrame(fn)
누가 시간을 정하나내가 ms로 지정브라우저(모니터 주사율)가 알아서
화면 갱신과의 관계상관없음그리기 직전에 맞춰 실행
시간의 의미"최소 이만큼은 기다린다" (스택이 차 있으면 더 밀림)프레임마다 실행
다른 탭일 때계속 실행 (브라우저가 간격을 늘리기도 함)대부분 멈춤
취소방법clearIntervalcancelAnimationFrame

rAF로 애니메이션을 만들면 좋은 점

장점이유
부드럽다화면을 그리는 타이밍에 맞춰 실행되니까 프레임이 덜 끊긴다
모니터가 달라도 된다60Hz든 120Hz든 브라우저가 알아서 맞춰 준다
배터리 절약안 보이는 탭에서는 멈춘다
속도 일정timestamp로 경과 시간을 계산하면 기기 성능과 상관없이 같은 속도로 움직인다
멈추기 쉬움cancelAnimationFrame로 종료 가능

헷갈리기 쉬운 두 가지

🔍 "그럼 rAF 안에서 무거운 작업을 해도 돼?"
안 된다. rAF 콜백은 화면을 그리기 직전에 실행된다. 콜백이 오래 걸리면 그만큼 화면 그리기가 늦어져서 오히려 끊긴다. 위치 계산처럼 가벼운 작업만 넣어야 한다.

🔍 "rAF가 setTimeout보다 우선순위가 높아?"

  • rAF는 따로 줄 서 있는 큐가 아니라 "화면 그리는 단계"에 포함된 작업이다.
  • 그리는 단계는 매 바퀴가 아니라, 그릴 타이밍이 왔을 때만 실행된다.
  • 그래서 setTimeout이 먼저 나올지 rAF가 먼저 나올지는 그릴 타이밍이 언제 오느냐에 따라 달라진다. 지금 바퀴에 그릴 타이밍이 없으면 setTimeout이 먼저 실행되고 rAF는 다음 프레임으로 넘어간다.
    예를 들어 마이크로 태스크를 다 비운 시점에, 지난번 그린 뒤로 다음 그릴 시점(60Hz 기준 약 16.6ms 간격, 모니터 주사율에 따라 다름)이 아직 안 왔으면 렌더링 단계를 건너뛰고 매크로 태스크로 가고, 왔으면 rAF를 실행한 뒤 그린다.
profile
frontend

0개의 댓글