NestJS AsyncLocalStorage 실전

AsyncLocalStorage란?

Node.js의 AsyncLocalStorage는 Java의 ThreadLocal과 동일한 역할을 한다. 비동기 호출 체인 전체에서 컨텍스트 데이터를 암묵적으로 전파하는 메커니즘이다. Express/Fastify의 요청 객체를 매개변수로 끌고 다니지 않아도, 어떤 레이어에서든 현재 요청의 사용자 정보, 트레이싱 ID, 테넌트 정보에 접근할 수 있다.

NestJS에서는 @nestjs/coreClsModule이나 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-clsProxy 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를 사용하면 성능 저하 없이 컨텍스트 전파를 달성할 수 있다.

관련 글: NestJS 멀티테넌시 설계 | NestJS 구조화 로깅: Pino·CLS

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux