이성열 Seongyeol Yi

JS, 스레드, 비동기

25.08

아래 질문에 대한 답을 찾아봅시다.

Q. 자바스크립트는 싱글 스레드 언어인데 비동기 처리는 어떻게 가능한가요? 이벤트 루프, 콜 스택, 태스크 큐(Microtask 포함) 중심으로 설명해주세요.

스레드

질문을 쪼개서 기본 개념을 이해해봅시다.

Q. 자바스크립트는 싱글 스레드 언어인데 비동기 처리는 어떻게 가능한가요? 이벤트 루프, 콜 스택, 태스크 큐(Microtask 포함) 중심으로 설명해주세요.

스레드가 뭘까요? 정의를 찾아봅시다.

스레드(thread)는 어떠한 프로그램 내에서 실행되는 흐름의 단위를 말한다. 위키피디아

흐름의 단위란 말이 굉장히 추상적이니 직접 봅시다. A 열 번, B 열 번을 출력하는 같은 프로그램을 위에서는 스레드 하나로, 아래에서는 스레드 두 개로 실행합니다.

싱글 스레드

작업 A··········작업 B··········출력····················

멀티 스레드

작업 A··········작업 B··········출력····················

싱글 스레드는 실행 흐름이 하나뿐이라 작업 A를 다 끝낸 뒤에야 작업 B를 시작합니다. 반면 멀티 스레드에서는 두 작업이 동시에 진행되어 출력이 뒤섞이고, 섞이는 순서는 실행할 때마다 달라집니다. 비유적으로 말하자면 보통의 프로그램은 어떤 코드를 실행중인지 손가락 하나로 짚을 수 있지만 스레드가 생성되면 손가락 여러 개로 짚어야 합니다.

브라우저의 스레드

그럼 프론트엔드 개발에서 스레드는 언제 등장할까요?

메인 스레드(main thread)는 브라우저가 사용자 이벤트와 페인트를 처리하는 곳입니다. 기본적으로, 브라우저는 단일 스레드를 사용하여 페이지의 모든 JavaScript를 실행하고 레이아웃, 리플로우 및 가비지 컬렉션을 수행합니다. 오래 실행되는 JavaScript 함수가 스레드를 차단하여 페이지가 응답하지 않고, 사용자 경험이 저하될 수 있다는 것을 의미합니다. MDN

직접 확인해봅시다. 아래 자바스크립트 코드는 3초 동안 실행됩니다.

const now = Date.now();

while (Date.now() - now < 3000) {
  // 3초 동안 대기
}

아래 버튼을 눌러 위 함수를 실행시켜봅시다.

00:00:00

시계가 멈추고, 텍스트 선택이나 버튼 클릭 같은 기능도 멈추는 것을 확인할 수 있습니다.

신기하게도 스크롤은 동작하는데 이유는 다음과 같습니다.

Over the years, browser vendors have recognized that offloading work to background threads can yield enormous improvements to smoothness and responsiveness. Scrolling, being so important to the core user experience of every browser, was quickly identified as a ripe target for such optimizations. Nowadays, every major browser engine supports off-main-thread scrolling to one degree or another. stackoverflow

비동기

스레드를 이해했으니 이제 비동기가 무엇인지 알아봅시다.

Q. 자바스크립트는 싱글 스레드 언어인데 비동기 처리는 어떻게 가능한가요? 이벤트 루프, 콜 스택, 태스크 큐(Microtask 포함) 중심으로 설명해주세요.

비동기 작업이란 이전 객체 또는 이벤트가 완료될 때까지 기다리지 않고 발생하는 여러 관련 작업을 의미합니다. 웹 환경에서는 네트워크 요청, 타이머, 사용자 입력 등이 대표적인 비동기 작업입니다. 이들은 메인 스레드를 막지 않고 백그라운드에서 처리됩니다. 대표적으로 setTimeout이 있습니다.

setTimeout(() => {
  ...
}, 3000);

아래 버튼을 누르면 시계가 멈추지 않고 3초 뒤에 버튼이 활성화됩니다. ‘3초 기다린다’라는 작업이 완료될 때까지 기다리지 않고 브라우저가 시계의 렌더링 작업을 이어가고 있음을 확인하실 수 있습니다.

00:00:00

반면 window.alert는 동기 함수입니다. 브라우저는 시계 렌더링 전 alert가 닫기기를 동기적으로 기다립니다. 따라서 얼럿이 떠있는 동안 타이머가 멈춥니다.

00:00:00

이벤트 루프

