nestjs) Interceptor에 대한 이해

김명성·2023년 10월 6일

커스텀 인터셉터를 구현하다보면 들어오는 요청에 대한 정보를 담고 있는 context와 응답하는 정보를 담고 있는 next 객체를 다루어야 한다.

오늘은 interceptor를 파고들며 context: ExcutionContextnext의 내부 구조를 살펴보고 그 작동방식에 대해 이해해보고자 한다.

예제는 어드민/일반유저가 id를 통해 유저를 검색하려고 하는 상황에서, 어드민에게는 비밀번호를 포함하여 유저정보를 보내주고,일반유저는 비밀번호를 제외한 유저정보를 보내주는 플로우다.

단계별로 하나씩 알아보자.


  1. DTO 내부에서 사용되는 Expose, Exclude 데코레이터

DTO (Data Transfer Object)는 클라이언트와 서버가 교환하는 데이터를 정의한 객체이다.

// src/users/dtos/user.dto.ts

import { Expose,Exclude } from 'class-transformer'

export class UserDto {

  @Expose()
  id: number;

  @Expose()
  email: string;

  @Exclude()
  password: string;
}

@Expose,@Exclude 데코레이터는 컨트롤러에서 응답으로 보내는 데이터에 어떤 것이 포함,배제되어야 하는지 인터셉터에게 알려주는 데코레이터이다


  1. SerializeInterceptor 구현
import { NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable,map } from 'rxjs';
import { plainToInstance } from 'class-transformer';

// 여기서의 Serialize의 뜻은 클래스를 JSON으로 변환하는 것이다.
export class SerializeInterceptor implements NestInterceptor {
  constructor(private dto: any) {}
  
  intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
    // 해당 구역에서는 요청 핸들러가 요청을 처리하기 전에 실행되는 로직을 작성할 수 있다.
    // 즉 컨트롤러에 아직 해당 요청이 접근하지 않은 상태로
    // 위 컨트롤러의 console.log('핸들러 실행중') 이전에 context 객체를 조정할 수 있다.
    console.log('핸들러에 접근중');

    return handler.handle().pipe(
      map((data: any) => {
        // 이제 핸들러에 진입한 상태로 console.log('핸들러 실행중')이 실행 된 상태이다.
        // 응답으로 데이터를 내보내기 이전에 serialize된 데이터를 data 변수를 통해 볼 수 있다.
        // 현재 data는 DTO에 정의한 데코레이터가 실행되지 않은 상태로
        // 일반 유저의 응답에 보내려는 데이터에도 비밀번호가 포함되어 있는 상태이다.
        console.log('핸들러에 접근 완료');
        
        
        // plainToInstance 메서드는
        // 1. 해당 인터샙터를 사용하고 있는 클래스
        // 2. 조작해야 할 데이터, 
        // 3. 옵션 객체
        // 3가지를 입력해야 한다.
        // 여기서는 excludeExtraneousValues 옵션을 통해 DTO의 Expose, Exclude를 보고 포함해야 할 데이터만 보내준다
        return plainToInstance(this.dto, data, {
          excludeExtraneousValues: true,
        });

      }),
    );
  }

}

위 인터셉터는 모든 컨트롤러의 응답을 가로채어 plainToClass를 통해 변환한 뒤 컨트롤러의 반환값에 넣어준다.


  1. 작성한 커스텀 인터샙터를 사용할 데코레이션 정의
// src/decorators/serialize.decorator.ts
import { UseInterceptors } from "@nestjs/common";
import { SerializeInterceptor } from "src/interceptors/serialize.interceptor";

export function Serialize(dto: any) {
  return UseInterceptors(new SerializeInterceptor(dto));
}

UseInterceptors는 사용할 인터샙터를 바인딩하는 데코레이션이다.
Serialize 함수는 UseInterceptors 데코레이터를 반환하며 데코레이션처럼 사용할 수 있게 된다.


  1. 컨트롤러에서 사용
