Carryduo

Carryduo | 계층 분리를 위한 DTO, Entity 리팩토링

차가운에스프레소 2023. 3. 13. 21:21

0. 개요

- 최근 들어, 아키텍처와 관련된 글들을 꽤 작성했다.

- 이 글은 그러한 글들의 발단이 된 리팩토링 작업에 대한 글이다. 아키택처 글들은 리팩토링 작업을 하면서 해당 작업의 배경 지식들을 공부한 내용이었다. 

- 리팩토링한 내용은 아직 100% 만족하진 않는다. 공부를 하는 과정에서, "아, 이건 이런 식으로 하는 게 더 좋았을 수도 있겠다."하는 생각들이 많이 들었기 떄문이다.

- 일단, 이 글은 작업된 내용을 기반으로 한다. 중간중간 작업된 내용 중 개선점에 대해서도 서술해보겠다.

 

많이 부족한 글이기 때문에, 잘못된 내용에 대한 피드백 혹은 격려 주시면 정말 감사하겠습니다. 

1. Carryduo 백엔드의 계층 구조와 기존 코드의 문제점

1) Carryduo의 백엔드 계층 구조

- Carryduo는 다음과 같이 Layered-Architecture로 아키텍처가 구성되어 있다.

Controller
- FE로부터 request 요청을 받아, Service에 이를 전달한다. Service로부터 처리가 완료되면, FE로 response를 응답한다.

Service
- 비즈니스 로직을 담당한다.

Repository
- 데이터베이스 서버와의 통신, 즉 쿼리를 담당한다.

- Layered-Architecture의 핵심은 "관심사에 따른 계층 분리"이다.

- 요컨대, 각 계층의 소스코드는 자신이 담당하는 관심사에 해당하는 소스코드만이 포함되어야 한다.

- 이를 통해, Layered-Architecture는 1) 계층 분리에 따른 코드 가독성/유지보수성 제고, 2) 모듈 교체 용이, 3) 수월한 테스트 환경이라는 이득을 취한다.


2) 기존 코드의 문제점

- Carryduo가 이러한 계층 구조를 가지고 있음에도 불구하고, 코드 상에서 나타난 가장 큰 문제점은 "특정 계층에서 해당 계층의 관심사가 아닌 모듈/객체가 참조되고 있다."는 것이었다.

- 사례 코드는 총 두 가지다. 먼저 service 계층의 사례다.

(1) Service 계층

  async getIndiviualChampData(champId: string, position: string): Promise<IndiviudalChampResponseDto[] | { result: any[]; message: string }> {
    const versions = await this.combinationStatRepository.getVersions();
    const versionList = await sortPatchVersions(versions);
    const versionList: string[] = await sortPatchVersions(versions);

    let option: { category: Brackets; champ: Brackets };
    switch (position) {
      case 'top':
        option = {
          category: new Brackets((qb) => {
            qb.where('COMBINATION_STAT.category = :category', {
              category: 0,
            });
          }),
          champ: new Brackets((qb) => {
            qb.where('COMBINATION_STAT.mainChampId = :mainChampId', {
              mainChampId: champId,
            });
          }),
        };
        ....
    }
return value
})

- 문제가 되는 지점은 switch-case문이다.

- Brackets는 typeorm에서 제공하는 객체로, 아래 메소드에서는 where 쿼리문을 커스텀하는데 사용되었다.

- 데이터베이스 서버에 접근하기 위한 쿼리의 기반이 되는 객체인 Bracket을 비즈니스 로직만 담당하는 service 계층에서 생성하는 것이 옳지 않다고 느꼈다.


(2) repository 계층

async updateUserOptionInfo(userId: string, body: OptionRequestDTO) {
    const { nickname, profileImg, bio, preferPosition, enableChat, preferChamp1, preferChamp2, preferChamp3, tier } = body;
    await this.usersRepository
      .createQueryBuilder()
      .update(UserEntity)
      .set({
        nickname,
        profileImg,
        bio,
        tier,
        preferPosition,
        enableChat,
        preferChamp1: () => String(preferChamp1),
        preferChamp2: () => String(preferChamp2),
        preferChamp3: () => String(preferChamp3),
      })
      .where('userId = :userId', { userId })
      .execute();
    return;
  }

