Contents

Node.js 백엔드TypeScript + typeORM으로 무한 대댓글 가공하기

<사진: unsplash>

 

기능 구현 목표

  1. 댓글, 대댓글… 대댓글 등의 무한대댓글 구조
  2. typeORM의 Entity를 연계한 createQueryBuilder 등을 지향하며, 최대한 직접적인 QueryRunner 방식 지양
  3. 삭제 및 비공개 댓글 가림
  4. json 출력시 불필요한 요소 제거 (특히 Date 타입에서 !! )

 

가장 오랜시간을 지연시켰던 Blocker

typeORM createQueryBuilder 메소드로 출력시  

2023-02-14T08:55:24.090Z
//        ^         ^^^^     <- 거추장스럽다.

 

이런 식으로 나오는 문제가 있는데 꽤나 씨름했다.  

우선 Entity의 Date type은 datetime이 아닌 timestamp로 했다.
mySQL에서의 출력은 DB가 설치된 서버의 시간대를 따르기에
아래와 같이 문제가 없다.  

select * from comments where feedId = 1

 

2023-02-14 17:55:24.106558  
-- 이게 실제 한국 시간

 

문제는,
typeORM에서의 repository + createQueryBuilder를 이용한 직접 출력이다.
typeORM의 .findcreateQueryBuilder 메소드를 통해 출력을 하게 되면 ISO 8601 형식으로 내보내지기 때문에 먼저 보여줬던 예와 같이 문제가 생기는데 다시 정리하면 아래와 같다.

  1. 시간대 안맞음 (실제 시간은 17:55:24)
  2. 시간대 앞 뒤로 T, 090Z와 같은 부가요소가 함께 출력  

현재 데이터를 출력하려는 query는 다음과 같다.  

export const CommentRepository = dataSource.getRepository(Comment).extend({
  async getCommentList(id: number) {
    return await this.createQueryBuilder('comment')
      .leftJoinAndSelect('comment.children', 'children')
      .leftJoinAndSelect('comment.user', 'user')
      .leftJoinAndSelect('comment.feed', 'feed')
      .leftJoinAndSelect('children.user', 'childrenUser')
      .leftJoinAndSelect('children.feed', 'childrenFeed')
      .where('comment.feed = :id', { id })
      .andWhere('comment.parentId IS NULL')
      .orderBy('comment.id', 'ASC')
      .addOrderBy('children.id', 'ASC')
      .setParameter('id', id)
      .getMany();
  },
}

 

해결방안

2024.03.03 시간대 관련 수정글 추가

이 글을 작성할 당시에 DB와 시간대에 관한 개념이 확실하게 잡혀있지 않았다.
따라서 시간대와 관련된 최신 글의 링크를 아래와 같이 남기지만, 해당 원글은 그대로 보존하도록 한다.
typeORM 시간대 설정에 관한 고찰 - typeORM의 dateStrings와 timezone 옵션에 따른 시간대 혼란 - inchan.dev

아래 글 중 잘못된 내용은 아래와 같다.

  1. local 시간대 맞추기 - timezone 옵션 내용
  2. 시간 구분자 제거 - datastrings 옵션 내용

1. local 시간대 맞추기

수정 전 내용 보기

우선, 이를 local의 시간대로 맞추기 위해 아래와 같이 DataSource 설정에 timezone: 'Z' 를 추가하여 local 시간대로 출력되게끔 한다.
 

const dataSource = new DataSource({  
  type: process.env.TYPEORM_CONNECTION,  
  host: process.env.TYPEORM_HOST,  
  port: process.env.TYPEORM_PORT,  
  username: process.env.TYPEORM_USERNAME,  
  password: process.env.TYPEORM_PASSWORD,  
  database: process.env.TYPEORM_DATABASE,  
  entities: [__dirname + '/../**/*.entity.{js,ts}'],  
  timezone: 'Z',  // <-- 이렇게 추가
  logging: Boolean(process.env.TYPEORM_LOGGING),  
  synchronize: Boolean(process.env.TYPEORM_SYNCHRONIZE),  
  charset: 'utf8mb4',  
});

 

// 출력시,
2023-02-14T17:55:24.106Z

 

자 이제 시간대는 맞춰졌다.  


2. 시간 구분자 제거

수정 전 내용 보기

다음은 문제의 저 TZ를 날려보자.
 

dateStrings: true,

 
DataSouce 연결 설정에 이걸 넣어준다.  

const dataSource = new DataSource({  
  type: process.env.TYPEORM_CONNECTION,  
  host: process.env.TYPEORM_HOST,  
  port: process.env.TYPEORM_PORT,  
  username: process.env.TYPEORM_USERNAME,  
  password: process.env.TYPEORM_PASSWORD,  
  database: process.env.TYPEORM_DATABASE,  
  entities: [__dirname + '/../**/*.entity.{js,ts}'],  
  timezone: 'Z',  
  dateStrings: true,  // <-- 이렇게 추가
  logging: Boolean(process.env.TYPEORM_LOGGING),  
  synchronize: Boolean(process.env.TYPEORM_SYNCHRONIZE),  
  charset: 'utf8mb4',  
})

 

그리고 다시 출력을 해보면  

2023-02-14 17:55:24.106558

 

드디어 불필요한 알파벳은 사라졌다…라기보단, 그런데 더 거추장스럽게도 밀리초(?)로 바뀌었다.
이제 이걸 제거해보자!
 

3. 정밀 초단위는 제거하고 시,분까지만 출력

여기서 정말 많이 찾아보고 공부했다.
typeORM의 queryRunner를 사용하거나,
createQueryBuilder에서 getRawMany()를 사용해 자료를 뽑아낸다면,
mySQL의 출력방식을 따르기때문에 SQL의 query문에 직접적인 수정을 가하여 컬럼값을 조정할 수가 있다.
(또는 toISOString()과 같은 메소드를 사용하여도 된다.)
 

예시)

