저는 nest js 와 typeorm을 활용해 개발 중인데요. 이때 typeorm에서 제공하는 메서드를 사용하게 되면 흔히 접할 수 있는 문제가 있습니다. 바로 distinct 쿼리인데요. 중복을 방지하기 위해 typeorm에서는 디폴트로 distinct 쿼리를 먼저 실행하는 모습을 확인할 수 있습니다. 하지만 이런 부분이 오히려 성능에 악영향을 미치는 경우가 허다한데요. 오늘은 제가 경험한 사례와 해결 방법에 대해 공유해보겠습니다.
예시 상황
먼저 제가 사용한 typeorm 메서드는 findAndCount()입니다. 제가 하려던 작업은 특정 페이지 post 조회 기능입니다. 즉, 1페이지를 누르면 1페이지에 해당하는 게시물 10개를 조회하고 2페이지를 누르면 2페이지에 해당하는 게시물 10개를 조회하는 형태입니다. 게시물 서비스에서는 굉장히 흔한 기능입니다. 이때 제가 작성한 코드와 실제로 실행된 쿼리를 확인해봅시다.
코드
// 작성한 코드
const [posts, totalCount] = await this.postRepository.findAndCount({
order: {
createdAt: 'DESC',
},
relations: ['tags'],
take: 10,
skip: offset,
});
post라는 테이블이 존재합니다. 여기서 offset-pagination을 활용해 일부 post 데이터를 조회하고 전체 post 개수도 조회하는 코드입니다.(relations에는 tags만 적어놓았지만 엔티티 단계 설정을 통해 tags정보뿐만 아니라 category, subcategory, user 정보도 모두 join으로 가져오도록 설정했습니다)
쿼리
쿼리는 총 3번 실행되었습니다. 내용은 아래와 같습니다.
1. 첫번째 쿼리
SELECT DISTINCT `distinctAlias`.`post_id` AS `ids_post_id`, `distinctAlias`.`post_createdAt`
FROM (
SELECT `post`.`id` AS `post_id`, `post`.`title` AS `post_title`, `post`.`createdAt` AS `post_createdAt`,
`t`.`id` AS `t_id`, `t`.`name` AS `t_name`,
`c`.`id` AS `c_id`, `c`.`name` AS `c_name`,
`s`.`id` AS `s_id`, `s`.`name` AS `s_name`,
`u`.`id` AS `u_id`, `u`.`nickname` AS `u_nickname`
FROM `post` `post`
LEFT JOIN `post_tags_tag` `post_t` ON `post_t`.`postId` = `post`.`id`
LEFT JOIN `tag` `t` ON `t`.`id` = `post_t`.`tagId`
LEFT JOIN `category` `c` ON `c`.`id` = `post`.`categoryId`
LEFT JOIN `subcategory` `s` ON `s`.`id` = `post`.`subcategoryId`
LEFT JOIN `user` `u` ON `u`.`id` = `post`.`userId`
) `distinctAlias`
ORDER BY `distinctAlias`.`post_createdAt` DESC, `post_id` ASC
LIMIT 10 OFFSET 30;
위 내용을 보시면 FROM ( ) 안에 들어간 서브 쿼리를 확인할 수 있습니다. 해당 서브 쿼리는 전체 데이터를 모두 JOIN을 통해 조회하는 쿼리입니다. 이후 DISTINCT를 이용해 서브 쿼리로 조회한 전체 데이터 중 같은 post_id, post_createdAt를 가진 중복 데이터를 삭제하겠다는 의도로 보입니다.
해당 쿼리 소요 시간은 대략 3초였습니다.
2. 두번째 쿼리
SELECT
`post`.`id` AS `post_id`,
`post`.`title` AS `post_title`,
`post`.`createdAt` AS `post_createdAt`,
`t`.`id` AS `t_id`,
`t`.`name` AS `t_name`,
`c`.`id` AS `c_id`,
`c`.`name` AS `c_name`,
`s`.`id` AS `s_id`,
`s`.`name` AS `s_name`,
`u`.`id` AS `u_id`,
`u`.`nickname` AS `u_nickname`
FROM
`post` `post`
LEFT JOIN
`post_tags_tag` `post_t` ON `post_t`.`postId`=`post`.`id`
LEFT JOIN
`tag` `t` ON `t`.`id`=`post_t`.`tagId`
LEFT JOIN
`category` `c` ON `c`.`id`=`post`.`categoryId`
LEFT JOIN
`subcategory` `s` ON `s`.`id`=`post`.`subcategoryId`
LEFT JOIN
`user` `u` ON `u`.`id`=`post`.`userId`
WHERE
`post`.`id` IN (1179661, 1179662, 1179663, 1179664, 1179665, 1179666, 1179667, 1179668, 1179669, 1179670)
ORDER BY `post_createdAt` DESC;
여기서 IN ( ) 안에 들어가는 값들은 위 첫번째 쿼리로 반환된 데이터에서 post_id 값들입니다. 즉, post 테이블에서 특정 id값을 가진 데이터들을 JOIN을 이용해 연관된 데이터들까지 모두 조회하는 쿼리입니다.
해당 쿼리 소요 시간은 대략 0.03초였습니다.
3. 세번째 쿼리
SELECT COUNT(DISTINCT `post`.`id`) AS `cnt`
FROM `post` `post`
LEFT JOIN `post_tags_tag` `post_t`
ON `post_t`.`postId` = `post`.`id`
LEFT JOIN `tag` `t`
ON `t`.`id` = `post_t`.`tagId`
LEFT JOIN `category` `c`
ON `c`.`id` = `post`.`categoryId`
LEFT JOIN `subcategory` `s`
ON `s`.`id` = `post`.`subcategoryId`
LEFT JOIN `user` `u`
ON `u`.`id` = `post`.`userId`;
세번째 쿼리는 COUNT를 통해 전체 데이터 갯수를 조회하는 쿼리입니다.
해당 쿼리 소요 시간은 대략 1.5초였습니다.
종합을 해보면 첫번째 쿼리로 내가 원하는 post의 id값들을 조회하고, 두번째 쿼리로 해당 post id값들 정보와 연관된 정보를 JOIN을 통해 모두 조회합니다. 그리고 세번째 쿼리로 전체 데이터 갯수를 조회합니다. 여기서 가장 문제가 되는 부분은 첫번째 쿼리입니다. 사실 첫번째 쿼리로 얻고 싶은 핵심 정보는 post 테이블에서 limit 10, offset 30에 해당하는 데이터의 post_id입니다. DISTINCT나 JOIN이 전혀 필요없습니다. 이로 인해 불필요한 쿼리 소요 시간이 발생하는 것입니다.
쿼리 수정
결과적으로 저는 첫번째 쿼리와 두번째 쿼리를 합쳐서 하나의 쿼리로 만들었고, 불필요한 JOIN과 DISTINCT는 삭제했습니다. 아래 쿼리가 그 결과입니다.
SELECT
`post`.`id` AS `post_id`,
`post`.`title` AS `post_title`,
`post`.`createdAt` AS `post_createdAt`,
`t`.`id` AS `t_id`,
`t`.`name` AS `t_name`,
`c`.`id` AS `c_id`,
`c`.`name` AS `c_name`,
`s`.`id` AS `s_id`,
`s`.`name` AS `s_name`,
`u`.`id` AS `u_id`,
`u`.`nickname` AS `u_nickname`
FROM `post` `post`
LEFT JOIN `post_tags_tag` `post_t` ON `post_t`.`postId` = `post`.`id`
LEFT JOIN `tag` `t` ON `t`.`id` = `post_t`.`tagId`
LEFT JOIN `category` `c` ON `c`.`id` = `post`.`categoryId`
LEFT JOIN `subcategory` `s` ON `s`.`id` = `post`.`subcategoryId`
LEFT JOIN `user` `u` ON `u`.`id` = `post`.`userId`
INNER JOIN (
SELECT `id`
FROM `post`
ORDER BY `post`.id DESC
LIMIT 10
OFFSET 0
) AS `latest_posts` ON `latest_posts`.`id` = `post`.`id`
ORDER BY `post_createdAt` DESC;
이를 통해 한 번의 쿼리만 이용해 제가 원하는 결과를 얻을 수 있었습니다. 또한 쿼리 소요 시간도 획기적으로 줄일 수 있었습니다.
해당 쿼리 소요 시간은 대략 0.05초였습니다.
또한 전체 post 게시물 개수를 조회하는 쿼리에도 불필요한 DISTINCT와 JOIN이 존재했으므로 해당 부분을 빼고 다음과 같이 수정했습니다.
SELECT COUNT(*) FROM post;
해당 쿼리 소요 시간은 대략 0.1초였습니다.
위 2 개의 쿼리를 TypeORM의 queryBuilder로 만들면 다음과 같습니다.
const posts = await this.postRepository
.createQueryBuilder('post')
.innerJoin(
(qb) =>
qb
.select('id')
.from(Post, 'post')
.orderBy('post.id', 'DESC')
.limit(10)
.offset(offset),
'latest_posts',
'latest_posts.id = post.id',
)
.leftJoin('post.tags', 't')
.leftJoin('post.category', 'c')
.leftJoin('post.subcategory', 's')
.leftJoin('post.user', 'u')
.select([
'post.id AS post_id',
'post.title AS post_title',
'post.createdAt AS post_createdAt',
't.id AS t_id',
't.name AS t_name',
'c.id AS c_id',
'c.name AS c_name',
's.id AS s_id',
's.name AS s_name',
'u.id AS u_id',
'u.nickname AS u_nickname',
])
.orderBy('post.createdAt', 'DESC')
.getMany();
const totalCount = await this.postRepository
.createQueryBuilder('post')
.getCount();
마무리
결국 4초나 걸리던 API를 0.2초로 소요 시간을 확 줄일 수 있었습니다. 분명 TypeORM에서 제공하는 메서드를 활용해 코드를 짜면 코드 가독성도 높고 코드 작성 시간도 획기적으로 줄어 편하지만, 실질적인 성능에 악영향을 미치는 경우가 꽤 있는 것 같습니다. 이런 경우 저처럼 쿼리빌더를 이용해 직접 쿼리를 짜서 넣는 것이 좋을 것 같습니다.
'데이터베이스, ORM > TypeORM' 카테고리의 다른 글
[TypeORM] connection pool size 조정해서 데이터베이스 작업 효율 높이기 (0) | 2025.04.20 |
---|---|
[TypeORM] 내가 트랜잭션을 사용하는 이유와 조심해야할 부분 (0) | 2024.12.17 |
[TypeORM] SQL 쿼리 튜닝 - 2. 외래키 정보 join없이 조회하기 (0) | 2024.12.02 |
[TypeORM] SQL 쿼리 튜닝 - 1. findBy VS findOneBy 뭐가 더 좋을까? (0) | 2024.11.25 |
[TypeORM] @OneToMany, @ManyToOne - 옵션: onDelete / onUpdate (0) | 2024.11.23 |