10/22/2023

인터셉터와 직렬화

발단

사용자 API를 확인하는 도중 큰 문제점을 발견했다. 바로 사용자의 비밀번호를 응답에 그대로 가져오는 것이었다!! 물론 해시를 통해서 단방향 암호화를 했지만 비밀번호와 같이 보안에 민감한 컬럼은 응답에서 완전히 배제되어야 한다. 공식 문서를 읽다보니 이 문제는 인터셉터와 직렬화와 관련이 있다는 것을 알았다.


인터셉터

인터셉터는 들어오는 요청이나 나가는 응답을 가로채고 이를 조작할 수 있는 미들웨어이다. 인터셉터의 기능 중 하나는 함수에서 반환된 결과를 변형하는 것이다. 공식 문서의 TECHNIQUES 섹션의 Serialization 부분을 따르면 엔티티에 @Exclude() 데코레이터를 추가하고 UseInterceptors() 데코레이터에 ClassSerializerInterceptor 인터셉터를 인자로 전달해서 인터셉터를 구현할 수 있다. ClassSerializerInterceptor 인터셉터는 응답을 가로채서 class-transformer 패키지의 instanceToPlain() 함수를 호출해 응답을 JSON으로 직렬화 한다(다시 말해, 사용자 엔티티 인스턴스를 JSON으로 변형한다.). 여기서 class-transformer 패키지의 데코레이터를 사용해서 규칙을 엔티티/DTO 클래스에 적용한다. @Exclude() 데코레이터는 해당 컬럼을 직렬화 과정에서 제외하는 데코레이터라서 이를 비밀번호 컬럼에 추가했다.
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
// user.entity.ts
@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column({ length300, nullable: false })
  name: string;
 
  @Column({ length500, unique: true, nullable: false })
  email: string;
  
  @Exclude()
  @Column({ length500, nullable: false })
  password: string;
 
  ...
}
 
// auth.controller.ts
@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly usersSerivce: UsersService,
    private readonly configService: ConfigService,
  ) {}
 
  @HttpCode(HttpStatus.CREATED)
  @UseInterceptors(ClassSerializerInterceptor)
  @Post('/signup')
  async signUp(@Body() createUserDto: CreateUserDto) {
    return this.authService.signUp(createUserDto);
  }
 
  ...
}
cs


한계

작동은 잘 했지만 뭔가 부족했다. 만약 관리자 기능이 추가되면 관리자와 사용자에 대한 API 경로는 다르고 응답도 당연히 다르다. 관리자 API의 응답은 사용자에 관한 많은 정보를 담아야 하지만 사용자 API는 가능한 적은 정보를 그리고 비밀번호처럼 특정 정보는 배제해야 한다. 위의 방식은 이러한 문제에 유연하게 대응할 수 없다. 왜냐하면 두 API는 모두 동일한 사용자 엔티티 인스턴스를 반환하기 때문이다. 관리자 별 메서드를 따로 만들 수 있는데 코드 중복 문제도 있고 유지보수하기 어렵다.


DTO

일단 엔티티에 추가한 class-transformer 패키지의 데코레이터를 제거했다. 언급한 관리자 기능과 같은 문제를 해결하는 확장성을 가지기 어렵기 때문이다. 대신, DTO를 사용하고 직렬화 규칙을 적용하고 직접 구현한 인터셉터에서 사용자 엔티티 인스턴스를 사용자 DTO 인스턴스로 변형해서 직렬화하는 것이다. 이를 통해 기능에 맞는 DTO(e.g., 사용자 DTO, 관리자 DTO)를 여러 개 생성해서 위의 문제를 해결할 수 있다. NestJS는 엔티티 인스턴스를 자동으로 JSON으로 변환하지만 직접 만든 인터셉터를 사용해서 이 과정을 조작해야 한다. 다시 말해, 사용자 엔티티 인스턴스를 사용자 DTO 인스턴스로 변경하고 다양한 직렬화 규칙을 추가하고 이를 반환하면 NestJS는 인스턴스를 가져와서 자동으로 JSON으로 변환하고 해당 JSON을 응답으로 보낸다.


직렬화 인터셉터

인터셉터를 직접 구현하려면 NestInterceptor 인터페이스의 intercept() 메서드를 구현해야 하는데 이 메서드는 인터셉터가 실행되어 들어오는 요청이나 나가는 응답을 처리해야 할 때 자동으로 호출된다. ExecutionContext와 CallHandler라는 두 개의 매개변수를 가지는데 간략하게 말하면 ExecutionContext는 들어오는 요청과 관련된 정보를 둘러싼(wrap) 것이고 CallHandler는 컨트롤러 내부의 경로 핸들러 메서드에 대한 일종의 참조(reference)라고 생각할 수 있다.
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
53
54
55
56
57
58
// user.dto.ts
export class UserDto {
  id: number;
 
  @Expose()
  name: string;
 
  @Expose()
  email: string;
 
  @Expose()
  phoneNumber: string;
 
  @Transform(({ value }) => moment(value).format('YYYY-MM-DD'))
  @Type(() => Date)
  @Expose()
  birthday: Moment;
 
  @Expose()
  roles: Role[];
 
