홈시리즈

© 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는 Express 위에서 어떻게 동작하는가 — 플랫폼 추상화의 설계 (7편)

정기창·2026년 3월 23일

6편까지 NestJS의 내부를 추적하면서, 한 가지 계속 미뤄둔 질문이 있었습니다. NestJS는 Express 위에서 동작한다고 하는데, 정확히 어떻게 감싸고 있는 걸까요? 그리고 Fastify로 바꿀 수 있다는 건, NestJS가 특정 HTTP 프레임워크에 종속되지 않도록 설계되어 있다는 뜻입니다. 이 추상화가 어떻게 구현되어 있는지 소스코드에서 확인해보겠습니다.

이번 글이 NestJS Deep Dive 시리즈의 마지막 편입니다.

"NestJS는 Express 위에서 동작한다"의 정확한 의미

NestJS를 처음 접하면 "Express 기반 프레임워크"라는 설명을 자주 봅니다. 이 말은 반쯤 맞고 반쯤 틀립니다. NestJS는 Express가 아닙니다. Express를 내부 HTTP 엔진으로 사용하는 것입니다.

이전 Node.js 시리즈에서 "Node.js = V8 + libuv"라는 구조를 확인했습니다. V8이 JavaScript를 실행하고, libuv가 비동기 I/O를 처리합니다. Node.js 자체는 이 두 엔진을 조합한 런타임이었습니다.

NestJS도 같은 구조입니다.

Node.js = V8(JS 엔진) + libuv(이벤트 루프) + Node API
NestJS  = Express/Fastify(HTTP 엔진) + NestJS 프레임워크 로직(DI, 모듈, 라이프사이클)

Express는 NestJS의 "전부"가 아니라 "일부"입니다. HTTP 요청을 받고 응답을 보내는 역할만 담당합니다. 나머지—의존성 주입, 모듈 시스템, 가드, 인터셉터, 파이프—는 전부 NestJS가 직접 구현한 것입니다.

이걸 코드로 확인할 수 있습니다.

const app = await NestFactory.create(AppModule);

// NestJS 애플리케이션 객체
console.log(app); // NestApplication

// 내부의 실제 Express 인스턴스
console.log(app.getHttpServer()); // http.Server
console.log(app.getHttpAdapter().getInstance()); // Express app

getHttpServer()를 호출하면 Node.js의 http.Server 인스턴스가 나옵니다. getHttpAdapter().getInstance()를 호출하면 실제 Express의 app 객체가 나옵니다. NestJS는 이 Express 인스턴스를 내부에 들고 있으면서, 자신의 라우팅 시스템을 그 위에 연결하는 방식으로 동작합니다.

AbstractHttpAdapter — 추상화의 핵심

NestJS가 Express와 Fastify를 모두 지원할 수 있는 이유는 하나의 추상 클래스 때문입니다. packages/core/adapters/http-adapter.ts에 있는 AbstractHttpAdapter입니다.

// packages/core/adapters/http-adapter.ts (NestJS 소스코드)
export abstract class AbstractHttpAdapter<
  TServer = any,
  TRequest = any,
  TResponse = any,
> {
  protected httpServer: TServer;

  constructor(protected instance?: any) {}

  // HTTP 메서드 추상화
  public abstract get(handler: RequestHandler): any;
  public abstract get(path: string, handler: RequestHandler): any;

  public abstract post(handler: RequestHandler): any;
  public abstract post(path: string, handler: RequestHandler): any;

  public abstract put(handler: RequestHandler): any;
  public abstract delete(handler: RequestHandler): any;
  public abstract patch(handler: RequestHandler): any;
  public abstract options(handler: RequestHandler): any;
  public abstract head(handler: RequestHandler): any;

  // 미들웨어
  public abstract use(...args: any[]): any;

  // 서버 라이프사이클
  public abstract listen(port: number | string, callback?: () => void): any;
  public abstract listen(
    port: number | string,
    hostname: string,
    callback?: () => void,
  ): any;
  public abstract close(): any;

  // 응답 처리
  public abstract reply(response: any, body: any, statusCode?: number): any;
  public abstract status(response: any, statusCode: number): any;
  public abstract redirect(response: any, statusCode: number, url: string): any;
  public abstract render(response: any, view: string, options: any): any;

  // 요청 정보 추출
  public abstract getRequestHostname(request: any): string;
  public abstract getRequestMethod(request: any): string;
  public abstract getRequestUrl(request: any): string;

  // 정적 파일, CORS, 바디 파서 등
  public abstract enableCors(options: any): any;
  public abstract useStaticAssets(...args: any[]): any;
  public abstract setViewEngine(engine: string): any;

  // 내부 인스턴스 접근
  public getInstance(): T {
    return this.instance as T;
  }

  public getHttpServer(): TServer {
    return this.httpServer;
  }

  public setHttpServer(httpServer: TServer) {
    this.httpServer = httpServer;
  }

  // 라우트 등록 — 내부적으로 Express/Fastify의 라우터에 위임
  public abstract registerParserMiddleware(prefix?: string, rawBody?: boolean): any;
  public abstract createMiddlewareFactory(
    requestMethod: RequestMethod,
  ): ((path: string, callback: Function) => any) | Promise<(path: string, callback: Function) => any>;
}

