프론트엔드

브라우저와 Node.js의 비동기 처리와 이벤트 루프

고은수 2025. 1. 31. 14:31

브라우저

자바스크립트는 싱글 스레드 언어이다. 만약 DOM에 여러 스레드가 접근할 수 있다면 어떻게 동작할지 예측하기가 매우 어려울 것이다. 예를 들어, 하나의 버튼을 여러 스레드가 동시에 삭제하고 생성하려 한다면 어떤 결과가 나올지 예측하기가 매우 어렵지 않을까? 그래서 자바스크립트는 하나의 실행 컨텍스트 스택을 가지고, 코드가 평가되는 단계에서는 실행 컨텍스트가 스택에 push 되고, 해당 코드의 실행이 완료되면 스택에서 pop 되면서 작동한다.

그런데 이런 구조에서 오래 걸리는 작업(Network IO, setTimeout, ...)이 스택에 들어가면 어떻게 될까?

그 작업이 끝날때까지 다른 코드들이 실행되지 않고 계속 대기하게 되고 다른 작업들이 지연되어 사용자 인터페이스가 응답하지 않게 될 것이다.

그래서 브라우저는 이러한 오래 걸리는 작업들이 들어오게 되면 스택에 들어오자마자 pop 하고, 브라우저의 다른 곳에서 병렬적으로 처리하도록 한다. 이러한 브라우저의 기능들을 WebAPI라고 하고 각 기능별로 브라우저 엔진의 네이티브 코드(C++)에서 각각 독립적인 스레드에서 실행된다.(사진에서는 편의상 통합된 하나의 WebAPI라는 영역에서 실행되는 것처럼 되어있지만 아님) 크롬에서 WebAPI의 몇 가지 예시를 들어보면,

  • 네트워크 요청 (fetch, XMLHttpRequest) : 브라우저의 네트워크 스택으로 요청 전달. 네트워크 서비스 프로세스에서 처리.
  • 타이머 (setTimeout, setInterval) : 브라우저의 타이머 시스템으로 전달. TaskRunner와 MessageLoop 시스템에서 처리.
  • DOM 이벤트 : 브라우저의 이벤트 시스템으로 전달. Blink 엔진의 EventHandler에서 처리.

등이 있다.

이 오래 걸리는 코드들이 네트워크 응답이 도착했거나, 타이머가 만료되었거나, 이벤트가 발생하거나 하면 그 이후 작업들을 실행하기 위해 실행 컨텍스트 스택에 넣어야 한다. 그러나 이 작업들은 바로 실행 컨텍스트 스택으로 가지 않고, 먼저 태스크 큐(콜백 큐 라고도 함)에 들어간다.

이때 이벤트 루프가 계속해서 두 가지를 검사한다.

  1. 실행 컨텍스트 스택이 비어있는지
  2. 태스크 큐에 처리할 작업이 남아있는지

이벤트 루프가 스택과 큐를 계속 째려보다가 실행 컨텍스트 스택이 비어있고, 태스크 큐에 처리할 작업이 있다면, 이벤트 루프는 큐에서 가장 오래된 작업을 꺼내서 스택에 넣는다. 만약 스택에서 오래 걸리는 작업을 계속해서 수행하느라 큐에서 스택으로 작업을 못 넣으면?

응답 없는 페이지가 뜨게 된다. 그러니까 복잡한 연산을 해서 스택을 바쁘게 한다거나 버튼 하나에 이벤트 리스너를 무지 많이 달아서 이벤트 큐를 바쁘게 한다거나 하지 말자. 복잡한 연산은 Web Worker를 활용하면 별도의 스레드에서 다른 스택에 맡길 수 있다.
그런데 만약 네트워크 요청을 보내고 곧바로 setTimeout을 사용하여 몇 초 후에 알림 메시지를 띄우는 동작을 한다고 생각해보자. 그럼 네트워크 응답도 몇초동안 처리되지 못하고 큐에서 대기 할 것이다. 그럼 어떻게 해야할까?

