Serialization은 먹는 게 아닙니다 - 직렬화 개념과 NestJS에서의 직렬화

제이슨·2023년 2월 22일
6
post-thumbnail

다른 분들은 어떻게 느끼셨는지 모르지만, 저한테는 ‘직렬화’라는 단어가 딱딱하고 어렵게 느껴져서 직렬화 개념을 한번 정리해보는 시간을 가지고 싶었습니다!

차라리 먹는 거면 좋았을텐데 말이죠ㅠㅠ

우선, 직렬화에 대해 알아봅시다!

직렬화란?

직렬화에 대한 개념적 정의는 아래와 같습니다.

직렬화는 시스템 내부에서 사용하는 데이터 또는 객체를 바이트로 변환해 외부 시스템에서도 사용할 수 있도록 변환하는 과정입니다.

조금 더 쉽게 설명하자면, dumb이라는 클래스가 있다고 가정했을 때 이 dumb 클래스의 객체를 JSON 형식으로 받게 만드는 과정이 직렬화입니다.

export class Dumb {
  id: number;
  name: string;
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}

---------------- 직렬화 ----------------

GET: http://localhost:3000/dumb

{
    "id": 2,
    "name": "저는 Dumb인디요"
}

그리고 이 과정의 반대(외부의 데이터를 내부 시스템에서 객체 또는 데이터로 변환하는 과정)를 ‘역직렬화’라고 합니다!

음.. 그래도 직렬화가 필요한 이유에 대해서 한번 정리해보면 좋겠죠?

직렬화가 필요한 이유에 대해서 알아봅시다!

직렬화가 왜 필요한가요?

직렬화는 아래와 같은 이유로 사용합니다.

  1. 데이터 저장
    • 직렬화된 데이터는 파일 또는 데이터베이스에 저장할 수 있습니다.
    • 데이터를 직렬화한 후에 저장하면 나중에 데이터를 복원하거나 다른 프로그램에서 사용할 수 있도록 저장할 수 있습니다.
  2. 데이터 전송
    • 직렬화된 데이터는 네트워크를 통해 전송할 수 있습니다.
    • 네트워크를 통해 데이터를 전송할 때는 직렬화된 데이터를 사용하여 데이터를 전송하고, 수신 측에서는 역직렬화를 통해 데이터를 복원합니다.
  3. 다른 시스템과의 상호 운용성
    • 직렬화된 데이터는 서로 다른 시스템 간에 데이터를 공유하는 데 사용됩니다.
    • 예를 들어, 서로 다른 프로그래밍 언어를 사용하는 두 시스템 간에 데이터를 전송할 때는 직렬화된 데이터를 사용하여 데이터를 전송합니다.

결과적으로, 서버 내부의 데이터를 다른 시스템으로 옮기려면 직렬화가 필요합니다!

NestJS에서의 직렬화

사실 우리는 NestJS에서 컨트롤러를 통해 응답을 할 때 직렬화를 사용하고 있습니다.

NestJS의 공식 문서에 따르면,

Using this built-in method, when a request handler returns a JavaScript object or array, it will automatically be serialized to JSON.

When it returns a JavaScript primitive type (e.g., string, number, boolean), however, Nest will send just the value without attempting to serialize it.

This makes response handling simple: just return the value, and Nest takes care of the rest.

Nest는 자동으로 객체, 배열과 같이 JSON 형식으로 변환이 필요한 경우 변환해 JSON 형식으로 반환하고 변환이 필요하지 않은 원시 타입은 변환하지 않습니다.

즉, 우리가 JSON.stringify()를 따로 사용할 필요가 없다는 말이죠!

그렇다면, NestJS에서 직렬화 얘기를 할 때 연관되는 인터셉터, ‘ClassSerializerInterceptor’는 뭘까요?

ClassSerializerInterceptor

ClassSerializerInterceptor는 응답을 보내기 전에 응답 객체를 가로채 엔터티 또는 DTO에 붙은 class-transformer의 데코레이터를 인식하고 그에 맞춰 응답 객체를 변형하는 인터셉터입니다.

어렵다고요? 코드로 살펴보아요 😊

앞에서 소개한 Dumb 클래스는 좀 멍청하니까 조금 더 실전적인 예제를 가져왔습니다.

