JavaScript 비동기 처리의 내부 동작 원리

2025-09-20
  • JavaScript

JavaScript를 공부하다 보면 JavaScript는 싱글 스레드인데 어떻게 비동기 처리가 가능한가?라는 의문이 생긴다. 이 질문에 답하려면 JavaScript 실행 환경의 구조를 이해해야 한다.

✍️ JavaScript 실행 환경의 전체 구조

JavaScript 코드가 실행되려면 두 가지 핵심 요소가 필요하다:

  1. JavaScript 엔진: 코드를 해석하고 실행하는 주체
  2. 실행 환경 (브라우저 또는 Node.js): 엔진을 구동하고 비동기 처리를 지원하는 플랫폼

이 둘은 서로 다른 역할을 담당하며, 이 역할 분담이야말로 JavaScript 비동기 처리의 핵심이다. 지금부터 각각이 어떤 역할을 하는지 자세히 살펴보자.

JavaScript 엔진: 코드 실행의 핵심

JavaScript 엔진(V8, SpiderMonkey 등)은 두 가지 핵심 구성 요소를 가지고 있다:

  • 콜스택(Call Stack): 실행 컨텍스트 스택으로, 함수 호출과 실행 순서를 관리하는 핵심적인 구조다. 싱글 스레드이기 때문에 한 번에 하나의 작업만 처리할 수 있다.
  • 힙(Heap): 객체가 저장되는 메모리 공간으로, 실행 컨텍스트는 힙에 저장된 객체를 참조한다.

여기서 중요한 점은 JavaScript 엔진 자체는 타이머나 HTTP 요청 같은 비동기 작업을 처리할 수 없다는 것이다. 그럼 누가 처리할까?

브라우저 환경: 비동기 처리의 실질적 주체

소스코드의 평가와 실행은 JavaScript 엔진이 담당하지만, 그 외의 모든 처리는 브라우저 환경이 담당한다. 브라우저나 Node.js 같은 JavaScript 실행 환경은 비동기 처리에서 중요한 역할을 한다.

예를 들어 setTimeout의 경우, 브라우저가 호출 스케줄링을 위한 타이머 설정과 콜백 함수 등록을 담당하고, 콜백 함수의 평가와 실행은 JavaScript 엔진이 담당한다. 이런 역할 분담이 JavaScript의 비동기 처리를 가능하게 하는 핵심이다.

중요한 점은 JavaScript 엔진은 싱글 스레드로 동작하지만, 브라우저는 멀티 스레드로 동작한다는 것이다. JavaScript가 싱글 스레드 방식으로 동작한다고 할 때, 이는 브라우저가 아닌 브라우저에 내장된 JavaScript 엔진을 의미한다.

JavaScript 런타임 환경을 구성하는 요소들:

  • Web APIs (또는 Node.js APIs): 타이머, HTTP 요청, DOM 이벤트 등을 멀티 스레드로 처리
  • 태스크 큐들: 비동기 작업이 완료된 후 실행할 콜백 함수들을 보관
  • 이벤트 루프: 콜스택과 태스크 큐를 감시하며 적절한 타이밍에 콜백을 실행

✍️ JavaScript 엔진과 브라우저 환경을 연결하는 이벤트 루프

이벤트 루프?

이제 JavaScript 엔진과 브라우저 환경을 연결하는 핵심 메커니즘인 이벤트 루프를 살펴보자.

이벤트 루프는 JavaScript의 동시성을 지원하는 핵심 메커니즘이다. HTTP 요청으로 서버 데이터를 가져오면서 동시에 렌더링을 하는 것 같은 작업을 가능하게 해준다.

이벤트 루프의 동작 방식은 간단하다. 콜스택이 비어있고 태스크 큐에 대기 중인 함수가 있으면, 순차적(선입선출)으로 태스크 큐에 대기 중인 함수를 콜스택으로 이동시킨다.

태스크 큐(Task Queue)란?

태스크 큐는 setTimeout과 같은 비동기 함수의 콜백 함수나 이벤트 핸들러가 일시적으로 보관되는 영역이다.

여기서 주의할 점이 있다. 태스크 큐에는 여러 종류가 있으며, 일반적으로 말하는 태스크 큐는 매크로태스크 큐를 의미한다. 이는 마이크로태스크 큐와는 별도로 존재한다.

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

마이크로태스크는 현재 스크립트 실행 직후 즉시 완료되어야 하는 작업들을 위해 스케줄링된다. 주로 프로미스와 관련된 태스크들이 여기에 해당한다.