맞다. 사실 큐는 하나 더 있다. 바로 마이크로태스크 큐이다. 이 큐에는Promisethen, catch, finally 콜백, await/await의 비동기 작업이 들어간다. 마이크로태스크 큐는 태스크 큐보다 우선순위가 높은데, 스택이 비어있으면 이벤트 루프는 먼저 마이크로태스크 큐를 확인하고 모든 마이크로태스크를 처리한 후 태스크 큐의 작업을 처리한다. 즉, Promise와 관련된 작업은 다른 비동기 작업보다 먼저 실행된다. 마이크로태스크 큐는 태스크 큐보다 우선순위가 높기 때문에, 마이크로태스크 큐에 작업이 계속 추가되면 태스크 큐의 작업이 지연될 수 있다. 이러한 구조로 인해, Javascript싱글 스레드 언어이지만, 브라우저멀티 스레드로 동작하며, 비동기 작업을 효율적으로 처리할 수 있다.

Node.js

Node.js는 Javascript를 브라우저 밖에서 사용할 수 있도록 해 준다. 왜 만들어졌을까 생각해보면, 하나의 언어로 프론트엔드와 백엔드에 모두 사용할 수 있는 것도 있고 V8이 성능이 좋은것도 있지만(IE를 생각해 보면..), blocking I/O + 멀티스레드 모델인 기존의 Apache나 Java의 대신 non-blocking I/O + 단일 스레드이벤트 기반 모델로 적은 리소스로도 많은 동시 연결을 처리하여 I/O 집약적 작업에 적합하기 때문일 것이다.

blocking vs non-blocking, sync vs async

I/O는 스토리지에서 데이터를 가져오는걸 말한다. 이건 CPU에서 하는 게 아니라 디스크까지 가야 하기 때문에 매우 느린 작업이다. 먼저 blocking + sync 의 경우를 생각해 보자. 사용자 앱이 커널에 IO 요청을 보내면 커널이 앱을 재우고 디스크에 데이터를 요청한다. 디스크가 데이터를 로드했다면 인터럽트를 보내고 커널은 앱을 다시 깨운다. 이 재우고 깨우는 과정 중에 컨텍스트 스위칭이 발생할 수 있으며, 데이터를 기다리는 동안 CPU는 놀고 있기 때문에 비효율적이다. non-blocking + async는 데이터를 요청한 후, 기다리지 않고 하던 일을 계속 하게 한다. 그런데 이때 데이터를 다 가져왔다는 걸 어떻게 알 수 있을까? blocking + sync에서는 그냥 함수의 반환으로 주면 되는데 non-blocking + async 에서는 데이터를 가져왔을 때 이미 다른 코드가 실행 중이기 때문에 이 실행 중인 코드를 정지시키고 데이터 처리를 하는 코드를 실행할 수 없다. Javascript는 이 문제를 위에 설명한 이벤트 루프로 해결했다. 큐에 콜백함수를 넣어두고, 스택이 비어있으면 이벤트 루프가 콜백함수를 스택에 넣어준다. 이 구조는 여러 스레드가 서로 큐에서 가져가려고 하면 경쟁상태 문제가 생기므로 단순한 만큼 멀티코어를 활용하기 어려운 단점이 있다.

blocking + async : 작업을 기다리느라 블로킹되어 있으면서도 결과는 나중에 비동기로 받음. 비효율적이라 거의 사용되지 않는다.
non-blocking + sync : 계속 다른일을 하면서 주기적으로 완료 여부를 확인하는 폴링을 통해 동작한다.

코루틴

멀티코어에서는 이걸 어떻게 해결할까? 디스패처가 스레드 풀을 관리하고, 데이터가 도착하면 놀고 있는 스레드에게 데이터를 처리하도록 던져주면 될 것 같다. 그런데 모든 스레드가 일을 하고 있으면 작업이 지연되고 스레드 생성 및 관리에도 리소스가 많이 소모된다. 그래서 실행을 일시 중지하고 재개할 수 있는 함수인 코루틴이 도입되었다. 코루틴의 종류에는 두 가지가 있는데 : 

  1. 스택리스 코루틴
    • 어떤 위치에서 코드를 정지할 수 있는지 코드상에 명시적으로 표시함
    • 가볍고 효율적이지만 함수가 일시정지를 지원하지 않으면 함수가 끝날 때까지 계속 대기해야 한다.
    • Kotlin
  2. 스택풀 코루틴
    • 자신만의 콜스택을 가지고 있어서 함수의 어떤 위치에서는 현재 실행 상태를 저장하고 일시정지 시킬 수 있음
    • 그래서 데이터가 오면 즉시 기존 코드를 정지시키고 데이터 처리를 할 수 있음
    • Go

Springblocking + 멀티스레드 구조로 CPU 집약적인 작업에 적합하여 대규모 애플리케이션에 더 적합하고, Node.jsnon-blocking + 단일 스레드 구조로 I/O 집약적 작업과 간단한 설정으로 간단한 애플리케이션에 더 적합하다.

Node.js 구조

좀 옆으로 샜는데 아무튼 Node.js도 브라우저랑 비슷한 구조이다. 다만 브라우저에서는 오래 걸리는 네트워크 요청, 이벤트 리스너, setTimeout 등이 WebAPI를 통해 처리된 반면 Node.js는 네트워크 요청, DB 쿼리, 파일시스템 제어 등이 libuv라는 C++ 라이브러리를 통해 처리된다. 그리고 Worker Thread를 통해 멀티 스레드 작업도 지원하여 CPU 집약적인 작업을 병렬로 처리할 수 있다. (사실 Spring도 Reactive Programming을 통해 non-blocking I/O를 지원한다.)

이벤트 루프의 구조도 좀 더 복잡하며 각 단계에서 특정 작업이 처리된다.

각각의 박스는 특정 작업을 수행하기 위한 페이즈들을 의미하고, 각 페이즈는 각자 하나씩 큐를 가지고 있으며, 자바스크립트의 실행은 이 페이즈들 중 Idle, prepare 페이즈를 제외한 어느 단계에서나 할 수 있다. 큐가 좀 많아져서 그렇지 브라우저랑 똑같다. 각 페이즈에서 해당 페이즈의 큐에 있는 작업을 하나씩 꺼내서 스택에 올려 실행한다. nextTickQueuemicroTaskQueue는 이벤트 루프의 일부가 아니며, 가장 높은 우선순위로 실행된다. 얘네는 현재 실행 중인 이벤트 루프의 단계와 상관없이, 현재 작업이 완료되면 즉시 실행되며 이벤트 루프의 단계 사이에서도 실행될 수 있다. 예를 들어, 어떤 페이즈에서 어떤 작업이 완료되면, 해당 콜백이 해당 페이즈의 큐에 추가된다. 이벤트 루프가 그 작업을 스택에 올려보내 실행하기 전에, nextTickQueuemicroTaskQueue에 있는 작업을 먼저 실행하고, 이후에 해당 큐의 작업을 처리한다. 각 페이즈들을 살펴보자.

  • Timer : Timer Phase는 이벤트 루프의 시작을 알리는 페이즈이다. setTimeout이나 setInterval같은 타이머들의 콜백을 저장한다. 
  • Pending I/O callbacks : 이벤트 루프의 pending_queue에 들어있는 콜백들을 실행한다. 이 큐에 들어와 있는 콜백들은 현재 돌고 있는 루프 이전에 한 작업에서 이미 큐에 들어와 있던 콜백들이다. TCP 핸들러 콜백 함수에서 파일에 뭔가를 썼다면, TCP 통신이 끝나고 파일 쓰기도 끝나고 나서 파일 쓰기의 콜백이 이 큐에 들어온다. 또한 에러 핸들러 콜백도 이 큐로 들어오게 된다.
  • Idle, Prepare : 이름은 Idle Phase이지만 이 페이즈는 매 tick마다 실행된다. Prepare Phase또한 매 폴링마다 실행된다. 이 두 개의 페이즈는 이벤트 루프와 관련이 있다기보다는 Node.js의 내부적인 관리를 위한 것이기 때문에 패스한다.
  • Poll : 이 페이즈는 새로운 수신 커넥션(새로운 소켓 설정 등)과 데이터(파일 읽기 등)를 허용한다. 여기서 일어나는 일을 크게 두 가지로 나눌 수 있는데, 
    • 만약 watch_queue(poll phase가 가지고 있는 큐)가 비어있지 않으면, 큐가 비거나 시스템 최대 실행 한도에 다다를 때까지 동기적으로 모든 콜백을 실행한다.
    • watch_queue가 비어있다면, 곧바로 다음 페이즈로 넘어가는 것이 아니라 약간 대기시간을 가진다. 기다리는 시간은 여러 요인에 따라 달라지는데, 이 부분은 밑에서 따로 다룬다.
  • Check : 이 페이즈는 setImmediate의 콜백만을 위한 페이즈이다. 이거도 밑에서 자세히 다룬다.
  • Close : socket.on('close', () => {})과 같은 close 이벤트 타입의 핸들러들이 여기서 처리된다.
  • nextTickQueue, microTaskQueue : nextTickQueueprocess.nextTick() API의 콜백들을 가지고 있으며 microTaskQueue는 Resolve 된 프로미스의 콜백을 가지고 있다. 위에서 설명했던 대로 이벤트 루프에 포함된, 즉 libuv에 포함된 것이 아니라 Node.js에 포함되어 있고 현재 실행되고 있는 작업이 끝나자마자 호출된다. 그리고 nextTickQueue에 있는 콜백이 먼저 실행된다.
