-
Notifications
You must be signed in to change notification settings - Fork 3
NestJS 요청 응답 처리 파이프라인
NestJS의 파이프라인을 적절히 활용하여 로깅, 인증, 예외 처리 등을 체계적으로 구현한 과정에 대해 기록합니다.
프로젝트를 진행하면서 다음과 같은 요구사항들이 있었습니다:
- HTTP 요청/응답의 전 과정을 추적하고 모니터링할 수 있는 로깅 시스템 필요
- JWT 및 세션 기반의 안전한 인증/인가 시스템 구축 필요
- 일관된 응답 형식과 예외 처리를 통한 클라이언트와의 안정적인 통신 보장 필요
이러한 요구사항들을 Controller-Service-Repository Layer에서 처리하기보다는, NestJS의 파이프라인을 활용하여 비즈니스 로직과 분리하는 방향으로 설계했습니다.
NestJS 파이프라인의 각 컴포넌트별 실행 순서를 고려하여 다음과 같이 구현했습니다:
- Middleware (가장 먼저 실행) - 로깅
- Guards (두 번째) - 인증/인가
- Interceptors (세 번째) - 응답 형식 통일
- Exception Filters (예외 발생 시) - 에러 처리
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();
}
}
인증을 위해 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,
) {
//생략
}
저희 팀은 다음과 같은 형식으로 성공 응답을 통일하여 응답하기로 약속했습니다.
{
"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 한 곳의 변경만으로 쉽게 해결할 수 있었습니다.
두 가지 레벨의 Exception Filter를 구현했습니다 :
- HttpExceptionFilter: HTTP 예외 전용 처리
- GlobalExceptionFilter: 미처리 예외 포착
이 두 가지 Exception Filter는 응답 형식 표준화 및 에러 로깅을 위해 사용되었습니다.
Exception Filter가 여러개이면 구체적인 것부터 실행된다는 특징을 살려 코드를 작성했습니다.
-
코드 중복 감소
Interceptor 도입 덕분에 Controller에서 매번 반복되던 return 형식 코드를 줄일 수 있었습니다.
-
유지보수성 향상
관심사 분리를 통해 비즈니스 로직만을 명확히 구분해낼 수 있었습니다.
-
응답 시간 모니터링
로깅 시스템을 통해 각 엔드포인트별 응답 시간을 추적하여 성능 병목 지점 식별할 수 있게 되었습니다.