위 영상과 같이 스크롤을 내리면서 다음 페이지를 탐색할 수 있게 하는 방법을 무한 스크롤
이라고 한다.
이는 스프링 data jpa 의 Slice
를 이용하여 주로 구현하는데 이는 Page
와 달리 count
쿼리가 나가지 않는다는 장점이 있다.
따라서 지금까지 나는 아래와 같이 통상적인 방법으로 이 부분을 구현했었다.
public CommunityPostSearchWithSliceResponseDto searchCommunityPostByCommunityBoardId(final Long communityBoardId,
final Pageable pageable){
Slice<CommunityPostSearchDBResponseDto> communityPosts =
communityPostRepository.findCommunityPostKeyWordSearchDBByCommunityBoardId(communityBoardId,pageable);
List<CommunityPostSearchResponseDto> communityPostSearchResponseDtoList =
communityPosts.stream().map(communityPostSearchDBResponseDto -> {
return CommunityPostSearchResponseDto.of(communityPostSearchDBResponseDto.getCommunityPostId(),
communityPostSearchDBResponseDto.getTitle(),
replyRepository.totalReplyCount(communityPostSearchDBResponseDto.getCommunityPostId()),
communityPostSearchDBResponseDto.getWriterNickName(),
communityPostSearchDBResponseDto.getCreatedAt());
}).collect(Collectors.toList());
return CommunityPostSearchWithSliceResponseDto.of(communityPostSearchResponseDtoList,communityPosts.hasNext());
}
@Query("select new com.gaduationproject.cre8.domain.community.dto.CommunityPostSearchDBResponseDto(cp.id,cp.title,m.nickName,cp.createdAt)"
+ "from CommunityPost cp join cp.writer m where cp.communityBoard.id=:communityBoardId")
Slice<CommunityPostSearchDBResponseDto> findCommunityPostKeyWordSearchDBByCommunityBoardId(@Param("communityBoardId") final Long communityBoardId,
final Pageable pageable);
하지만 이와 같은 방법은 단점이 있다.
결국 위와 같은 코드는
데이터베이스에
select
cp1_0.community_post_id,
cp1_0.title,
w1_0.nick_name,
cp1_0.created_at
from
community_post cp1_0
join
member w1_0
on w1_0.member_id=cp1_0.writer_id
where
cp1_0.community_board_id=1
order by
cp1_0.community_post_id desc
limit
50010,10;
이런 형태의 쿼리를 날리며
이 형태의 쿼리는
offset
, limit
의 구조로
되어 있으므로
offset
+ limit
만큼의 레코드 수를 읽은 후 offset
만큼의 개수를 버린다.
따라서 불필요한 연산이 들어감에 틀림이 없다.
sql 실행 순서상
limit, order by 가 가장 마지막에 실행 되는 것이기 때문이다. 그렇기 때문에 다 읽어 들일 수 밖에 없다.
이는 다양한 형태로 확인할 수 있다.
먼저 실험을 위해 대략 50000 건의 커뮤니티 게시글을 DB에 저장해두었다.
page가 0일 때는 offset+limit 만큼의 게시글을 읽기 때문에
최종적으로 limit 만큼의 게시글을 읽는다.
따라서 Jmeter 를 이용하여 부하테스트를 가할 때 상대적으로 빠르다.
마찬가지로 offset+ limit 만큼의 게시글을 읽기 때문에 50010+A 만큼의 게시글을 읽어 버린다.
따라서 Jmeter 를 이용하여 부하 테스트를 가할 때 상대적으로 느리다.
explain analyze select
cp1_0.community_post_id,
cp1_0.title,
w1_0.nick_name,
cp1_0.created_at
from
community_post cp1_0
join
member w1_0
on w1_0.member_id=cp1_0.writer_id
where
cp1_0.community_board_id=1
order by
cp1_0.community_post_id desc
limit 10
offset 50010;
explain analyze select
cp1_0.community_post_id,
cp1_0.title,
w1_0.nick_name,
cp1_0.created_at
from
community_post cp1_0
join
member w1_0
on w1_0.member_id=cp1_0.writer_id
where
cp1_0.community_board_id=1
order by
cp1_0.community_post_id desc
limit 0,10;
두 쿼리를 비교한다.
-> Limit/Offset: 10/50010 row(s)
(cost=11518 rows=0) (actual time=168..168 rows=9 loops=1)
-> Nested loop inner join (cost=11518 rows=24900)
(actual time=0.189..166 rows=50019 loops=1)
-> Filter: (cp1_0.writer_id is not null) (cost=2803 ro...
-> Limit: 10 row(s)
(cost=11518 rows=10) (actual time=0.0891..0.0995 rows=10 loops=1)
-> Nested loop inner join (cost=11518 rows=24900)
(actual time=0.0884..0.0983 rows=10 loops=1)
-> Filter: (cp1_0.writer_id is not null) (cost=2803 rows=2...
이로써 page 의 개수가 커지면 커질 수록
내가 구현한 무한 스크롤의 성능이 나빠진다는 것을 확인할 수 있었다.
explain 키워드는 위 모두 아래와 같이 동일한 결과를 가진다.
따라서 offset
을 사용하면
페이지의 개수가 커지면 커질 수록 성능이 나빠진다는 것을 파악했다.
이를 해결하기 위해 위와 같이 정렬 기준이 최신순으로 고정되어 있는 경우에 적합한 방법이 있다.
말그대로 offset
을 쓰지 않는 No offset
방식이 있다.
offset
을 이용하지 않는 대신 where
절에 id 를 넣어준다.(현재 pk autogenerated 형태이다).
sql 쿼리를 예로 들면
select
cp1_0.community_post_id,
cp1_0.title,
w1_0.nick_name,
cp1_0.created_at
from
community_post cp1_0
join
member w1_0
on w1_0.member_id=cp1_0.writer_id
where
cp1_0.community_post_id<12
and cp1_0.community_board_id=1
order by
cp1_0.community_post_id desc
limit
10;
이런 형태를 가진다.
그럼 결과적으로 위에 작성했던 sql 쿼리와 동일한 결과가 나온다 .
또한 where
절의 12 라는 아이디 값은 이전 페이지의 마지막 게시물의 아이디를 의미한다.
이는 프론트 측에서 얼마든지 백앤드에게 전달해 줄 수 있는 값이다.
아무튼 where
절에 community_post_id
값을 구체적으로 명시해줌으로써
클러스터링 인덱스를 활용하여 원하는 값을 더 빠르게 검색하여
어느 page 에서나 동일하게 빠른 검색 속도를 가질 수 있다. (순서상 from
,where
절이 첫 번째에 실행되므로 필요 없는 부분을 빠르게 쳐낼 수 있다.)
단 이 경우 첫 번째 페이지에서는 이전 id 가 없으므로
null 과 Query DSL
을 활용하여 이 문제를 해결한다. (아니면 백앤드 와 프론트가 따로 협의하여 lastPostId의 값이 0 이라면 따로 쿼리를 만드던가 등등)
public CommunityPostSearchWithSliceResponseDto searchCommunityPostByCommunityBoardIdAndLastPostId(final Long communityBoardId,
final Long lastPostId, final Pageable pageable){
Slice<CommunityPostSearchDBResponseDto> communityPosts =
communityPostRepository.showCommunityPostWithNoOffSet(lastPostId,communityBoardId,pageable);
List<CommunityPostSearchResponseDto> communityPostSearchResponseDtoList =
communityPosts.stream().map(communityPostSearchDBResponseDto -> {
return CommunityPostSearchResponseDto.of(communityPostSearchDBResponseDto.getCommunityPostId(),
communityPostSearchDBResponseDto.getTitle(),
replyRepository.totalReplyCount(communityPostSearchDBResponseDto.getCommunityPostId()),
communityPostSearchDBResponseDto.getWriterNickName(),
communityPostSearchDBResponseDto.getCreatedAt());
}).collect(Collectors.toList());
return CommunityPostSearchWithSliceResponseDto.of(communityPostSearchResponseDtoList,communityPosts.hasNext());
}
@RequiredArgsConstructor
public class CommunityPostCustomRepositoryImpl implements CommunityPostCustomRepository{
private final JPAQueryFactory queryFactory;
@Override
public Slice<CommunityPostSearchDBResponseDto> showCommunityPostWithNoOffSet(final Long lastCommunityPostId,final Long communityBoardId,final Pageable pageable){
List<CommunityPostSearchDBResponseDto> results = queryFactory.select(
Projections.constructor(CommunityPostSearchDBResponseDto.class,
communityPost.id, communityPost.title,communityPost.writer.nickName,communityPost.createdAt))
.from(communityPost)
.join(communityPost.writer)
.where(ltStoreId(lastCommunityPostId),
findByCommunityBoardId(communityBoardId))
.orderBy(communityPost.id.desc())
.limit(pageable.getPageSize()+1)
.fetch();
return checkLastPage(pageable,results);
}
private BooleanExpression ltStoreId(final Long lastCommunityPostId) {
if (lastCommunityPostId == null) {
return null;
}
return communityPost.id.lt(lastCommunityPostId);
}
private BooleanExpression findByCommunityBoardId(final Long communityBoardId){
return communityPost.communityBoard.id.eq(communityBoardId);
}
private Slice<CommunityPostSearchDBResponseDto> checkLastPage(final Pageable pageable, final List<CommunityPostSearchDBResponseDto> results) {
boolean hasNext = false;
if (results.size() > pageable.getPageSize()) {
hasNext = true;
results.remove(pageable.getPageSize());
}
return new SliceImpl<>(results, pageable, hasNext);
}
}
동적 쿼리를 사용하여 lastPostId
가 null 일 때 첫 번째 페이지임을 확인하고, 첫 번째 페이지라면 null 을 반환하여 조건절을 없앤다.
애초에 size + 1 만큼 조회한 이후에 이를 통해서 nextPage 가 있는지 여부를 프론트에게 반환 하여 준다.
참고로 이전 No Offset
을 적용하기 전에는
동일한 환경에서 이랬다.
단순히 100번 쿼리를 실행하고 그 평균 값을 계산하여 비교하자 .
평균은 그럭저럭 봐줄만 한데 TPS 가 6으로 낮다.
-> Limit: 10 row(s) (cost=2.47 rows=0.9)
(actual time=0.0631..0.0802 rows=9 loops=1)
-> Nested loop inner join (cost=2.47 rows=0.9)
(actual time=0.0623..0.0788 rows=9 loops=1)
-> Filter: ((cp1_0.community_board_id = 1) and (cp1_0.community_...
기존 filtered 와 rows 를 비교하였을 때 상당 부분이 개선되었음을 알 수 있다.