libuv는 윈도우나 리눅스 커널을 추상화해서 wrapping 하고 있는 구조이다. 즉 커널에서 어떤 비동기 작업들을 지원해 주는지 알고 있기 때문에 커널을 사용하여 처리할 수 있는 비동기 작업을 발견하면 바로 커널로 넘겨버린다. 이후 작업들이 종료되어 커널로부터 시스템 콜을 받으면 이벤트 루프에 콜백을 등록한다. 만약 커널이 지원하지 않는 작업일 경우 별도의 스레드에 작업을 던져서 처리한다.

이벤트 루프의 작업 흐름

node script.js를 콘솔에서 실행하면, 이벤트 루프 바깥에서 script.js를 실행한다. 이벤트 루프를 돌릴 필요가 없다면 process.on('exit', () => {})를 실행하고 이벤트 루프를 종료한다. 이벤트 루프를 돌려야 할 상황이라면 첫 번째 페이즈인 Timer phase부터 실행한다.

Timer phase

이벤트 루프가 Timer phase에 들어가면 오름차순으로 저장된 타이머들을 하나씩 확인한다. now >= registeredTime + delta 같은 조건을 통해 타이머의 콜백을 실행할 시간이 되었는지 검사하고 조건을 만족하는 타이머는 큐에 추가된다. 조건을 만족하지 않는 타이머를 만나면 탐색을 종료한다. 힙은 오름차순으로 정렬되어 있으므로 이후 타이머들은 확인할 필요가 없다. 이후 이벤트 루프는 큐에 있는 콜백을 꺼내어 스택에 올려서 실행한다. 페이즈는 시스템의 실행 한도에도 영향을 받고 있으므로, 실행되어야 하는 타이머가 아직 남아있다고 하더라도 시스템 실행 한도에 도달한다면 바로 다음 페이즈로 넘어가게 된다.

Pending I/O phase