- 문제가 되는 지점은 메소드가 파라미터로 받는 body의 typepreferChamp1: () => String(preferChamp1) ... 부분이다.

- 쿼리는 typeorm의 createQueryBuilder() 메소드로 작성되는데, update 쿼리에 들어가는 value들은 set의 파라미터로 들어간다.

- preferChamp ~ value들만 다른 value들과 다르게 함수형태로 값을 지정한 것이 보일 것이다. 이유는 preferChamp은 foreignKey이기 때문이다.

- 위 코드를 작성할 당시에 preferChamp를 함수형태로 값을 넣게 된 구체적인 배경은 다음과 같다. 

1. userEntity에서 preferChamp는 다음과 같이 정의되어있다.(아래 코드 참고)
2. preferChamp의 타입은 userEntity가 1대다 관계를 맺고 있는 ChampEntity다.
3. 그래서 userEntity에서 preferChamp를 변경하고자 한다면, preferChamp의 타입은 ChampEntity로 해주어야 한다.
4. 위에서 preferChamp1: () => String(preferChamp1)와 같이 preferChamp 값을 넣게 된 까닭은 body에서 preferChamp1/2/3의 타입은 ChampEntity가 아니라 string 타입이었기 떄문이다.
5. body에서 최초로 제공한 preferChamp의 타입인 string은 ChampEntity가 아니기 떄문에, body 값 그자체를 set에 담을 수 없다.
6. IDE에서 제공해주는 문제 해결 대안으로 제시된 것은 함수형태로 치환하는 것이었다. 
@Entity({
  name: 'USER',
})
export class UserEntity extends OmitType(CommonEntity, ['id']) {
  @ManyToOne(() => ChampEntity, (champEntity: ChampEntity) => champEntity.id, {
    onDelete: 'CASCADE',
    onUpdate: 'CASCADE',
  })
  @JoinColumn([
    {
      name: 'preferChamp1',
      referencedColumnName: 'id',
    },
  ])
  preferChamp1: ChampEntity;

  @ManyToOne(() => ChampEntity, (champEntity: ChampEntity) => champEntity.id, {
    onDelete: 'CASCADE',
    onUpdate: 'CASCADE',
  })
  @JoinColumn([
    {
      name: 'preferChamp2',
      referencedColumnName: 'id',
    },
  ])
  preferChamp2: ChampEntity;

  @ManyToOne(() => ChampEntity, (champEntity: ChampEntity) => champEntity.id, {
    onDelete: 'CASCADE',
    onUpdate: 'CASCADE',
  })
  @JoinColumn([
    {
      name: 'preferChamp3',
      referencedColumnName: 'id',
    },
  ])
  preferChamp3: ChampEntity;
}

- 지금 와서 돌이켜보면 정말 부끄러운 코드지만, 저 당시에는 소위 말하는 "일단 돌아가니까... 다른 거 구현하기 바쁘니까 다음에 보자." 라는 생각으로 넘어갔었다.

- 그리고 그 "다음"인 지금, 이 문제가 발생하게 된 이유는 결국에 계층의 관심사였음을 깨달을 수 있었다.

- preferChamp1: () => String(preferChamp1)를 다시 회고해보자면, 이 코드는 repository 계층의 관심사와 어긋나는 객체가 파라미터로 전달됨에 따라 발생한 에러를 일시적으로 막는 임시방편이었던 것이다.

요컨대, 애당초 위 코드, updateUserOptionInfo 메소드에서 발생한 문제(body 파라미터의 preferChamp 값이 로직에서 그 값 그대로 쓰이지 않는 문제)를 근본적으로 해결하려면, 파라미터로 전달받은 객체의 타입을 userEntity 타입으로 변경해주었어야 했다는 것이다.

