웹 페이지를 구동하는 JavaScript 이벤트 루프는 Node.js 서버를 구동하는 이벤트 루프와 매우 다르게 동작하며, 주의하지 않으면 UI가 멈추거나 I/O가 막힐 수 있습니다. 두 환경의 차이점을 아는 것은 두 환경 모두에서 실행되는 비동기 코드를 작성하는 사람에게 필수적입니다.

왜 이 차이가 중요한가

이벤트 루프는 ECMAScript 명세에 정의되어 있지 않으며, 호스트(host) 환경에 존재합니다. 브라우저는 프레임을 렌더링하는 동안 페이지의 응답성을 유지해야 하는 반면, Node.js는 논블로킹(non-blocking) I/O를 중심으로 구축되었습니다. 한 호스트에서 작동하는 패턴을 다른 호스트에 혼용하면 재현하기 어려운 버그가 발생할 수 있습니다. 예를 들어, 긴 프로미스(promise) 체인은 브라우저의 리페인트(repaint)를 지연시킬 수 있고, 제어되지 않은 process.nextTick 루프는 Node가 I/O 단계에 도달하는 것을 방해할 수 있습니다.

브라우저의 턴 기반 루프

브라우저에서 루프는 태스크 실행, 마이크로태스크 소진(draining), 렌더링을 교차하며 단일 사이클을 실행합니다:

  1. 하나의 매크로태스크(macrotask) 실행 (클릭 핸들러, setTimeout 등).
  2. 모든 마이크로태스크 소진 (프로미스, queueMicrotask).
  3. 프레임 렌더링 시점이 되면, 목표 60fps를 맞추기 위해 페인트(paint) 및 컴포지트(composite) 수행.
  4. 반복.

두 API는 개발자에게 이 사이클에 개입할 수 있는 명시적인 훅(hook)을 제공합니다:

  • requestAnimationFrame – 브라우저가 페인트하기 직전에 호출됩니다. 콜백이 현재 마이크로태스크 이후, 다음 프레임 이전에 실행되므로 애니메이션 작업에 적합합니다.
  • requestIdleCallback – 브라우저에 우선순위가 높은 작업이 없을 때 호출됩니다. 분석(analytics)이나 데이터 프리로딩(pre-loading)과 같이 영향이 적은 작업에 유용합니다.

주의할 점: 마이크로태스크 기아(starvation) 현상

브라우저는 렌더링을 하기 전에 마이크로태스크 큐를 비우기 때문에, 긴 프로미스 체인은 UI가 전혀 페인트되지 않도록 만들 수 있습니다. 콜 스택이 차단되는 것은 아니지만, 페이지가 렌더링 단계에 도달하지 못하게 되어 사용자에게는 화면이 멈춘 것처럼 느껴집니다.

Node의 libuv 기반 루프

Node.js는 루프 처리를 libuv에 위임합니다. libuv는 작업을 각각 고유한 큐를 가진 별도의 단계(phase)로 나눕니다:

  1. TimerssetTimeoutsetInterval의 콜백.
  2. Pending callbacks – OS 수준에서 이미 완료된 지연된 I/O 콜백.
  3. Poll – 새로운 I/O 이벤트(파일 읽기, 네트워크 데이터)를 가져옵니다.
  4. ChecksetImmediate 콜백을 실행합니다.
  5. Close callbacks – 소켓이나 핸들이 닫힐 때 실행됩니다.

이 단계 순서 외부에 존재하는 두 가지 구조가 있습니다:

  • process.nextTick – 현재 작업이 완료된 직후, 마이크로태스크 큐보다 먼저 실행됩니다.

주의할 점: I/O 기아(starvation) 현상

함수가 제어권을 넘기지 않고 반복적으로 process.nextTick을 예약하면, Node는 "next-tick" 단계를 넘어가지 못합니다. 네트워크 요청, 파일 읽기, 타이머 등이 대기 상태로 머물게 되어 서버 측 지연 시간(latency)이 급증하거나 아예 멈춰버릴 수 있습니다.

실무에서의 setImmediate vs. setTimeout

둘 다 다음 반복을 위해 콜백을 예약하지만, 상대적인 순서는 호출되는 위치에 따라 달라집니다:

  • 최상위 코드(Top-level code) – 순서가 보장되지 않으며, 프로세스가 얼마나 빨리 시작되는지에 달려 있습니다.
  • I/O 콜백 내부 – 순서가 결정론적(deterministic)입니다: setImmediatesetTimeout(fn, 0)보다 먼저 실행됩니다. Poll 단계가 끝나면 libuv는 지연 시간이 0인 타임아웃을 위해 Timers 단계로 다시 들어가기 전에 Check 단계(setImmediate가 있는 곳)로 이동합니다.

이러한 미묘한 차이는 읽기가 완료된 직후 리소스를 정리하는 것과 같이 정확한 순서에 의존할 때 중요합니다.

한눈에 보는 주요 차이점

  • 목표: 브라우저는 시각적 업데이트를 우선시하며, Node는 I/O 준비 상태를 우선시합니다.
  • 렌더링 훅: requestAnimationFrame (브라우저 전용).
  • 단계별 훅: setImmediate (Node 전용, Check 단계에서 실행).
  • 고우선순위 큐: process.nextTick (Node 전용, 마이크로태스크 이전에 실행).
  • 기아(Starvation) 위험: 브라우저에서는 긴 프로미스 체인, Node에서는 무제한적인 process.nextTick.

다음에 주의 깊게 살펴볼 사항

두 환경 모두에서 실행되는 코드베이스(예: isomorphic 라이브러리)를 유지 관리한다면, 다음 상황을 점검하십시오:

  • 이벤트 루프에 제어권을 넘기지 않고 많은 프로미스를 체이닝하는 경우. await new Promise(r => setTimeout(r, 0))를 삽입하거나 브라우저에서는 requestIdleCallback을 사용하여 렌더러에게 기회를 주십시오.
  • 미룰 수 있는 작업에 process.nextTick을 사용하는 경우. "next-tick" 수준의 긴급함이 필요하지 않다면 setImmediate나 일반 프로미스를 선호하십시오.
  • setTimeout(fn, 0)setImmediate가 서로 교체 가능하다(interchangeable)고 가정하는 경우. 순서가 중요하다면 I/O 콜백 내부에서의 실행 순서를 테스트하십시오.

핵심 요약

이벤트 루프는 범용적인 JavaScript 기능이 아니라 호스트 환경에 특화된 스케줄러입니다. 브라우저는 렌더링을 루프의 일부로 포함시키며, Node는 I/O를 libuv 단계로 격리합니다. 우선순위 메커니즘(브라우저의 microtasks, Node의 process.nextTick)을 잘못 사용하면 각 환경이 지원하도록 설계된 시스템의 특정 부분을 고갈시킬 수 있습니다. 비동기 패턴을 호스트의 루프 모델에 맞추면, 페이지 프리징과 서버 블로킹을 모두 방지할 수 있습니다.