홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

NestJS 서버로 추적하는 이벤트 루프 — 이론에서 실전으로 (5편)

정기창·2026년 2월 28일

들어가며

1~4편에서 Node.js의 구조를 이론으로 배웠습니다. V8과 libuv의 역할, 콜 스택과 이벤트 루프, 6개 페이즈와 마이크로태스크 큐까지. 하지만 이론만으로는 "실제로 내 서버에서 어떻게 동작하는가?"라는 질문에 답하기 어렵습니다.

이번 글에서는 실제 NestJS 서버 코드를 따라가며, 이벤트 루프가 어떻게 동작하는지 추적합니다. 서버가 시작될 때 무슨 일이 벌어지는지, HTTP 요청 한 건이 어떤 여정을 거치는지, 그리고 왜 이 서버가 Node.js에 적합한지를 코드로 확인합니다.

서버 시작 — bootstrap() 추적

0단계: import 해석 — bootstrap() 호출 전에 일어나는 일

node main.js를 실행하면, V8 엔진이 초기화된 후 코드를 실행하기 전에 먼저 모든 import문을 해석합니다.

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import * as cookieParser from 'cookie-parser';
import * as express from 'express';
import { AppModule } from './app.module';
import { AppLogger } from './utils/logger';

이 import문들은 전부 동기입니다. 그리고 import { AppModule }이 핵심입니다. app.module.ts를 로드하면, 그 안의 import도 연쇄적으로 로드됩니다.

import AppModule
  → import MongooseModule, ConfigModule, ScheduleModule, ThrottlerModule...
  → import AuthModule, BlogPostsModule, SwimmingModule, SaasModule...
    → import BlogPostsController, BlogPostsService...
      → import CreateBlogPostDto, BlogPostSchema...

이 과정에서 @Module(), @Controller(), @Injectable() 같은 데코레이터가 실행됩니다. 하지만 이 데코레이터들은 메타데이터를 등록만 합니다. "이 모듈은 이 컨트롤러와 이 서비스가 필요하다"는 정보를 기록해두는 것이지, 실제로 인스턴스를 만들거나 DB에 연결하지는 않습니다.

node main.js
  ↓
① V8 초기화
  ↓
② import 해석 (동기)
  - 모든 모듈 파일 로드 (수십 개)
  - @Module, @Controller, @Injectable 데코레이터 실행
  - 메타데이터 등록 ("무엇이 필요한지" 기록만)
  - 아직 인스턴스 없음, DB 연결 없음
  ↓
③ bootstrap() 호출
  ↓
④ NestFactory.create(AppModule)  ← 여기서 비로소 실제 인스턴스 생성, DB 연결

②와 ④의 차이가 중요합니다. ②는 "설계도 읽기"이고, ④는 "설계도대로 건물 짓기"입니다. 설계도를 읽는 건 동기(콜 스택), 건물을 짓는 건 비동기(이벤트 루프)입니다.

1단계: bootstrap() 호출

import 해석이 끝나면 파일의 마지막 줄인 bootstrap()이 호출됩니다.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.use(express.json({ limit: '100mb' }));
  app.use(cookieParser());
  app.useGlobalPipes(new ValidationPipe({ ... }));
  app.enableCors({ ... });
  
  await app.listen(3000);
}
bootstrap();

겉보기엔 단순한 코드입니다. 하지만 이 안에서 이벤트 루프의 거의 모든 개념이 동작합니다.

2단계: NestFactory.create(AppModule) — 가장 많은 일이 벌어지는 한 줄

await NestFactory.create(AppModule)은 0단계에서 등록해둔 메타데이터를 읽고, 실제로 모든 것을 만듭니다.

작업처리 방식이벤트 루프 위치
ConfigModule 환경변수 로드동기 (메모리)콜 스택
DI 컨테이너 구성동기 (메모리)콜 스택
MongoDB 연결OS 비동기 API (TCP)Poll에서 연결 완료 콜백
MySQL 연결OS 비동기 API (TCP)Poll
Redis 연결OS 비동기 API (TCP)Poll

await이 붙어 있으니, 이 Promise가 resolve될 때까지 bootstrap()의 나머지 코드는 실행되지 않습니다. 그 사이에 이벤트 루프가 돌면서 DB 연결 등의 I/O 콜백을 처리합니다.

MongoDB 연결 — DNS 조회는 왜 스레드 풀을 쓸까?

MongoDB 연결 과정을 상세히 추적하면, 3편에서 배운 "세 가지 처리 경로"가 그대로 드러납니다.

