2026년 3월 29일

JavaScript Event Loop: 단일 스레드의 마법, 비동기 코드를 춤추게 하다

130
JavaScript Event Loop: 단일 스레드의 마법, 비동기 코드를 춤추게 하다

JavaScript Event Loop: 단일 스레드의 마법, 비동기 코드를 춤추게 하다

JavaScript Event Loop: 단일 스레드의 마법, 비동기 코드를 춤추게 하다

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

안녕하세요! 10년 경력의 소프트웨어 엔지니어이자 기술 교육자로서, 오늘은 많은 초중급 개발자들이 궁금해하지만, 때로는 깊이 이해하기 어려워하는 JavaScript의 핵심 개념인 **이벤트 루프(Event Loop)**에 대해 이야기해보고자 합니다.

정의: JavaScript 런타임의 심장 박동

이벤트 루프는 JavaScript 런타임 환경(웹 브라우저 또는 Node.js)에서 비동기 작업을 처리하고 콜백 함수를 실행하는 핵심 메커니즘입니다. JavaScript는 기본적으로 "단일 스레드" 언어이기 때문에, 한 번에 하나의 작업만 수행할 수 있습니다. 하지만 웹 애플리케이션이나 서버는 동시에 여러 작업을 처리해야 하죠. 예를 들어, 웹 페이지에서 이미지를 로딩하는 동안 사용자가 버튼을 클릭하거나, Node.js 서버에서 수많은 동시 요청을 처리하는 경우 등입니다. 이벤트 루프는 이 단일 스레드의 제약을 넘어, 마치 여러 작업을 동시에 처리하는 것처럼 보이게 만드는 마법 같은 존재입니다.

탄생 배경: UI 블로킹과 서버 성능의 딜레마

JavaScript가 처음 등장했을 때, 웹 페이지에서 복잡한 계산이나 네트워크 요청 같은 시간이 오래 걸리는 작업을 수행하면 사용자 인터페이스(UI)가 멈춰버리는 문제가 발생했습니다. 사용자는 페이지가 응답하지 않는 것처럼 느껴 짜증을 느끼게 되죠. 이러한 현상을 "UI 블로킹"이라고 합니다.

Node.js 환경에서는 이 문제가 더욱 심각합니다. 수많은 클라이언트 요청을 처리해야 하는 서버가 하나의 요청 때문에 멈춰버린다면, 서비스는 마비될 것입니다. 이러한 문제를 해결하기 위해 비동기(Asynchronous) 처리의 필요성이 대두되었고, 이를 가능하게 하는 핵심적인 장치가 바로 이벤트 루프입니다. 이벤트 루프는 JavaScript 엔진 외부의 메커니즘들을 활용하여, 시간이 오래 걸리는 작업을 백그라운드에 맡기고, 작업이 완료되면 그 결과를 받아와 다시 JavaScript 스레드에서 처리할 수 있도록 돕습니다.

왜 중요한가: 반응성, 성능, 그리고 예측 가능성

이벤트 루프를 이해하는 것은 JavaScript 개발자에게 다음과 같은 이유로 매우 중요합니다.

  1. 반응성 유지: 웹 브라우저에서는 네트워크 요청이나 복잡한 계산으로 인해 UI가 멈추는 것을 방지하여 사용자 경험을 최적화합니다. 사용자는 페이지가 항상 반응하는 것처럼 느낄 수 있습니다.
  2. 성능 최적화: Node.js 서버에서는 파일 I/O, 데이터베이스 쿼리, 외부 API 호출 등 시간이 오래 걸리는 I/O(Input/Output) 작업 대기 시간 동안 다른 클라이언트의 요청을 처리하여 시스템의 처리량(Throughput)과 동시성(Concurrency)을 극대화합니다. 이는 단일 스레드의 한계를 극복하고 높은 성능을 달성하는 비결입니다.
  3. 디버깅 및 예측: setTimeout, Promise, async/await 등 비동기 코드를 작성할 때, 그 실행 순서를 정확히 예측하고 예상치 못한 버그를 해결하는 데 필수적인 지식입니다. "왜 setTimeout(0)이 바로 실행되지 않을까?"와 같은 질문에 답할 수 있게 됩니다.