[물론 DTO 자체를 잘 설계하면 (Objec.seal로 원하지 않는 변수가 바인딩되는 것을 차단하거나, Object.asign을 사용하지 않고 생성자에 엔터티를 파라미터로 받아서 필드 값을 바인딩거나...) 문제를 피해갈 수 있는 방법은 많습니다]

사용자가 ‘GET: /users/:id ‘ 요청을 보낼 때 User 엔터티에 password를 빼고 반환하고 싶습니다.
(사용자 비밀번호는 소중하니까요…👉👈)

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

  @Column()
  email: string;

  @Column()
  password: string;
}

그래서 DTO를 만들었는데,

export class ReadUserDto {
  id: number;
  email: string;

  constructor(user: User) {
    this.id = user.id;
    this.email = user.email;
  }
}

-------------------------------

@Injectable()
export class UsersService {
	...
	async findOne(id: number): Promise<null | object> {
    const user = await this.repository.findOneBy({ id });

    if (!user) {
      return null;
    }

    const readUserDto = new ReadUserDto();
    Object.assign(readUserDto, user);

    return readUserDto;
  }
	...
}

-------------------------------
@Controller('users')
export class UsersController {
	...
  @Get('/:id')
  async findUser(@Param('id') id: string) {
    const user = await this.usersService.findOne(+id);

    if (!user) {
      throw new NotFoundException();
    }

    return user;
  }
	...
}

아 글쎄 선언도 안된 password가 그대로 반환되지 뭐에요?!?

GET: http://localhost:3000/users/3
{
    "id": 3,
    "email": "aswe@mail.com",
    "password": "asdqweq"
}

이럴 때 사용할 수 있는 게 ClassSerializerInterceptor입니다.

ClassSerializerInterceptor는 전역, 클래스 단위, 메서드 단위로 사용할 수 있는 @nestjs/common 모듈의 인터셉터인데, 우선 DTO에 class-transformer의 @Exclude 데코레이터를 붙여봅시다.

export class ReadUserDto {
  id: number;

  email: string;

  @Exclude()
  password: string;
}

그 다음 사용하고자 하는 컨트롤러의 메서드 위에 인터셉터 사용을 명시합니다.

@Controller('users')
export class UsersController {
	...
  @Get('/:id')
  @UseInterceptors(ClassSerializerInterceptor)
  async findUser(@Param('id') id: string) {
    const user = await this.usersService.findOne(+id);

    if (!user) {
      throw new NotFoundException();
    }

    return user;
  }
	...
}

이제 한번 같은 HTTP 요청을 보내볼까요?

GET: http://localhost:3000/users/3
{
    "id": 3,
    "email": "aswe@mail.com"
}

우리가 의도했던대로 문제가 잘 해결되었습니다!

ClassSerializerInterceptor 사용하지 않고 목적 달성하기

음.. 하지만, 처음에 의도했던 게 password를 바깥에 노출시키지 않는 것인데 데코레이터를 덕지덕지 붙여놓는 게 최선일까요?
(간단히 대체할 수 있는 데코레이터를 사용하는 것도 복잡성을 높이는 원인중 하나라고 생각합니다)

DTO만 수정하면 ClassSerializerInterceptor 와 @Exclude() 데코레이터 쓰지 않고도 목적과 동일하게 동작하는 코드를 만들 수 있습니다.

import { User } from '../user.entity';

export class ReadUserDto {
  id: number;
  email: string;

  constructor(user: User) {
    this.id = user.id;
    this.email = user.email;

		// 선언된 변수 외 외부 할당 차단
		//  이 방법도 Object.assign을 막을 수는 없습니다.
		//  (런타임 오류 발생)
		// 그러니까 가급적 Object.assign을 dto에 쓰지 마세요.
		// 생성자에서 엔터티 객체를 받아 
		//   원하는 값만 할당하길 권장합니당
    Object.seal(this);   
  }
}

service의 findOne 메서드를 수정합니다.

@Injectable()
export class UsersService {
  ...
  async findOne(id: number): Promise<null | object> {
    const user = await this.repository.findOneBy({ id });

    if (!user) {
      return null;
    }

    return new ReadUserDto(user);
  }
  ...
}

HTTP 요청을 보내면 같은 결과가 출력됩니다✌️

GET: http://localhost:3000/users/3
{
    "id": 3,
    "email": "aswe@mail.com"
}
profile
계속 읽고 싶은 글을 쓰고 싶어요 ☺

0개의 댓글