인터셉터
Intro
시작
유저의 정보를 요청해봅시다. /api/users/1
아래와 같은 정보가 응답데이터로 발송됩니다. 다만 취약한 정보 또한 발송됩니다. 바로 password
입니다.
{
id: 1,
email: 'wooglim@gamil.com',
password: 'qwer1234!'
}
현재 요청 프로세스를 살펴봅시다.
/api/users/1
로 유저 정보를 요청합니다.- 컨트롤러에 라우팅되고, 서비스를 호출합니다.
- 서비스는 검색된 user 인스턴스를 컨트롤러에게 return 합니다.
- 컨트롤러는 user 인스턴스 그대로 응답으로 발송합니다. 이때 Json으로 변환해 발송합니다.
Nest는 서비스 계층의 return 단계부터 아래와 같이 적용할 것을 권장합니다.
- 컨트롤러에 return 하기 전 일반 객체로 변환하여 return 합니다.
- 컨트롤러에서 클라이언트에게 응답을 발송하는 과정에서
클래스 직렬화 인터셉터
를 추가합니다. 인터셉터의 경우 요청/응답 데이터를 가로채 가공하는데 사용됩니다.
우선 users.entity.ts
코드를 수정합니다. password
의 경우 @Exclude()
데코레이터를 설정해 응답 데이터에서 제외되도록 합니다.
// users.entity.ts
@Column()
@Exclude()
password: string;
그 후 @Exclude()
데코레이터를 인터셉터에서 인식하도록 해당 요청을 처리하는 컨트롤러에 @UseInterceptor(ClassSerializerInterceptor)
데코레이터를 추가합니다.
// users.controller.ts
import { ClassSerializerInterceptor, UseInterceptors } from '@nestjs/common';
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
// ...
@UseInterceptors(ClassSerializerInterceptor)
@Get('/:id')
async findUser(@Param('id') id: string) {
// url 로 전송되는 정보는 대부분 string 이다.
const user = await this.usersService.findOne(parseInt(id));
if (!user) {
throw new NotFoundException('user not found');
}
return user;
}
}
다른 직렬화 방법 찾아보기
단 위 방법이 최선은 아닙니다. 다음과 같은 문제가 있습니다.
사용자 이름, 나이, 주소 등 추가 식별 정보가 들어갈 수 있죠. 그때 마다 @Exclude()
데코레이터를 추가하겠지만, 관리자의 경우 이 정보 또한 조회할 필요가 있을 겁니다. 이때 문제가 발생합니다.
다시 서비스 계층의 return 단계부터 아래와 같이 수정합니다.
- 컨트롤러에 return 할때 유저 Entity 인스턴스를 그대로 return 합니다.
- 응답용 DTO 를 추가로 생성하고 유저 Entity 인스턴스를 직렬화해 특정 필드만 포함된 일반 Json 객체를 생성합니다. 또한 응답시 내용이 적용되도록
Custom Interceptor
의 인자로 DTO를 전달합니다.
Custom InterCeptor 생성하기
인터셉터는 미들웨어와 유사합니다. 클라이언트에서 특정 컨트롤러에 분배되기 전 단계에 위치할 수 있고 반대로 컨트롤러에서 클라이언트에게 발송되기 전 단계에 위치할 수 있습니다. users
관련 컨트롤러 메서드를 정의하는 UserController
에 적용해 users
라우팅 전역에 인터셉터가 동작하도록 할 수도 있습니다.
인터셉터 역할을 하는 경우 해당 클래스는 명시적으로 ...Interceptor
로 이름을 정합니다. 이 클래스는 intercpt
메서드를 정의하고 첫 번째 인자로는 들어오는 요청에 대한 정보를 둘러싼 컨텍스트, 두번째 인자로는 정보를 전달할 다음 핸들러를 인자로 갖습니다.
class CustomInterCeptor{
intercept(context: ExcutionContext, next: CallHandler)
}
우선 시작에 앞서 엔티티에 설정한 @Exclude()
데코레이터를 제거합니다.
// users.entity.ts
@Column()
// @Exclude()
password: string;
커스텀 인터셉터를 만들고 작동을 위해 테스트 해봅시다.
// src/interceptors/serialize.interceptor.ts
import { CallHandler, ExecutionContext, NestInterceptor } from "@nestjs/common";
import { map, Observable } from "rxjs";
export class SerializerInterceptor implements NestInterceptor{
// NestInterceptor 인터페이스를 구현합니다.
intercept(context: ExecutionContext, handler: CallHandler<any>): Observable<any> {
// handler(next) 로 전달되기전 데이터를 가공합니다. 요청 데이터는 context에 존재
console.log('handler 이전 작동하는 인터셉터', context);
return handler.handle().pipe(
map((data: any) => {
// 응답으로 다시 보낼 데이터
console.log('handler로 사용자에게 응답으로 보내기 전 작동하는 인터셉터', data);
})
)
}
}
컨트롤러의 인터셉터를 SerializerInterceptor로 교체합니다.
// users.controller.ts
// @UseInterceptors(ClassSerializerInterceptor)
@UseInterceptors(SerializerInterceptor)
@Get('/:id')
async findUser(@Param('id') id: string) {
console.log('handler is running')
// url 로 전송되는 정보는 대부분 string 이다.
const user = await this.usersService.findOne(parseInt(id));
if (!user) {
throw new NotFoundException('user not found');
}
return user;
}
호출하면 다음과 같이 터미널에 console.log
정보가 출력됩니다.
handler로 전달되기 전 작동하는 인터셉터 ExecutionContextHost {
args: [
IncomingMessage {
_readableState: ...
# ...
}
]
}
handler is running
handler로 사용자에게 응답으로 보내기 전 작동하는 인터셉터 { id: 1, email: 'wooglim@gmail.com', password: 'qwer1234!'}
인터셉터는 서비스로부터 유저 Entity 인스턴스를 전달 받은 후 컨트롤러를 타고 응답 데이터를 발송하기 전 가공에 이르기까지 일련의 사이클을 거치게 됩니다.
Custom Interceptor 로 데이터 직렬화하기
응답으로 전송할 UserDto
를 생성합니다.
// src/users/dtos/user.dto.ts
import { Expose } from "class-transformer";
export class UserDto {
@Expose()
id: number;
@Expose()
email: string;
}
SerializerInterceptor
에서 UserDto
를 기준으로 응답데이터를 Json 형태로 직렬화합니다.
// src/interceptors/serialize.interceptor.ts
import { CallHandler, ExecutionContext, NestInterceptor } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { map, Observable } from "rxjs";
import { UserDto } from "../users/dtos/user.dto";
export class SerializerInterceptor implements NestInterceptor{
// NestInterceptor 인터페이스를 구현합니다.
intercept(context: ExecutionContext, handler: CallHandler<any>): Observable<any> {
return handler.handle().pipe(
map((data: any) => {
// data 객체를 UserDto 클래스로 변환한 후,
// @Expose 데코레이터가 적용된 속성들만 포함된 직렬화된 객체로 반환합니다.
return plainToInstance(UserDto, data, {
excludeExtraneousValues: true,
})
})
)
}
}
재사용이 가능하도록 리펙토링하기
위에서 정의한 인터셉터는 UserDto
기준으로만 직렬화하도록 하드코딩되어 있습니다.
인터셉터 또한 클래스 이므로 생성자를 이용해 새로운 인스턴스로 만들고 인터셉터로 설정하는 방법을 적용합니다.
export class SerializerInterceptor implements NestInterceptor{
constructor(private dto: any) {}
// NestInterceptor 인터페이스를 구현합니다.
intercept(context: ExecutionContext, handler: CallHandler<any>): Observable<any> {
return handler.handle().pipe(
map((data: any) => {
// user 인스턴스를 UserDto 인스턴스로 변환하고 Json 객체로 직렬화 합니다.
return plainToInstance(this.dto, data, {
excludeExtraneousValues: true,
})
})
)
}
}
사용하는 컨트롤러에서 새로운 인스턴스로 생성해 해당 DTO로 초기화한 후 직렬화에 적용합니다.
// users.controller.ts
// @UseInterceptors(ClassSerializerInterceptor)
@UseInterceptors(new SerializerInterceptor(UserDto))
@Get('/:id')
async findUser(@Param('id') id: string) {
console.log('handler is running')
// url 로 전송되는 정보는 대부분 string 이다.
const user = await this.usersService.findOne(parseInt(id));
if (!user) {
throw new NotFoundException('user not found');
}
return user;
}
하지만 코드가 너무 길어졌습니다. UseInterceptors 데코레이터를 정의하고 SerializerInterceptor 인터셉터를 사용하는데, UserDto로 dto를 초기화 한 인스턴스를 이용한다는 것을 매번 명시해줘야 합니다.
커스텀 데코레이터 생성하기
작성했던 커스텀 인터셉터 코드를 수정합니다.
// src/interceptors/serialize.interceptor.ts
interface ClassConstructor {
// 모든 클래스
new (...args: any[]): {}
}
export function Serialize(dto: ClassConstructor) {
return UseInterceptors(new SerializerInterceptor(dto));
}
export class SerializerInterceptor implements NestInterceptor{
constructor(private dto: any) {}
// NestInterceptor 인터페이스를 구현합니다.
intercept(context: ExecutionContext, handler: CallHandler<any>): Observable<any> {
return handler.handle().pipe(
map((data: any) => {
// user 인스턴스를 UserDto 인스턴스로 변환하고 Json 객체로 직렬화 합니다.
return plainToInstance(this.dto, data, {
excludeExtraneousValues: true,
})
})
)
}
}
컨트롤러 또한 다음과 같이 수정합니다.
// users.controller.ts
import { Serialize } from '../interceptors/serialize.interceptor';
// @UseInterceptors(ClassSerializerInterceptor)
// @UseInterceptors(new SerializerInterceptor(UserDto))
@Serialize(UserDto)
@Get('/:id')
async findUser(@Param('id') id: string) {
console.log('handler is running')
// url 로 전송되는 정보는 대부분 string 이다.
const user = await this.usersService.findOne(parseInt(id));
if (!user) {
throw new NotFoundException('user not found');
}
return user;
}
만일 다른 라우팅또한 DTO가 동일하다면 컨트롤러 전역에 적용할 수 있습니다. 클래스 위에 데코레이터를 명시해줍니다.
// users.controller.ts
@Controller('users')
@Serialize(UserDto)
export class UsersController {
// ...
}