자바스크립트가 싱글 스레드로 작동한다는건 하나의 흐름으로만 프로그램을 실행한다는 의미입니다. 따라서 setTimeout이 ‘3초를 기다린다’라는 작업을 하는 동안에는 다른 작업을 할 수 없어야 할 것 처럼 느껴집니다. 그렇다면 어떻게 setTimeout의 실행과 동시에 시계의 렌더링을 수행하는걸까요?

아래가 함정입니다.

자바스크립트 엔진은 싱글 스레드지만, 브라우저는 멀티 스레드이다.

헷갈리니 자바스크립트 엔진은 뭔지, 브라우저와 자바스크립트 엔진의 관계를 살펴봅시다.

자바스크립트 엔진(예: V8, SpiderMonkey)은 자바스크립트 코드를 실행하는 역할을 담당하며, 흔히 자바스크립트가 싱글 스레드라고 하는 것은 바로 이 엔진이 한 번에 하나의 작업만 처리할 수 있기 때문입니다.

반면 브라우저는 여러 스레드/프로세스로 나뉘어 있습니다. 네트워크·타이머·이벤트 등은 JS 엔진 밖의 Web API들이 관리합니다. 자바스크립트 엔진 자체는 비동기 함수를 구현하고 있지 않습니다. setTimeout이나 fetch와 같은 비동기 함수는 브라우저가 제공하는 Web API에 정의되어 있으며, 자바스크립트 엔진 외부에 구현되어 있습니다. 즉, 자바스크립트 엔진은 단순히 코드를 실행하는 역할만 맡고 비동기 작업 자체는 브라우저의 다른 스레드에서 처리되는 것입니다.

그렇다면 브라우저의 멀티 스레드 작업이 어떻게 싱글 스레드인 자바스크립트 엔진과 소통할까요? 이 둘을 연결해주는 것이 바로 이벤트 루프입니다. 이벤트 루프는 브라우저에 구현되어 있으며, 브라우저의 백그라운드에서 비동기 작업이 완료되면 그 결과로 실행되어야 하는 콜백 함수를 자바스크립트 엔진의 콜 스택으로 옮겨주는 역할을 합니다.

이벤트 기반 프로그래밍

이벤트 루프를 제대로 이해하기 위해서는 우선 인식의 전환이 필요합니다.

일반적으로 학교에서 배우는 과제나 숙제로 하는 프로그램들은 시작 → 작업 → 종료라는 명확한 순서를 따릅니다. 스크립트가 실행되고, 모든 작업을 마친 후 프로그램이 끝나는 것이 일반적입니다.

하지만 브라우저 환경의 자바스크립트는 다릅니다. 스크립트가 한 번 실행되고 끝나더라도, 이후에 클릭과 스크롤 같이 사용자가 만드는 이벤트, 네트워크 이벤트, 타이머 이벤트 등이 계속해서 발생하고, 이에 등록해둔 콜백이 실행됩니다. 이러한 패러다임을 이벤트 기반 프로그래밍이라고 합니다.

그렇다면 이런 콜백 함수들은 언제 실행될까요? 바로 콜 스택이 비어있을 때입니다.

콜 스택과 이벤트 루프

콜 스택은 자바스크립트 함수의 호출 순서를 기록하는 스택 구조입니다. 함수가 호출될 때 스택에 쌓이고 함수가 실행을 마치면 스택에서 제거됩니다.

실제로 코드가 실행될 때 콜 스택이 어떻게 변하는지 확인해봅시다. Error.stack을 사용하면 현재 콜 스택의 호출 정보를 확인할 수 있습니다.

function third() {
  console.log(new Error().stack);
}

function second() {
  third();
}

function first() {
  second();
}

first();

// Error
//     at third (<anonymous>:2:15)
//     at second (<anonymous>:6:3)
//     at first (<anonymous>:10:3)
//     at <anonymous>:13:1

위 코드를 실행하면 first 함수가 second를, second 함수가 third를 호출하므로 콜 스택에는 first → second → third 순서로 함수가 쌓입니다.

콜 스택이 비어 있다는 것은 현재 실행 중인 자바스크립트 코드가 없다는 뜻입니다. 이벤트 루프는 콜 스택이 비어있는지 계속해서 확인하며, 이것이 비동기 작업을 처리할 수 있는 중요한 신호가 됩니다.

run-to-completion

앞서 이벤트 루프가 콜 스택이 비어있을 때만 새로운 콜백을 실행한다고 했습니다. 이는 자바스크립트의 Run-to-completion 모델과 연결됩니다. Run-to-completion은 현재 실행 중인 함수가 완전히 끝날 때까지는 다른 코드로 제어권이 넘어가지 않는다는 의미입니다. 즉, 한 번 콜 스택에 올라간 함수는 중간에 멈추지 않고 끝까지 실행됩니다.

