NestJS HttpModule과 Undici란?
Node.js 18+에서 fetch()의 내부 엔진인 Undici는 HTTP/1.1 파이프라이닝, 커넥션 풀링, 제로카피 파싱을 지원하는 고성능 HTTP 클라이언트입니다. NestJS의 기본 HttpModule(Axios 기반) 대비 2~5배 빠른 처리량을 제공합니다. 마이크로서비스 간 대량 API 호출이 필요한 환경에서 성능 병목을 해소합니다.
Axios vs Undici 비교
| 비교 항목 | Axios (HttpModule) | Undici |
|---|---|---|
| 커넥션 풀링 | http.Agent 기반 | 네이티브 Pool/Client |
| HTTP 파이프라이닝 | ❌ | ✅ |
| 처리량 (req/s) | ~5,000 | ~15,000+ |
| 메모리 사용 | 높음 (버퍼 복사) | 낮음 (제로카피) |
| 인터셉터 | Axios Interceptors | Dispatcher 체인 |
| 스트리밍 | 제한적 | 네이티브 지원 |
| Node.js 내장 | ❌ (외부 의존성) | ✅ (18+) |
NestJS Undici 모듈 설계
Undici의 Pool을 NestJS DI에 통합하는 커스텀 모듈입니다.
// undici.module.ts
import { DynamicModule, Module, Provider } from '@nestjs/common';
import { Pool } from 'undici';
export interface UndiciModuleOptions {
baseUrl: string;
connections?: number; // 최대 커넥션 수 (기본: 10)
pipelining?: number; // 파이프라이닝 깊이 (기본: 1)
keepAliveTimeout?: number; // Keep-Alive 타임아웃 ms
headersTimeout?: number; // 헤더 수신 타임아웃 ms
bodyTimeout?: number; // 바디 수신 타임아웃 ms
}
export const UNDICI_POOL = Symbol('UNDICI_POOL');
@Module({})
export class UndiciModule {
static forRoot(options: UndiciModuleOptions): DynamicModule {
const poolProvider: Provider = {
provide: UNDICI_POOL,
useFactory: () => new Pool(options.baseUrl, {
connections: options.connections ?? 10,
pipelining: options.pipelining ?? 1,
keepAliveTimeout: options.keepAliveTimeout ?? 30_000,
headersTimeout: options.headersTimeout ?? 10_000,
bodyTimeout: options.bodyTimeout ?? 30_000,
}),
};
return {
module: UndiciModule,
providers: [poolProvider, UndiciService],
exports: [UndiciService],
global: true,
};
}
static forRootAsync(options: {
useFactory: (...args: any[]) => UndiciModuleOptions;
inject?: any[];
}): DynamicModule {
const poolProvider: Provider = {
provide: UNDICI_POOL,
useFactory: (...args: any[]) => {
const config = options.useFactory(...args);
return new Pool(config.baseUrl, {
connections: config.connections ?? 10,
pipelining: config.pipelining ?? 1,
keepAliveTimeout: config.keepAliveTimeout ?? 30_000,
headersTimeout: config.headersTimeout ?? 10_000,
bodyTimeout: config.bodyTimeout ?? 30_000,
});
},
inject: options.inject ?? [],
};
return {
module: UndiciModule,
providers: [poolProvider, UndiciService],
exports: [UndiciService],
global: true,
};
}
}
UndiciService: 타입 안전 HTTP 클라이언트
// undici.service.ts
import { Inject, Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
import { Pool, Dispatcher } from 'undici';
import { UNDICI_POOL } from './undici.module';
export interface RequestOptions {
headers?: Record<string, string>;
query?: Record<string, string>;
body?: unknown;
timeout?: number;
signal?: AbortSignal;
}
@Injectable()
export class UndiciService implements OnModuleDestroy {
private readonly logger = new Logger(UndiciService.name);
constructor(@Inject(UNDICI_POOL) private readonly pool: Pool) {}
async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
return this.request<T>('GET', path, options);
}
async post<T>(path: string, body: unknown, options: RequestOptions = {}): Promise<T> {
return this.request<T>('POST', path, { ...options, body });
}
async put<T>(path: string, body: unknown, options: RequestOptions = {}): Promise<T> {
return this.request<T>('PUT', path, { ...options, body });
}
async delete<T>(path: string, options: RequestOptions = {}): Promise<T> {
return this.request<T>('DELETE', path, options);
}
private async request<T>(
method: string,
path: string,
options: RequestOptions,
): Promise<T> {
const url = this.buildUrl(path, options.query);
const startTime = performance.now();
const { statusCode, headers, body } = await this.pool.request({
method: method as Dispatcher.HttpMethod,
path: url,
headers: {
'content-type': 'application/json',
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
headersTimeout: options.timeout ?? 10_000,
bodyTimeout: options.timeout ?? 30_000,
signal: options.signal,
});
const responseBody = await body.json() as T;
const duration = Math.round(performance.now() - startTime);
this.logger.debug(
`${method} ${path} → ${statusCode} (${duration}ms)`,
);
if (statusCode >= 400) {
throw new HttpClientError(method, path, statusCode, responseBody);
}
return responseBody;
}
private buildUrl(path: string, query?: Record<string, string>): string {
if (!query) return path;
const params = new URLSearchParams(query);
return `${path}?${params.toString()}`;
}
// Pool 통계 (모니터링용)
getStats() {
const stats = this.pool.stats;
return {
connected: stats.connected,
free: stats.free,
pending: stats.pending,
queued: stats.queued,
running: stats.running,
size: stats.size,
};
}
async onModuleDestroy() {
await this.pool.close();
this.logger.log('Undici Pool 종료');
}
}
// 커스텀 에러 클래스
export class HttpClientError extends Error {
constructor(
public readonly method: string,
public readonly path: string,
public readonly statusCode: number,
public readonly responseBody: unknown,
) {
super(`HTTP ${statusCode}: ${method} ${path}`);
}
}
다중 서비스 Pool 관리
마이크로서비스 환경에서 서비스별 독립 커넥션 풀을 관리하는 패턴입니다.
// multi-pool.module.ts
@Module({})
export class MultiPoolModule {
static register(services: Record<string, UndiciModuleOptions>): DynamicModule {
const providers: Provider[] = Object.entries(services).map(
([name, config]) => ({
provide: `UNDICI_POOL_${name.toUpperCase()}`,
useFactory: () => new Pool(config.baseUrl, {
connections: config.connections ?? 10,
pipelining: config.pipelining ?? 1,
}),
}),
);
return {
module: MultiPoolModule,
providers: [...providers, PoolManagerService],
exports: [PoolManagerService],
global: true,
};
}
}
// 사용 예시
@Module({
imports: [
MultiPoolModule.register({
payment: { baseUrl: 'http://payment-service:3001', connections: 20 },
inventory: { baseUrl: 'http://inventory-service:3002', connections: 10 },
notification: { baseUrl: 'http://notification-service:3003', connections: 5, pipelining: 4 },
}),
],
})
export class AppModule {}
// PoolManagerService
@Injectable()
export class PoolManagerService implements OnModuleDestroy {
constructor(
@Inject('UNDICI_POOL_PAYMENT') private readonly paymentPool: Pool,
@Inject('UNDICI_POOL_INVENTORY') private readonly inventoryPool: Pool,
@Inject('UNDICI_POOL_NOTIFICATION') private readonly notificationPool: Pool,
) {}
getPool(service: 'payment' | 'inventory' | 'notification'): Pool {
const pools = {
payment: this.paymentPool,
inventory: this.inventoryPool,
notification: this.notificationPool,
};
return pools[service];
}
async onModuleDestroy() {
await Promise.all([
this.paymentPool.close(),
this.inventoryPool.close(),
this.notificationPool.close(),
]);
}
}
Retry + Circuit Breaker Dispatcher
Undici의 Dispatcher 체인으로 재시도와 서킷브레이커를 구현합니다.
import { Pool, RetryAgent, Dispatcher } from 'undici';
// RetryAgent: 자동 재시도
const retryPool = new RetryAgent(
new Pool('http://payment-service:3001', { connections: 10 }),
{
maxRetries: 3,
minTimeout: 500, // 첫 재시도 대기 ms
maxTimeout: 5000, // 최대 대기 ms
timeoutFactor: 2, // 지수 백오프 배수
retryAfter: true, // Retry-After 헤더 존중
statusCodes: [502, 503, 504], // 재시도 대상 상태 코드
errorCodes: [
'ECONNRESET',
'ECONNREFUSED',
'UND_ERR_SOCKET',
],
},
);
// 커스텀 Circuit Breaker Dispatcher
class CircuitBreakerDispatcher extends Dispatcher {
private failures = 0;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private nextAttempt = 0;
constructor(
private readonly upstream: Pool,
private readonly threshold: number = 5,
private readonly resetTimeout: number = 30_000,
) {
super();
}
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
const error = new Error('Circuit breaker OPEN');
handler.onError?.(error);
return false;
}
this.state = 'HALF_OPEN';
}
return this.upstream.dispatch(opts, {
...handler,
onHeaders: (statusCode, headers, resume, statusText) => {
if (statusCode >= 500) {
this.recordFailure();
} else {
this.recordSuccess();
}
return handler.onHeaders?.(statusCode, headers, resume, statusText) ?? true;
},
onError: (err) => {
this.recordFailure();
handler.onError?.(err);
},
});
}
private recordFailure() {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.resetTimeout;
}
}
private recordSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
}
스트리밍 응답 처리
// 대용량 응답 스트리밍 (메모리 효율)
async streamLargeResponse(path: string): Promise<ReadableStream> {
const { body, statusCode } = await this.pool.request({
method: 'GET',
path,
});
if (statusCode !== 200) {
// body를 소비해야 커넥션 반환
await body.dump();
throw new Error(`HTTP ${statusCode}`);
}
// Node.js ReadableStream으로 반환
return body;
}
// NDJSON 스트리밍 파싱
async *streamNdjson<T>(path: string): AsyncGenerator<T> {
const { body } = await this.pool.request({
method: 'GET',
path,
headers: { accept: 'application/x-ndjson' },
});
let buffer = '';
for await (const chunk of body) {
buffer += chunk.toString();
const lines = buffer.split('n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.trim()) {
yield JSON.parse(line) as T;
}
}
}
}
성능 튜닝 가이드
// 최적 설정 (서비스 특성별)
// API Gateway → 백엔드: 높은 동시성
new Pool(url, {
connections: 50, // 백엔드당 최대 커넥션
pipelining: 1, // HTTP/1.1 파이프라이닝 (서버 지원 확인 필수)
keepAliveTimeout: 60_000,
});
// 외부 API 호출: 보수적 설정
new Pool(url, {
connections: 10,
pipelining: 1,
headersTimeout: 5_000,
bodyTimeout: 15_000,
keepAliveTimeout: 15_000,
});
// 내부 마이크로서비스: 파이프라이닝 활용
new Pool(url, {
connections: 20,
pipelining: 4, // 한 커넥션에 4개 요청 파이프라이닝
keepAliveTimeout: 120_000,
});
모니터링과 헬스체크
// Pool 상태 모니터링 엔드포인트
@Controller('health')
export class HealthController {
constructor(private readonly undici: UndiciService) {}
@Get('pools')
getPoolStats() {
return this.undici.getStats();
// { connected: 8, free: 2, pending: 0, queued: 0, running: 6, size: 10 }
}
}
// 주의사항:
// - body를 반드시 소비해야 커넥션이 풀에 반환됨
// → body.json(), body.text(), body.dump()
// - AbortSignal로 타임아웃 제어 필수
// - Pool.close()를 onModuleDestroy에서 호출해야 graceful shutdown
Undici는 Node.js 생태계에서 가장 빠른 HTTP 클라이언트입니다. NestJS의 기본 HttpModule이 성능 병목이라면 Undici Pool로 전환을 고려하세요. NestJS HTTP 관련 패턴은 NestJS Middleware 실전 설계 가이드를, 서킷브레이커 패턴은 Spring Resilience4j 서킷브레이커 글을 참고하세요.