bootstrap() 실행
  → NestFactory.create() → MongooseModule.forRootAsync()
    → mongoose.connect(uri) 호출
      → dns.lookup("cluster.mongodb.net")
        → libuv: "getaddrinfo()는 블로킹이니까 워커에게"
        → 워커 스레드: getaddrinfo() 실행
        
bootstrap()은 여기서 멈춤 (await)
이벤트 루프가 돔:
  → Poll: DNS 응답 도착 → TCP 핸드셰이크 진행 → 연결 완료 콜백
  → MongoDB 연결 성공 → Promise resolve
  
bootstrap() 재개

dns.lookup()은 OS의 getaddrinfo() 함수를 호출합니다. 이 함수는 블로킹 함수입니다. 호출하면 결과가 올 때까지 해당 스레드가 멈춥니다. 메인 스레드에서 직접 호출하면 이벤트 루프 전체가 정지되기 때문에, libuv는 이걸 스레드 풀 워커에게 넘깁니다.

DNS 조회로 IP 주소를 얻은 후, TCP 연결은 OS 비동기 API를 통해 진행됩니다. 하나의 연결 과정에서 스레드 풀과 OS 비동기 API 두 경로가 모두 사용되는 것입니다.

경로사용 조건이 서버에서의 예
스레드 풀OS가 비동기로 못 해주는 작업MongoDB Atlas DNS 조회
OS 비동기 APIOS가 비동기로 처리해주는 작업MongoDB/MySQL/Redis TCP 연결
이벤트 루프 자체시간 기반 (위임 불필요)keepAlive 핑, 크론 스케줄

await의 동작 — 콜 스택이 비워지고 마이크로태스크로 재개되기까지

여기서 await이 정확히 어떻게 동작하는지 짚고 넘어가겠습니다. bootstrap()이 호출되는 지점을 봅시다.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // ... 나머지 코드
}
bootstrap();
// ← 이 아래에 코드가 없다. 파일 끝.

await이 pending Promise를 만나면, async 함수 자체가 return됩니다. "기다리는 게 아니라, 함수를 나가는 것"입니다. bootstrap() 아래에 실행할 코드가 없으니, 콜 스택이 즉시 비워지고 이벤트 루프가 자유롭게 돕니다.

콜 스택 변화:
1. bootstrap() 호출           → 콜 스택: [ bootstrap ]
2. NestFactory.create() 호출  → 콜 스택: [ bootstrap, NestFactory.create ]
3. pending Promise 반환       → 콜 스택: [ bootstrap ]
4. await이 pending Promise    → bootstrap() return → 콜 스택: [ ]

콜 스택이 비워짐 → 이벤트 루프가 자유롭게 돈다

그러면 이벤트 루프는 Poll 페이즈에서 대기합니다. MongoDB TCP 연결이 완료되면 어떻게 bootstrap()이 재개될까요? 여기서 마이크로태스크가 등장합니다.

1. Poll 대기 중...
   ↓
2. TCP 연결 완료! Poll 페이즈에서 I/O 콜백 실행
   ↓
3. 콜백 안에서 Promise.resolve(connection)
   → await의 나머지 코드가 마이크로태스크 큐에 등록
   ↓
4. 콜백 종료 → 마이크로태스크 큐 체크
   ↓
5. bootstrap()이 await 다음 줄부터 재개
   → app.use(express.json(...))
   → app.use(cookieParser())
   → ...
   → await app.listen(port)  ← 또 pending Promise! 다시 중단

await은 .then()의 문법 설탕이기 때문에, 내부적으로는 이렇게 동작합니다.

// await 문법
const app = await NestFactory.create(AppModule);
app.use(express.json({ limit: '100mb' }));
// ...
await app.listen(port);

// 내부적으로는 이것과 같다
NestFactory.create(AppModule).then((app) => {
  app.use(express.json({ limit: '100mb' }));
  // ...
  return app.listen(port);
});

이 .then() 안의 코드가 마이크로태스크로 실행됩니다. 4편에서 배운 "매 페이즈 사이에 마이크로태스크 큐를 체크한다"가 여기서 동작하는 것입니다.

핵심은 libuv(Poll)와 V8(마이크로태스크)을 연결하는 다리가 Promise.resolve()라는 점입니다.

libuv (Poll, I/O 완료)  →  Promise.resolve()  →  마이크로태스크 큐  →  await 재개 (V8)
      이벤트 루프                다리                  V8이 처리

3단계: 미들웨어 등록 — 전부 동기

app.use(express.json({ limit: '100mb' }));
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ ... }));
app.enableCors({ ... });

이 코드는 전부 동기입니다. "이 요청이 오면 이 함수를 실행해라"는 규칙을 등록하는 것이지, 실제로 실행하는 게 아닙니다. 콜 스택에서 바로 처리되고 이벤트 루프를 거치지 않습니다.