3) 기존 코드의 개선사항

- 위에서 기존 코드의 사례 코드와 그것의 문제점들을 서술했는데, 많이 장황하다.

- 요약하자면 다음과 같다.

1. Layered-Architecture의 핵심은 관심사에 따른 계층 분리다.
2. 계층을 명확히 분리하기 위해, 계층은 관심사에 해당하는 모듈/객체만 사용해야한다.
3. 그런데, 기존 코드에서는 계층에서 사용되는 모듈/객체 중 그 계층에 어긋나는 모듈/객체들이 있었다.

- 그래서 개선해야하는 사항도 다음과 같이 설정했다.

1. 계층의 관심사에 어긋나는 코드는 다른 계층으로 이관한다. 
2. 계층 간 데이터 통신 시에는 그 계층에서 요구되는 형태로 객체를 변환해서 전달한다.

- 개선사항1은 작업이 비교적 직관적이다. 예컨대, 2) - (1)에 소개했던 service 계층에 있는 Brackets 객체를 생성하는 로직은 repository 계층으로 이관하는 방식으로 수행 가능하다.

- 개선사항2는 작업을 위해서 DTO와 Entity 코드를 수정해줘야한다. 다음 내용부터는 개선사항2에 대한 내용들이다.


3. DTO, Entity 개선

- 개선사항 2, "계층 간 데이터 통신 시에는 그 계층에서 요구하는 형태로 객체를 변환한다."를 수행하기 위해, DTO와 Entity를 개선했다.

- 먼저, DTO와 관련된 개념들부터 살펴보겠다.


1) DTO, VO, Entity

(1) DTO

- DTO는 data transfer object로 데이터 통신 규격(객체)로, 말 그대로 소프트웨어 아키텍처에서 계층 간 데이터 통신을 위해 이용되는 객체이다.

- object로서 DTO는 getter와 setter를 갖출 수 있다.

- 하지만, DTO는 데이터 전달이 역할이기 떄문에, method를 가질 수 없다.

 

(2) VO

- VO는 Value Object로 값 객체이다.

- object로서 VO는 객체의 불변성을 보장한다.

- DTO와 달리, 로직을 가질 수 있다.

- domain entity를 선언할 때, vo가 이용될 수 있을 것이다.

(3) Entity

- Entity는 여러 맥락에서 의미가 달라질 수 있는데, 여기서 말하는 Entity는 DB Entity이다.

- Entity는 데이터베이스 테이블과 매핑되는 객체를 의미한다.

- Entity는 VO와 마찬가지로 로직을 가질 수 있다고 한다.

=> 이에 대해서 개인적으로는 Entity가 로직을 가지는 것이 긍정적인지 고민 중이다.


2) DTO, Entity 개선 포인트

- DTO, VO, Entity에 대한 개념을 숙지하고, 다음과 같이 개선 포인트를 잡았다.

1. DTO는 Controller, Service 계층 각각에 request/response로 나누어 생성한다.
=> 예컨대, controller.request.dto / service.request.dto / service.response.dto / controller.response.dto로 구성하는 것이다.
=> 이 때, response의 경우, service와 controller의 값이 같다면, 동일한 dto를 이용한다.
=> request의 경우, service와 controller에서 값이 동일하더라도, 별도의 dto를 이용한다. controller dto는 http로부터 받은 request 데이터의 validation, service dto는 데이터를 entity 객체로 반환하는 책임을 주로 가진다.

3. 각 객체를 생성할 책임은 각 객체가 가진다.
=> 계층 간 데이터 통신을 할 떄, 객체 타입을 변환해주어야 한다.
=> 예컨대, controller에서 service로 데이터를 넘길 때, service 객체로 변환해야하는데, 그 책임은 service에 두는 것이다.
=> 마찬가지로, entity로 객체를 변환할 때, 그 책임은 entity가 지는 것이다.

