Data Serialization

onyoo·2023년 1월 14일
0

NestJs

목록 보기
7/8
post-thumbnail

원하지 않는 내부응답 정보 숨기기

우리는 class-transformer 라이브러리를 이용해서 내부응답으로 돌아오는 response의 값을 보이지 않도록 할것이다.

user.entity.ts

import {
  AfterInsert,
  AfterRemove,
  AfterUpdate,
  Entity,
  Column,
  PrimaryGeneratedColumn,
} from 'typeorm';

import { Exclude } from 'class-transformer';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;

  @Column()
  **@Exclude() // 제외한다는 데코레이터**
  password: string;

  @AfterInsert()
  logInsert() {
    console.log('Inserted User with id', this.id);
    //this 는 여기에서 참조한 데이터이다.
  }
  //새사용자를 삽입할때마다 이 함수가 호출될것이다.

  @AfterUpdate()
  logUpdate() {
    console.log('Updated User with id', this.id);
  }

  @AfterRemove()
  logRemove() {
    console.log('Removed User with id', this.id);
  }
}

제외한다는 데코레이터를 이용하여 entity를 작성해준다.

User Entity Instance 의 경우 클래스의 인스턴스가 어떠한 방식으로 일반 개체로 변형되는지 알려주는 것을 말한다.

Class Serializer Interceptor 의 경우 유저 엔티티의 인스턴스를 일반 객체로 몇가지 규칙에 의해 바꾸는 작업을 한다.

우리가 한 작업은 후자의 작업 Class Serializer Interceptor 이다. password라는 정보를 보이지 않도록 규칙을 설정한 것이다.

이렇게 우리에게 돌아오는 대답에 password가 표현되지 않도록 수정하였다.

여기에서 나아가 지금까지 했던 접근법의 단점을 살펴보고 조금 더 나은 구현을 해볼것이다.

우리가 현재 가지고 있는 데이터의 양은 매우적지만, 이후에 더 많은 정보를 저장하게 된다고 생각을 해보자.

그렇게 되면 우리는 내부 혹은 외부에서 관리기능을 통합하려고 할 것이다. 즉, 데이터에 대한 관리자 사용자를 만든다는 얘기이다.

이렇게 될 경우 관리자의 경우는 내부 정보를 볼 수 있어야 한다.

따라서 우리는 admin 사용자가 요청을 받을 경우 해당 요청이 관리자로부터 수신되는지 확인해야하는 것이다.

admin 사용자가 보낸 요청에 대한 응답과 일반 사용자가 보낸 요청에 대한 응답을 다르게 해야한다는 것이다.하지만 단순하게 admin 과 일반요청을 구분지어서 라우팅을 하는 것이 아니라 두 응답요청을 같은 곳에서 하되 요청자에 따라 리턴하는 정보의 값이 다르도록 설정하고 싶은 것이다.

이러기 위해서는 우리는 직렬화와 관련된 형식 정보다 어떤 것도 사용자 엔티티 인스턴스에 직접 연결하지 않을 것이다. 우리는 새로운 경로 핸들러를 작성하고 서비스에 추가하는 것이 아니라 interceptor를 만들것이다.

우리는 사용자 엔티티를 형식화 하기 위해 사용자 dto를 만들 것이다 → 유저 데이터가 각각의 라우팅 핸들러에게 어떻게 serialize 될지 설명하는 dto를 만들 것이다.

Intercepter 만들기

우리는 이제부터 커스텀 인터셉터를 만들것이다. 커스텀 인터셉터를 만들때는 위의 사진과 같은 일반적인 명명 규칙을 따라서 작성할 것이다.

intercept 메서드는 인터셉터를 실행해야 할 때 마다 자동으로 호출된다. 따라서 수신 요청 또는 발신 응답을 이곳에서 처리하면 된다.

context 는 들어오는 요청에 대한 일부 정보를 감싸는 wrapper이다.

next 는 우리가 가지고 있는 컨트롤러의 request 핸들러를 참조하는 것을 말한다

이제 본격적으로 코드를 작성해보자!

