NestJS - @Res() 데코레이터와 Interceptor를 함께 사용하면서 겪었던 이슈

2025. 3. 5. 21:55Nodejs

반응형

Node.js로 백엔드 개발을 시작하면 Express, Koa, Fastify 같은 프레임워크를 자주 접하게 됩니다. 특히 Express는 Nodejs 환경에서 가장 널리 사용되는 웹 프레임워크 중 하나입니다. 저 역시 첫 시작은 Express로 했고, 프로젝트의 특성에 따라 Koa를 활용해 더 미니멀한 방식으로 서버를 구축한 경험이 있습니다.

 

그러나 최근에는 NestJS를 사용하는 경우가 점점 많아지고 있습니다. 채용 공고만 살펴봐도 대부분 NestJS 사용 경험을 요구하는 경우가 많죠. 그렇다고 NestJS가 Express나 Fastify와 관련이 없는 것은 아닙니다. NestJS는 기본적으로 Express 또는 Fastify를 기반으로 동작하는 프레임워크로, 구조화된 아키텍처와 풍부한 내장 기능을 제공합니다. 공식 문서를 보면 알 수 있듯이, NestJS는 REST API, GraphQL, WebSocket, 마이크로서비스 등 다양한 기능을 제공합니다. 그래서 저도 최근 몇 년간 진행했던 프로젝트는 모두 NestJS를 사용하였습니다.

 

그런데 최근에 프로젝트를 진행하면서 기존에 만들었던 Logging Interceptor와 Serialize Interceptor를 개선하게 되었는데요. 이 과정에서 겪게 된 이슈를 기록하고자 글을 쓰게 되었습니다.

 

1. NestJS 프레임워크의 동작

NestJS는 @Controller() 데코레이터를 통해 클라이언트의 요청을 처리하고 응답을 반환하는 컨트롤러를 선언합니다.

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return [{ name: 'kitty', breed: 'British Blue' }];
  }
}

 

Express를 사용한다면 아래와 같이 만들 수 있습니다. Express에서는 res 객체에 직접 응답을 설정합니다.

// router.js
import express from 'express';

const router = express.Router();

router.get('/', (req, res) => {
  res.status(200).json([{ name: 'kitty', breed: 'British Blue'}])
})

export default router;

// app.js
import express from 'express';
import router from './router';

const app = express();

app.use('/cats', router);

app.listen(3000, () => {
  console.log('Start server at localhost:3000')
})

 

물론 NestJS도 @Res() 데코레이터를 사용하면 직접 res 객체를 가져와서 사용할 수 있습니다. 하지만 대부분의 경우 이렇게 할 필요는 없습니다. NestJS는 기본적으로 컨트롤러에서 값을 반환하면 자동으로 JSON 응답을 처리해 주기 때문입니다. 즉, res.json()을 직접 호출하지 않아도 프레임워크에서 적절한 응답 형식을 생성해 줍니다. 공식 문서에서는 이러한 방식을 기본(Standard) 응답 처리 방식으로 설명하며, 다음과 같이 소개하고 있습니다.

NestJS의 기본 메서드를 사용하면, 요청 핸들러가 JavaScript 객체나 배열을 반환할 때 자동으로 JSON으로 직렬화됩니다. 그러나 문자열, 숫자, 불리언과 같은 JavaScript 기본 타입을 반환하면, NestJS는 이를 직렬화하지 않고 그대로 응답합니다. 따라서 응답 처리는 매우 간단하며, 단순히 값을 반환하면 NestJS가 나머지 작업을 처리해 줍니다.
또한, 응답의 기본 상태 코드는 일반적으로 200이며, POST 요청의 경우 201이 사용됩니다. 이 동작은 핸들러 수준에서 @HttpCode(...) 데코레이터를 추가하여 쉽게 변경할 수 있습니다.

 

2. @Res() 데코레이터를 언제 사용할까?

그렇다면 NestJS에서 제공하는 @Res() 데코레이터는는 언제 사용까요? 주로, 응답 헤더를 설정하거나 쿠키를 설정할 때, 또는 조건문을 통해 상태 코드를 다르게 응답해야 하는 경우가 있을 수 있습니다.

근데 이 @Res() 데코레이터를 사용할 때 주의해야 할 점이 있습니다. 공식 문서에 나와있는 것 처럼 @Res() 데코레이터를 사용하게 되면, 프레임워크가 이를 감지하여 기본 응답 처리 방식을 비활성화하게 됩니다.