타임 페이즈가 종료된 후, 이벤트 루프는 pending I/O 페이즈에 진입한다. pending_queue를 확인하여 이전 작업들의 콜백이 실행 대기 중인지 확인하고, 있다면 큐가 비거나 실행 한도 초과에 도달할 때까지 콜백들을 스택으로 올려보내 실행시킨다. 이 과정이 종료되면 이벤트 루프는 Idle Handler phase로 이동하게 된 후 내부 처리를 위한 Prepare phase를 거쳐 Poll phase에 도달한다.

Poll phase

이 페이즈는 폴링 하는 단계이다. 즉 I/O 이벤트가 완료되었는지 주기적으로 확인하고, 완료된 이벤트의 콜백을 실행하는 단계이다. watcher_queue 내부에 파일 읽기의 응답 콜백, HTTP 응답 콜백 같이 수행해야 할 작업들이 있다면 실행하고 마찬가지로 큐가 비거나 실행 한도 초과에 다다를 때까지 계속된다. 만약 더 이상 콜백들을 실행할 수 없는 상태가 된다면 check_queue, pending_queue, closing_callback_queue에 해야 할 작업들이 있는지 검사하고 없다면 계속 여기서 대기하게 된다. 무한정 대기하는 건 아니고 타이머 힙에서 첫 번째 타이머가 실행 가능한 상태라면 그 타이머의 딜레이 시간만큼만 대기한다.

Check phase

이 페이즈는 큐가 비거나 시스템 실행 한도 초과에 도달할 때까지 setImmediate() API의 콜백을 실행한다.

Close callback

Close callback에서는 closedestory 콜백 타입들을 관리한다. 이벤트 루프가 Close callback들과 함께 종료되고 나면 다음에 돌아야 할 루프가 있는지 다시 체크한다. 없다면 이벤트 루프는 그대로 종료되고, 남아있다면 다시 Timer Phase부터 시작한다.

nextTickQueue & microTaskQueue

이 두 큐에 들어있는 콜백들은 어떤 페이즈에서 다음 페이즈로 넘어가기 전에 자신이 가지고 있는 콜백들을 최대한 빨리 실행해야 하는 역할을 맡고 있다. 이때 페이즈에서 다른 페이즈로 넘어가는 작업을 tick이라고 한다. 이 두 큐는 시스템 실행 한도 초과에 영향을 받지 않기 때문에 항상 완전히 비워질 때까지 콜백들을 실행한다. nextTickQueuemicroTaskQueue보다 높은 우선순위를 가진다. 

Thread pool

파일 읽기, DNS Lookup 등 OS 커널이 비동기 API를 지원하지 않는 작업들의 경우에는 별도의 스레드 풀을 사용하게 되는데, 이때 기본 값으로 4개의 스레드를 사용하도록 되어있다. uv_threadpool 환경 변수를 사용하면 최대 128개까지 스레드 개수를 늘릴 수도 있다.

Node.js + MySQL vs Node.js + MongoDB

Node.js는 non-blocking + async 하게 작동하는데 MySQL을 사용하면 block 되는 상황이 발생해서 그 궁합이 좋지 않다고 한다. 한번 알아보자.

MySQL : InnoDB 스토리지 엔진

  • 데이터의 ACID 속성을 보장하기 위해 매우 안전하고 체계적으로 설계됨
  • 데이터 변경 시 redo log buffer에 먼저 기록하고, 이를 디스크의 redo log file동기적으로 기록함
  • 로그 버퍼에 내용을 디스크의 redo log file에 기록할 때 디스크 I/O가 발생하고 이 과정에서 블로킹이 발생함. 즉 디스크 I/O가 완료될 때까지 대기해야 함.

MongoDB : WiredTiger 스토리지 엔진

  • 성능 최적화에 중점을 두고 설계
  • 메모리 매핑 파일을 사용하여 파일 I/O를 운영체제의 가상 메모리 시스템에 위임함. 즉 데이터를 메모리에 먼저 기록하고 운영체제가 적절한 시점에 디스크에 씀
  • 주기적으로 체크포인트를 생성하여 메모리의 데이터를 디스크와 동기화하는데, 이 과정에서 디스크 I/O가 발생하지만 실시간으로 디스크 I/O가 발생하지 않음