4단계: app.listen(port) — 서버가 "살아있는" 이유

await app.listen(3000);

이 한 줄이 서버를 살아있게 만듭니다.

app.listen(3000)
  → 내부적으로 net.createServer() 호출
  → server.listen(3000)
    → libuv에 TCP 서버 핸들 등록
    → OS에 포트 3000 바인딩 (bind + listen 시스템 콜)
    → 성공 → 'listening' 이벤트 → Promise resolve

이 시점에 libuv에 활성 핸들(active handle)이 등록됩니다. 핸들이란 libuv가 오래 살아있으면서 감시하는 대상입니다. uv_run()은 활성 핸들이 하나라도 남아있으면 "아직 감시할 대상이 있다"고 판단하고 계속 돕니다.

이 서버가 시작되면 다음과 같은 활성 핸들들이 등록됩니다.

활성 핸들감시 대상libuv 타입
HTTP 서버 소켓포트 3000에 들어오는 요청uv_tcp_t
MongoDB 연결MongoDB와의 TCP 소켓uv_tcp_t
MySQL 연결MySQL과의 TCP 소켓uv_tcp_t
Redis 연결Redis와의 TCP 소켓uv_tcp_t
keepAlive 타이머30초마다 DB 핑uv_timer_t
SIGTERM/SIGINT종료 시그널uv_signal_t

핸들은 일회성 작업인 요청(request)과 다릅니다. fs.readFile()은 파일을 한 번 읽고 끝나는 요청이지만, app.listen(3000)은 서버가 살아있는 동안 계속 연결을 감시하는 핸들입니다.

비교해보면:

// 이벤트 루프가 계속 도는 경우
const server = http.createServer().listen(3000);
// → active handle 존재 → uv_run() 계속 실행

// 이벤트 루프가 바로 종료되는 경우
console.log('hello');
// → active handle 없음 → uv_run() 즉시 반환 → 프로세스 종료

5단계: 시그널 핸들러 등록

process.on('SIGTERM', async () => {
  await app.close();
  process.exit(0);
});

이것도 동기 코드입니다. libuv에 시그널 핸들(uv_signal_t)을 등록합니다. Ctrl+C를 누르거나 배포 시 프로세스 종료 시그널이 오면, app.close()가 위 표의 모든 핸들을 닫습니다. 핸들이 전부 닫히면 uv_run()의 종료 조건이 충족되어 이벤트 루프가 멈춥니다.

서버 대기 상태 — 6개 페이즈가 실제로 하는 일

bootstrap()이 완료된 후, 이벤트 루프는 계속 돌면서 각 페이즈를 반복합니다. 이 서버에서 각 페이즈가 실제로 담당하는 일을 대입해보면:

┌─────────────────────────────────────────┐
│ Timers                                   │
│ - ScheduleModule 크론 (매일 리포트 등)   │
│ - MongoDB keepAlive 핑 (30초마다)        │
│ - MySQL 연결 풀 유지보수                 │
│ - Rate limit 윈도우 초기화 (60초)        │
├─────────────────────────────────────────┤
│ Pending                                  │
│ - (보통 비어있음, 네트워크 에러 시에만)   │
├─────────────────────────────────────────┤
│ Idle/Prepare                             │
│ - GC 힌트, Poll 타임아웃 계산            │
├─────────────────────────────────────────┤
│ Poll ← 대부분의 시간을 여기서 보냄       │
│ - HTTP 요청 수신 (TCP 소켓)              │
│ - MongoDB 쿼리 응답                      │
│ - MySQL 쿼리 응답                        │
│ - Redis 응답                             │
├─────────────────────────────────────────┤
│ Check                                    │
│ - (setImmediate 사용 시)                 │
├─────────────────────────────────────────┤
│ Close                                    │
│ - 클라이언트 연결 종료 시                │
└─────────────────────────────────────────┘

요청이 없으면 어떻게 될까요? Poll 페이즈에서 blocking wait합니다. 새로운 I/O 이벤트가 올 때까지 멈춰서 기다립니다. CPU를 거의 쓰지 않습니다. 이게 Node.js 서버가 유휴 상태에서 리소스를 거의 소모하지 않는 이유입니다.

Poll 타임아웃의 실제 동작

4편에서 Prepare 페이즈가 Poll의 대기 시간을 계산한다고 했습니다. 이 서버에서 구체적으로 어떻게 적용되는지 보겠습니다.

예를 들어 MongoDB keepAlive 핑이 30초 간격으로 설정되어 있다면:

