Seongyeol Yi

자바스크립트는 싱글 스레드 언어인데 비동기 처리는 어떻게 가능한가요?

This post is not yet available in English and is shown in Korean.

아래 질문에 대한 답을 찾아보자.

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

스레드

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

스레드란 뭘까?

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

흐름의 단위란 말이 굉장히 추상적이니 코드로 살펴보자.

import threading
import time

def worker(name):
    for _ in range(10):
        print(name, end="")
        time.sleep(0.01)

# 메인 프로그램(프로세스)에서 두 개의 스레드 생성
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))

t1.start()
t2.start()

# 메인 스레드는 두 스레드가 끝날 때까지 대기
t1.join()
t2.join()

# 예시 결과: ABBABABABAABABBAABBA
# 결과값은 매번 다름

메인 스레드에서 두 개의 스레드를 만들고, 두 개의 스레드에서 메인 스레드와는 독립된 작업을 하는 것이 보인다. 비유적으로 말하자면 보통의 프로그램은 어떤 코드를 실행중인지 손가락 하나로 짚을 수 있지만 스레드가 생성되면 손가락 여러 개로 짚어야 한다.

브라우저의 스레드

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

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

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

const now = Date.now();

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

아래 버튼을 눌러 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 (Blink, EdgeHTML, Gecko, WebKit) supports off-main-thread scrolling to one degree or another (with Firefox being the most recent member of the club, as of Firefox 46). stackoverflow

비동기

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

비동기는 컴퓨터 프로그래밍에서 메인 프로그램의 흐름과 독립적으로 발생하는 사건과 그러한 사건을 처리하는 방법을 의미한다.

웹 환경에서는 네트워크 요청, 타이머, 사용자 입력 등이 대표적인 비동기 작업이다. 이들은 메인 스레드를 막지 않고 백그라운드에서 처리된다. 대표적으로 setTimeout이 있다.

아래 버튼을 누르면 시계가 멈추지 않고 3초 뒤에 버튼이 활성화된다. 메인 스레드와 별개로 3초 기다리는 작업이 이루어짐을 확인해보자.

00:00:00

window.alert는 동기 함수이다. 얼럿이 떠있는 동안 타이머가 멈춘다.

00:00:00

참고: 브라우저 정책에 따라 탭 전환 등 특정 상황에서는 타이머가 움직일 수 있다.

이벤트 루프

자바스크립트가 싱글 스레드로 작동한다면 setTimeout을 호출한 뒤에 다른 코드를 실행할 수 없어야 할 것 같다. setTimeout이 ‘3초를 기다린다’라는 작업을 하는 동안에는 다른 작업을 할 수 없어야 한다는 말이다. 하지만 위에서 비동기 버튼 예제에서 봤듯이 3초 기다리는 동안 시계 컴포넌트가 잘 업데이트된다.

아래가 함정이다.

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

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

자바스크립트 엔진(예: 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은 브라우저의 화면 업데이트 주기와 완벽하게 동기화된다.

참고 자료

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

프론트엔드 주제의 다른 글들

글 전체보기