  constructor(email: string, phoneNumber: string) {
    this.email = email;
    this.phoneNumber = phoneNumber;
  }
}
 
// serialize.interceptor.ts 
export class SerializeInterceptor implements NestInterceptor {
 
  // ExecutionContext은 ArgumentsHost를 확장하며 현재 실행 프로세스에 대한 추가적인 세부 정보를 제공한다.
  // CallHandler 인터페이스는 handle() 메서드를 구현하며 이를 통해 인터셉터 내에서 경로 핸들러 메서드를 언제든지 호출할 수 있다.
  // intercept() 메서드 구현에서 handle() 메서드를 호출하지 않으면 경로 핸들러 메서드가 실행되지 않는다.
  // intercept() 메서드는 요청/응답 스트림을 래핑(wrap)한다. 즉, 최종 경로 핸들러의 실행 전후에 사용자 정의 논리를 구현할 수 있다.
  // intercept() 메서드에서 handle()을 호출하기 전에 코드를 작성할 수 있으며 handle() 메서드는 Observable을 반환하므로 RxJS 연산자를 사용하여 응답을 추가로 조작할 수 있다.
  // 다시 말해, 스트림에는 경로 핸들러에서 반환된 값이 포함되어 있으며 RxJS의 map() 연산자를 사용하여 쉽게 변형할 수 있다.
  // 관점 지향 프로그래밍 용어를 사용하면 경로 핸들러의 호출(즉, handle() 호출)은 포인트컷(Pointcut)이라고 불리며 이는 추가 논리가 삽입되는 지점이다.
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    // 경로 핸들러 메서드에 의해 요청이 처리되기 전에 작업을 실행한다.
    console.log('요청 전 호출!');
 
    // data는 사용자 엔티티 인스턴스, 이를 사용자 DTO 인스턴스로 변환해야 한다.
    return next.handle().pipe(
      map((data: any) => {
          // 응답을 전송하기 전에 작업을 실행한다.
        console.log('응답 전 호출!');
          return plainToInstance(UserDto, data, {
          // excludeExtraneousValues 옵션이 true면 data 객체에 DTO에 대응되는 속성이 없는 경우 해당 속성을 제외한다.
          excludeExtraneousValues: true
 
        });
      }),
    );
  }
}
cs


이메일과 전화번호만 반환~


하드 코딩

작동은 잘 하는데 또 다른 문제점이 있다. 이 코드는 UserDto 클래스를 하드 코딩해서 사용자 DTO 인스턴스만 다룰 수 있다. 다른 엔티티에도 직렬화를 적용할 수 있도록 확장성을 가져야 한다. 즉, 매개변수를 맞춤화 해야한다! 구현하는 방법은 인터셉터에 생성자를 정의하면 된다. 생성자는 특정 DTO를 받아서 필드로 저장하고 직렬화 논리를 실행할 때 해당 DTO를 사용한다. 이제 UseInterceptors(new SerializeInterceptor(dto)) 코드를 사용해서 인터셉터를 적용할 수 있다. 근데 코드가 너무 길다... 데코레티어를 만들어서 이를 줄여주자!
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
export function Serialize(dto: any) {
  return UseInterceptors(new SerializeInterceptor(dto));
}
 
@Injectable()
export class SerializeInterceptor implements NestInterceptor {
  // DTO를 생성자의 인자로 받아서 인터셉터를 모든 엔티티에 적용할 수 있다.
  constructor(private dto: any) {}
 
  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
    return next.handle().pipe(
      map((data: any) => {
        return plainToInstance(this.dto, data, {
          excludeExtraneousValues: true,
        });
      }),
    );
  }
}
 
// 사용자 DTO 클래스인 UserDto
@HttpCode(HttpStatus.CREATED)
@Serialize(UserDto)
@Post('/signup')
async signUp(@Body() createUserDto: CreateUserDto) {
  return this.authService.signUp(createUserDto);
}
 
// 장바구니 DTO 클래스인 CartDto
@HttpCode(HttpStatus.OK)
@Serialize(CartDto)
@Patch('/:id')
async updateCart(
  @Req() request: RequestWithUser,
  @Param('id', ParseIntPipe) id: number,
  @Body('quantity', ParseIntPipe) quantity: number,
) {
  const { user } = request;
 
  return this.cartsService.update(id, quantity, user);
}
cs


중첩 객체

상품의 상세 정보를 반환하는 API를 테스트 했는데 무제가 발생했다! 상품 DTO 클래스 안의 출시 날짜는 ISO 형식으로 옵션, 이미지, 카테고리는 전부 빈 객체({})로 표시된다. 검색을 해보니 직접 만든 인터셉터의 문제가 아니라 class-transformer 패키지의 데코레이터에 답이 있었다~
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export class ItemDto {
  ...
 
  @Expose()
  releaseDate: Date;
 
  @Expose()
  options: Option[];
 
  @Expose()
  images: Image[];
 
  @Expose()
  categories: Category[];
}
cs

출시날짜, 옵션, 이미지, 카테코리가 이상하다.