주요 특징:

  • 선입선출(FIFO) 구조
  • 콜스택에 실행할 것이 아무것도 남지 않았을 때 마이크로태스크 큐에 있는 작업이 실행됨
  • 마이크로태스크 큐 내의 모든 작업이 완료되기 전까지 큐 외부의 다음 작업으로 이동하지 않음
  • UI 변화나 네트워크 이벤트 핸들링은 마이크로태스크가 전부 처리된 후에 일어남

매크로태스크 큐(Macrotask Queue)

매크로태스크 큐는 일반적으로 말하는 태스크 큐다. 이벤트 큐나 콜백 큐로도 불린다.

포함되는 작업들:

  • HTML 파싱 및 DOM 생성
  • script 코드 실행
  • I/O 작업
  • DOM 이벤트 핸들러

마이크로태스크 큐는 매크로태스크 큐보다 우선순위가 높다. 모든 마이크로태스크 큐 내의 작업을 완료한 후에야 매크로태스크 큐의 작업이 실행된다.

async/await와 마이크로태스크

async/await는 프로미스를 더 편리하게 사용하는 문법으로서 내부적으로는 마이크로태스크 큐를 활용한다.

  • await 키워드를 만나면 async 함수가 일시 정지(suspended) 된다
  • async 함수의 나머지 부분은 마이크로태스크로 등록되어 나중에 실행된다
  • 엔진은 async 함수 밖으로 나가 다른 코드를 계속 실행한다
  • 콜스택이 비면 마이크로태스크 큐의 async 함수가 재개된다

이것이 프로미스의 then과의 주요 차이점이다. await은 함수를 일시 정지시키지만, then을 사용하면 코드가 계속 실행된다.

✍️ 코드로 보는 전체 메커니즘

function foo() {
    console.log('foo');
}

function bar() {
    console.log('bar');
}

setTimeout(foo, 0);
bar();

이 코드의 실행 과정은 다음과 같다:

1. 전역 코드 평가: 전역 실행 컨텍스트가 생성되고 콜 스택에 푸시된다.

2. setTimeout 호출: setTimeout 함수의 실행 컨텍스트가 생성되고 콜스택에 푸시되어 실행된다. JavaScript 엔진은 브라우저에 콜백 함수 호출을 위한 타이머 설정을 요청하고, 컨텍스트는 종료되어 콜 스택에서 팝된다.

3. 병렬 처리: 이제 두 가지 태스크가 병렬로 진행된다.

  • 브라우저가 타이머를 설정하고 만료를 기다린다. 타이머가 만료되면 콜백 함수 foo가 태스크 큐에 푸시되어 대기한다.
  • bar 함수가 호출되고 실행 컨텍스트가 생성되어 콜스택에 푸시되어 즉시 실행된다. bar 함수가 종료되면 콜스택에서 팝된다.

4. 전역 코드 종료: 전역 실행 컨텍스트가 콜 스택에서 팝되어 콜스택이 비워진다.

5. 이벤트 루프 동작: 이벤트 루프가 콜스택이 비워진 것을 감지한다. 매크로태스크 큐에서 대기 중인 foo 함수가 이벤트 루프에 의해 콜스택에 푸시된다. foo 함수가 실행되어 ‘foo’가 출력된 후 콜스택에서 팝된다.

여기서 중요한 점은 setTimeout의 지연 시간이 0이어도 콜스택이 비어야만 실행되기 때문에 bar보다 나중에 실행된다는 것이다.

✍️ 이벤트 루프 알고리즘

이제 이벤트 루프의 정확한 동작 알고리즘을 정리해보자:

  1. 매크로태스크 큐에서 가장 오래된 태스크를 꺼내 실행한다
    • (예: 스크립트 실행)
  2. 모든 마이크로태스크를 실행한다
    • 마이크로태스크 큐가 빌 때까지 이어지며, 태스크는 오래된 순서대로 처리된다
  3. 렌더링할 것이 있으면 처리한다
  4. 매크로태스크 큐가 비어있으면 새로운 매크로태스크가 나타날 때까지 기다린다
  5. 1번으로 돌아간다

이 알고리즘이 계속 반복되면서 JavaScript의 비동기 처리가 이루어진다.

✍️ 마이크로태스크큐와 매크로태스크큐의 우선순위

다음 코드의 실행 결과는 어떻게 될까?

