현재 공유게시판을 들어가면 모든 공유 게시글을 가져오는데 이때 offset 기반의 페이지네이션을 채택했다. cursor 기반도 고민했었지만 페이지를 제공해야 한다는 점에서 offset을 채택할 수밖에 없었다.
그런데 가장 문제가 되는 것이 성능 저하의 문제가 발생하는 것.
offset 기반으로 동작하면 offset과 limit이 주어지는데 offset까지 데이터를 풀스캔하고 그 데이터를 버리고 limit 만큼 찾아오기 때문에 불필요한 데이터 조회가 발생하게 되는 것이다.
아래는 현재 쿼리의 상태이다.
List<ApiRecommendPostsResponseDTO> apiRecommendPostsResponseDTOS = queryFactory.
select(Projections.constructor(ApiRecommendPostsResponseDTO.class,
post.id, post.methodType, post.title, member.nickname, post.likeCount, post.viewCount))
.from(post)
.leftJoin(post.member, member)
.where(searchCondition(searchType, searchKey))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(post.id.desc(), post.createdDate.desc())
.fetch();
100만 개의 더미 데이터를 집어넣어두고 테스트 진행. 이 상태로 조회를 실행하면
1.547 초가 걸리는 것을 확인할 수 있다.
Deferred join
일종의 lazy loading을 수행하는 방법이다.
기존에는 단일 쿼리로 조인과 데이터를 즉시 로딩을 진행했다.
OFFSET과 LIMIT이 증가하면 더 많은 데이터를 풀스캔하여 건너뛰게 돼서 성능 저하가 발생한다.
deferred join의 핵심은 하나의 쿼리로는 필요한 데이터의 ID 목록만 가져오고, 두 번째 쿼리에서 ID를 기반으로 데이터를 가져온다.
이 방식으로 수행하면 id와 일치하는 데이터만 전부 가져오기 때문에 불필요한 데이터가 줄어든다는 장점이 있다.
첫 번째가 기존 쿼리, 두 번째가 deferred join 방법을 사용한 쿼리이다.
List<ApiRecommendPostsResponseDTO> apiRecommendPostsResponseDTOS = queryFactory.
select(Projections.constructor(ApiRecommendPostsResponseDTO.class,
post.id, post.methodType, post.title, member.nickname, post.likeCount, post.viewCount))
.from(post)
.leftJoin(post.member, member)
.where(searchCondition(searchType, searchKey))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(post.id.desc(), post.createdDate.desc())
.fetch();
List<Long> postIds = queryFactory
.select(post.id)
.from(post)
.where(searchCondition(searchType, searchKey))
.orderBy(orderSpecifier)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
List<ApiRecommendPostsResponseDTO> apiRecommendPostsResponseDTOS = queryFactory
.select(Projections.constructor(ApiRecommendPostsResponseDTO.class,
post.id, post.methodType, post.title, member.nickname, post.likeCount, post.viewCount))
.from(post)
.leftJoin(post.member, member)
.where(post.id.in(postIds))
.orderBy(orderSpecifier)
.fetch();
우선 개선한 코드를 보면 조건에 맞는 ID 값만 전부 조회를 진행한다. 그러면 limit의 수만큼 ID가 조회되는데 이 ID만 이용해서 필요한 데이터를 찾아오기 때문에 불필요한 데이터 조회가 줄어들게 되는 것.
즉 그만큼 성능 향상이 되는 방법이다.
1.5초에서 0.7초로 절반이 줄어든 것을 확인할 수 있다.
filesort 제거하기
ORDER BY 절에서는 기본적으로 index가 있다면 index 정렬을, 없다면 filesort로 동작하게 된다.
post 테이블을 기준으로 실행계획을 살펴보면 id에는 index가 잡혀있고 created_date에는 index가 없는 상태다.
id 기준 실행계획
Extra를 보면 index scan을 확인할 수 있다. 즉 이미 id 기준 인덱스 테이블이 존재하기 때문에 해당 테이블을 통해 정렬을 진행한다.
created_date 기준 실행계획
Extra를 보면 Using filesort라고 적힌 것을 확인할 수 있다. 즉 얘는 filesort를 사용해서 진행한다.
Filesort는 정렬하려는 컬럼에 index가 걸려있지 않을 경우 동작하게 되는데 데이터베이스의 메모리나 디스크 공간을 사용하기 때문에 index 정렬에 비해서 성능이 떨어지게 되는 것이다.
현재 ORDER BY 절에 id, created_date를 같이 사용하고 있다. 그런데 인덱스는 id만 존재하기 때문에 이 두 개를 합쳐놓은 복합 인덱스를 사용하면 성능 개선에 효과를 볼 수 있다.
현재 Deferred join을 적용한 쿼리의 실행계획을 봐도 filesort로 동작하는 것을 확인할 수 있었다.
나는 어차피 DTO로 조회하기에 필요한 데이터만 가져오도록 하기 위해 커버링 인덱스를 적용했다.
아래 쿼리로 커버링 인덱스를 생성해주고 실행 계획을 확인해 보면
CREATE INDEX idx_covering_post ON post(
id,
method_type,
title,
like_count,
view_count
);
CREATE INDEX idx_covering_member ON member(
nickname);
Using Index로 인덱스를 타도록 변경된 것을 확인할 수 있다.
시간 측정 결과 0.015초로 줄은 것을 확인할 수 있다.
즉 현재까지 1.5초 -> 0.75초 -> 0.015초로 100배의 시간 단축을 이뤄낼 수 있었다.
'프로젝트 > RESTAPI 추천 서비스' 카테고리의 다른 글
매번 가져오는 유저를 공통으로 처리하기(Feat, Resolver) (1) | 2024.06.08 |
---|---|
프로젝트 코드리뷰 (2) (0) | 2024.06.07 |
공유 게시글의 좋아요를 광클한다면?(Feat, @Table) (0) | 2024.06.04 |
프로젝트 코드리뷰 (1) (0) | 2024.06.03 |