import { UsersService } from './users.service';
import {  Controller, Get, NotFoundException, Param, ParseIntPipe } from '@nestjs/common';
import { UserDto } from './dtos/user.dto';
import { Serialize } from 'src/decorators/serialize.decorator';

@Serialize(UserDto)
@Controller('auth')
export class UsersController {
  
  constructor(private usersService:UsersService){}

  @Get('/user/:id')
  async findById(@Param('id',ParseIntPipe) id: number) {
    console.log('handler is running');
    const user = await this.usersService.findOne(id);
    if(!user) {
      throw new NotFoundException('찾으시는 유저는 존재하지 않습니다.')
    }
    return user;
  }

}

이제 UsesController 내부의 핸들러는 @Serialize 데코레이션을 통해 비밀번호를 제외한 나머지 유저정보를 반환한다.

AdminController는 구현하지 않았지만, @Serialize 데코레이션을 사용하지 않는다면 인터샙터로 데이터가 조작될 일이 없으므로 그대로 비밀번호를 포함한 유저정보를 받을 수 있게된다.


Interceptor의 context 내부

ExecutionContextHost {
  args: [
    IncomingMessage {
      _readableState: [ReadableState],
      _events: [Object: null prototype] {},
      _eventsCount: 0,
      _maxListeners: undefined,
      socket: [Socket],
      httpVersionMajor: 1,
      httpVersionMinor: 1,
      httpVersion: '1.1',
      complete: true,
      rawHeaders: [Array],
      rawTrailers: [],
      joinDuplicateHeaders: undefined,
      aborted: false,
      upgrade: false,
      url: '/auth/user/1',
      method: 'GET',
      statusCode: null,
      statusMessage: null,
      client: [Socket],
      _consuming: true,
      _dumped: false,
      next: [Function: next],
      baseUrl: '',
      originalUrl: '/auth/user/1',
      _parsedUrl: [Url],
      params: [Object],
      query: {},
      res: [ServerResponse],
      body: [Object],
      _body: true,
      length: undefined,
      route: [Route],
      [Symbol(kCapture)]: false,
      [Symbol(kHeaders)]: [Object],
      [Symbol(kHeadersCount)]: 14,
      [Symbol(kTrailers)]: null,
      [Symbol(kTrailersCount)]: 0
    },
    ServerResponse {
      _events: [Object: null prototype],
      _eventsCount: 1,
      _maxListeners: undefined,
      outputData: [],
      outputSize: 0,
      writable: true,
      destroyed: false,
      _last: false,
      chunkedEncoding: false,
      shouldKeepAlive: false,
      maxRequestsOnConnectionReached: false,
      _defaultKeepAlive: true,
      useChunkedEncodingByDefault: true,
      sendDate: true,
      _removedConnection: false,
      _removedContLen: false,
      _removedTE: false,
      strictContentLength: false,
      _contentLength: null,
      _hasBody: true,
      _trailer: '',
      finished: false,
      _headerSent: false,
      _closed: false,
      socket: [Socket],
      _header: null,
      _keepAliveTimeout: 5000,
      _onPendingData: [Function: bound updateOutgoingData],
      req: [IncomingMessage],
      _sent100: false,
      _expect_continue: false,
      _maxRequestsPerSocket: 0,
      locals: [Object: null prototype] {},
      statusCode: 200,
      [Symbol(kCapture)]: false,
      [Symbol(kBytesWritten)]: 0,
      [Symbol(kNeedDrain)]: false,
      [Symbol(corked)]: 0,
      [Symbol(kOutHeaders)]: [Object: null prototype],
      [Symbol(errored)]: null,
      [Symbol(kUniqueHeaders)]: null
    },
    [Function: next]
  ],
  constructorRef: [class UsersController],
  handler: [AsyncFunction: findById],
  contextType: 'http'
}

Interceptor의 map에서 사용하는 변수 data의 내부

{
  id: 1,
  email: 'interceptor@test.com',
  password: 'password?'
}

dto의 타입에 any를 넣는것을 원치 않는다면

export interface ClassConstructor {
  new (...args: any[]): {}
}

인터페이스를 만들어 제공할 수 있다.

0개의 댓글