이 클래스가 정의하는 것을 정리하면 이렇습니다.

AbstractHttpAdapter
├── HTTP 메서드:      get(), post(), put(), delete(), patch(), options(), head()
├── 미들웨어:         use()
├── 서버 생명주기:    listen(), close()
├── 응답 처리:        reply(), status(), redirect(), render()
├── 요청 정보:        getRequestHostname(), getRequestMethod(), getRequestUrl()
├── 서버 설정:        enableCors(), useStaticAssets(), setViewEngine()
└── 인스턴스 접근:    getInstance(), getHttpServer()

핵심은 abstract 키워드입니다. 이 클래스는 "HTTP 서버라면 이런 메서드들이 있어야 한다"는 계약만 정의합니다. 실제 구현은 ExpressAdapter와 FastifyAdapter가 각각 합니다.

이 설계는 1편에서 봤던 NestFactory.create()와 연결됩니다. NestFactory는 애플리케이션을 생성할 때 이 어댑터를 주입받습니다. 어떤 어댑터를 넣느냐에 따라 Express 위에서 동작할 수도 있고, Fastify 위에서 동작할 수도 있습니다.

ExpressAdapter 소스코드 추적

이제 실제 구현을 봅니다. packages/platform-express/adapters/express-adapter.ts입니다.

// packages/platform-express/adapters/express-adapter.ts (NestJS 소스코드, 핵심 부분)
import * as express from 'express';
import * as http from 'http';
import * as https from 'https';
import { AbstractHttpAdapter } from '@nestjs/core';

export class ExpressAdapter extends AbstractHttpAdapter {
  private readonly openConnections = new Set();

  constructor(instance?: any) {
    super(instance || express());
  }

  // reply() — Express의 res.json() / res.send()를 호출
  public reply(response: express.Response, body: any, statusCode?: number) {
    if (statusCode) {
      response.status(statusCode);
    }
    if (isNil(body)) {
      return response.send();
    }
    return isObject(body) ? response.json(body) : response.send(String(body));
  }

  // status() — Express의 res.status()를 호출
  public status(response: express.Response, statusCode: number) {
    return response.status(statusCode);
  }

  // redirect() — Express의 res.redirect()를 호출
  public redirect(response: express.Response, statusCode: number, url: string) {
    return response.redirect(statusCode, url);
  }

  // render() — Express의 res.render()를 호출
  public render(response: express.Response, view: string, options: any) {
    return response.render(view, options);
  }

  // 요청 정보 추출 — Express의 req 객체에서 직접 읽기
  public getRequestHostname(request: express.Request): string {
    return request.hostname;
  }

  public getRequestMethod(request: express.Request): string {
    return request.method;
  }

  public getRequestUrl(request: express.Request): string {
    return request.originalUrl;
  }

  // listen() — http.Server를 생성하고 listen
  public listen(port: string | number, callback?: () => void): http.Server;
  public listen(port: string | number, hostname: string, callback?: () => void): http.Server;
  public listen(port: any, ...args: any[]): http.Server {
    return this.httpServer.listen(port, ...args);
  }

  // close() — 서버 종료
  public close() {
    this.closeOpenConnections();

    if (!this.httpServer) {
      return undefined;
    }
    return new Promise(resolve => this.httpServer.close(resolve));
  }