일단 class-transformer의 GitHub 공식문서를 보면 중첩된 객체를 변환하려고 할 때 어떤 타입의 객체를 변환하려는지를 알아야 하며 각 속성이 어떤 타입의 객체를 포함하는지 명시적으로 지정해야 한다. @Type() 데코레이터를 사용하여 수행할 수 있음을 알 수 있다. 이에 따라 releaseDate, options, images, categories 컬럼을 @Type() 데코레이터를 추가했다. 하지만 추가적인 데이터 변형이 필요했고 공식 문서에 따라 @Transform() 데코레이터를 사용했다. 출시 날짜의 경우 YYYY-MM-DD 형식을 원했기에 검색을 통해 moment 패키지를 적용했다. 문제는 나머지 중첩 객체였다. 공식 문서를 보면 value 인자는 변형 전 속성 값이라고 적혀있어서 이를 통해 해결할 수 있는 줄 알았는데 그렇지 않았다. 이상하게 속성이 사라졌다! 남은 인자 중 obj 말고 선택지가 없었다. 적용했는데 다행히 해결됐다! 하지만 배열 같은 경우 @Type() 데코레이터 만으로 해결할 수 있다고 적혀있는데 나의 경우는 그렇지 않았다. 왜 그럴까? 또 중첩 객체의 특정 속성만을 선택하는 방법을 검색했지만 해결의 실마리를 발견하지 못했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export class ItemDto {
  ...
 
  @Transform(({ value }) => moment(value).format('YYYY-MM-DD'))
  @Type(() => Date)
  @Expose()
  releaseDate: Moment;
 
  // obj 인자로 해결했지만 그냥 @Type() 데코레이터로 충분한 것 같은데 이유를 모르겠다...
  @Transform(({ obj }) => obj.options)
  @Type(() => Option)
  @Expose()
  options: Option[];
 
  @Transform(({ obj }) => obj.images)
  @Type(() => Image)
  @Expose()
  images: Image[];
 
  @Transform(({ obj }) => obj.categories)
  @Type(() => Category)
  @Expose()
  categories: Category[];
}
cs

일단 해결했지만 이것도 문제가 있다. id 컬럼을 제외하고 싶은데 어떻게 해야할까?

이것저것 하다가 갑자기 중첩 객체가 생각이 나서 여러 시도를 했는데 일단 해결했다!! map() 메서드가 해결의 실마리를 제공했다. 다시 말해, @Transform() 데코레이터에서 인자 obj에 map() 메서드를 적용하는 것이다. 코드를 보면 options, images, categories 속성이 배열이기에 map() 메서드를 적용한 다음 아이디만 반환하게 변형시킨다.
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
export class ItemDto {
  ...
 
  @ApiProperty({
    example: [
      {
        id: 14,
      },
    ],
  })
  @Transform(({ obj }) => obj.options.map((option: Option) => option.id))
  @Type(() => Option)
  @Expose()
  options: number[];
 
  @ApiProperty({
    example: [
      {
        id: 43,
      },
    ],
  })
  @Transform(({ obj }) => obj.images.map((image: Image) => image.id))
  @Type(() => Image)
  @Expose()
  images: number[];
 
  @ApiProperty({
    example: [
      {
        id: 1,
      },
    ],
  })
  @Transform(({ obj }) =>
    obj.categories.map((category: Category) => category.id),
  )
  @Type(() => Category)
  @Expose()
  categories: number[];
}
cs

해결!


Res()

인터셉터는 의도대로 작동하지만 공식 문서에 따르면 라이브러리별 모드에서는 작동하지 않는다... 그러니까 경로 핸들러 메서드의 응답을 ExpressJS의 Res() 데코레이터로 사용해서 반환하면 인터셉터는 아무것도 하지 않는다. 이 때문에 로그인 API의 경우 사용자 DTO 인스턴스를 따로 생성해서 반환하는데 이것도 수정이 필요하다~
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
@HttpCode(HttpStatus.OK)
@UseGuards(LocalAuthGuard)
@Post('/signin')
async signIn(
  @Req() request: RequestWithUser,
  // ExpressJS를 사용하는 라이브러리별 모드
  @Res({ passthrough: true }) response: Response,
) {
  const { user } = request;
 
  const { token: accessToken } = await this.authService.createToken(
    user.id,
    Token.ACCESS_TOKEN,
  );
 
  const { token: refreshToken } = await this.authService.createToken(
    user.id,
    Token.REFRESH_TOKEN,
  );
 
  await this.usersSerivce.setRefreshToken(refreshToken, user.id);
 
  const accessTokenOptions = createCookieOptions(
    this.configService.get<string>('JWT_ACCESS_TOKEN_EXPIRATION'as string,
  );
 
  const refreshTokenOptions = createCookieOptions(
    this.configService.get<string>('JWT_REFRESH_TOKEN_EXPIRATION'as string,
  );
 
  const { email, phoneNumber } = user;
 
  const userDto = new UserDto(email, phoneNumber);
 
  response
    .cookie(`access_token`, accessToken, accessTokenOptions)
    .cookie(`refresh_token`, refreshToken, refreshTokenOptions)
    .send(userDto);
}
cs

update: 2024.01.21

댓글 없음:

댓글 쓰기