AsyncLocalStorage란?
Node.js의 AsyncLocalStorage는 Java의 ThreadLocal과 동일한 역할을 한다. 비동기 호출 체인 전체에서 컨텍스트 데이터를 암묵적으로 전파하는 메커니즘이다. Express/Fastify의 요청 객체를 매개변수로 끌고 다니지 않아도, 어떤 레이어에서든 현재 요청의 사용자 정보, 트레이싱 ID, 테넌트 정보에 접근할 수 있다.
NestJS에서는 @nestjs/core의 ClsModule이나 nestjs-cls 라이브러리로 이를 통합한다. 하지만 라이브러리 없이 직접 구현하면 동작 원리를 완벽히 이해할 수 있고, 프로젝트 요구에 맞게 커스텀할 수 있다.
Node.js AsyncLocalStorage 기초
import { AsyncLocalStorage } from 'async_hooks';
// 스토리지 인스턴스 생성
const als = new AsyncLocalStorage<Map<string, any>>();
// 컨텍스트 내에서 실행
als.run(new Map(), () => {
const store = als.getStore();
store.set('requestId', 'abc-123');
// 비동기 호출 체인에서도 컨텍스트 유지
setTimeout(() => {
const store = als.getStore();
console.log(store.get('requestId')); // 'abc-123' ✅
}, 100);
// Promise 체인에서도 유지
Promise.resolve()
.then(() => als.getStore().get('requestId')) // 'abc-123' ✅
.then(console.log);
});
핵심은 als.run(store, callback)이다. callback 내부에서 발생하는 모든 비동기 작업(setTimeout, Promise, async/await)에서 동일한 store에 접근할 수 있다.
NestJS에서 직접 구현: RequestContext
Step 1: 컨텍스트 클래스 정의
// request-context.ts
import { AsyncLocalStorage } from 'async_hooks';
export interface RequestContextData {
requestId: string;
userId?: string;
tenantId?: string;
startTime: number;
metadata: Record<string, any>;
}
export class RequestContext {
private static readonly als = new AsyncLocalStorage<RequestContextData>();
// 컨텍스트 시작
static run(data: RequestContextData, fn: () => void): void {
this.als.run(data, fn);
}
// 현재 컨텍스트 조회
static get(): RequestContextData | undefined {
return this.als.getStore();
}
// 개별 필드 접근 (편의 메서드)
static getRequestId(): string {
return this.get()?.requestId ?? 'unknown';
}
static getUserId(): string | undefined {
return this.get()?.userId;
}
static getTenantId(): string | undefined {
return this.get()?.tenantId;
}
// 컨텍스트에 값 추가
static set<K extends keyof RequestContextData>(
key: K,
value: RequestContextData[K],
): void {
const store = this.get();
if (store) {
store[key] = value;
}
}
// 메타데이터 추가
static setMeta(key: string, value: any): void {
const store = this.get();
if (store) {
store.metadata[key] = value;
}
}
}
Step 2: 미들웨어로 컨텍스트 초기화
// request-context.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import { RequestContext } from './request-context';
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const requestId = (req.headers['x-request-id'] as string)
?? randomUUID();
const contextData = {
requestId,
startTime: Date.now(),
metadata: {},
};
// 응답 헤더에 Request ID 포함
res.setHeader('X-Request-Id', requestId);
// AsyncLocalStorage 컨텍스트 안에서 나머지 파이프라인 실행
RequestContext.run(contextData, () => {
next();
});
}
}
// app.module.ts
@Module({ ... })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(RequestContextMiddleware)
.forRoutes('*'); // 모든 라우트에 적용
}
}
Step 3: Guard에서 사용자 정보 주입
// auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
const payload = this.jwtService.verify(token);
// Guard에서 컨텍스트에 사용자 정보 주입
RequestContext.set('userId', payload.sub);
RequestContext.set('tenantId', payload.tenantId);
return true;
}
}
어디서든 컨텍스트 접근
AsyncLocalStorage의 진가는 매개변수 전달 없이 어떤 레이어에서든 컨텍스트에 접근할 수 있다는 것이다.
// order.service.ts — Service 레이어
@Injectable()
export class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly logger: AppLogger,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
// RequestContext에서 직접 userId 가져옴 (매개변수 불필요!)
const userId = RequestContext.getUserId();
const tenantId = RequestContext.getTenantId();
this.logger.log(`Creating order for user ${userId}`);
// 로거도 내부적으로 RequestContext 사용
return this.orderRepo.save({
...dto,
userId,
tenantId,
});
}
}
// order.repository.ts — Repository 레이어
@Injectable()
export class OrderRepository {
constructor(private readonly dataSource: DataSource) {}
async save(order: Partial<Order>): Promise<Order> {
// Repository에서도 컨텍스트 접근 가능
const tenantId = RequestContext.getTenantId();
return this.dataSource
.getRepository(Order)
.save({ ...order, tenantId });
}
}
구조화 로깅: Correlation ID 자동 주입
// app.logger.ts
@Injectable()
export class AppLogger {
private readonly logger = new Logger();
log(message: string, context?: string): void {
const ctx = RequestContext.get();
this.logger.log({
message,
requestId: ctx?.requestId,
userId: ctx?.userId,
tenantId: ctx?.tenantId,
elapsed: ctx ? Date.now() - ctx.startTime : undefined,
context,
});
}
error(message: string, trace?: string): void {
const ctx = RequestContext.get();
this.logger.error({
message,
requestId: ctx?.requestId,
userId: ctx?.userId,
trace,
});
}
}
// 로그 출력 예시:
// {
// "message": "Creating order for user usr_abc",
// "requestId": "550e8400-e29b-41d4-a716-446655440000",
// "userId": "usr_abc",
// "tenantId": "tenant_xyz",
// "elapsed": 23,
// "context": "OrderService"
// }
nestjs-cls 라이브러리 활용
직접 구현 대신 nestjs-cls 라이브러리를 사용하면 더 간편하다. NestJS의 DI 시스템과 완전히 통합되어 있다.
// 설치: npm install nestjs-cls
// app.module.ts
import { ClsModule } from 'nestjs-cls';
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: {
mount: true, // 미들웨어 자동 등록
setup: (cls, req) => {
// 미들웨어에서 초기값 설정
cls.set('requestId', req.headers['x-request-id'] ?? randomUUID());
cls.set('ip', req.ip);
},
},
}),
],
})
export class AppModule {}
// 서비스에서 사용
@Injectable()
export class OrderService {
constructor(private readonly cls: ClsService) {}
async createOrder(dto: CreateOrderDto) {
const requestId = this.cls.get('requestId');
const userId = this.cls.get('userId');
// ...
}
}
// 타입 안전한 CLS (제네릭)
interface AppStore {
requestId: string;
userId: string;
tenantId: string;
permissions: string[];
}
@Injectable()
export class TypedService {
constructor(private readonly cls: ClsService<AppStore>) {}
getUser(): string {
return this.cls.get('userId'); // 타입 추론됨
}
}
Proxy Provider: REQUEST 스코프 대체
NestJS의 Scope.REQUEST는 성능 문제가 있다. 요청마다 프로바이더를 재생성하기 때문이다. nestjs-cls의 Proxy Provider는 이를 AsyncLocalStorage 기반으로 대체한다.
// 기존: Scope.REQUEST (성능 문제)
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
constructor(@Inject(REQUEST) private request: Request) {}
getUserId() { return this.request.user.id; }
}
// 대체: CLS Proxy Provider (성능 최적)
// tenant-context.ts
@Injectable()
@InjectableProxy() // CLS 프록시로 등록
export class TenantContext {
tenantId: string;
dbSchema: string;
features: string[];
}
// app.module.ts
ClsModule.forRoot({
global: true,
middleware: { mount: true },
proxyProviders: [TenantContext], // 프록시 프로바이더 등록
})
// Guard에서 설정
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private readonly tenantContext: TenantContext) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const tenantId = this.extractTenantId(context);
const config = await this.loadTenantConfig(tenantId);
// 프록시 객체에 값 설정
this.tenantContext.tenantId = tenantId;
this.tenantContext.dbSchema = config.schema;
this.tenantContext.features = config.features;
return true;
}
}
// 서비스에서 주입받아 사용 (싱글톤처럼 동작하지만 요청별 값)
@Injectable()
export class ProductService {
constructor(
private readonly tenantContext: TenantContext, // 일반 DI
) {}
async getProducts() {
// 현재 요청의 테넌트 정보에 접근
const schema = this.tenantContext.dbSchema;
return this.dataSource.query(
`SELECT * FROM "${schema}".products`
);
}
}
주의사항과 함정
1. 이벤트 리스너에서 컨텍스트 유실
// ❌ EventEmitter2 이벤트는 새로운 비동기 컨텍스트
@OnEvent('order.created')
handleOrderCreated(event: OrderCreatedEvent) {
// RequestContext.get() → undefined!
}
// ✅ 이벤트에 컨텍스트 데이터를 포함시킴
this.eventEmitter.emit('order.created', {
...orderData,
_context: {
requestId: RequestContext.getRequestId(),
userId: RequestContext.getUserId(),
},
});
2. BullMQ/큐 워커에서 컨텍스트 없음
// 큐 프로세서는 별도 실행 컨텍스트 → ALS 없음
// Job 데이터에 필요한 컨텍스트를 직접 포함해야 함
@Processor('orders')
export class OrderProcessor {
@Process()
async process(job: Job<OrderJob>) {
const { requestId, userId, tenantId } = job.data;
// 수동으로 컨텍스트 복원
RequestContext.run(
{ requestId, userId, tenantId, startTime: Date.now(), metadata: {} },
() => this.processOrder(job.data)
);
}
}
3. 성능 오버헤드
// AsyncLocalStorage의 오버헤드는 매우 작음
// Node.js 16+에서 약 2~5% 수준
// 그러나 store에 대용량 데이터를 저장하면 GC 부담 증가
// ✅ 좋은 예: 가벼운 식별자만 저장
{ requestId: 'uuid', userId: 'id', tenantId: 'tid' }
// ❌ 나쁜 예: 대용량 객체 저장
{ requestId: 'uuid', user: { ...fullUserObject }, permissions: [...hundreds] }
AsyncLocalStorage는 NestJS에서 횡단 관심사(Cross-cutting Concerns)를 우아하게 해결하는 핵심 도구다. 로깅, 멀티테넌시, 감사 로그, 트레이싱 — 모두 “현재 요청의 컨텍스트”가 필요한 문제다. Scope.REQUEST 대신 AsyncLocalStorage를 사용하면 성능 저하 없이 컨텍스트 전파를 달성할 수 있다.