  // use() — Express의 app.use()를 호출
  public use(...args: any[]) {
    return this.instance.use(...args);
  }

  // enableCors() — cors 미들웨어 적용
  public enableCors(options: CorsOptions | CorsOptionsDelegate) {
    return this.use(cors(options));
  }

  // initHttpServer() — http/https 서버 생성
  public initHttpServer(options: NestApplicationOptions) {
    const isHttpsEnabled = options && options.httpsOptions;
    if (isHttpsEnabled) {
      this.httpServer = https.createServer(
        options.httpsOptions,
        this.getInstance(),
      );
    } else {
      this.httpServer = http.createServer(this.getInstance());
    }
    // 연결 추적 (graceful shutdown용)
    this.trackOpenConnections();
  }

  // 바디 파서 등록
  public registerParserMiddleware(prefix?: string, rawBody?: boolean) {
    const bodyParserJsonOptions = getBodyParserOptions(rawBody);
    const bodyParserUrlencodedOptions = getBodyParserOptions(rawBody, {
      extended: true,
    });

    const parserMiddleware = {
      jsonParser: bodyParser.json(bodyParserJsonOptions),
      urlencodedParser: bodyParser.urlencoded(bodyParserUrlencodedOptions),
    };
    Object.keys(parserMiddleware)
      .filter(parser => !this.isMiddlewareApplied(parser))
      .forEach(parserKey => this.use(parserMiddleware[parserKey]));
  }

  // HTTP 메서드 라우팅 — Express의 app.get(), app.post() 등을 직접 호출
  public get(handler: RequestHandler): any;
  public get(path: string, handler: RequestHandler): any;
  public get(...args: any[]) {
    return this.instance.get(...args);
  }

  public post(...args: any[]) {
    return this.instance.post(...args);
  }

  public put(...args: any[]) {
    return this.instance.put(...args);
  }

  public delete(...args: any[]) {
    return this.instance.delete(...args);
  }

  public patch(...args: any[]) {
    return this.instance.patch(...args);
  }
}

패턴이 보입니다. ExpressAdapter의 모든 메서드는 Express 객체의 메서드를 그대로 호출합니다.

ExpressAdapter 메서드        →  Express 내부 호출
──────────────────────────────────────────────
reply(res, body)             →  res.json(body) 또는 res.send(body)
status(res, code)            →  res.status(code)
redirect(res, code, url)     →  res.redirect(code, url)
getRequestHostname(req)      →  req.hostname
getRequestUrl(req)           →  req.originalUrl
use(middleware)               →  express.app.use(middleware)
get(path, handler)           →  express.app.get(path, handler)
post(path, handler)          →  express.app.post(path, handler)
listen(port)                 →  httpServer.listen(port)
enableCors(options)          →  express.app.use(cors(options))

생성자를 주목해야 합니다.

constructor(instance?: any) {
  super(instance || express());
}

외부에서 Express 인스턴스를 주입하지 않으면 express()를 호출해서 새로 만듭니다. 이 인스턴스가 this.instance에 저장되고, 이후 모든 라우팅과 미들웨어가 이 인스턴스를 통해 등록됩니다.

initHttpServer()도 중요합니다. 여기서 Node.js의 http.createServer()에 Express 인스턴스를 전달합니다. Node.js 시리즈 1편에서 봤던 바로 그 http.createServer()입니다. Express 앱은 결국 Node.js의 HTTP 서버에 요청 핸들러로 전달되는 콜백 함수입니다.

요청 흐름:
클라이언트 → Node.js http.Server → Express app(콜백) → NestJS 라우터 → 컨트롤러

FastifyAdapter — 같은 계약, 다른 구현

이제 FastifyAdapter를 봅니다. packages/platform-fastify/adapters/fastify-adapter.ts입니다. 같은 AbstractHttpAdapter를 상속하지만, 구현이 다릅니다.

// packages/platform-fastify/adapters/fastify-adapter.ts (NestJS 소스코드, 핵심 부분)
import {
  FastifyInstance,
  FastifyReply,
  FastifyRequest,
  RawServerBase,
} from 'fastify';
import { AbstractHttpAdapter } from '@nestjs/core';

