데이터 직렬화

custom Interceptor 만들

Custom Data Serialization

응답에서 특정 속성 제거하기

비밀번호는 보안에서 중요한 만큼 응답에서 제외하는 것이 맞다. 이 과정을 진행해보자. 어떻게 다룰지는 Nest Document를 살펴볼 것.

현재 요청-응답 Flow는 다음과 같다.

  1. 요청이 발생한다.
  2. 컨트롤러로 이동
  3. 서비스로 이동
  4. 서비스가 Repository 이용하여 Entity instance를 컨트롤러에게 반환
  5. 이 인스턴스는 JSON으로 변환된다.

Document에서 권장하는 방법

  • 서비스에서 컨트롤러로 이동하는 Entity instance에 인스턴스를 plain object로 변경하는 것에 관한 규칙 라이브러리를 추가한다.
  • class serializer interceptor 라는 decorator를 컨트롤러에서 추가한다.
    • 이 interceptor는 들어오는 요청이나 내보내는 응답을 가로채서 entity를 plain object로 변경해준다.
    • 우리의 경우 나가는 응답을 가로채서 entity를 라이브러리로 추가한 규칙에 맞게 plain object로 변경해줄것

결론적으로 우리는 일단 password는 빼고 response를 보내도록 코드를 추가해줄 예정이다.

코드 추가하기

Entity 파일로 가서 password는 제외(exclude)해주자

1
2
3
4
5
6
7
// user.entity.ts
import { Exclude } from 'class-transformer';

  @column()
  @Exclude() // 추가한 코드
  password: string;

이제 컨트롤러 파일 차례

1
2
3
4
5
6
7
8
9
10
11
12
// users.controller.ts

import { UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common';
  @UseInterceptors(ClassSerializerInterceptor) // 추가한 코드
  @Get('/:id')
  async findUser(@Param('id') id: string) {
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not fond');
    }
    return user;
  }

requests.http로 가서 서버를 실행한 후 id로 유저를 찾는 요청을 보내면 password가 제외되어 오는지 확인해본다.

단점

이 방법의 문제점은 뭘까?

예를 들어, 나중에 우리가 유저의 나이, 성별, 직업 등 더 많은 속성들을 받고 있다고 가정하자. 그리고 서비스 관리를 위한 관리자 계정이 있다고 생각해보자. 관리자는 유저 정보를 받을 떄 모든 정보를 다 받아야 할 것이고, 위에서 나온 상황은 특정 속성만 받으면 될 것이다. 인스턴스는 때에 따라 다르게 속성을 가지고 있어야 한다는 의미이다. 관리자한는 모두, id로 조회하는 일반 유저들에겐 특정 정보만 응답으로 반환하면 된다. 현재 Nest에서 권장하고 우리가 적용한 방식에서는 이것이 불가능하다. 그럼 어떤 방법으로 이 목표를 이룰 수 있을까?

해결책

우리는 우리만의 interceptor를 만들 것이다. 이 인터셉터는 User DTO로서 특정 route에서 instnace를 어떻게 serialize할 지를 관리해준다. 방금 단점에서 언급한 상황에서는 각 route마다 DTO를 생성하면 된다. 우리의 custom interceptor를 route handler에 적용해보자.

Interceptor 만들기

Interceptor는 ‘middleware’처럼 나가는 응답이나 들어오는 요청을 가로챈다. 인터셉터는 전체 컨트롤러에 적용할 수도 있고 각 루트 핸들러에도 적용할 수 있다.

  • CustomInterceptor라는 클래스를 만들자.

  • 메서드는 인터셉터를 실행할 때 마다 자동으로 호출되도록 만들자
  • 메서드의 인수는 2가지이다.
    • context : ExecutionContext, 들어오는 요청의 정보를 감싸는 wrapper
    • next : CallHandler, 컨트롤러 내의 요청 핸들러와 비슷한 것

코드를 짜보자.

entitiy 파일로 돌아가서 추가했던 exclue 관련 코드들은 모두 지우자. 이후 src 폴더에 interceptors라는 새 폴더를 만들고 그 안에 serialize.interceptor.ts 파일을 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// serialize.interceptor.ts
import {
  UseInterceptors,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer';

export class SerializeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
    // Run something before a request is handled
    // by the request handler
    console.log('Im running before the handler', context);

    return handler.handle().pipe(
      map((data: any) => {
        // Run something before the response is sent out
        console.log('Im running before response is sent out', data);
      })
    );
  }
}

컨트롤러 파일에 적용해주자

1
2
3
4
5
6
7
8
9
10
11
12
13
// users.controller.ts
import { SerializeInterceptor } from  '../interceptors/serialize.interceptor';

  @UseInterceptors(SerializeInterceptor) // 추가된 코드 1
  @Get('/:id')
  async findUser(@Param('id') id: string) {
    console.log('handler is running'); // 추가된 코드 2
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not fond');
    }
    return user;
  }

API Client로 테스트하여 3가지 console.log가 출력되는지 확인해보자.

3개의 메시지가 모두 뜨면 인터셉터 적용에 성공!

인터셉터 내 직렬

위 테스트에서 3번쨰 메시지를 보자

Im running before response is sent out User { id: 1, email: 'junsoopooh@naver.com', password: ' 19940411' }

여전히 User라는 entity instance로 작동되고 있음을 알 수 있다. 우리는 User Entity instance를 User DTO instance로 바꿔주는 코드를 추가하고자 한다. 우선 User DTO를 만들어보자. 이전에 만든 DTO와 달리 나가는 정보에 대한 validation은 필요 없다.

dto 폴더 내에 user.dto.ts 파일을 만들자