Nest는 핸들러가 @Res() 또는 @Next()를 사용하는 경우를 감지하여 라이브러리별 옵션을 선택했음을 나타냅니다. 두 가지 접근 방식을 동시에 사용하는 경우 표준 접근 방식은 이 단일 경로에 대해 자동으로 비활성화되고 더 이상 예상대로 작동하지 않습니다. 두 가지 접근 방식을 동시에 사용하려면(예: 쿠키/헤더만 설정하도록 응답 객체를 주입하고 나머지는 프레임워크에 맡기는 경우) @Res({passthrough: true}) 데코레이터에서 passthrough 옵션을 true로 설정해야 합니다.

 

이로 인해 Interceptor가 정상적으로 동작하지 않게 됩니다. Interceptor는 원래 컨트롤러에서 반환된 데이터를 가공하거나 로깅, 변환 등의 작업을 수행하는 역할을 하지만, @Res()를 사용하면 컨트롤러가 직접 응답을 반환하기 때문에 Interceptor가 실행되지 않습니다. 정확하게는 실행은 되지만, 응답에 관여할 수 없습니다.

이 문제를 해결하기 위해 NestJS는 @Res() 데코레이터에 passthrough 옵션을 제공합니다. 이 옵션을 사용하면 기본 응답 처리 방식을 다시 활성화할 수 있습니다.

저는 프로젝트를 진행하면서 Interceptor를 자주 활용하는데요. 주로 요청과 응답 정보를 로그로 남기거나, 응답 값을 특정 형태로 변환할 때 사용합니다. 따라서 @Res() 데코레이터를 사용할 경우, 항상 passthrough 옵션을 함께 사용합니다.

 

3. @Res() 데코레이터와 Interceptor를 함께 사용하면서 겪었던 이슈

위에서 언급한 것 처럼 저는 요청과 응답 정보를 로그로 남기거나 응답 값을 특정 형태로 변환할 때 사용합니다.

 

먼저, 요청과 응답 정보를 로그로 남기는 LoggingInterceptor는 response 객체의 json 메소드를 확장하는 방식으로 작성해서 사용했었습니다. 컨트롤러는 @Res() 데코레이터를 통해 res 객체를 가져오고, res.json()을 통해 응답을 설정합니다.

[cats.controller.ts]

import { Response } from 'express';
import { Controller, Get, Res, UseInterceptors } from '@nestjs/common';
import { LoggingInterceptor } from './logging.interceptor';

@Controller('/cats')
export class CatsController {
  @UseInterceptors(LoggingInterceptor)
  @Get()
  findAll(@Res() res: Response) {
    const data = [{ name: 'kitty', breed: 'British Blue' }];
    res.status(200).json(data);
  }
}

 

[logging.interceptor.ts]

import { Response } from 'express';
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { nanoid } from 'nanoid';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    const request = context.switchToHttp().getRequest<any>();
    const response = context.switchToHttp().getResponse<Response>();

    const logId = nanoid();

    /** 요청 로그 */
    const { method, path } = request;
    const requestLog: Record<string, any> = {
      type: 'request',
      logId,
      method,
      path,
    };
    console.log(JSON.stringify(requestLog));

    /** 응답 로그 */
    const baseResponseLog = {
      type: 'response',
      logId,
      method,
      path,
    };
    const sendJson = response.json;
    response.json = function (value): any {
      request.response = JSON.parse(
        JSON.stringify({
          message: value.message,
          data: value.data,
        }),
      );
      return sendJson.call(this, value);
    };

    return next.handle().pipe(
      tap({
        next: () =>
          console.log(
            JSON.stringify({
              ...baseResponseLog,
              response: { ...(request.response || {}) },
            }),
          ),
        error: (err) =>
          console.error(
            JSON.stringify({
              ...baseResponseLog,
              response: err,
            }),
          ),
      }),
    );
  }
}

 

이렇게 작성했던 이유는 @Res() 데코레이터를 사용한다면 res.json()을 통해 응답을 직접 설정하기 때문에 응답값에 접근할 수 있는 방법이 없습니다. 컨트롤러에서 아무 값을 리턴하지 않기 때문에 next.handle().pipe(…) 를 사용하더라도 파라미터로 아무 값도 전달되지 않습니다.