export class FastifyAdapter extends AbstractHttpAdapter {
  constructor(
    instanceOrOptions?: FastifyInstance | FastifyStaticOptions | FastifyBodyParserOptions,
  ) {
    const instance =
      instanceOrOptions && (instanceOrOptions as FastifyInstance).server
        ? instanceOrOptions
        : fastify({
            ...(instanceOrOptions as FastifyStaticOptions),
          } as FastifyServerOptions);
    super(instance);
  }

  // reply() — Fastify의 reply.send()를 호출
  public reply(
    response: FastifyReply,
    body: any,
    statusCode?: number,
  ) {
    if (statusCode) {
      response.status(statusCode);
    }
    if (isNil(body)) {
      response.send();
      return;
    }
    response.send(body);
  }

  // status() — Fastify의 reply.status()
  public status(response: FastifyReply, statusCode: number) {
    return response.status(statusCode);
  }

  // redirect() — Fastify의 reply.redirect()
  public redirect(response: FastifyReply, statusCode: number, url: string) {
    response.status(statusCode).redirect(url);
  }

  // 요청 정보 추출 — Fastify의 request 객체에서 읽기
  public getRequestHostname(request: FastifyRequest): string {
    return request.hostname;
  }

  public getRequestMethod(request: FastifyRequest): string {
    return request.method ?? request.raw.method;
  }

  public getRequestUrl(request: FastifyRequest): string {
    return this.getRequestOriginalUrl(request.raw);
  }

  // listen() — Fastify의 listen() 호출
  public async listen(port: string | number, callback?: () => void): Promise;
  public async listen(
    port: string | number,
    hostname: string,
    callback?: () => void,
  ): Promise;
  public async listen(
    port: string | number,
    ...args: any[]
  ): Promise {
    const hasHostname =
      args.length >= 2 || typeof args[0] === 'string';
    const hostname = hasHostname ? (args[0] as string) : undefined;
    const callback = hasHostname ? args[1] : args[0];

    await this.instance.listen({
      port: Number(port),
      host: hostname,
    });

    callback?.();
  }

  // use() — Fastify는 Express와 미들웨어 등록 방식이 다름
  public use(...args: any[]) {
    // Fastify는 Express 스타일 미들웨어를 직접 지원하지 않음
    // @fastify/middie 플러그인을 통해 지원
    return this.instance.use(...args);
  }

  // HTTP 메서드 라우팅 — Fastify의 route 시스템 사용
  public get(handler: RequestHandler): any;
  public get(path: string, handler: RequestHandler): any;
  public get(...args: any[]) {
    return this.instance.get(...args);
  }

  public post(...args: any[]) {
    return this.instance.post(...args);
  }

  // initHttpServer() — Fastify는 자체 서버를 이미 가지고 있음
  public initHttpServer(options: NestApplicationOptions) {
    this.httpServer = this.instance.server;
  }

  // CORS — Fastify 전용 플러그인 사용
  public async enableCors(options: any) {
    this.instance.register(
      require('@fastify/cors'),
      options,
    );
  }

  // 바디 파서 — Fastify는 내장 파서 사용
  public registerParserMiddleware(prefix?: string, rawBody?: boolean) {
    if (rawBody) {
      this.instance.addContentTypeParser(
        'application/json',
        { parseAs: 'buffer' },
        (req, body, done) => {
          // rawBody 처리 로직
        },
      );
    }
    // Fastify는 기본적으로 JSON 파싱이 내장되어 있음
    // Express처럼 body-parser를 별도로 등록할 필요 없음
  }
}

Express와 비교하면 차이가 명확합니다.

같은 메서드, 다른 구현:

reply()
  Express:  res.json(body) 또는 res.send(body)
  Fastify:  reply.send(body)  ← Fastify는 json/send 구분 불필요 (자동 감지)

listen()
  Express:  httpServer.listen(port)           ← 동기적 콜백
  Fastify:  instance.listen({ port, host })   ← Promise 기반 (async)

initHttpServer()
  Express:  http.createServer(expressApp)     ← Node.js 서버를 직접 생성
  Fastify:  this.instance.server              ← Fastify가 이미 서버를 가지고 있음

enableCors()
  Express:  app.use(cors(options))            ← 미들웨어로 등록
  Fastify:  instance.register(@fastify/cors)  ← 플러그인으로 등록