Prepare: "다음 타이머 만료까지 25초 남음. Poll에서 최대 25초만 기다려"
  ↓
Poll 진입:
  ① 큐에 쌓인 I/O 콜백 → 전부 실행 (중단 없음)
  ② 새 I/O 대기... 최대 25초
     → 3초째 HTTP 요청 도착 → 즉시 처리
     또는
     → 25초 경과, 새 I/O 없음 → Poll 빠져나감
  ↓
Check → Close → Timers (keepAlive 핑 실행)

중요한 것: 타임아웃은 "새 I/O를 얼마나 기다릴지"를 제한합니다. 이미 큐에 있는 콜백 실행은 중단하지 않습니다. 콜백을 전부 처리한 후에 대기 시간에만 적용됩니다.

HTTP 요청 한 건의 여정

클라이언트가 GET /blog-posts를 요청하면 어떤 일이 벌어지는지 추적합니다.

클라이언트: GET /blog-posts 전송
  ↓
Poll 페이즈에서 TCP 소켓에 데이터 도착 감지
  ↓
콜 스택에서 Express 미들웨어 체인 실행 (동기)
  json parser → cookieParser → CORS 체크 → ValidationPipe
  ↓
NestJS 라우팅 → BlogPostsController.findAll() 호출
  ↓
BlogPostsService.findAll() → mongoose.find() 호출
  → MongoDB에 TCP 소켓으로 쿼리 전송 (OS 비동기)
  → await에서 콜 스택이 비워짐
  ↓
이벤트 루프가 다시 돌기 시작
  → 다른 요청이 있으면 처리 가능! (싱글 스레드인데 동시 처리)
  ↓
Poll에서 MongoDB 응답 도착
  → 콜백 실행 → Promise resolve
  → async 함수 재개 → JSON 응답을 클라이언트에 전송

여기서 핵심은 await입니다. mongoose.find()가 실행되면 쿼리를 TCP 소켓으로 보내고 콜 스택은 비워집니다. 이 순간 이벤트 루프는 자유롭게 돌 수 있고, 다른 HTTP 요청을 받을 수 있습니다. 1편에서 배운 "싱글 스레드인데 동시에 처리할 수 있는 이유"가 바로 이것입니다.

이 서버가 Node.js에 적합한 이유

I/O-bound vs CPU-bound

작업의 병목이 어디에 있는지에 따라 두 가지로 나뉩니다.

용어병목특징
I/O-bound네트워크, 디스크CPU는 놀고 있고, 응답을 기다리는 시간이 대부분
CPU-boundCPU 연산기다리는 게 아니라, 계산하는 시간이 대부분

"bound"는 "묶여있다"는 뜻입니다. CPU-bound = CPU가 끝나야 다음으로 갈 수 있다.

// I/O-bound — Node.js에 적합
const posts = await mongoose.find();
// MongoDB 응답을 기다리는 동안 CPU는 놀고 있음
// → 이벤트 루프가 다른 요청 처리 가능

// CPU-bound — Node.js에 부적합
const sorted = heavySort(millionRecords);
// CPU가 계속 계산 → 콜 스택이 안 비워짐 → 이벤트 루프 정지

서버의 모든 작업을 분류해보면

작업유형위임 경로
MongoDB CRUDI/O (TCP)OS 비동기 API
MySQL CRUDI/O (TCP)OS 비동기 API
Redis 조회I/O (TCP)OS 비동기 API
HTTP 요청 수신I/O (TCP)OS 비동기 API
Gemini AI 호출I/O (TCP → 외부 API)OS 비동기 API
Cloudinary 이미지 업로드I/O (TCP → 외부 API)OS 비동기 API
Slack 알림I/O (TCP → 외부 API)OS 비동기 API
MongoDB Atlas DNS 조회블로킹 시스템 콜스레드 풀

대부분 OS 비동기 API 경로입니다. 스레드 풀은 서버 시작 시 DNS 조회 정도에서만 쓰이고, 운영 중에는 거의 사용되지 않습니다. Node.js가 가장 잘하는 패턴 — 요청을 받고, I/O를 위임하고, 응답을 전달하는 것 — 에 정확히 맞는 서버입니다.

CPU-bound 작업은 어떻게 처리하는가

이 서버에서 AI 분석, 이미지 처리 같은 무거운 연산은 어떻게 하고 있을까요?

// AI 연산 — Google Gemini API로 위임
const result = await gemini.generateContent(prompt);
// AI 연산은 Google 서버에서 처리
// Node.js 입장에서는 TCP 요청-응답 → I/O-bound

