AsyncLocalStorage란?
Node.js의 AsyncLocalStorage는 Java의 ThreadLocal에 해당하는 API로, 비동기 호출 체인 전체에 걸쳐 컨텍스트 데이터를 전파한다. NestJS에서 요청 ID, 사용자 정보, 테넌트 ID 등을 서비스 계층까지 전달할 때 매번 파라미터로 넘기지 않고 암묵적으로 공유할 수 있다.
import { AsyncLocalStorage } from 'async_hooks';
// 요청 컨텍스트 타입
interface RequestContext {
requestId: string;
userId?: string;
tenantId?: string;
startTime: number;
}
// 글로벌 스토리지 인스턴스
export const requestStorage = new AsyncLocalStorage<RequestContext>();
// 사용: 어디서든 현재 요청 컨텍스트 접근
const ctx = requestStorage.getStore();
console.log(ctx?.requestId); // 'abc-123'
AsyncLocalStorage.run()으로 스코프를 열면, 그 안에서 실행되는 모든 비동기 코드(Promise, setTimeout, DB 쿼리 콜백 등)에서 getStore()로 동일한 컨텍스트에 접근할 수 있다.
NestJS 미들웨어로 컨텍스트 초기화
요청마다 AsyncLocalStorage.run()으로 새 스코프를 시작하는 미들웨어를 만든다.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import { requestStorage, RequestContext } from './request-storage';
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const context: RequestContext = {
requestId: (req.headers['x-request-id'] as string) || randomUUID(),
userId: undefined, // Guard에서 설정
tenantId: req.headers['x-tenant-id'] as string,
startTime: Date.now(),
};
// 응답 헤더에 요청 ID 포함
res.setHeader('X-Request-Id', context.requestId);
// 이 스코프 안에서 실행되는 모든 코드가 context 접근 가능
requestStorage.run(context, () => next());
}
}
// AppModule에 미들웨어 등록
@Module({
imports: [],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(RequestContextMiddleware)
.forRoutes('*'); // 모든 라우트에 적용
}
}
서비스로 추상화: RequestContextService
직접 requestStorage.getStore()를 호출하는 대신, NestJS의 DI를 활용한 서비스로 감싸면 테스트와 유지보수가 쉬워진다.
@Injectable()
export class RequestContextService {
getContext(): RequestContext {
const store = requestStorage.getStore();
if (!store) {
throw new Error('RequestContext not initialized');
}
return store;
}
getRequestId(): string {
return this.getContext().requestId;
}
getUserId(): string | undefined {
return this.getContext().userId;
}
setUserId(userId: string): void {
this.getContext().userId = userId;
}
getTenantId(): string | undefined {
return this.getContext().tenantId;
}
getElapsedMs(): number {
return Date.now() - this.getContext().startTime;
}
}
// Guard에서 userId 설정
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reqCtx: RequestContextService,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
const payload = this.jwtService.verify(token);
this.reqCtx.setUserId(payload.sub); // 컨텍스트에 저장
return true;
}
}
// 서비스에서 파라미터 없이 사용
@Injectable()
export class OrderService {
constructor(
private reqCtx: RequestContextService,
private orderRepo: OrderRepository,
private logger: Logger,
) {}
async createOrder(dto: CreateOrderDto) {
const userId = this.reqCtx.getUserId();
const requestId = this.reqCtx.getRequestId();
this.logger.log(`[${requestId}] 주문 생성: user=${userId}`);
return this.orderRepo.save({
...dto,
userId,
createdBy: userId,
});
}
}
REQUEST 스코프 vs AsyncLocalStorage
NestJS는 @Injectable({ scope: Scope.REQUEST })로 요청 스코프 프로바이더를 지원한다. 하지만 성능 차이가 크다.
| 비교 | REQUEST 스코프 | AsyncLocalStorage |
|---|---|---|
| 인스턴스 생성 | 요청마다 전체 DI 트리 재생성 | 싱글턴 유지 |
| 성능 영향 | 심각 (의존 체인 전파) | 미미 (V8 최적화) |
| 전파 범위 | DI 주입된 곳만 | 모든 비동기 코드 |
| Scope 버블링 | 있음 (주입받는 모든 서비스도 REQUEST로) | 없음 |
| WebSocket/gRPC | 제한적 | 완전 지원 |
REQUEST 스코프의 가장 큰 문제는 스코프 버블링이다. REQUEST 스코프 프로바이더를 주입받는 서비스도 자동으로 REQUEST 스코프가 되어, DI 체인 전체가 요청마다 재생성된다. AsyncLocalStorage는 이 문제가 없다.
멀티테넌시 패턴
AsyncLocalStorage는 멀티테넌트 설계에서 테넌트별 DB 연결 전환에 유용하다.
@Injectable()
export class TenantAwareRepository {
constructor(
private reqCtx: RequestContextService,
private dataSources: Map<string, DataSource>,
) {}
getRepository<T>(entity: EntityTarget<T>): Repository<T> {
const tenantId = this.reqCtx.getTenantId();
if (!tenantId) throw new Error('Tenant not resolved');
const ds = this.dataSources.get(tenantId);
if (!ds) throw new Error(`DataSource not found: ${tenantId}`);
return ds.getRepository(entity);
}
}
// 사용: 테넌트 ID를 명시적으로 넘기지 않아도 됨
@Injectable()
export class UserService {
constructor(private tenantRepo: TenantAwareRepository) {}
async findAll(): Promise<User[]> {
const repo = this.tenantRepo.getRepository(User);
return repo.find(); // 현재 요청의 테넌트 DB에서 조회
}
}
로깅 통합: 자동 요청 ID 주입
모든 로그에 요청 ID를 자동으로 포함시키는 패턴이다.
import { ConsoleLogger, Injectable } from '@nestjs/common';
@Injectable()
export class ContextAwareLogger extends ConsoleLogger {
private formatMessage(message: string): string {
const store = requestStorage.getStore();
if (store) {
return `[${store.requestId}] [${store.userId || 'anonymous'}] ${message}`;
}
return message;
}
log(message: string, ...args: any[]) {
super.log(this.formatMessage(message), ...args);
}
error(message: string, ...args: any[]) {
super.error(this.formatMessage(message), ...args);
}
warn(message: string, ...args: any[]) {
super.warn(this.formatMessage(message), ...args);
}
}
// AppModule에서 글로벌 로거로 등록
const app = await NestFactory.create(AppModule, {
logger: new ContextAwareLogger(),
});
테스트에서의 컨텍스트 설정
describe('OrderService', () => {
let service: OrderService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [OrderService, RequestContextService, /* ... */],
}).compile();
service = module.get(OrderService);
});
it('should create order with current user', async () => {
const ctx: RequestContext = {
requestId: 'test-req-1',
userId: 'user-42',
tenantId: 'tenant-a',
startTime: Date.now(),
};
// AsyncLocalStorage 스코프 안에서 테스트 실행
await requestStorage.run(ctx, async () => {
const order = await service.createOrder({ productId: 1, quantity: 2 });
expect(order.userId).toBe('user-42');
});
});
});
// 헬퍼 함수로 간소화
function withContext(ctx: Partial<RequestContext>, fn: () => Promise<void>) {
const full: RequestContext = {
requestId: ctx.requestId || 'test',
userId: ctx.userId,
tenantId: ctx.tenantId,
startTime: ctx.startTime || Date.now(),
};
return requestStorage.run(full, fn);
}
주의사항
| 주의 사항 | 설명 |
|---|---|
| Worker Threads | AsyncLocalStorage는 워커 스레드 간 전파 안 됨 |
| EventEmitter | 수동 emit 시 컨텍스트 손실 가능 → run() 내부에서 emit |
| 네이티브 애드온 | 일부 C++ 애드온 콜백에서 컨텍스트 손실 |
| 메모리 | 스코프 종료 시 자동 GC, 큰 객체 저장 주의 |
정리
Node.js AsyncLocalStorage는 NestJS에서 요청 컨텍스트를 전파하는 가장 효율적인 방법이다. REQUEST 스코프의 성능 문제와 스코프 버블링을 완전히 회피하면서, 요청 ID 로깅, 사용자 인증 정보 전달, Interceptor와의 연동, 멀티테넌시 DB 전환까지 모든 요청 스코프 패턴을 싱글턴으로 구현할 수 있다.