import {
  AfterInsert,
  AfterRemove,
  AfterUpdate,
  Entity,
  Column,
  PrimaryGeneratedColumn,
} from 'typeorm';

~~import { Exclude } from 'class-transformer';~~

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;

  @Column()
  ~~@Exclude() // 제외한다는 데코레이터~~
  password: string;

  @AfterInsert()
  logInsert() {
    console.log('Inserted User with id', this.id);
    //this 는 여기에서 참조한 데이터이다.
  }
  //새사용자를 삽입할때마다 이 함수가 호출될것이다.

  @AfterUpdate()
  logUpdate() {
    console.log('Updated User with id', this.id);
  }

  @AfterRemove()
  logRemove() {
    console.log('Removed User with id', this.id);
  }
}

우리는 src 폴더안에 interceptor 폴더를 만들고 그 안에 다음과 같은 파일을 생성할 것이다.

serialize.interceptor.ts

//객체를 받아서 json으로 직렬화

import {
  UseInterceptors,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';

import { Observable } from 'rxjs';
import { map } from 'rxjs';
import { plainToClass } from 'class-transformer';

export class SerializeInterceptor implements NestInterceptor {
  //implements를 하면 typescript 가 여기에 적절한 method 가 구현되었는지 확인한다.
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> {
    // Run something before a request is handled
    // bye the request handler
    // 요청이 끝나기 전에 무언가를 하고싶을때 이곳에 코드를 작성한다
    console.log('im running before th handler', context);

    return next.handle().pipe(
      map((data: any) => {
        //run something before th response is sent out
        //발신 데이터와 함께 응답이 전송되기 전에 무언가를 수행하려면 이곳에 코드를 작성한다
        console.log('im running before response is sent out', data);
      }),
    );
  }
}

implements를 이용하여 구현을 하면, 다음과 같은 에러로그가 발생할 수 있다. 이는 typescript 에서 발생하는 에러로 상속을 받았을 경우 부모클래스에서 필수로 작성해야하는 method가 무엇인지 알려주는 것이다.

우리는 여기에 필수로 작성해야하는 intercept 함수를 작성했고 그 안에 아까 위에서 보았던 intercept 함수의 기본형을 작성할 것이다. 그리고 두 부분을 볼 수 있다.

  1. 요청이 끝나기 전에 무언가를 하고싶을 때 코드를 작성하는 부분
  2. 발신 데이터와 함께 응답이 전송되기 전에 무언가를 하고싶을 때 코드를 작성하는 부분

이렇게 코드를 작성하고 controller의 다음 부분을 수정해주면 작업이 완료된다.

	**@UseInterceptors(SerializeInterceptor)**
  @Get('/:id')
  async findUser(@Param('id') id: string) {
    console.log('handler is running');
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not found');
    }
    return user;
  }

interceptor가 적용되길 원하는 method 위에 이렇게 작성해주면된다.

요청은 다음과 같은 순서로 처리된다.

**im running before th handler ExecutionContextHost** {
  args: [
    IncomingMessage {
      _readableState: [ReadableState],
      _events: [Object: null prototype] {},
      _eventsCount: 0,
      _maxListeners: undefined,
      socket: [Socket],
      httpVersionMajor: 1,
      httpVersionMinor: 1,
      httpVersion: '1.1',
      complete: false,
      rawHeaders: [Array],
      rawTrailers: [],
      aborted: false,
      upgrade: false,
      url: '/auth/2',
      method: 'GET',
      statusCode: null,
      statusMessage: null,
      client: [Socket],
      _consuming: false,
      _dumped: false,
      next: [Function: next],
      baseUrl: '',
      originalUrl: '/auth/2',
      _parsedUrl: [Url],
      params: [Object],
      query: {},
      res: [ServerResponse],
      body: {},
      route: [Route],
      [Symbol(kCapture)]: false,
      [Symbol(kHeaders)]: [Object],
      [Symbol(kHeadersCount)]: 8,
      [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(kEndCalled)]: false,
      [Symbol(kNeedDrain)]: false,
      [Symbol(corked)]: 0,
      [Symbol(kOutHeaders)]: [Object: null prototype],
      [Symbol(kUniqueHeaders)]: null
    },
    [Function: next]
  ],
  constructorRef: [class UsersController],
  handler: [AsyncFunction: findUser],
  contextType: 'http'
}