registerParserMiddleware()
  Express:  body-parser 미들웨어 등록          ← 별도 패키지 필요
  Fastify:  기본 내장 (추가 등록 불필요)       ← fast-json-stringify 사용

가장 큰 차이는 initHttpServer()입니다. Express는 Node.js의 http.createServer()에 Express 인스턴스를 콜백으로 전달해서 서버를 만듭니다. Fastify는 이미 내부에 서버를 가지고 있어서 this.instance.server로 꺼내기만 합니다. 이 차이를 어댑터가 흡수합니다.

Express vs Fastify — 어댑터 뒤의 실체 비교

항목

Express (@nestjs/platform-express)

Fastify (@nestjs/platform-fastify)

기본 탑재

✅ NestJS 기본값

별도 설치 필요

성능

보통

빠름 (벤치마크 기준 2-3배)

미들웨어 생태계

방대 (passport, multer, cors 등)

플러그인 방식 (일부 Express 미들웨어 미호환)

요청/응답 객체

req: Request, res: Response

request: FastifyRequest, reply: FastifyReply

JSON 파싱

body-parser 별도 등록

내장 (fast-json-stringify)

스키마 검증

없음 (class-validator 별도)

내장 (Ajv)

TypeScript 지원

@types/express 별도 설치

타입 내장

서버 생성 방식

http.createServer(app) 직접 생성

Fastify 인스턴스가 서버 내장

async 지원

콜백 기반 (async 래핑 필요)

네이티브 async/await

NestJS 전환 난이도

—

중간 (일부 미들웨어 수정 필요)

성능 차이가 나는 이유는 아키텍처 수준에서 다르기 때문입니다. Express는 미들웨어 체인을 순서대로 순회하며, Fastify는 Radix Tree 기반 라우팅과 JSON 직렬화 최적화(fast-json-stringify)를 사용합니다. 하지만 NestJS에서는 프레임워크 자체의 오버헤드(DI, 가드, 인터셉터 등)가 있기 때문에, 순수 Express vs Fastify 벤치마크만큼 차이가 크지는 않습니다.

어댑터 패턴이 NestJS에 주는 것

왜 이렇게 설계했을까요? Express 위에서 직접 동작하게 만들면 더 간단할 텐데, 왜 중간에 추상화 레이어를 넣었을까요?

1. 프레임워크 독립성

Express가 deprecated 되거나, 더 빠른 HTTP 프레임워크가 등장해도 NestJS는 영향받지 않습니다. 새 프레임워크에 대한 어댑터만 만들면 됩니다. 실제로 NestJS 팀은 Express 외에 Fastify 어댑터를 공식으로 제공하고 있고, 커뮤니티에서 다른 어댑터를 만들 수도 있습니다.

2. NestFactory.create()와의 연결

1편에서 봤던 부트스트랩 과정을 다시 봅니다.

// Express (기본값) — 어댑터를 명시하지 않으면 ExpressAdapter 사용
const app = await NestFactory.create(AppModule);

// Fastify로 전환 — 어댑터만 교체
const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  new FastifyAdapter(),
);

코드 한 줄로 HTTP 엔진이 바뀝니다. 컨트롤러, 서비스, 가드, 파이프—나머지 코드는 하나도 바꿀 필요가 없습니다. 이것이 추상화의 힘입니다.

NestFactory 내부에서 이 어댑터가 어떻게 사용되는지 보면 더 명확합니다.

// packages/core/nest-factory.ts (NestJS 소스코드, 간략화)
export class NestFactoryStatic {
  public async create(
    moduleCls: any,
    serverOrOptions?: AbstractHttpAdapter | NestApplicationOptions,
  ): Promise<INestApplication> {

    // 어댑터가 전달되지 않으면 기본값 = ExpressAdapter
    const [httpServer, appOptions] = this.isHttpServer(serverOrOptions)
      ? [serverOrOptions, {}]
      : [this.createHttpAdapter(), serverOrOptions];

    // 어댑터를 NestApplication에 주입
    const instance = new NestApplication(
      container,
      httpServer,  // ← AbstractHttpAdapter
      config,
      graphInspector,
      appOptions,
    );

    return instance;
  }