1
2
3
4
5
6
7
8
9
10
// user.dto.ts
import { Expose } from 'class-transformer'; // Expose는 exclude와 반대로 보여주고 공유한다는 뜻이다.

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

  @Expose()
  email: string;
}

이 새로운 dto를 interceptor에 적용해주자. 기존에 확인을 위해 만들어뒀던 각주와 콘솔 메시지 코드는 모두 삭제한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// serialize.interceptor.ts
import {
  UseInterceptors,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer';
import { UserDto } from '../users/dtos/user.dto';

export class SerializeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
    return handler.handle().pipe(
      map((data: any) => {
        return plainToClass(UserDto, data, {
          excludeExtraneousValues: true,
        });
      })
    );
  }
}

API 테스트를 해본다. ID로 특정 유저 정보를 찾는 요청을 해보자. 우린 exclude를 분명 지웠지만 우리가 새로 만든 UserDto를 통해 여전히 password는 나타나지 않도록 코드를 작성했으므로 반환된 데이터에 password는 나타나지 않는다.

UserDto에서 decorator를 포함한 email과 관련된 코드를 주석처리하면 email도 나타나지 않고 id만 반환되는 것도 확인할 수 있다.

이제 이 인터셉터를 여러 곳에서 재사용할 수 있도록 리팩터링 해보자!

인터셉터의 DTO 리팩터링

현재는 SerializeInterceptor의 코드에 UserDto를 인수로 넣어줬다. 여러 곳에서 재사용하기 위해 그때마다 필요한 Dto를 인자로 받도록 SerializeIntercepotr의 복사본을 사용하도록 하면 된다.

1
2
3
4
5
6
7
8
9
10
11
// users.controller.ts
  @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 fond');
    }
    return user;
  }

위 처럼 코드를 변경하면 당연히 오류가 뜰 것이다. 냅둔채로 인터셉터를 수정해주자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// serialize.interceptor.ts
import {
  UseInterceptors,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer'; // 더이상 userdto를 넣을 필요 없으니 import 삭제

export class SerializeInterceptor implements NestInterceptor {
  constructor(private dto: any) {} // 생성자 추가

  intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
    return handler.handle().pipe(
      map((data: any) => {
        return plainToClass(this.dto, data, {
          // userdto를 넣어줄 필요 없으니 코드 변경
          excludeExtraneousValues: true,
        });
      })
    );
  }
}

컨트롤러 코드의 오류가 감지되지 않을 것이다. API test로 여전히 잘 작동되는 것을 확인할 수 있다.

그런데 컨트롤러의 decorator 코드 @UseInterceptors(new SerializeInterceptor(UserDto))는 너무 길다.. 편하게 호출할 수 있도록 바꿔보자

1
2
3
4
// serilize.interceptor.ts import 바로 밑에 추가하자.
export function Serialize(dto: any) {
  return UseInterceptors(new SerializeInterceptor(dto));
}

문제의 긴 decorator를 호출하는 함수를 만들었다. 이제 컨트롤러에 import하여 적용하자.

1
2
3
4
// users.controller.ts
import { Serialize } from '../interceptors/serialize.interceptor'; // import도 이렇게 바꿔주자
  @Serialize(UserDto) // 이렇게 짧아졌다
  @Get('/:id')

컨트롤러 전체에 적용하기

이 interceptor가 컨트롤러 전체에 적용할 수도 있을까?

기존의 위치에서 지우고 controller 전체에 적용해보자

1
2
3
4
// users.controller.ts
@Controller('auth')
@Serialize(UserDto) // 기존 위치에서 지우고 여기에 넣어보자
export class UsersController {

이 후 API Client로 테스트하면 다른 요청들에도 모두 password가 없어진다. 컨트롤러 전체에도 적용될 수 있음이 증명됐다. 특정 요청에만, 혹은 전체에 넣어도 되고 요청마다 다른 Dto를 넣어주면 된다. 우선은 전체에 적용한채로 유지해두자.

이 시점에서 사용하지 않는 import나 각주를 지워서 정리해주자. 현재 상태는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// users.controller.ts
import {
  Controller,
  Post,
  Body,
  Get,
  Patch,
  Delete,
  Param,
  Query,
  NotFoundException,
} from '@nestjs/common';
import { CreateUserDto } from './dtos/create-user.dto';
import { UpdateUserDto } from './dtos/update-user.dto';
import { UsersService } from './users.service';
import { Serialize } from '../interceptors/serialize.interceptor';
import { UserDto } from './dtos/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) {
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not fond');
    }
    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);
  }
}

any type

현재 우리 코드(특히 인터셉터 생성 코드에서) any type이 많다. 말했듯이 지양해야할 상황이다. 하만 이를 없애는 건 현재 상황에서 꽤 복잡한 일이기 때문에 적어도 오류는 감지할 수 있도록만 바꿔주자. 현재 오류를 감지 못하는지 궁금하다면 컨트롤러에 적용해놓은 @Serialize(UserDto)에 Userdto 대신 타자를 마구 입력해도 typescript가 미리 오류를 감지하지 못한다. 실행하면 당연히 안되겠지만.. 미리 개발중에 오류를 감지 못하는 것은 typescript의 의미를 잃어버리는 거니까.

적어도 Serialize가 클래스를 받아야 한다는 코드는 추가해주자.

1
2
3
4
5
6
7
8
9
10
// serialize.interceptor.ts
interface ClassConstructor {
  // 이 코드는 그냥 클래스라는 걸 말해주는 코드이다.
  new (...args: any[]): {};
}

// any에서 바꿔주었다. 이제 클래스가 아닌 것이 들어오면 오류처리될 것이다.
export function Serialize(dto: ClassConstructor) {
  return UseInterceptors(new SerializeInterceptor(dto));
}