handler is running

**im running before response is sent out User { id: 2, email: 'aaaa@aaaa.com', password: 'hello!!' }**
  1. Interceptor의 method에 적힌 코드 실행
  2. controller 실행
  3. controller 리턴전 Interceptor의 리턴코드 실행

우리는 이 Interceptor를 이용해서 요청자에 따라 다른 데이터를 리턴해줄 것이다.

어떻게 할것이냐면 들어오는 User Entity Instance 를 hijaking 해서 User DTO Instance 로 바꾼다음 Nest에서는 해당 데이터를 받아 응답으로 다시 보낼것이다.

또한 나가는 데이터의 유효성 검사를 확인할 필요가 없기 때문에 DTO에 유효성 검사를 추가하지는 않을 것이다.이제 진짜로 DTO를 작성해보자.

user.dto

import { Expose } from 'class-transformer';

//Expose 해당 속성을 공유하겠다는 말

export class UserDto {
  @Expose()
  id: number;
  @Expose()
  email: string;
}

Expose 데코레이터를 이용하여 해당 속성을 보여주겠다는 표시를 해준다.

serialization.interceptor.ts

//객체를 받아서 json으로 직렬화

import {
  UseInterceptors,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';

import { Observable } from 'rxjs';
import { map } from 'rxjs';
import { plainToClass } from 'class-transformer';
import { UserDto } from 'src/users/dto/user.dto';

export class SerializeInterceptor implements NestInterceptor {
  //implements를 하면 typescript 가 여기에 적절한 method 가 구현되었는지 확인한다.
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> {
    return next.handle().pipe(
      map((data: any) => {
        //변환 프로세스
        return plainToClass(UserDto, data, {
          excludeExtraneousValues: true,
        });
        //excludeExtraneousValues: true 특별하게 표시된 다른 속성만 공유하거나 노출되도록 하는 노출값
      }),
    );
  }
}

앞에 코드에서 필요없는 부분 → 현재 필요하지 않은 부분들은 모조리 삭제를 한다. 왜냐하면 우리는 요청을 받고, 해당 요청을 처리한 뒤 보낼 응답에 대해서만 처리가 필요하지 요청을 받기전에 무엇을 처리할 필요는 없기 때문이다.

변환 부분을 보면 plainToClass라는 라이브러리를 이용한 함수를 통해 data를 UserDto로 변환하는 과정을 거치고있다 추가적으로 우리에게 필요한 기능을 위해서 option 값을 부여하고 있다. excludeExtraneousValues: true, 라고 명시하여 exclude된 속성만 표시되도록 하였다.

이제 마지막으로 controller를 손볼시간이다.

user.controller.ts

  @UseInterceptors(**SerializeInterceptor**)
  @Get('/:id')
  async findUser(@Param('id') id: string) {
    console.log('handler is running');
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not found');
    }
    return user;
  }

어떤 인터셉터를 사용할지 설정하는 부분에 우리가 작성한 인터셉터가 사용되도록 지정해주었다.

이제 요청을 날리면 !?

내가 원하지 않는 값은 날라오지 않는다!

하지만, 우린 여기서 만족할 수 없다. 우리가 작성한 Interceptor 코드는 UserDTO에 대해서만 작동하도록 되어있다. 이러한 코드는 재사용하기가 어렵다. 만약, 우리가 다른 데이터,, 이를테면 유저가 아닌 다른 데이터에서 인터셉터를 사용하려면 어떻게 해야할까? 이러한 문제를 해결하기 위해 해당 코드를 리팩토링 해보자.

일단 우리는 Controller에 가서 우리가 Interceptor를 호출하는 방식을 변경할 것이다.

import {
  Body,
  Controller,
  Post,
  Param,
  Query,
  Patch,
  Get,
  Delete,
  NotFoundException,
  UseInterceptors,
  ClassSerializerInterceptor,
} from '@nestjs/common';
//use interscptor
//class -> 응답을 가로채기 위한 라이브러리
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
import { SerializeInterceptor } from 'src/interceptors/serialize.interceptor';
**import { UserDto } from './dto/user.dto';**

