FE 필수 JavaScript - 이벤트 루프 · 태스크 큐

Yoon Robin·2025년 9월 21일
2

FE 필수 JavaScript

목록 보기
1/1
post-thumbnail

0. 들어가며

해당 내용은 신입으로 취업하기 전인 2023년에 포스팅하였으나, 연차가 쌓인 2025년에 다시 해당 포스팅을 읽어보니 부정확한 부분을 많이 발견했습니다.

옛날 글에서 부정확했던 부분을 바로잡아, 이번 포스팅에서 다시 정리합니다.

ℹ️ 다음 내용을 알아야 이번 포스팅을 이해할 수 있어요.

- 스택(stack) & 큐(queue)
- 동기 vs 비동기
- 콜백/Promise/async–await 기초
- 타이머 API: `setTimeout`, `setInterval` 기본 동작

1. TL;DR

자바스크립트는 메인 스레드에서 한 번에 하나의 작업만 실행합니다.

그러나 브라우저의 Web API → 태스크 큐(태스크/마이크로태스크) → 이벤트 루프 덕분에 비동기처럼 동작합니다.

2. 싱글 스레드와 “동시에 되는 것처럼 보이는” 이유

흔히 자바스크립트는 싱글 스레드 언어라고 합니다.

이게 무슨 의미일까요?

자바스크립트는 브라우저의 메인 스레드에서 한 번에 하나의 작업만 실행됩니다.

즉, 한 시점에서 활성된 콜 스택(Call Stack)은 1개이며, 콜 스택이 비어야만 다음 작업을 시작할 수 있습니다. 그래서 자바스크립트는 싱글 스래드 언어라고 불립니다.

그런데 실제로는 여러 일이 “동시에” 굴러가는 듯 보입니다.
버튼 클릭을 처리하면서도 네트워크 요청을 보내고, 여러 개의 HTTP 요청을 동시에 날려도 화면이 멈추지 않습니다.

자바스크립트는 싱글 스레드 언어라면서 어떻게 이런 일이 가능할까요?

비밀은 런타임(브라우저)에 있습니다.

런타임은 자바스크립트가 공연을 펼치는 ‘무대’와 같습니다.
무대에는 조명, 배경, 소품, 등장 인물들, 스태프들, 감독이 있어야 제대로 진행되듯,
런타임에도 JS 코드가 실행되는 메인 스레드 뿐만 아니라, Web API, 콜백 큐, 이벤트 루프 등이 있죠.

ℹ️ 참고
Web Worker를 쓰면 메인 스레드 외에 **추가 스레드/콜 스택**을 만들 수 있습니다만, 
이 포스팅에선 생략합니다.

갑자기 처음 보는 용어가 많이 나왔군요!

이제 하나씩 용어 정리를 해봅시다.

브라우저에서 JS 실행을 돕는 것들

영상 출처: JavaScript Event Loop And Call Stack Explained
  • 콜 스택(call stack)

    • 함수 호출을 관리하는, 자바스크립트의 데이터 구조입니다.
    • 콜 스택에서 비동기 작업을 Web API에 위임합니다.
  • Web API

    • 브라우저(런타임)가 제공하는 비동기 API 모음입니다.
      (예: setTimeout, fetch , DOM 이벤트 등)
    • JS가 이 API들을 호출하면 브라우저가 백그라운드에서 작업을 진행하고, 콜 스택은 즉시 반환됩니다.
    • 작업이 끝나면 상황에 맞는 큐/단계에 결과를 등록합니다.
      • 타이머·이벤트·XHR 등 → 태스크 큐에 등록
  • 태스크 큐(= 콜백 큐)

    • 콜백 큐는 비동기적으로 실행된 콜백 함수가 보관되는 곳입니다.
    • 일종의 콜 스택에 콜백 함수를 보내기 위한 ‘대기 공간’입니다.
    • 콜백함수는 콜 스택이 비었을 때, 콜백 큐에 들어온 순서대로 나가집니다.
  • 이벤트 루프(Event loop)

    • 스택이 비는 시점에 태스크 큐에서 작업을 선택해 콜 스택으로 올려 실행시킵니다.

아래 예시 코드를 같이 봅시다.

console.log(1);

setTimeout(() => {
    console.log(2);
}, 0);

console.log(3);

//코드 출처: https://talkwithcode.tistory.com/89

위 코드 블록을 실행하면 콘솔에 1 -> 3 -> 2 순서대로 출력됩니다.