return next.handle().pipe(
  tap({
    next: (data) => {
      // 컨트롤러에서 아무 값도 반환하지 않기 때문에 data는 undefined가 됩니다.
      console.log(data);
      ...
    },
  }),
);

 

그래서 응답 값을 확인하기 위해서 response 객체의 json() 메소드를 컨트롤러가 호출하기 전에 인터셉터에서 확장합니다. res.json()을 호출할 때 전달되는 데이터를 request 객체에 임시로 저장한 후, 클라이언트에게 응답을 보내기 전에 가져와서 로그를 출력하게 됩니다.

const sendJson = response.json;
response.json = function (value): any {
  request.response = JSON.parse(
    JSON.stringify({
      code: value.code,
      message: value.message,
      body:
        typeof value.result === 'object'
          ? JSON.stringify(value.result)
          : String(value.result),
    }),
  );
	
  return sendJson.call(this, value);
};

 

하지만 이렇게 복잡하게 할 필요 없이, 그냥 컨트롤러에서 res.json()으로 응답 값을 설정하고, 응답 값을 반환하도록 하면 해결되지 않을까요? 네, 아래와 같이 작성하면 next.handle().pipe(…) 에서 확인이 가능합니다. 하지만 문제는 다른 인터셉터를 함께 사용하기 시작하면서 발생하게 됩니다.

[cats.controller.ts]

@UseInterceptor(LoggingInterceptor)
@Get()
findAll(@Res() res: Response) {
  const data = [{ name: 'kitty', breed: 'British Blue'}];
  res.status(200).json(data);
  return data;
}

 

 

역시 위에서 언급한 것 처럼 응답 값을 특정 형태로 변환하는 인터셉터인 SerializeInterceptor를 추가해보겠습니다. SerializeInterceptor의 코드는 아래와 같습니다.

[serialize.interceptor.ts]

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

@Injectable()
export class SerializeInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    const response = context.switchToHttp().getResponse<Response>();
    return next
      .handle()
      .pipe(
        map((data) => {
          const responseBody = {
            message: response.message,
            data,
          };
          return responseBody;
        }),
      )
      .pipe(
        // eslint-disable-next-line
        catchError((err: unknown) => {
          return throwError(() => err);
        }),
      );
  }
}

 

이제 컨트롤러에 SerializeInterceptor를 추가한 후 실행해서 테스트해 보면 SerializeInterceptor는 실행되지만, SerializeInterceptor가 반환하는 값이 아니라 컨트롤러가 res.json()을 호출할 때 전달한 값이 반환됩니다. 디버깅해 보면, 인터셉터가 응답 전에 실행되는 것이 아니라 응답 후에 실행된다는 것을 확인할 수 있습니다.

[cats.controller.ts]

import { Response } from 'express';
import { Controller, Get, Res, UseInterceptors } from '@nestjs/common';
import { LoggingInterceptor } from './logging.interceptor';
import { SerializeInterceptor } from './serialize.interceptor';

@Controller('/cats')
export class CatsController {
  @UseInterceptors(LoggingInterceptor, SerializeInterceptor)
  @Get()
  findAll(@Res({ passthrough: true }) res: Response) {
    const data = [{ name: 'kitty', breed: 'British Blue' }];
    res.status(200).json(data);
  }
}

 

어쩌면 이는 당연한 결과입니다. @Res() 데코레이터를 사용할 때 passthrough 옵션을 전달하지 않았기 때문에, NestJS의 기본 응답 처리 방식이 비활성화되었고, 응답은 즉시 반환됩니다. 그로 인해, 인터셉터가 반환하는 값은 실제 응답에 반영되지 않게 됩니다.

LoggingInterceptor만 사용할 때 이는 문제가 되지 않습니다. LoggingInterceptor는 단순히 로그만 남기면 되기 때문에 응답이 반환된 후에 실행되어도 상관이 없죠. 하지만 SerializeInterceptor는 응답 값을 변환하기 때문에 응답이 반환되기 전에 실행되어야 합니다.

 

4. @Res() 데코레이터에 passthrough 옵션을 사용하여 해결하기

먼저, 문제를 해결하기 위해서 @Res() 데코레이터를 사용할 때 passthrough 옵션을 추가해야 합니다. 이렇게 하면 NestJS의 기본 응답 처리 방식이 활성화 되고, 인터셉터에서 반환하는 값을 NestJS가 응답에 반영하게 됩니다. 위의 코드에서 passthrough 옵션만 추가하면 아래와 같습니다.