@Controller('auth')
export class UsersController {
  constructor(private usersService: UsersService) {}
  
	//앞뒤 관련없는 코드는 삭제함

  **@UseInterceptors(new SerializeInterceptor(UserDto))**
  @Get('/:id')
  async findUser(@Param('id') id: string) {
    console.log('handler is running');
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not found');
    }
    return user;
  }

}

우리는 컨트롤러에 UserDTO를 불러온뒤 인터셉터의 복사본을 생성하여 UserDto를 인자로 줄 것이다. 이제 인자로 전달한 UserDto를 Interceptor에서 처리해보자.

//객체를 받아서 json으로 직렬화

import {
  UseInterceptors,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';

import { Observable } from 'rxjs';
import { map } from 'rxjs';
import { plainToClass } from 'class-transformer';

export class SerializeInterceptor implements NestInterceptor {
  **constructor(private dto: any) {}**
  //implements를 하면 typescript 가 여기에 적절한 method 가 구현되었는지 확인한다.
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> {
    return next.handle().pipe(
      map((data: any) => {
        //변환 프로세스
        return plainToClass(**this.dto**, data, {
          excludeExtraneousValues: true,
        });
        //excludeExtraneousValues: true 특별하게 표시된 다른 속성만 공유하거나 노출되도록 하는 노출값
      }),
    );
  }
}

추가된 부분에 대한 내용은 다음과 같다.

  • Constuctor를 이용하여 dto를 받는 부분 , 어떤 dto 형태가 들어올지 모르니 any 형태로 선언한다.
  • 위의 생성자 덕분에 class가 dto 객체를 가지게 되어서 this.dto로 원하는 dto 객체로 변환할 수 있게 되었다!

하지만 여기 코드에도 조금 걸리는 점이 있다. controller에 인터셉터를 적용하기 위해서 무려 세개를 import 해야한다는 점이다.

interceptor 하나를 사용하기 위해서 다음과 같은 코드들이 import 된다.

import { SerializeInterceptor } from 'src/interceptors/serialize.interceptor'

import { UserDto } from './dto/user.dto'

import { UseInterceptors } from '@nestjs/common'

여기에 대한 리팩토링을 해보자.

Custom Decorator

앞서 언급했던 문제를 해결하기 위해서 우리는 custom decorator를 만들것이다.

//객체를 받아서 json으로 직렬화

import {
  UseInterceptors,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';

import { Observable } from 'rxjs';
import { map } from 'rxjs';
import { plainToClass } from 'class-transformer';

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

export class SerializeInterceptor implements NestInterceptor {
  constructor(private dto: any) {}
  //implements를 하면 typescript 가 여기에 적절한 method 가 구현되었는지 확인한다.
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> {
    return next.handle().pipe(
      map((data: any) => {
        //변환 프로세스
        return plainToClass(this.dto, data, {
          excludeExtraneousValues: true,
        });
        //excludeExtraneousValues: true 특별하게 표시된 다른 속성만 공유하거나 노출되도록 하는 노출값
      }),
    );
  }
}

우리가 추가할 부분은 볼드처리 된 저 부분이다. controller에서 복잡하게 Import해야 사용할 수 있던 부분을 interceptor에서 미리 만들어주는 것이다. 이미 코드 내부에 SerializeInterceptor 가 선언 되어있기 때문에 import 문을 controller에서 선언하는 것보다 더 줄일 수 있다.

이것을 우리가 컨트롤러에서 사용하려면 어떻게 해야 할까?

	@Serialize(UserDto)
  @Get('/:id')
  async findUser(@Param('id') id: string) {
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not found');
    }
    return user;
  }

아주 단순하다 ! Serialize를 import 한 뒤 거기에 인자로 UserDto를 넣어준다. 그럼 끝이다!

자, 여기까지 아주 순조롭게 코드를 작성했지만 그럼에도 불구하고 맘에 들지 않는 부분이 여럿 존재한다.

일단 첫번째로 컨트롤러 대신 Serialize 데코레이터를 컨트롤러 대신 적용하고 싶다. 이게 무슨말이냐 하면 우리가 적용한 단일 요청에 대한 것 뿐 아니라 다른 User 데이터를 처리하는 다른 요청 핸들러에 적용하고 싶다는 말이다!

다음으로는 우리의 인터셉트 코드에 any가 너무 남발되어있다. 이 부분에 대해서 어느정도 수정이 필요하다.

이 부분에 대해서 처리를 해보자 !

Custom Decorator가 제대로 사용되고있는지 확인하기

인터셉터는 컨트롤러의 두 부분에 붙일 수 있다.

첫번째로는 각각의 라우터마다 붙여주는 것이고, 두번째로는 컨트롤러 전체에 붙여주는 것이다.

하나의 라우터마다 설정해주는 것은 번거로우니 컨트롤러 전체에 해당 인터셉터를 붙여보자.

import {
  Body,
  Controller,
  Post,
  Param,
  Query,
  Patch,
  Get,
  Delete,
  NotFoundException,
} from '@nestjs/common';

import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
import { Serialize } from 'src/interceptors/serialize.interceptor';
import { UserDto } from './dto/user.dto';

@Controller('auth')
**@Serialize(UserDto)**
export class UsersController {
  constructor(private usersService: UsersService) {}
  @Post('/signup')
  createUser(@Body() body: CreateUserDto) {
    this.usersService.create(body.email, body.password);
  }

  @Get('/:id')
  async findUser(@Param('id') id: string) {
    console.log('handler is running');
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not found');
    }
    return user;
  }

  @Get()
  findAllUsers(@Query('email') email: string) {
    return this.usersService.find(email);
  }

  @Delete('/:id')
  removeUser(@Param('id') id: string) {
    return this.usersService.remove(parseInt(id));
  }

  @Patch('/:id')
  updateUser(@Param('id') id: string, @Body() body: UpdateUserDto) {
    return this.usersService.update(parseInt(id), body);
  }
}