- 이러한 개선 포인트에 따라, controller부터 repository까지 계층 간 데이터 통신을 다음 사례 코드와 같이 개선했다.


3) 개선 사례 코드

1. controller

  async updateUserOptionInfo(
    @User() user: LoginResponseDto,
    @Body() body: UpdateUserOptionRequestBodyDto,
  ): Promise<CommonResponseDto> {
    await this.userService.updateUserOptionInfo(user.toUpdateOptionRequestDto(body));
    return new CommonResponseDto(true, '설정 변경 완료되었습니다');
  }

- controller에서 service로 데이터를 보내기 전에, 아래 dto를 통해 service request dto로 변환한다.

 

2. controller request dto

// loginresponsedto

export class LoginResponseDto {
  @Exclude() private readonly _userId: string;
  @Exclude() private readonly _nickname: string;
  @Exclude() private readonly _profileImg: string;

  constructor(userId: string, nickname: string, profileImg: string) {
    this._userId = userId;
    this._nickname = nickname;
    this._profileImg = profileImg;
  }

  @Expose()
  get userId() {
    return this._userId;
  }

  @Expose()
  get nickname() {
    return this._nickname;
  }

  @Expose()
  get profileImg() {
    return this._profileImg;
  }

  toUpdateOptionRequestDto(body: UpdateUserOptionRequestBodyDto) {
    return UpdateUserOptionRequestDto.createDto(this._userId, body);
  }
}

- LoginResonseDto는 클라이언트로부터 헤더를 통해 받은 토큰을 복호화해서 받은 유저 정보에 대한 데이터이다.

- toUpdateOptionRequestDto()를 통해 controller dto -> service dto 변환이 실행된다.

- 위 컨트롤러에서는 LoginResponseDto와 UpdateUserOptionRequestBodyDto 두 가지가 있는데, dto를 service dto로 변환하는 책임을 LoginResponseDto에 위임하였다.

=> 로직을 실행시키는 요청은 로그인한 유저에게 있다고 판단했기 때문이다.

=> 이와 비슷하게 reqeust가 param, header, body에서 복수로 받는 api들도 모두 이와 같이 dto 변환 책임을 LoginResponseDto에 위임했다.

// UpdateUserOptionRequestBodyDto

export class UpdateUserOptionRequestBodyDto {
  nickname?: string | null;

  profileImg?: string | null;

  bio?: string | null;

  preferPosition?: string | null;

  tier?: number | null;

  enableChat?: boolean | null;

  preferChamp1?: string | null;

  preferChamp2?: string | null;

  preferChamp3?: string | null;
}

- 클라이언트로부터 받은 body 값을 controller에서 validation하는 것이 목표인 dto이다. validation 데코레이터는 코드 길이로 인해 생략했다.

 

3. service

  async updateUserOptionInfo(data: UpdateUserOptionRequestDto) {
    try {
      const option = data.toEntity();
      const preferChamp = await this.userRepository.findPreferchamps(option.userId);
      const preferChampList = [
        preferChamp.preferChamp1,
        preferChamp.preferChamp2,
        preferChamp.preferChamp3,
      ];
      for (const pcl of preferChampList) {
        await this.champRepository.delPreferChampCache(pcl);
      }
      await this.userRepository.updateUserOptionInfo(option);
      return;
    } catch (error) {
      console.log(error);
      throw new HttpException('설정 변경 실패했습니다', 400);
    }
  }

 

- repository로 데이터를 넘길 때, 아래 service dto의 toEntity() 통해 entity로 객체를 변환한다.

 

4. service dto

export class UpdateUserOptionRequestDto {
  private _userId: string;
  private _nickname?: string | null;
  private _profileImg?: string | null;
  private _bio?: string | null;
  private _preferPosition?: string | null;
  private _tier?: number | null;
  private _enableChat?: boolean | null;
  private _preferChamp1?: string | null;
  private _preferChamp2?: string | null;
  private _preferChamp3?: string | null;

