Skip to content

NestJS 요청 응답 처리 파이프라인

wlgh1553 edited this page Dec 3, 2024 · 3 revisions

📄 NestJS 요청/응답 처리 파이프라인

NestJS의 파이프라인을 적절히 활용하여 로깅, 인증, 예외 처리 등을 체계적으로 구현한 과정에 대해 기록합니다.

🧩 배경 및 필요성

프로젝트를 진행하면서 다음과 같은 요구사항들이 있었습니다:

  • HTTP 요청/응답의 전 과정을 추적하고 모니터링할 수 있는 로깅 시스템 필요
  • JWT 및 세션 기반의 안전한 인증/인가 시스템 구축 필요
  • 일관된 응답 형식과 예외 처리를 통한 클라이언트와의 안정적인 통신 보장 필요

이러한 요구사항들을 Controller-Service-Repository Layer에서 처리하기보다는, NestJS의 파이프라인을 활용하여 비즈니스 로직과 분리하는 방향으로 설계했습니다.

🗺️ 문제 해결 과정

NestJS 파이프라인의 각 컴포넌트별 실행 순서를 고려하여 다음과 같이 구현했습니다:

  1. Middleware (가장 먼저 실행) - 로깅
  2. Guards (두 번째) - 인증/인가
  3. Interceptors (세 번째) - 응답 형식 통일
  4. Exception Filters (예외 발생 시) - 에러 처리

1. Logger 도입을 위한 Middleware 구현

Middleware를 선택한 이유는 다음과 같습니다 :

  • 요청 처리 전 가장 먼저 실행되어 모든 HTTP 요청을 캡처 가능
  • res.on('finish') 이벤트를 통해 응답 완료 시점까지 추적 가능
  • Interceptor 대비 더 이른 시점에 동작하여 전체 요청-응답 사이클 추적에 적합
@Injectable()
export class HttpLoggerMiddleware implements NestMiddleware {
  constructor(private readonly logger: LoggerService) {}

  use(req: Request, res: Response, next: NextFunction) {
    //요청 정보 수집
    const { method, originalUrl } = req;
    const userAgent = req.get('user-agent') || '';
    const clientIp = req.headers['x-forwarded-for'] || req.ip;
    const formattedIp = clientIp === '::1' ? '127.0.0.1' : clientIp; // IPv6 루프백 변환
    const startTime = Date.now();

    //응답 완료 시점에 로그 기록
    res.on('finish', () => {
      const { statusCode } = res;
      const contentLength = res.get('content-length');
      const elapsedTime = Date.now() - startTime;

      const logMessage = `[HTTP] ${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${userAgent} ${elapsedTime}ms - IP: ${formattedIp}`;

      this.logger.log(logMessage);
    });

    next();
  }
}

2. 계층화된 인증을 위한 Guards 구현

인증을 위해 Guard를 선택한 이유는 다음과 같습니다 :

  • 라우트 핸들러 바로 위에 데코레이터로 인증 로직 명시적 표현 가능
  • 여러 Guard를 조합하여 복잡한 인증 로직 구현 가능

다음과 같이 다양한 Guard를 만들고 조합하여 활용했습니다.

@Post()
@UseGuards(JwtAuthGuard)
async create(@Body() createSessionDto: CreateSessionDto, @Req() request: Request) {
  //생략
}
@Patch(':questionId/body')
@UseGuards(SessionTokenValidationGuard, QuestionExistenceGuard, QuestionOwnershipGuard)
async updateQuestionBody(
  @Param('questionId', ParseIntPipe) questionId: number,
  @Body() updateQuestionBodyDto: UpdateQuestionBodyDto,
  @Req() req: any,
) {
  //생략
}

3. 응답 형식 표준화를 위한 Interceptor 구현

저희 팀은 다음과 같은 형식으로 성공 응답을 통일하여 응답하기로 약속했습니다.

{
  "type": "success",
  "data": {}
}

이를 위해 TransformInterceptor를 구현하여 컨트롤러의 반복적인 코드를 제거했습니다:

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<object> {
    return next.handle().pipe(
      map((data) => {
        return {
	        type: "success",
	        data,
        }
      }),
    );
  }
}

구현 이후에 type: success를 응답으로 보내는 것이 불필요하다고 판단되어 응답 형식을 변경해야 하는 상황이 발생했습니다. 하지만 Interceptor를 사용했기에 모든 컨트롤러를 수정하지 않고도 Interceptor 한 곳의 변경만으로 쉽게 해결할 수 있었습니다.

4. 체계적인 예외 처리를 위한 Exception Filters 구현

두 가지 레벨의 Exception Filter를 구현했습니다 :

  1. HttpExceptionFilter: HTTP 예외 전용 처리
  2. GlobalExceptionFilter: 미처리 예외 포착

이 두 가지 Exception Filter는 응답 형식 표준화 및 에러 로깅을 위해 사용되었습니다.

Exception Filter가 여러개이면 구체적인 것부터 실행된다는 특징을 살려 코드를 작성했습니다.

📈 결과 및 성과

  1. 코드 중복 감소

    Interceptor 도입 덕분에 Controller에서 매번 반복되던 return 형식 코드를 줄일 수 있었습니다.

  2. 유지보수성 향상

    관심사 분리를 통해 비즈니스 로직만을 명확히 구분해낼 수 있었습니다.

  3. 응답 시간 모니터링

    로깅 시스템을 통해 각 엔드포인트별 응답 시간을 추적하여 성능 병목 지점 식별할 수 있게 되었습니다.

Clone this wiki locally