이벤트 루프는 JavaScript 런타임의 내부 동작을 이해하는 열쇠이며, 이를 통해 더 효율적이고 견고한 애플리케이션을 개발할 수 있습니다.

2. 핵심 원리 설명: 단일 요리사와 바쁜 주방 보조의 비유

2. 핵심 원리 설명: 단일 요리사와 바쁜 주방 보조의 비유

JavaScript의 이벤트 루프는 마치 작은 식당의 주방과 같습니다. 이 비유를 통해 핵심 원리를 이해해 봅시다.

주방 구성 요소

  1. 요리사 (JavaScript Call Stack):
    • 주방에서 단 한 명의 요리사만 존재합니다. 이 요리사는 한 번에 하나의 요리(작업)만 할 수 있습니다.
    • 요리사는 주문을 받으면 즉시 요리를 시작하고, 요리가 끝나면 다음 주문을 받습니다. 모든 동기(Synchronous) 작업은 이 요리사에 의해 순차적으로 처리됩니다.
  2. 주방 보조 (Web API / Node.js C++ API):
    • 요리사 옆에는 여러 명의 주방 보조들이 있습니다. 이들은 요리사 대신 시간이 오래 걸리는 재료 준비(파일 읽기, 네트워크 요청, 타이머 설정 등)를 담당합니다.
    • 요리사는 오래 걸리는 재료 준비가 필요할 때, 주방 보조에게 일을 맡기고 바로 다음 요리를 시작합니다. 요리사는 재료 준비를 기다리느라 멈추지 않습니다.
  3. 주문표 (Callback Queue 또는 MacroTask Queue):
    • 주방 보조가 재료 준비를 마치면, 해당 요리(콜백 함수)를 일반 주문표에 올려놓습니다. 요리사는 이 주문표를 주기적으로 확인합니다.
  4. 긴급 주문표 (MicroTask Queue):
    • 일반 주문표와 별개로, 긴급 주문표가 있습니다. Promisethen(), catch(), finally() 같은 작업들은 이 긴급 주문표에 올라갑니다. 요리사는 일반 주문표보다 긴급 주문표를 항상 먼저 확인합니다.
  5. 매니저 (Event Loop):
    • 주방 전체를 감독하는 매니저가 있습니다. 매니저는 요리사가 한가해지면(Call Stack이 비면), 재빨리 긴급 주문표부터 확인하여 요리사에게 다음 요리를 전달합니다.
    • 긴급 주문표가 완전히 비면, 그제야 일반 주문표에서 다음 요리 하나를 꺼내 요리사에게 전달합니다. 이 과정은 요리사가 계속 바쁘게 움직이도록 반복됩니다.

동작 과정 다이어그램 (개념적 설명)

  1. 초기 상태: 요리사는 main() 함수라는 첫 번째 주문을 받고 요리를 시작합니다.
  2. 동기 작업 처리: 요리사는 console.log() 같은 간단한 요리를 즉시 처리하고, Call Stack에서 제거합니다.
  3. 비동기 작업 위임: setTimeout()이나 fetch() 같은 오래 걸리는 요리 주문이 들어오면, 요리사는 해당 작업을 Web API 또는 Node.js C++ API에 있는 주방 보조에게 맡깁니다. 그리고 이 요리의 완성 후 처리할 콜백 함수는 기억해 둡니다. 요리사는 즉시 Call Stack에서 이 작업을 제거하고 다음 동기 작업을 계속 처리합니다.
  4. 비동기 작업 완료: 주방 보조가 맡은 작업을 완료하면, 미리 기억해둔 콜백 함수Callback Queue (또는 Promise 같은 경우 MicroTask Queue)에 추가합니다.
  5. 매니저 (Event Loop)의 역할:
    • 매니저는 요리사가 현재 요리 중인지(Call Stack이 비어있는지) 계속 주시합니다.
    • 요리사가 한가해지면(Call Stack이 비면), 매니저는 가장 먼저 MicroTask Queue를 확인하여 그 안에 있는 모든 콜백 함수를 하나씩 요리사에게 전달합니다. 요리사는 이 긴급 주문들을 모두 처리합니다.
    • MicroTask Queue가 완전히 비워지면, 매니저는 Callback Queue (MacroTask Queue)에서 하나의 콜백 함수만 꺼내 요리사에게 전달합니다.
  6. 반복: 요리사는 전달받은 콜백 함수를 처리하고, 다시 매니저는 요리사가 한가해지면 위의 5번 과정을 반복합니다.

이러한 메커니즘 덕분에 JavaScript는 단일 스레드임에도 불구하고, I/O 작업이나 타이머 대기 시간 동안 UI가 멈추거나 서버가 블로킹되지 않고 다른 작업을 처리할 수 있게 됩니다.

3. 코드 예제: 이벤트 루프 동작 시뮬레이션

아래 예제 코드를 통해 이벤트 루프가 실제로 어떻게 작동하는지 예측해 봅시다.

예제 1: setTimeoutPromise의 실행 순서

이 예제는 setTimeoutPromise가 이벤트 루프 내에서 어떻게 다른 우선순위를 가지는지 보여줍니다.

console.log('Start'); // 1. 동기 작업: Call Stack에 즉시 실행

// 2. 비동기 작업: Web API로 넘어가 타이머 시작. 0ms 후 MacroTask Queue로 이동.
setTimeout(() => {
  console.log('setTimeout callback (MacroTask)'); // 4. MacroTask Queue에서 가져와 실행
}, 0);

// 3. 비동기 작업: Promise는 즉시 resolve되고, then() 내부 콜백은 MicroTask Queue로 이동.
Promise.resolve().then(() => {
  console.log('Promise resolve then (MicroTask)'); // 3. MicroTask Queue에서 가져와 실행 (MacroTask보다 우선)
});

console.log('End'); // 2. 동기 작업: Call Stack에 즉시 실행

예측되는 출력 순서:

  1. Start
  2. End
  3. Promise resolve then (MicroTask)
  4. setTimeout callback (MacroTask)

설명:

  • console.log('Start')console.log('End')는 동기 작업이므로 Call Stack에 바로 올라가 실행됩니다.
  • setTimeout(() => {...}, 0)은 Web API로 넘어가 타이머를 시작하고, 0ms가 지나면 콜백 함수가 MacroTask Queue로 들어갑니다.
  • Promise.resolve().then(() => {...})는 즉시 resolve되며, then() 안의 콜백 함수는 MicroTask Queue로 들어갑니다.
  • Call Stack이 StartEnd를 모두 실행하여 비워지면, 이벤트 루프가 작동합니다.
  • 이벤트 루프는 MicroTask Queue를 먼저 확인하여 모든 작업을 실행합니다. 따라서 Promise resolve thensetTimeout보다 먼저 실행됩니다.
  • MicroTask Queue가 비워지면, 이벤트 루프는 MacroTask Queue에서 setTimeout 콜백을 가져와 실행합니다.

예제 2: 블로킹 코드와 비동기 I/O의 상호작용 (Node.js 환경)

이 예제는 Node.js 환경에서 CPU를 많이 사용하는 동기 작업이 어떻게 이벤트 루프를 블로킹하는지, 그리고 비동기 I/O 작업이 어떻게 처리되는지 보여줍니다. large_example.txt 파일은 미리 생성하여 충분히 큰 내용(예: 1MB 이상)을 넣어두면 좋습니다.

const fs = require('fs'); // Node.js 파일 시스템 모듈

console.log('Script start'); // 1. 동기 작업

// 2. 비동기 파일 읽기: Node.js의 C++ API(libuv)로 I/O 작업 위임.
// 파일 읽기가 완료되면 콜백 함수는 MacroTask Queue로 이동.
fs.readFile('./large_example.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  console.log('Async file read complete (MacroTask)'); // 5. MacroTask Queue에서 가져와 실행
});

// 3. Promise를 이용한 비동기 작업: 콜백은 MicroTask Queue로 이동.
Promise.resolve().then(() => {
  console.log('Promise microtask executed (MicroTask)'); // 4. MicroTask Queue에서 가져와 실행
});

// 4. CPU를 많이 사용하는 동기 작업: Call Stack을 블로킹함.
function heavyComputation() {