  constructor(userId: string, body: UpdateUserOptionRequestBodyDto) {
    this._userId = userId;
    this._nickname = body.nickname;
    this._profileImg = body.profileImg;
    this._bio = body.bio;
    this._preferPosition = body.preferPosition;
    this._tier = body.tier;
    this._enableChat = body.enableChat;
    this._preferChamp1 = body.preferChamp1;
    this._preferChamp2 = body.preferChamp2;
    this._preferChamp3 = body.preferChamp3;
  }

  static createDto(userId: string, body: UpdateUserOptionRequestBodyDto) {
    return new UpdateUserOptionRequestDto(userId, body);
  }

  toEntity(): UserEntity {
    const champ1 = ChampEntity.createChampIdOption(this._preferChamp1);
    const champ2 = ChampEntity.createChampIdOption(this._preferChamp2);
    const champ3 = ChampEntity.createChampIdOption(this._preferChamp3);
    return UserEntity.createUpdateOption({
      userId: this._userId,
      nickname: this._nickname,
      profileImg: this._profileImg,
      bio: this._bio,
      preferPosition: this._preferPosition,
      tier: this._tier,
      enableChat: this._enableChat,
      preferChamp1: champ1,
      preferChamp2: champ2,
      preferChamp3: champ3,
    });
  }

 

5. repository

  async updateUserOptionInfo(option: UserEntity) {
    const {
      userId,
      nickname,
      profileImg,
      bio,
      preferPosition,
      enableChat,
      preferChamp1,
      preferChamp2,
      preferChamp3,
      tier,
    } = option;

    await this.usersRepository
      .createQueryBuilder()
      .update(UserEntity)
      .set({
        nickname,
        profileImg,
        bio,
        tier,
        preferPosition,
        enableChat,
        preferChamp1,
        preferChamp2,
        preferChamp3,
      })
      .where('userId = :userId', { userId })
      .execute();
    return;
  }

- 파라미터를 entity 객체 값으로 받음으로써, "preferChamp1: () => String(preferChamp1)"와 같은 형태를 개선했다.


4. 작업 회고

1) 작업에 따른 이득

- 위 작업을 통해, 계층 간 관심사를 명확히 분리할 수 있었다.

- 이를 통해, 아직도 잘 정리되었다고 확신하진 않으나, 기존보다 좀 더 계층들의 관심사와 책임이 명확해졌다고 생각한다.

2) 작업 내용에 대해 "아직" 고민 중인/공부가 필요한 사항들

- DTO들의 이름이 너무 중구난방이었다. 사실 작업할 때는 크게 인지하지 않았지만, 지금 와서 다시 보니 매우 엉망이다.

=> 예컨대, service.reqeust.dto들은 사실상 dto가 아니라 domain으로 보는 것이 더 적합하지 않았을까 고민이다.

- 이와 더불어서, 특히 controller <-> service 통신 간 controller DTO가 DTO임에도 불구하고 createDTO()처럼 메소드가 있는 상태 또한 고민인 부분이다.

=>  Layered Architecture에서 어쨋든 상위 계층은 하위 계층에 의존하기 떄문에, service에서 이용하는 service dto(domain?)을 호출해서 변환하는 것이 더 적절하지 않았을까 싶다.

- getter 패턴 DTO를 너무 중구난방으로 쓰지 않았나 싶다. 사실상 controller의 request dto를 제외하면 모든 DTO에 이를 적용했는데, 이것이 적절한지 고민이다.


5. 참고자료

- https://seungtaek-overflow.tistory.com/14

 

[OOP] DTO, Entity와 객체지향적 사고

express, mongoose 두 스택을 사용할 때는 DTO와 Entity를 사용하는 법은커녕 개념조차 깊게 이해하고 있지 못했다. 그러다가 Nest.js, TypeORM 스택을 이용해서 개발을 하다보니 DTO와 Entity에 대해 알게 되고

seungtaek-overflow.tistory.com