여기서 console.log(1)console.log(3)은 ‘동기(synchronous)’ 코드이기 때문에, 현재 태스크(스크립트 실행)에서 즉시 실행됩니다. 반면 setTimeout 콜백(console.log(2))은 비동기다음 태스크 턴에 실행되죠.

동작 과정

  1. console.log(1)이 콜스택에 들어가 실행된 뒤 스택에서 사라집니다. (동기)
  2. setTimeout 호출 자체는 콜 스택에서 금방 끝나지만, 타이머의 실제 대기/만료 관리브라우저 Web API(타이머)가 담당합니다. (비동기)
  3. console.log(3) 이 콜 스택에 올라 실행된 뒤 스택에서 사라집니다. (동기)
  4. 타이머가 만료되면(요청한 0ms 후, 실제로는 최소 지연이 적용될 수 있음) setTimeout의 콜백태스크 큐(= 콜백 큐) 에 들어가 대기합니다.
  5. 콜 스택이 비면 이벤트 루프가 태스크 큐에서 가장 오래된 콜백 1개를 꺼내 콜 스택에 올려 실행합니다 → console.log(2) 출력.

이 예시를 통해 알 수 있는 점

  • 동기 코드는 콜 스택에서 즉시 실행되어 먼저 출력된다.
  • 비동기 작업은 Web API로 위임되고, 콜백은 태스크 큐에서 대기한다.
  • 콜 스택이 비는 순간 이벤트 루프가 태스크큐에서 콜백을 가져와 다음 태스크로 실행한다.

3. 태스크 큐의 두 종류

자바스크립트의 태스크 큐는 매크로태스크 큐와 마이크로태스크 큐로 나뉩니다. 

이들 큐는 비동기 작업의 우선순위를 관리하고, 이벤트 루프가 적절한 시점에 콜백을 실행하기 위해 사용됩니다.

매크로태스크 큐(Macrotask Queue)

  • 일반적인 비동기 작업의 콜백이 저장되는 큐입니다.
  • 매크로태스크 큐 예
    • <script> 실행
    • 사용자가 이벤트 (click, mousemove 등..)
    • setTimeout에서 설정한 시간이 다 된 경우, 콜백 함수를 실행하는 것

마이크로태스크 큐(Microtask Queue)

  • 매크로태스크 큐보다 우선 순위가 더 높은 비동기 작업들이 대기하는 큐입니다.
  • Promise.then/catch/finally, queueMicrotask, MutationObserver, await 후속 처리.
  • 다음 매크로 태스크를 진행 하기 전, 마이크로태스크 체크포인트를 수행하여 마이크로태스크 큐를 전부 비워야합니다.
📌 마이크로태스크 체크포인트(microtask checkpoint)란?
    
마이크로태스크 큐를 모두 비워 주는 절차입니다.

4. 이벤트 루프의 한 사이클

이벤트 루프는 다음 사이클을 반복합니다.

이벤트 루프 사이클을 보여주는 gif 이미지 이미지 출처: JavaScript Visualized: Promises & Async/Await
  1. 콜 스택에 있는 태스크 실행 후, 콜 스택이 빈 상태가 됨.
  2. 콜스택이 비면 제일 먼저 마이크로태스크 큐를 확인하고 가장 오래된 태스크부터 꺼내서 콜스택으로 전달하는데, 이걸 마이크로 태스크 큐가 텅 비어있을때까지 수행합니다.
  3. 모든 마이크로 태스크가 처리된 직후, 렌더링 작업 수행(필요시)
    (렌더링 단계에도 태스크 큐 처럼 종류와 순서가 나눠집니다만, 이 포스팅에서는 자세한 설명을 생략합니다.)
  4. 매크로태스크 큐에 있는 태스크 중 가장 오래된 태스크 1개를 콜스택으로 보냅니다.
  5. 1 ~4번 반복

아래 이미지를 보면 더 쉽게 이해가 됩니다.

이벤트 루프 사이클 이미지
출처: 이벤트 루프와 매크로태스크, 마이크로태스크

위의 이미지에서 script, mousemove, setTimeout은 매크로 태스크입니다.

브라우저의 이벤트 루프매크로 태스크 1개의 실행이 끝날 때마다
마이크로태스크 체크포인트를 돌려 마이크로태스크 큐를 빌 때까지 모두 처리한 뒤,
render 기회를 주고 다음 태스크(매크로)로 넘어갑니다.