select 
	SUBSTRING(created_at, 1, 19) AS created_at
from
	comments

 

결과  

2023-02-14 17:55:24

 

하지만, 위에서 쓰고 있는 Entity에서 그대로 가져오는 getMany() 메소드를 이용해 자료를 가져오게 되면,
이런 방법이 불가하다.

이것도 확실한지는 모르겠는데 며칠을 찾아보고 시도해본 결과,
 

TypeORM의 getRawMany() 메서드는 쿼리 결과를 직접적으로 반환하는 반면, getMany() 메서드는 Entity 객체를 반환한다. 때문에 select <column>에 바로 수정을 가하게 되면 Entity의 type을 그대로 가져다 쓰는 특성상, 이 컬럼의 type error가 난다. 즉, select 또는 addSelect로 Alias하거나 수정을 함과 동시에 출력이 되지 않고 되려 해당 컬럼이 사라진다.

이러한 상태로의 해결방법은 Entity에서 직접 Alias 해줘야 하는데, 또 이렇게 되면 CreateDateColumn의 데코레이션 default 옵션이 깨진다.(피나는 시도 결과 ;; ㅠ)


때문에 결국 양자택일을 하는 수밖에 없었다.

  • getMany() 방식을 포기하거나,
  • service단에서 복잡하지만 수정을 가한다.
굳이 getMany() 방식을 고집하는 이유는 다음과 같다.
  • 현재 저 코드 Comment Entity에서 가져오며, 해당 Entity는 parent와 children<Comment[]>의 자기참조 컬럼을 가지고 있는 Tree 구조로 이루어져 있다. 그리고 typeORM의 createQueryBuilder는 이 구조 특성을 그대로 살려서 출력이 가능하다.
  • 반대로 query runner 방식으로 처리하여 같은 tree 구조를 출력하려면, repository단에서 굉장히 지독히도 가독성이 떨어지는 코드로 구현된다(물론 현재의 내 수준에서;;).1 typeORM의 Tree 방식으로 출력가능한 getMany() 메소드를 포기하게 된다면, repository단에서의 query가 생각보다 복잡하게 된다.
  • 추후 유지보수의 가능성을 염두에 둔다.

