Programming

TypeOrm : QueryBuilder 동적 쿼리 만들기

사용자를 연구하는 개발자 2023. 10. 31. 00:36
반응형

복잡한 쿼리 어떻게?

여러 다른 서비스에 존재하는 피드들을 해시태그 기반으로 한 곳에서 모아 볼 수 있는 서비스 기능을 개발하는 과정에서, 게시글을 쿼리의 조건별로 필터링과 정렬을 하는 쿼리를 작성해야 했다. Typeorm을 처음 제대로 사용해 봤기 때문에 좀 헤맸지만, 결국 결과물은 만들어 냈다.

api 조건을 살펴보자.

게시물 스키마
게시물 목록 조회 api

위의 내용에서 확인 할 수 있듯이, 쿼리 파라미터로 들어오는 조건에 따라 쿼리의 결과를 동적으로 필터링하여 데이터를 처리하여야 했다.
이 부분에서 typeorm의 querybuilder를 사용하였는데, 발생했던 문제를 기록으로 남겨야 겠다고 생각했다.

구현해야 할 조건은 다음과 같았다.

  1. 쿼리로 받아온 검색조건을 반영하여 데이터 처리하기
  2. 해당 게시글에 모든 해시태그 같이 보여주기
  3. content의 길이가 길면 20자까지만 잘라서 반환하기
  4. 페이지네이션 적용하기

문제 해결

첫번째로 발생한 문제는, 페이지네이션이었다. 정렬하는 조건기능까지 구현한 후 페이지네이션을 적용하기 위해 skip(), take() 옵션을 사용해서 페이징 작업을 시도하였다. 하지만, 계속 적용이 되지 않았었다. 그 이유는 getRawMany()로 호출하였기 때문이다. content의 길이가 20자 이상일 경우, 20자까지 데이터를 가공하는 로직을 수행하기 위해 getMany()가 아닌 getRawMany()로 호출하였는데, 이때 skip() take()는 무시된다고 한다. content와 페이지네이션 로직을 한 번에 같이 쓸 수 없다는 것이었다. 해결 방법으로는 limit() offset()을 이용하여 직접 SQL 쿼리에 넣어주었다. 이걸 통해, getRawMany()로 호출할 수 있었다.

두 번째 발생한 문제는, 연관된 모든 해시태그를 가져오는 것이었다. 쿼리의 출발이 해시태그 값을 where절을 이용새 검색하여 해당하는 게시글들을 가져오는 것인데, 이때 해당 게시글의 모든 해시태그를 어떻게 가져올 수 있을지 많은 고민을 했다. where = '성수'라고 찾는데 어떻게 다른 해시태그들을 가져올까? 해결방법은 불러온 게시글의 아이디를 이용해서 다시 한번 해시태그만을 검색해서 불러오게 하였다. 이것이 최선의 방법인지는 아직까지 확신이 없다. 다른 곳에서 해답을 얻게 되면 업데이트해보겠다.

private buildQueryBuilder(postDto: PostDto) {
    const limit = postDto.pageCount;
    const offset = postDto.pageCount * (postDto.page - 1); // DTO에서 pageCount string과 page string을 number로 바꿈
    const queryBuilder = this.createQueryBuilder('posts')
      .leftJoin('posts.tags', 'tags')
      .where('tags.tag = :hashtag', {
        hashtag: postDto.hashtag,
      })
      .offset(offset)
      .limit(limit);

    if (postDto.type) {
      queryBuilder.andWhere('posts.type = :type', { type: postDto.type });
    }

    if (postDto.searchBy && postDto.search) {
      const searchBy = postDto.searchBy;
      const searchValue = `%${postDto.search}%`;

      queryBuilder.andWhere(
        new Brackets((search) => {
          if (searchBy === 'title') {
            search.orWhere('posts.title LIKE :searchValue', { searchValue });
          }
          if (searchBy === 'content') {
            search.orWhere('posts.content LIKE :searchValue', { searchValue });
          }
          if (searchBy === 'title,content') {
            search
              .orWhere('posts.title LIKE :searchValue')
              .orWhere('posts.content LIKE :searchValue', { searchValue });
          }
        }),
      );
    }
    let orderByColumn = [
      'updated_at',
      'like_count',
      'share_count',
      'view_count',
    ].includes(postDto.orderBy)
      ? postDto.orderBy
      : 'created_at';
    const orderDirection = postDto.orderDirection === 'ASC' ? 'ASC' : 'DESC';
    queryBuilder.orderBy(`posts.${orderByColumn}`, orderDirection);
    return queryBuilder;
  }
  //buildQueryBuilder에서 생성된 데이터를 가공
  async findPostByTag(postDto: PostDto) {
    const queryBuilder = this.buildQueryBuilder(postDto);
    const posts = await queryBuilder.getRawMany();
    const postIds = posts.map((post) => post.posts_id);

    const postsWithAllTags = await this.createQueryBuilder('posts')
      .leftJoinAndSelect('posts.tags', 'tags')
      .whereInIds(postIds)
      .getMany();

    const postsWithTruncatedContent = postsWithAllTags.map((post) => ({
      ...post,
      content: post.content.substring(0, 20),
    }));

    return { posts: postsWithTruncatedContent };
  }

출력 결과