5. ⭐️ 면접 대비: 실행 순서 맞히기

실제 기술 면접에서는 JS 코드를 보여주면서 실행 결과를 설명하라는 질문이 자주 나옵니다. 출력 결과도 맞혀야하며, 이에 관한 설명도 반드시 할 수 있어야 하므로 연습을 같이 해봅시다.

React 예제

아래 코드에서 버튼을 클릭하게 되면 콘솔 출력 결과가 어떻게 될까요?

import { useEffect } from "react";

export default function ClickOrderDemo() {
 
  const handleClick = () => {
    console.log("handler start");

    setTimeout(() => {
      console.log("timeout 0 (next task)");
    }, 0);

    Promise.resolve().then(() => {
      console.log("microtask (.then)");
    });

    console.log("handler end (sync)");
  };

  return (
    <button onClick={handleClick}>
      Click me
    </button>
  );
}

버튼을 클릭 후 콘솔 출력 결과:

handler start
handler end (sync)
microtask (.then)
timeout 0 (next task)

동작 과정 설명

  1. 클릭 발생 → handleClick 실행 시작
    • 클릭 핸들러 자체가 태스크 1개입니다. 메인 스레드의 콜 스택에선 클릭 핸들러의 내부 동기 코드가 실행됩니다.
    • 그래서 제일 먼저 handler start가 찍힙니다.
  2. setTimeout(..., 0) 등록
    • 콜 스택에서 setTimeout(fn, delay)가 호출됩니다.
      setTimeout 호출 자체는 동기지만, 브라우저 타이머(Web API)가 “fndelay 뒤에 실행할 수 있도록 예약(알람 설정)”을 받아갑니다.
    • 콜백 fn지금이 아니라 “다음 태스크 턴” 에 실행될 후보가 됩니다.
  3. Promise.resolve().then(...) 호출 및 등록
    • .then의 콜백은 마이크로태스크 큐에 들어갈 예약입니다.
    • 지금 당장 실행되는 건 아니고, 현재 태스크(= 클릭 핸들러)가 끝난 직후에 한꺼번에 처리됩니다.
  4. console.log("handler end (sync)")
    • 여전히 동기 실행이므로 바로 출력됩니다.
    • 이 시점에 클릭 핸들러(현재 태스크)가 종료됩니다.
  5. 마이크로태스크 체크포인트
    • 현재 태스크가 끝났으니, 브라우저는 마이크로태스크 큐를 ‘빌 때까지’ 전부 실행합니다.
    • 그래서 microtask (.then)즉시 다음으로 찍히죠.
  6. 다음 태스크 턴 → setTimeout 콜백 실행
    • 이제야 다음 태스크를 고르는 단계로 넘어갑니다.
    • 태스크 큐에 있던 콜백이 태스크 1개로 선택되어 실행 → timeout 0 (next task) 출력됩니다.

정리

  • 클릭 핸들러 자체가 태스크 1개다 → 그 안의 동기 코드즉시 실행.
  • Promise.then마이크로태스크현재 태스크가 끝난 직후에 먼저.
  • setTimeout 콜백은 다음 태스크에서 실행 → 가장 마지막.

조금 더 복잡한 예제 (Promise + 타이머 중첩)

setTimeout(() => {
    console.log('1')
    
    setTimeout(() => {console.log('2')} )

    Promise.resolve().then(()=> console.log('3'))
    
    console.log('4')
})

Promise.resolve().then(() => {
    console.log('5')
    
    setTimeout(() => {console.log('6')} )

    Promise.resolve().then(()=> console.log('7'))
    
    console.log('8')
})

console.log('9')

//코드 출처: https://www.maeil-mail.kr/question/214

출력 순서는 9 → 5 → 8 → 7 → 1 → 4 → 3 → 6 → 2입니다.

이 코드는 초기 스크립트(태스크 1개)마이크로태스크들타이머(다음 태스크들) 순서로 진행돼요.

동작 과정 설명

  1. 초기 스크립트(현재 태스크)
  • setTimeout(() => { ... }) 등록 → 타이머 T1 예약 (0ms)
  • Promise.resolve().then(() => { ... }) 등록 → 마이크로태스크 M_out 예약
  • console.log('9')출력: 9