대댓글 뿐만 아니라, 대대댓글 등의 무한 대댓글 구조역시 가능하도록 진행을 하는데 현재 Date type만의 수정을 원한다면, service단에서의 조정이 맞다는 판단이 들었다.

때문에 다음과 같이 service단에서 코드를 재귀함수(무한대댓글을 구현하기 위해)로 추가하며,
원하는 결과를 가져올 수 있도록 조정한다.

추가코드

// comments.service.ts

// 가공영역에 대한 재귀함수
const formatComment = (comment: any, userId: number): any => {  
  const isPrivate = comment.is_private === true && comment.user.id !== userId;  
  const isDeleted = comment.deleted_at !== null;  
  const formattedComment = {  
    // 로그인 사용자의 비밀덧글 조회시 유효성 확인 및 삭제된 덧글 필터링  
    ...comment,  
    comment: isDeleted  
      ? '## DELETED_COMMENT ##'  
      : isPrivate  
      ? '## PRIVATE_COMMENT ##'  
      : comment.comment,  
  
    // Date 타입의 컬럼에서 불필요한 밀리초 부분 제외  
    created_at: comment.created_at.substring(0, 19),  
    updated_at: comment.updated_at.substring(0, 19),  
    deleted_at: comment.deleted_at ? comment.deleted_at.substring(0, 19) : null,  
  
    // 대댓글 영역 --------------------------------    children: comment.children  
      ? comment.children.map((child: any) => formatComment(child, userId))  
      : [],  
  };  
  
  return formattedComment;  
};  

이후 리팩토링을 하며 만들어진 최종 대댓글 API 결과.


// 현재 로그인 유저의 id는 16번

[
  {
	"id": 95,            // <- 원댓글
    "created_at": "2023-02-14 21:36:43",  // <-- 짜잔!! 드디어 깔끔한 마무리!
    "updated_at": "2023-02-14 21:36:43",
    "deleted_at": null,
    "user": {
      "id": 3,
      "nickname": "103",
      "email": "[email protected]"
    },
    "feed": {
      "id": 96,
      "title": "vulputate luctus cum sociis"
    },
    "comment": "nulla tellus in sagittis",
    "is_private": false,
    "children": [            // <- 1차 대댓글 배열
      {
        "id": 212,            // <- 대댓글
        "created_at": "2023-02-14 21:36:43",
        "updated_at": "2023-02-14 21:36:43",
        "deleted_at": null,
        "user": {
          "id": 16,
          "nickname": "test nickname6",
          "email": "[email protected]"
        },
        "comment": "댓글 수정 테스트",
        "is_private": false,
        "children": [            // <- 2차 대댓글 배열
          {
            "id": 220,            // <- 대댓글에 대한 대댓글
            "created_at": "2023-02-16 20:04:19",     // <-여기까지 재귀함수가
            "updated_at": "2023-02-16 20:04:19",     // 잘 적용되고 있음을 확인!!
            "deleted_at": null,
            "user": {
              "id": 16,
              "nickname": "test nickname6",
              "email": "[email protected]"
            },
            "comment": "레포지토리 커멘트 생성 테스트",
            "is_private": true,
            "children": []
          }
        ]
      },
      {
        "id": 214,
        "created_at": "2023-02-14 21:36:43",
        "updated_at": "2023-02-14 21:36:43",
        "deleted_at": null,
        "user": {
          "id": 15,                               // 다른 유저가 작성한 비공개 대댓글
          "nickname": "test nickname5",
          "email": "[email protected]"
        },
        "comment": "## PRIVATE_COMMENT ##",       // <- 여기서도 정상적 차단 확인
        "is_private": true,
        "children": []
      },
      {
        "id": 215,
        "created_at": "2023-02-14 21:36:43",
        "updated_at": "2023-02-16 20:03:42",
        "deleted_at": "2023-02-16 20:03:42",       // <- 본인의 삭제한 댓글
        "user": {
          "id": 16,
          "nickname": "test nickname6",
          "email": "[email protected]"
        },
        "comment": "## DELETED_COMMENT ##",       // <- 정상적 차단 확인
        "is_private": false,
        "children": []
      }
    ]
  },
]