C나 Java 같은 멀티스레드 언어에서는 실행 중간에 다른 스레드가 개입하여 변수나 객체의 상태를 변경할 수 있습니다. 다음 C 코드를 보면:

#include <pthread.h>
#include <stdio.h>

int shared_counter = 0;

void* increment_function(void* arg) {
    for (int i = 0; i < 100000; i++) {
        // 다른 스레드가 중간에 개입 가능
        shared_counter++;
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_create(&thread1, NULL, increment_function, NULL);
    pthread_create(&thread2, NULL, increment_function, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("최종 카운터: %d\n", shared_counter);
    // 예상: 200000, 실제: 불확실 (예: 156789)
    return 0;
}

위 코드에서 shared_counter++는 실제로 “메모리에서 값 읽기 → 1 증가 → 메모리에 쓰기”라는 3단계로 이루어지는데, 이 과정 중간에 다른 스레드가 끼어들어 예상과 다른 결과가 나올 수 있습니다.

하지만 자바스크립트는 현재 함수가 끝나기 전까지는 다른 콜백이 실행되지 않습니다. 이 특성 덕분에 다음과 같은 코드가 문제없이 동작합니다:

document.body.appendChild(element);
element.style.display = "none";

만약 멀티스레드 환경이었다면 appendChild 실행 후 렌더링 스레드가 즉시 개입해서 사용자에게 요소가 잠깐 보였다가 사라질 수도 있습니다. 하지만 자바스크립트는 이 두 줄이 모두 실행된 후에야 렌더링이 일어나기 때문에, 사용자는 요소를 전혀 볼 수 없습니다. 이런 특성은 DOM 조작의 예측 가능성을 높여줍니다.

큐의 종류

이벤트 루프는 여러 개의 큐를 가지고 있으며, 각각 다른 우선순위와 역할을 가지고 있습니다. 그 중 가장 기본적인 것이 태스크 큐입니다. 매크로태스크 큐라고 부르기도 합니다.

이벤트 기반 프로그래밍에서 비동기 작업의 결과로 실행될 콜백들은 태스크 큐에 저장됩니다. 태스크 큐는 setTimeout, setInterval, DOM 이벤트 콜백 등이 들어가는 큐입니다.

간단한 예제로 태스크 큐의 동작을 살펴봅시다.

console.log("시작");

setTimeout(() => {
  console.log("첫 번째 타이머");
}, 0);

setTimeout(() => {
  console.log("두 번째 타이머");
}, 0);

console.log("");

// 시작
// 끝
// 첫 번째 타이머
// 두 번째 타이머

왜 이런 순서로 실행될까요? 먼저 console.log('시작')console.log('끝')이 콜 스택에서 즉시 실행되고, setTimeout의 콜백들은 태스크 큐에 추가됩니다. ‘시작’과 ‘끝’이 프린트된 뒤 콜 스택이 완전히 비워진 후에야 이벤트 루프가 태스크 큐에서 콜백을 하나씩 가져와 실행합니다. 콜백이 끝나고 나면 필요시 렌더링을 진행한 뒤 다시 콜백이 있는지 확인합니다.

이것이 암시하는 바는 setTimeout이 정확히 지정된 시간 후에 실행된다는 보장이 없다는 것입니다. 다음 예제를 보면:

console.log("시작");

setTimeout(() => {
  console.log("100ms 타이머");
}, 100);

// 5초 동안 메인 스레드를 바쁘게 만듦
const start = Date.now();
while (Date.now() - start < 5000) {
  // 5초간 대기
}

console.log("");

// 실행 결과:
// 시작
// (약 5초 후) 끝
// (약 5초 후, 100ms가 아님!) 100ms 타이머

위 예제에서 setTimeout은 100ms 후에 실행되어야 하지만, while문이 5초 동안 콜 스택을 점유하고 있기 때문에 실제로는 5초가 지난 후에 실행됩니다. 이는 setTimeout이 “최소 지연 시간”을 보장할 뿐, “정확한 실행 시간”을 보장하지는 않음을 보여줍니다.

마이크로태스크 큐

초기 자바스크립트에는 매크로태스크 큐만 존재했습니다. 하지만 ES6에서 Promise가 도입되고, DOM 변경을 감지하는 Mutation Observer 같은 기능들이 필요해지면서 문제가 생겼습니다.

이런 기능들의 공통점은 작업이 끝나자마자 바로 실행되어야 한다는 것입니다. 예를 들어 Mutation Observer는 DOM이 변경되는 순간을 감지해야 하는데, 만약 콜백을 매크로태스크 큐에 넣으면 다음 작업으로 넘어가기 전에 렌더링이 먼저 일어날 수 있습니다. 그렇게 되면 DOM이 바뀌고 화면에 반영된 뒤에야 콜백이 실행되기 때문에 원래 의도했던 “변경 직후 감지”가 불가능해집니다.

그래서 새로운 큐인 마이크로태스크 큐가 생겼습니다. 마이크로태스크 큐에 들어간 작업은 콜 스택이 비워지고 화면을 그리기 전에 반드시 실행됩니다. 덕분에 DOM 변경이나 Promise 결과 같은 것들을 “지연 없이” 처리할 수 있게 되었습니다.

마이크로태스크 큐에는 Promise.then(), Promise.catch(), Promise.finally(), async/await, queueMicrotask() 등의 콜백들이 들어갑니다. 각 태스크가 끝난 직후 브라우저는 마이크로태스크 큐를 전부 비우고, 그다음 렌더링/다음 태스크로 진행합니다.

console.log("시작");

setTimeout(() => {
  console.log("setTimeout (태스크 큐)");
}, 0);

Promise.resolve().then(() => {
  console.log("Promise.then (마이크로태스크 큐)");
});

console.log("");

// 실행 결과:
// 시작
// 끝
// Promise.then (마이크로태스크 큐)
// setTimeout (태스크 큐)

둘 다 0ms 지연이지만, Promise가 먼저 실행됩니다. 이는 이벤트 루프가 콜 스택이 비워진 후 마이크로태스크 큐를 먼저 확인하고, 마이크로태스크 큐에 있는 모든 콜백을 처리한 다음에야 다른 단계(렌더링, 태스크 큐 등)로 넘어가기 때문입니다.

마이크로태스크 큐의 특별한 점은 큐가 완전히 비워질 때까지는 이벤트 루프의 다음 단계로 넘어가지 않는다는 것입니다. 이는 Promise 체인이나 연속된 마이크로태스크들이 렌더링이나 다른 작업들보다 항상 우선하여 실행됨을 의미합니다.

태스크 큐 vs 마이크로태스크 큐

마이크로태스크와 태스크 큐의 차이를 보여주는 예제를 살펴봅시다.

// 마이크로태스크 무한 루프
function createMicrotasks() {
  queueMicrotask(() => {
    console.log("마이크로태스크 실행");
    createMicrotasks(); // 새로운 마이크로태스크를 계속 생성
  });
}

createMicrotasks();
// 결과: 마이크로태스크 큐가 절대 비워지지 않아 렌더링 완전 중단

반면 setTimeout을 사용한 무한 루프는 각 태스크 사이에 렌더링이 일어날 수 있습니다:

// 태스크 큐 무한 루프
function createTasks() {
  setTimeout(() => {
    console.log("태스크 실행");
    createTasks(); // 새로운 태스크를 계속 생성
  }, 0);
}

createTasks();
// 결과: 각 태스크 사이마다 렌더링이 일어남

이 차이는 이벤트 루프가 마이크로태스크 큐를 완전히 비운 후에만 렌더링으로 넘어가기 때문입니다.

requestAnimationFrame

애니메이션을 부드럽게 만들기 위해 사용하는 requestAnimationFrame은 태스크 큐도 마이크로태스크 큐도 아닌 특별한 위치에서 실행됩니다.

이벤트 루프는 다음 순서로 작업을 처리합니다:

  1. 콜 스택의 모든 작업 완료
  2. 마이크로태스크 큐 완전히 비움 (Promise, queueMicrotask 등)
  3. DOM 렌더링 필요 여부 판단 (브라우저가 결정)
  4. DOM 렌더링이 필요하다면:
    • requestAnimationFrame 콜백들 실행
    • 이후 렌더링

중요한 점은 requestAnimationFrame이 실제 DOM 렌더링 직전에 실행된다는 것입니다. 이는 브라우저가 화면을 다시 그리기로 결정했을 때만 호출되므로, 불필요한 계산을 방지하고 화면 업데이트와 완벽하게 동기화됩니다.

setTimeout을 사용한 애니메이션은 화면 업데이트 주기와 불일치할 수 있다는 문제가 있습니다. 위에서 살펴본 것처럼 setTimeout은 명시한 delay가 지난 뒤에 실행될 거라는 보장이 없으며, 이로 인해 콜백이 조금씩 밀리다 보면 일부 프레임은 건너뛰어지고 애니메이션이 끊어져 보입니다.

반면 requestAnimationFrame은 브라우저의 화면 업데이트 주기와 완벽하게 동기화됩니다.

참고 자료

아래 영상들이 굉장히 도움됩니다.