⇒ 현재 태스크(스크립트)가 끝났으므로 마이크로태스크 체크포인트로 이동

  1. 마이크로태스크 단계
    1) 실행: M_out

    M_out 콜백 본문

     console.log('5')
     setTimeout(() => {console.log('6')} ) //**타이머 T2** (0ms)
     Promise.resolve().then(()=> console.log('7')) //**마이크로태스크 M_in**
     console.log('8')
    • console.log('5')출력: 5
    • setTimeout(() => console.log('6'))
      타이머 T2 예약 (0ms)
    • Promise.resolve().then(() => console.log('7'))
      마이크로태스크 M_in 예약
    • console.log('8')출력: 8

    2) 이어서 실행: M_in

    • console.log('7')출력: 7

⇒ 마이크로태스크가 모두 비었으니 다음 태스크로 이동

(이 시점에 0ms 타이머: T1, T2가 준비됨. 먼저 예약된 T1이 선행)

  1. 타이머 T1 실행

    T1 콜백 본문:

    console.log('1');
    setTimeout(() => console.log('2')); // 타이머 T3 (0ms)
    Promise.resolve().then(() => console.log('3')); // 마이크로태스크 M3
    console.log('4');
    
    • console.log('1')출력: 1
    • 타이머 T3 (0ms)
    • 마이크로태스크 M3 예약
    • console.log('4')출력: 4

    ⇒ 현재 태스크(T1)가 끝났으므로 마이크로태스크 체크포인트로 이동

  2. 마이크로태스크 체크포인트

    • 실행 M3 → console.log('3')출력: 3

    ⇒ 마이크로태스크가 모두 비었으니 다음 태스크로 이동

    (이 시점에 0ms 타이머: T2, T3가 매크로태스크 큐에서 대기 중)

  1. 다음 태스크들
    매크로태스크 큐에는 T2(초기에 예약했던 것)와 T3(방금 T1에서 예약)가 있음.
    FIFOT2 → T3 순서.
    - 실행 T2console.log('6')출력: 6
    - 실행 T3console.log('2')출력: 2

보너스) delay가 서로 다른 setTimeout의 순서

지금까지 여러 setTimeout이 등록되었으나, delay가 0ms로 모두 같았습니다. delay가 같으면 등록한 순서대로 실행되죠(FIFO).

그런데 만약 delay가 다르다면 어떻게 될까요?

다음 코드의 출력 결과를 맞혀보세요.

setTimeout(() => console.log('T50'), 50);
setTimeout(() => console.log('T10'), 10);

Promise.resolve().then(() => console.log('micro'));
console.log('sync');

console.log('sync')는 동기 코드이니 sync 가 제일 먼저 출력되겠고,

Promies.then()의 콜백은 마이크로태스크이니, sync 출력 이후 micro가 출력되는 것을 우리는 압니다.

그런데 setTimeout의 경우는 어떻게 될까요?

T50이 T10의 윗줄에서 먼저 호출되었으니, T50이 T10보다 먼저 출력될까요?

정답

sync
micro
T10
T50

T10이 T50보다 먼저 출력되었습니다!

어떻게 된 일일까요?

T50이 Web API에 먼저 위임되는 것은 맞지만 fn(여기에선 console.log(’T50’))은 delay (여기에선 50ms)가 지난 뒤에야 Web API가 fn을 태스크 큐에 넣습니다!

즉, T10이 T50 보다 먼저 태스크 큐에 들어가게 되는 거죠!

ℹ️ 참고: setTimeout의 delay 

setTimeout의 delay는 정확히 delay ‘정각’에 실행된다는 의미가 아닙니다. 
delay는 “**최소 지연**(at least)”을 의미합니다.

정리하자면, 여러 타이머가 있을 때 순서:
1. 만료 시각이 이른 것부터,
2. 만료 시각이 같다면 등록 순서(FIFO) 로 실행됩니다.

7. 마무리 및 요약

이번 포스팅에서는 다음 내용을 공부했습니다.

  • 싱글 스레드: 한 시점에 하나의 작업만 실행
  • 이벤트 루프: 태스크 1개 → (모든) 마이크로태스크 → 렌더 → 다음 태스크
  • 우선순위: 동기 > (현재 태스크 종료 직후) 마이크로태스크 > (다음 턴) 태스크
  • 타이머 규칙: 만료 시각 우선, 동률은 등록 순서

벨로그에 본격적으로 포스팅하는 건 오랜만이네요!
앞으로도 이번 포스팅처럼 옛날 포스팅의 업그레이드 버전을 가져오도록 하겠습니다.
감사합니다!

profile
주니어 프론트엔드 개발자

0개의 댓글