아주 이쁘게 잘 나왔다.

typeORM 관련하여 전체적으로 내가 원하는 샘플코드는 구체적으로 검색해보기가 힘들어, 대부분 typeORM 공식문서를 참고하며 진행하였고, 생각보다 공식문서가 굉장히 잘되어 있어 놀랐다.
(공식문서 속에서 원하는 정보 찾는게 더 시간이 많이 걸렸;;)


전체 코드 보기

Entity

아! Entity에서 기본적으로 들어가는 id와 created_at, updated_at 등은 Base Entity로 따로 만들어 Entity 작업시 반복적으로 기입하지 않고 Embeded Entityes 방식으로 처리하였다.2

// comment.entity.ts

@Entity('comments')  
export class Comment extends Base {  
  @ManyToOne(type => User, users => users.comment, { nullable: false })  
  @JoinColumn({ name: 'userId' })  
  user: User;  
  
  @ManyToOne(type => Feed, feeds => feeds.comment, { nullable: false })  
  @JoinColumn({ name: 'feedId' })  
  feed: Feed;  
  
  @Column({ length: 1000 })  
  comment: string;  
  
  @Column('boolean', { default: false })  
  is_private: boolean;  
  
  @ManyToOne(type => Comment, comment => comment.children)  
  @JoinColumn({ name: 'parentId' })  
  parent: Comment;  
  
  @OneToMany(type => Comment, comment => comment.parent)  
  children: Comment[];  
}

service단!!

// comments.service.ts

// 무한 대댓글의 경우, 재귀적으로 호출되는 함수  
const formatComment = (comment: any, userId: number): any => {  
  const isPrivate = comment.is_private === true && comment.user.id !== userId;  
  const isDeleted = comment.deleted_at !== null;  
  const formattedComment = {  
    ...comment,  
    // 로그인 사용자의 비밀덧글 조회시 유효성 확인 및 삭제된 덧글 필터링  
    comment: isDeleted  
      ? '## DELETED_COMMENT ##'  
      : isPrivate  
      ? '## PRIVATE_COMMENT ##'  
      : comment.comment,  
  
    // Date 타입의 컬럼에서 불필요한 밀리초 부분 제외  
    created_at: comment.created_at.substring(0, 19),  
    updated_at: comment.updated_at.substring(0, 19),  
    deleted_at: comment.deleted_at ? comment.deleted_at.substring(0, 19) : null,  
  
    // 대댓글 영역 --------------------------------    children: comment.children  
      ? comment.children.map((child: any) => formatComment(child, userId))  
      : [],  
  };  
  
  return formattedComment;  
};  
  
const getCommentList = async (id: number, userId: number) => {  
  const result = await CommentRepository.getCommentList(id);  
  
  const formattedResult = [...result].map((comment: any) =>  
    formatComment(comment, userId)  
  );  
  
  return formattedResult;  
};

Repository단!!!

// comment.repository.ts

export const CommentRepository = dataSource.getRepository(Comment).extend({  
  async getCommentList(id: number) {  
    return await this.createQueryBuilder('comment')  
      .withDeleted()  
      .addSelect(['user.id', 'user.nickname', 'user.email'])  
      .addSelect(['feed.id', 'feed.title'])  
      .addSelect([  
        'childrenUser.id',  
        'childrenUser.nickname',  
        'childrenUser.email',  
      ])  
      .addSelect([  
        'childrenUser2.id',  
        'childrenUser2.nickname',  
        'childrenUser2.email',  
      ])  
      .leftJoin('comment.user', 'user')  
      .leftJoin('comment.feed', 'feed')  
      .leftJoinAndSelect('comment.children', 'children')  
      .leftJoin('children.user', 'childrenUser')  
      .leftJoinAndSelect('children.children', 'children2')  
      .leftJoin('children2.user', 'childrenUser2')  
      .where('comment.feed = :id', { id })  
      .andWhere('comment.parentId IS NULL')  
      .orderBy('comment.id', 'ASC')  
      .addOrderBy('children.id', 'ASC')  
      .setParameter('id', id)  
      .getMany();  
  },
}