Node.js 이벤트 루프와 비동기 처리
Node.js 이벤트 루프와 비동기 처리
Node.js가 기본적으로 단일 스레드인데도 파일 읽기나 네트워크 요청처럼 시간이 걸리는 작업을 하는 동안 다른 코드가 멈추지 않는 이유가 바로 이벤트 루프예요. 할 수 있을 때마다 작업을 OS 커널에 넘겨서, 결과가 나오면 콜백으로 다시 받아 처리하는 구조죠. 이 글에서는 이벤트 루프가 어떤 단계를 거쳐 도는지, 그리고 타이머·setImmediate·process.nextTick이 서로 어떻게 다른지 살펴볼게요.
출처: The Node.js Event Loop — Node.js 공식 문서
이벤트 루프가 뭔가요
Node.js는 기본적으로 자바스크립트 스레드 하나만 써요. 그런데도 비동기 I/O를 처리할 수 있는 건, 실행을 OS 커널에 맡길 수 있기 때문이에요. 대부분의 현대 커널은 멀티스레드라서 여러 작업을 백그라운드에서 동시에 처리할 수 있고요. 작업이 끝나면 커널이 Node.js에 알려주고, 그 시점에 해당 콜백이 poll 큐에 들어가서 나중에 실행돼요. 이렇게 '대신 처리해 줄 작업을 넘기고, 준비된 작업을 순서대로 꺼내 실행하는' 반복 구조가 이벤트 루프예요.
Node.js가 시작되면 이벤트 루프를 초기화하고 입력 스크립트를 처리한 뒤 루프를 돌기 시작해요. 루프는 여러 단계(phase)로 나뉘고, 각 단계는 콜백을 담는 FIFO 큐를 갖고 있어요. 루프가 특정 단계에 들어가면 그 단계의 큐가 비워질 때까지 콜백을 실행하지요.
단계(phase) 개요
각 단계가 하는 일을 짧게 정리하면 이래요.
- timers: setTimeout, setInterval로 예약한 콜백을 실행해요.
- pending callbacks: 이전 루프 반복에서 미뤄둔 I/O 콜백(예: 일부 TCP 오류)을 실행해요.
- idle, prepare: Node.js 내부에서만 쓰는 단계예요.
- poll: 새 I/O 이벤트를 가져오고 I/O 관련 콜백(타이머·setImmediate로 예약된 것 제외)을 실행해요. 상황에 따라 여기서 블로킹되기도 해요.
- check: setImmediate의 콜백이 실행되는 곳이에요.
- close callbacks: socket.on('close', ...) 같은 종료 콜백을 처리해요.
루프가 한 번 돌 때마다 Node.js는 처리 중인 비동기 I/O나 타이머가 남아 있는지 확인하고, 아무것도 없으면 깨끗하게 종료돼요.
참고로 libuv 1.45.0부터(Node.js 20) 타이머는 각 이벤트 루프 반복에서 poll 단계 이후에 실행돼요. 예전 버전은 poll 이전에 실행됐고요. 하위 호환을 위해 libuv 1.45.0은 이벤트 루프 진입 전에 타이머를 한 번은 먼저 실행해 줘요.
poll 단계가 타이머 실행 시점을 정해요
poll 단계의 역할은 두 가지예요. 예약된 타이머까지 남은 시간을 고려해 얼마나 오래 I/O를 기다릴지 계산하는 것, 그리고 준비된 poll 큐의 이벤트를 처리하는 거예요.
예를 하나 들어볼게요. 100ms 후 실행될 타이머를 만들고 파일 읽기를 시작했는데, 그 파일 읽기가 95ms 걸린다고 해볼게요. 이벤트 루프가 poll 단계에 들어왔을 때 큐는 비어 있어요(아직 readFile이 끝나지 않았죠). 그러면 가장 가까운 타이머의 임계값까지 남은 95ms 동안 기다려요. 그 사이 readFile이 끝나고, 10ms 걸리는 콜백이 poll 큐에 들어가서 실행돼요. 콜백이 끝나면 다시 큐가 비면서, 이벤트 루프는 '가장 가까운 타이머의 임계값에 도달했네'라고 판단하고 timers 단계로 되돌아가 타이머 콜백을 실행하게 돼요.
poll 단계가 이벤트 루프를 굶기지 않도록, libuv는 시스템에 따라 달라지는 상한을 두고 그 이상은 계속 폴링하지 않아요.
setImmediate와 setTimeout, 뭐가 다르죠
setImmediate와 setTimeout은 비슷해 보이지만 서로 다르게 동작해요.
- setImmediate: 현재 poll 단계가 끝난 직후에 실행되도록 설계된 콜백이에요.
- setTimeout: 최소한 ms만큼 시간이 흐른 뒤에 실행되는 콜백을 예약해요.
이름만 보면 setImmediate가 더 '바로' 실행될 것 같지만, 정확한 실행 시점은 어디서 호출하느냐에 따라 달라질 수 있어요.
process.nextTick은 특별해요
process.nextTick은 사실 이벤트 루프의 단계가 아니에요. nextTickQueue는 현재 작업이 끝나면 처리되는데, 이 때문에 좀 헷갈리기 쉬워요. 정리하면 이래요.
- process.nextTick: 같은 단계 안에서 즉시 실행돼요.
- setImmediate: 이벤트 루프의 다음 반복(틱)에 실행돼요.
이름을 서로 바꿔야 할 것 같죠. process.nextTick이 setImmediate보다 더 즉각적이니까요. 그런데 이건 오래된 역사적 이유 때문이라 쉽게 바뀌지 않아요. 이름을 바꾸면 npm의 많은 패키지가 깨질 거예요.
process.nextTick을 남용하면 문제가 생겨요. 한 단계에서 process.nextTick 콜백을 재귀적으로 계속 호출하면 이벤트 루프가 poll 단계에 도달하지 못해서 I/O를 굶겨버릴 수 있어요.
그런데 왜 이렇게 만들었을까요. 설계 철학 중 하나가 'API는 굳이 비동기일 필요가 없어도 항상 비동기처럼 동작해야 한다'는 거예요. 예를 들어 어떤 함수가 비동기 시그니처인데 사실은 동기로 콜백을 호출한다면, 콜백이 실행될 때 아직 초기화되지 않은 변수를 참조하는 문제가 생겨요. 이때 콜백을 process.nextTick으로 감싸면 스크립트가 끝까지 실행된 뒤 콜백이 호출되니까, 변수·함수 초기화가 끝난 다음 안전하게 실행돼요.
더 알아보기
- 이벤트 루프를 구현하는 라이브러리 libuv — libuv.org
- 타이머·폴링 API 참조 — Node.js timers 문서
- process.nextTick 관련 내용 — Node.js process 문서