위의 볼드처리된 부분으로 코드를 옮겼다.

이렇게 되면, 컨트롤러에 있는 모든 라우터들이 해당 인터셉터의 rule에 맞게 내부응답을 filter 한다.

만약, 각각의 라우터마다 다른 인터셉터 규칙을 적용하고 싶다면, 아주 간단하다! 라우터마다 각각 위에 인터셉터를 정의해주면 된다. 물론, rule이 될 dto도 다르게 규정해야겠지만.

지금의 경우 컨트롤러에 있는 모든 라우터들이 user 데이터를 다루기 때문에 하나의 인터셉터를 동일하게 사용하는 것이 적절하다.

Intercepter에 남발한 any type 정리하기

타입스크립트는 기본적으로 실행하기 전에 코드의 오류를 확인하는데에 목적을 두고 있다.

사실, 인터셉터 안에 있는 any 타입을 함부러 제거하기는 너무 어렵다 왜냐하면 컨트롤러에서 어떤 데이터가 올지 모르기 때문에, 그래서 우리는 작은 목표를 잡고 한번 정리해보려고 한다!

우리가 컨트롤러에 작성한 코드의 부분 중 @Serialize(UserDto) 라는 부분에 인자로 어떠한 것이 들어가도 에러를 내뱉지 않을 것이다 왜냐하면, any type으로 받도록 설정했기 때문이다.

아까 말했던 작은목표란 바로, 여기에 인자로 받는 부분이 최소한 클래스여야 한다는 제한을 두자는 것이다.

그렇게 하기 위해서 우리는 interceptor에 다음과 같은 코드를 추가 할 것이다.

interface ClassConstrucrtor {
  new (...args: any[]): {};
}

이 인터페이스는 어떠한 형태의 클래스던간에 상관없이 클래스라는 것을 의미한다.

이러한 식으로 개선된 코드는 다음과 같다

**interface ClassConstrucrtor {
  new (...args: any[]): {};
}**

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

이렇게 되면 컨트롤러에서 Serialize를 호출했을때 인자로서 적어도 클래스여야 한다는 제약을 둘 수 있다.

profile
반갑습니다 ! 백엔드 개발 공부를 하고있습니다.

0개의 댓글