[cats.controller.ts]

@UseInterceptors(LoggingInterceptor, SerializeInterceptor)
@Get()
findAll(@Res({ passthrough: true }) res: Response) {
  const data = [{ name: 'kitty', breed: 'British Blue' }];
  res.status(200).json(data);
  return data;
}

 

하지만, 실제로 실행해 보면 ERR_HTTP_HEADERS_SENT 에러가 발생합니다. 이 에러는 NestJS의 기본 응답 처리 방식이 활성화되면서, 인터셉터가 반환한 값을 응답에 반영하려고 하기 때문에 발생합니다. 컨트롤러에서 이미 res.json()을 호출하여 응답을 설정하고 클라이언트에게 반환한 상태인데, 다시 응답을 설정하려고 시도하면서 충돌이 발생하는 것입니다.

이를 해결하기 위해서, res.json()으로 응답을 설정하는 부분을 삭제해서, NestJS의 기본 응답 처리 방식을 활용하도록 변경합니다. 이렇게 하면 SerializeInterceptor가 반환한 값이 응답으로 설정되고, ERR_HTTP_HEADERS_SENT 에러도 발생하지 않습니다.

[cats.controller.ts]

@UseInterceptors(LoggingInterceptor, SerializeInterceptor)
@Get()
findAll(@Res({ passthrough: true }) res: Response) {
  const data = [{ name: 'kitty', breed: 'British Blue' }];
  res.status(200);
  return data;
}

 

하지만, 아직 해결해야 할 문제가 있습니다. LoggingInterceptor에서 응답 로그가 제대로 출력되지 않습니다. 이는 컨트롤러에서 res.json()을 호출하지 않으면서, 기존에 확장했던 res.json()이 더 이상 의미가 없기 때문입니다. 그러나, 이 문제는 오히려 더 간단하게 해결할 수 있습니다. 인터셉터가 반환한 값을 next.handle().pipe(…)에서 직접 접근할 수 있기 때문에, 별도의 처리없이 이 값을 바로 활용하면 됩니다. 결과적으로 LoggingInterceptor의 코드가 아래와 같이 더욱 단순해졌습니다.

[logging.interceptor.ts]

import { Request } from 'express';
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { nanoid } from 'nanoid';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    const request = context.switchToHttp().getRequest<Request>();

    const logId = nanoid();

    /** 요청 로그 */
    const { method, path } = request;
    const requestLog: Record<string, any> = {
      type: 'request',
      logId,
      method,
      path,
    };
    console.log(JSON.stringify(requestLog));

    /** 응답 로그 */
    const responseLog: Record<string, any> = {
      logId,
      method,
      path,
    };

    return next.handle().pipe(
      tap({
        next: (data) => {
          /** 응답 로그 */
          console.log(
            JSON.stringify({
              ...responseLog,
              type: 'response',
              response: data,
            }),
          );
        },
        error: (err) =>
          /** 에러 로그 */
          console.error(
            JSON.stringify({
              ...responseLog,
              type: 'error',
              response: err,
            }),
          ),
      }),
    );
  }
}

 

5. 마치며

NestJS의 응답 처리 방식에 대한 이해가 부족한 상태에서 LoggingInterceptor와 SerializeInterceptor를 사용했던 프로젝트에서는, @Res() 데코레이터를 사용하는 컨트롤러에 한해 인터셉터가 수행하던 작업을 컨트롤러 내부에서 수동으로 처리하도록 대응하고 있었습니다. 그러나 이 방식은 중복 코드가 많아지고, 간혹 빠뜨리는 경우가 생기면서 클라이언트가 API 연동 시 예상치 못한 혼란을 겪게 되었습니다.

이 문제를 반복해서 겪으면서, 더 이상 임시방편이 아닌 확실한 해결책을 찾아야겠다는 생각이 들었습니다. 이 과정을 통해 단순히 기능을 구현하는 것뿐만 아니라, 프레임워크의 동작 원리를 이해하는 것이 얼마나 중요한지를 다시 한번 깨닫게 되었습니다.

 

P.S 글로는 정리해서 풀어냈지만, 실제로는 이리저리 검색해보고 다양하게 코드를 수정하며 테스트하는 과정이 있었습니다. 아마 이렇게 글로 정리하지 않았다면 또 까먹지 않았을까 싶네요.

반응형