복잡한 쿼리 어떻게?
여러 다른 서비스에 존재하는 피드들을 해시태그 기반으로 한 곳에서 모아 볼 수 있는 서비스 기능을 개발하는 과정에서, 게시글을 쿼리의 조건별로 필터링과 정렬을 하는 쿼리를 작성해야 했다. Typeorm을 처음 제대로 사용해 봤기 때문에 좀 헤맸지만, 결국 결과물은 만들어 냈다.
api 조건을 살펴보자.
위의 내용에서 확인 할 수 있듯이, 쿼리 파라미터로 들어오는 조건에 따라 쿼리의 결과를 동적으로 필터링하여 데이터를 처리하여야 했다.
이 부분에서 typeorm의 querybuilder를 사용하였는데, 발생했던 문제를 기록으로 남겨야 겠다고 생각했다.
구현해야 할 조건은 다음과 같았다.
- 쿼리로 받아온 검색조건을 반영하여 데이터 처리하기
- 해당 게시글에 모든 해시태그 같이 보여주기
- content의 길이가 길면 20자까지만 잘라서 반환하기
- 페이지네이션 적용하기
문제 해결
첫번째로 발생한 문제는, 페이지네이션이었다. 정렬하는 조건기능까지 구현한 후 페이지네이션을 적용하기 위해 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 };
}