db에 대한 지식이 부족하기 때문에 간단하게 설명하면, MySQL은 데이터가 들어올 때마다 디스크에 접근해야 하고, 그 과정에서 다른 작업은 멈추게 된다(블로킹). MongoDB는 데이터를 먼저 메모리에 저장해 두고, 나중에 주기적으로 디스크에 정리해서 저장한다. 그래서 MySQL은 안전하지만 조금 느리고, MongoDB는 빠르지만 갑자기 시스템이 다운되면 최근 데이터가 날아가게 된다.
Node.js + MySQL 시나리오를 살펴보자

  1. HTTP 요청
    • 클라이언트가 서버에 HTTP 요청을 보낸다.
    • Node.js는 connection.query()를 호출한다. 이 함수는 MySQL에 쿼리를 보내 데이터를 요청한다.
  2. Thread Pool에서의 처리
    • MySQL 작업은 OS가 비동기로 지원하지 않는 작업이기 때문에 스레드 풀로 전달된다.
    • 한 스레드를 할당받아서 실행된다.
    • 이 스레드에서 MySQL 서버와 통신하고, 디스크 I/O가 발생한다. (이 과정에서 블로킹이 발생함)
  3. Poll phase에서 대기
    • 이벤트 루프는 Poll phase에서 스레드 풀의 작업 완료를 기다린다.
    • 이때 watcher_queue에서 MySQL 응답을 기다린다.
    • 다른 timer나 immediate 작업이 없다면 Poll phase에서 대기한다.
  4. MySQL 작업 후
    • 완료된 작업의 콜백은 Poll Phase의 watcher_queue에 들어간다.
    • 이벤트 루프는 이 콜백을 실행하여 응답을 보낸다.

이 과정에서 스레드 풀의 크기(기본 4개) 보다 많은 수의 쿼리가 실행되게 되면 나머지 쿼리는 대기해야 하고, 트랜잭션이 오래 걸리면 다른 쿼리들이 대기해야 한다. 또한 디스크 I/O가 동기적으로 발생하기 때문에 이 과정에서 블로킹이 발생할 수 있다. 근데 사실 연결을 미리 만들어 놓는 connection pool을 사용하고, 스레드 풀의 크기를 늘리고, 여러 쿼리를 하나의 트랜잭션으로 묶어서 최적화하고, mysql2와 같은 비동기 쿼리 라이브러리를 사용하면 어느 정도 해결할 수 있다. 물론 mongoDB도 마찬가지로 드라이버에 따라 동기 방식도 지원하고, Write Concern 설정과 Journal 활성화를 통해 데이터 지속성을 보장할 수 있다.
아래 참고의 포스팅을 보면 실제 테스트 결과를 확인해 볼 수 있다.

참고

https://evan-moon.github.io/2019/08/01/nodejs-event-loop-workflow/

 

로우 레벨로 살펴보는 Node.js 이벤트 루프

1년 전, 필자는 setImmediate & process.nextTick의 차이점에 대해 설명하면서 Node.js의 이벤트 루프 구조에 대해 살짝 언급한 적이 있었다. 놀랍게도 독자 분들은 원래 설명하려고 했던 부분보다 이벤트

evan-moon.github.io

https://sungbin.dev/post/javascript+mysql%EC%9D%80%20async+block%20%ED%95%98%EA%B2%8C%20%EC%9E%91%EB%8F%99%ED%95%A0%EA%B9%8C

 

JavaScript+MySQL은 Async+Block 하게 작동할까

> Node.JS에서 MySQL을 사용하면, async하게 작동하는 Node.JS에서 block 되는 상황이 발생해서 그 궁합이 좋지 않다는 이야기를 듣고, 어떤면에서 그런...

sungbin.dev