console.log('Start!')

setTimeout(() => console.log('Timeout!'), 0)

Promise.resolve('Promise!').then(res => console.log(res))

console.log('End!')

결과는 다음과 같다:

Start!
End!
Promise!
Timeout!

각 단계별로 무슨 일이 일어나는지 살펴보자.

1단계: 첫 번째 console.log 실행

엔진이 console.log('Start!') 메서드를 만난다. 이 메서드는 콜스택에 추가된 후 콘솔에 'Start!' 값을 출력한다. 메서드는 콜스택에서 제거되고 엔진은 다음 코드를 계속 실행한다.

2단계: setTimeout 처리

엔진이 setTimeout 메서드를 만나 콜스택에 추가한다. setTimeout은 브라우저 내장 기능이므로, 타이머가 완료될 때까지 콜백 함수가 웹 API에 추가된다. 여기서 흥미로운 점은 타이머 값으로 0을 제공했음에도 콜백은 먼저 웹 API에 푸시된 후 매크로태스크 큐에 추가된다는 것이다. (setTimeout은 매크로태스크이다.)

3단계: Promise 처리

엔진이 Promise.resolve() 메서드를 만난다. Promise.resolve() 메서드는 콜스택에 추가된 후 'Promise' 값을 가지는 프로미스로 resolve된다. 이후 then의 콜백 함수가 마이크로태스크 큐에 추가된다. setTimeout의 콜백과 달리 Promise의 콜백은 마이크로태스크 큐에 들어간다는 점을 기억하자.

4단계: 두 번째 console.log 실행

엔진이 console.log('End!') 메서드를 만난다. 이 메서드는 즉시 콜스택에 추가된 후 'End!' 값을 콘솔에 출력하고 콜스택에서 제거된다. 이제 동기 코드는 모두 실행이 완료되었다.

5단계: 마이크로태스크 실행

엔진은 콜스택이 비어 있음을 확인한다. 이제 이벤트 루프가 작동할 차례다. 콜스택이 비어 있으므로 이벤트 루프는 먼저 마이크로태스크 큐에 대기 중인 작업이 있는지 확인한다. 실제로 대기 중인 프로미스 콜백이 있다. 이 프로미스 콜백이 콜스택에 푸시되고, 프로미스의 해결된 값 'Promise!'을 로깅한다.

6단계: 매크로태스크 실행

엔진은 다시 콜스택이 비어 있음을 확인하고, 마이크로태스크 큐에 작업이 대기 중인지 확인한다. 마이크로태스크 큐는 완전히 비어 있다. 이제 매크로태스크 큐를 확인할 차례다. setTimeout 콜백이 여전히 대기 중이므로, 이 콜백이 콜스택에 푸시된다. 콜백 함수는 console.log 메서드를 실행하며 'Timeout!' 문자열을 기록한다. 이후 setTimeout 콜백이 콜스택에서 팝된다.

정리하자면, 프로미스의 후속 처리 메서드의 콜백함수가 마이크로태스크 큐에 저장되기 때문에 이와 같은 결과가 발생했다는 점이 중요하다. 마이크로태스크 큐는 매크로태스크 큐보다 우선순위가 높아 먼저 실행되기에 'Promise' 다음 'setTimeout'이 출력되는 것이다.

✍️ 성능 고려사항과 웹 워커

이벤트 루프를 이해했다면, 무거운 연산을 어떻게 처리해야 할지도 고민해야 한다. 오래 걸리는 연산이 이벤트 루프를 막으면 사용자 인터페이스가 멈추게 되기 때문이다.

이런 문제를 해결하기 위해 웹 워커(Web Worker)를 사용할 수 있다. 웹 워커는 별도의 백그라운드 스레드에서 코드를 병렬적으로 실행할 수 있게 해준다. 메인 스레드와 메시지를 교환할 수 있지만, 웹 워커에는 메인 스레드와 연관 없는 고유한 변수들과 자체 이벤트 루프가 있다.

다만 웹 워커는 DOM에 접근할 수 없기 때문에, 여러 CPU 코어를 동시에 사용해야 하는 무거운 연산 작업에 주로 사용된다. UI 업데이트가 필요한 작업은 여전히 메인 스레드에서 처리해야 한다.




참고 자료

모던 자바스크립트 딥다이브

코어 자바스크립트

모던 JavaScript 튜토리얼

The JavaScript Event Loop: Explained

Profile picture

박세리

Frontend Developer