  private createHttpAdapter(httpServer?: any): AbstractHttpAdapter {
    const { ExpressAdapter } = this.loadAdapter('@nestjs/platform-express');
    return new ExpressAdapter(httpServer);
  }
}

createHttpAdapter()에서 기본값으로 ExpressAdapter를 생성합니다. 이것이 "NestJS는 Express 위에서 동작한다"의 정확한 의미입니다. Express가 하드코딩된 것이 아니라, 기본값으로 선택된 것입니다.

3. 테스트 용이성

추상화가 있으면 테스트용 가짜 어댑터를 만들 수 있습니다. 실제 HTTP 서버를 띄우지 않고도 NestJS의 라우팅 로직을 테스트할 수 있습니다.

운영 환경:  NestApplication → ExpressAdapter → Express → http.Server → 네트워크
테스트 환경: NestApplication → TestAdapter   → 메모리 내 요청/응답 시뮬레이션

@Req(), @Res()와 플랫폼 독립성의 트레이드오프

NestJS는 플랫폼 독립적인 코드를 권장하지만, 때로는 Express의 기능을 직접 써야 할 때가 있습니다. @Req()와 @Res() 데코레이터가 그 통로입니다.

import { Controller, Get, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';

@Controller('files')
export class FilesController {
  @Get('download')
  download(@Res() res: Response) {
    // Express의 res 객체를 직접 사용
    res.download('/path/to/file.pdf');
  }

  @Get('info')
  getInfo(@Req() req: Request) {
    // Express의 req 객체에 직접 접근
    return { ip: req.ip, headers: req.headers };
  }
}

하지만 @Res()를 사용하면 중요한 것을 잃습니다. 인터셉터가 동작하지 않습니다.

5편에서 봤듯이, NestJS의 인터셉터는 컨트롤러의 반환값을 Observable로 감싸서 후처리합니다. 그런데 @Res()로 응답 객체를 직접 사용하면, NestJS는 "개발자가 응답을 직접 처리하겠다"고 판단하고 자동 응답 처리를 비활성화합니다. 인터셉터의 후처리(map, tap 등)도 함께 비활성화됩니다.

// @Res()를 쓰면 인터셉터의 후처리가 동작하지 않음
@UseInterceptors(TransformInterceptor)
@Get('download')
download(@Res() res: Response) {
  // TransformInterceptor의 map()이 호출되지 않음
  res.send('data');
}

// 해결: passthrough 옵션
@Get('download')
download(@Res({ passthrough: true }) res: Response) {
  res.header('Content-Type', 'application/pdf');
  return { data: 'value' }; // NestJS가 응답 처리, 인터셉터도 동작
}

{ passthrough: true } 옵션을 사용하면, Express의 응답 객체에 접근하면서도 NestJS의 자동 응답 처리를 유지할 수 있습니다. 하지만 이렇게 하더라도, 코드가 Express에 종속됩니다. Fastify로 전환하면 Request와 Response 타입이 달라지므로 수정이 필요합니다.

결론은 이렇습니다. @Req()와 @Res()는 탈출구이지, 일상적으로 사용할 도구가 아닙니다.

권장 접근법:
1순위: NestJS 내장 데코레이터 사용 (@Body, @Param, @Query, @Headers)  ← 플랫폼 독립적
2순위: @Res({ passthrough: true })                                    ← Express 기능 + NestJS 자동 처리
3순위: @Res()                                                         ← Express 직접 제어 (인터셉터 비활성화)

시리즈 전체 회고 — 7편에 걸쳐 추적한 NestJS의 내부

1편부터 7편까지, NestJS가 실행되는 전체 과정을 소스코드에서 추적했습니다.

편

주제

핵심 소스 파일

확인한 것

1편

NestFactory.create()

nest-factory.ts

부트스트랩 5단계 — 컨테이너 생성부터 서버 listen까지

2편

@Injectable()

injectable.decorator.ts

데코레이터 = Reflect.defineMetadata() 호출 한 줄

3편

DI 컨테이너

injector.ts

의존성 해결 = 재귀적 토큰 탐색과 인스턴스 캐싱

4편

@Module()

scanner.ts

모듈 그래프 = 의존성 경계를 정의하는 트리 구조

5편

요청 라이프사이클

router-explorer.ts

Guard → Interceptor → Pipe → Handler → Interceptor → Filter

6편

Custom Decorator

create-route-param-metadata.ts

데코레이터는 선언만, 실행은 라우트 바인딩 시점에

7편

플랫폼 추상화

http-adapter.ts

어댑터 패턴으로 HTTP 엔진 교체 가능

이 7개의 조각을 합치면, NestJS에서 코드 한 줄이 실행되기까지의 전체 그림이 보입니다.

NestJS 부트스트랩부터 요청 처리까지의 전체 흐름:

1. NestFactory.create(AppModule)             [1편]
   ├── HttpAdapter 생성 (Express/Fastify)    [7편]
   ├── 모듈 스캔 (DependenciesScanner)       [4편]
   ├── @Injectable() 메타데이터 수집          [2편]
   └── DI 컨테이너 의존성 해결 (Injector)    [3편]

2. app.listen(3000)
   └── HttpAdapter.listen() → http.Server    [7편]

3. HTTP 요청 도착
   ├── Express/Fastify가 요청 수신           [7편]
   ├── NestJS 라우터로 전달                  [5편]
   ├── Guard → 접근 제어                     [5편]
   ├── Interceptor (전처리)                  [5편]
   ├── Pipe → 데이터 변환/검증               [5편]
   ├── Custom Decorator 실행                 [6편]
   ├── Controller Handler 실행               [5편]
   ├── Interceptor (후처리)                  [5편]
   └── HttpAdapter.reply() → 응답 전송       [7편]

정리 — NestJS 소스코드를 열어본 이후

Node.js 시리즈에서 os.hostname() 한 줄이 JavaScript → C++ → C → 운영체제 커널을 거친다는 것을 확인했습니다. 당연하게 사용하던 API 한 줄 뒤에 여러 계층이 숨어 있었습니다.

NestJS 시리즈에서도 같은 경험을 했습니다. @Injectable() 한 줄 뒤에는 메타데이터 등록 → 모듈 스캔 → DI 해석 → 인스턴스 생성 → 라우트 바인딩이라는 과정이 숨어 있었습니다. NestFactory.create() 한 줄 뒤에는 HTTP 어댑터 생성, 모듈 그래프 구축, 의존성 트리 해결이라는 부트스트랩 과정이 있었습니다.

이 시리즈를 작성하면서 깨달은 것이 하나 있습니다. 프레임워크의 소스코드를 읽는 것은 "더 깊은 지식"을 얻기 위해서가 아닙니다. 내가 작성하는 코드가 실제로 무엇을 하는지 확인하는 것입니다.

@Controller()를 붙이면 "컨트롤러가 된다"고 알고 있었습니다. 소스코드를 열어보니 Reflect.defineMetadata()로 메타데이터를 등록하고, 스캐너가 그것을 읽어서 라우트 맵에 등록하는 것이었습니다. 마법처럼 보이던 것이 구체적인 절차가 되었습니다.

Node.js 시리즈에서 런타임의 실체를 확인했고, 이번 시리즈에서는 그 위에서 동작하는 프레임워크의 실체를 확인했습니다. 런타임이든 프레임워크이든, 소스코드를 열어보면 결국 사람이 작성한 코드입니다. 설계 의도가 보이고, 트레이드오프가 보이고, 때로는 TODO 주석도 보입니다. 그 사실을 확인하는 것 자체가 이 시리즈의 목적이었습니다.

NestJSExpressFastifyHttpAdapter플랫폼 추상화어댑터 패턴TypeScript

관련 글

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

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

관련도 92%

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

이벤트 루프 이론을 실제 NestJS 서버 코드에 대입합니다. 서버 시작부터 HTTP 요청 처리까지, 6개 페이즈가 실제로 어떤 역할을 하는지 추적합니다.

관련도 91%

요청이 컨트롤러에 도달하기까지 — Guard, Interceptor, Pipe, Filter (5편)

NestJS에서 HTTP 요청 하나가 컨트롤러 메서드에 도달하기까지 거치는 6단계를 소스코드로 추적합니다. Middleware, Guard, Interceptor, Pipe, ExceptionFilter — 각 레이어가 왜 존재하고 어떤 순서로 실행되는지 RouterExecutionContext 소스를 통해 확인합니다.

관련도 90%