// 이미지 처리 — Cloudinary로 위임
const uploaded = await cloudinary.upload(image);
// 이미지 변환은 Cloudinary 서버에서 처리
// Node.js 입장에서는 TCP 요청-응답 → I/O-bound

CPU-bound 작업을 외부 서비스로 위임하면, Node.js 입장에서는 네트워크 요청이 됩니다. CPU-bound가 I/O-bound로 바뀌는 것입니다. 이 서버에서 Worker Threads를 하나도 사용하지 않는 이유이기도 합니다.

Worker Threads — 만능은 아니다

Node.js 안에서 CPU-bound 작업을 처리해야 한다면 Worker Threads를 쓸 수 있습니다. 별도 스레드에서 실행하니 메인 스레드(이벤트 루프)가 막히지 않습니다.

하지만 한계가 있습니다.

Worker Threads별도 서비스 (Go, C++ 등)
이벤트 루프 보호✅✅
연산 속도JavaScript 속도 그대로네이티브 속도
적합한 경우가벼운 CPU 작업무거운 CPU 작업

Worker Threads는 "이벤트 루프를 안 막겠다"는 것이지, "Node.js가 CPU 작업을 잘한다"는 게 아닙니다. V8은 JIT 컴파일(실행 중 최적화)을 하지만, C++이나 Go처럼 미리 기계어로 컴파일된 코드보다 CPU 연산에서 오버헤드가 있습니다.

그래서 무거운 CPU 작업이 핵심인 서비스(영상 인코딩, ML 추론, 게임 물리 엔진 등)에는 Node.js 대신 다른 언어가 적합합니다.

정리

실제 NestJS 서버 코드를 이벤트 루프에 대입해보면, 1~4편의 이론이 구체적인 그림으로 바뀝니다.

이론 (1~4편)실전 (이 서버)
import는 동기, DB 연결은 비동기데코레이터로 메타데이터 등록(동기) → NestFactory.create에서 실제 연결(비동기)
스레드 풀 vs OS 비동기 APIDNS 조회(스레드 풀) → TCP 연결(OS 비동기)
Poll에서 대부분 대기HTTP 요청, DB 응답을 기다리며 여기서 멈춤
Timers 페이즈크론 작업, keepAlive 핑, rate limit 초기화
await에서 콜 스택 비워짐bootstrap() return → 이벤트 루프 자유 → Poll에서 I/O 완료 시 마이크로태스크로 재개
마이크로태스크 = .then() 콜백I/O 콜백이 Promise.resolve() → 마이크로태스크 큐 → await 이후 코드 실행
active handle → uv_run() 유지app.listen()이 서버를 살아있게 만듦
I/O-bound에 강함모든 작업이 I/O-bound, Worker Threads 불필요

이론만으로는 막연하지만, 실제 코드에 대입하면 이벤트 루프가 왜 이렇게 설계됐는지 보입니다. Node.js는 "모든 것을 잘하는 런타임"이 아니라, I/O를 위임하고 연결하는 일에 최적화된 런타임입니다. 이 서버가 그 설계 철학에 정확히 맞는 사례입니다.

Node.jsNestJS이벤트 루프I/O-boundCPU-boundlibuv면접 준비

관련 글

NestFactory.create()를 호출하면 무슨 일이 일어나는가 — NestJS 소스코드 추적 (1편)

NestJS로 서버를 만들 때마다 실행하는 NestFactory.create(AppModule) 한 줄. 이 한 줄이 내부에서 DI 컨테이너 생성, 모듈 스캔, 인스턴스 로딩, Express 바인딩까지 5단계를 거친다는 사실을 NestJS 소스코드를 직접 추적하며 확인합니다.

관련도 93%

Node.js는 싱글 스레드인데 어떻게 동시에 처리할까 — 콜 스택과 이벤트 루프 (3편)

1편에서 Node.js의 내부 구조를, 2편에서 프로세스와 스레드의 기본 개념을 확인했습니다. 이번에는 "싱글 스레드인데 어떻게 동시 처리가 가능한가"라는 질문에 답하기 위해, 콜 스택과 이벤트 루프의 관계, libuv가 작업을 처리하는 두 가지 방식, 그리고 이벤트 루프 6개 페이즈의 실체를 소스코드로 확인해봤습니다.

관련도 91%

이벤트 루프의 6개 페이즈 — 콜백은 어떤 순서로 실행되는가 (4편)

이벤트 루프의 6개 페이즈를 하나씩 살펴봅니다. 타이머 힙의 동작 방식, Poll이 메인 무대인 이유, setTimeout(0)과 setImmediate의 순서가 달라지는 원리, 그리고 process.nextTick과 Promise가 매 페이즈 사이에 끼어드는 구조까지 정리합니다.

관련도 91%