Querydsl을 활용한 Cursor기반 페이징 구현
이번에 기획팀에서 검색 관련 API 요청을 받았고 앱, 웹 상에서 스크롤 기능으로 사용할 것이기 때문에 Cursor기반 페이징 API를 구현하게 되었다.
실제 간단한 기능 구현을 통해 Cursor기반 페이징의 개념도 정리해보려고 합니다!
요구사항 및 사용 시나리오
- 내 친구(팔로워)가 여러 음식점(상점)에 대해 리뷰를 작성
- 사용자(나)가 검색어를 통해 검색
- 내 친구가 작성한 리뷰 중 검색어가 포함된 리뷰 반환
- 그 리뷰가 작성되어있는 음식점(상점)을 화면에 뿌린다
결국 상점 검색어가 포함된 리뷰가 있는 '상점'을 뽑아달라는 것이었다.
사용자의 입장에서 생각해보면 조금 기능이 이상했다.
리뷰 내용으로 검색하는데 그 리뷰가 담긴 상점을 반환한다?.. 리뷰를 반환해줘야 하는 게 아닐까..
하지만 이 부분은 어색하긴 하지만 나중에 쉽게 수정할 수 있으므로 일단은 구현해주도록 했다. (주석으로 남겨놓았다..)
구현 계획
- 간단한 테스트 코드를 통해 무엇을 제공해줘야 하는지, 어떤 정보가 입력되어야 하는지 고민해본다.
- MySQL에서 먼저 네이티브 쿼리를 만들어 결과를 확인해본다.
- Querydsl을 사용해 만들어본다.
- Swagger에서 잘 작동하는지 테스트
왜 커서 기반을 사용하는지, 오프셋과 차이, 성능 등에 대한 이야기는 여기 와 여기 정리가 잘 되어있고 구현 시에 참고하였습니다. 정리가 잘 된 글이 많기에 필요하다면 검색해보시길 바랍니다!
테스트 코드
@DisplayName("검색 내용이 주어지면 검색 내용이 포함된 친구 리뷰가 있는 상점을 반환한다.")
@Test
void givenKeyword_whenSearchShopByReviewContentAndFollowers_thenReturnShopInfo() throws Exception {
// Given
user = userRepository.getById(1L);
String searchWord = "content";
Integer size = 3;
// When
testLogin(user);
Page<ShopResponse> shops = reviewService.searchShopByReviewContentsAndFollowers(null, searchWord, size);
// Then
assertThat(shops).isNotEmpty();
}
테스트 코드는 처음 프로젝트를 시작할 때 잘못 틀을 짜 놔서 mocking 방식이 불가능하고(불가능하다기보다 다시 다 고쳐야 해서 시간 비용상 다른 Service, Controller 테스트부터 mockito방식을 적용하였다..) 테스트 코드를 짜기가 쉽지 않다.
절대 참고하지 말 것.. 뭐 테스트 코드에 옳고 그름이 있을까 싶지만 내 개인적인 생각으론 옳지 않다고 본다.
우선 주어져야 할 매개변수가 무엇이 있을지 생각을 해보았고, Controller에서 cursor(null)과 searchWord, 뿌려질 음식점 개수(size)만 받아오면 되겠다고 판단하여 실패하는 테스트 코드를 우선 짜보았다.
MySQL에서 먼저 네이티브 쿼리를 만들어 결과 확인
검색 API이기 때문에 비즈니스 로직보다는 쿼리를 어떻게 짜야할지부터 고민하게 되었고 여러 테이블을 건드리기 때문에 조금 복잡하여 MySQL에서 먼저 쿼리 테스트를 해보고 Querydsl을 통해 구현해보도록 하였다.
select * from test.shop
where shop.id in( select distinct review.shop_id from test.review
inner join test.shop on test.shop.id = review.shop_id
where review.writer_id in(1,2,3,4) and review.content Like '%맛%')
order by shop.place_name asc;
-- writer_id in(follower_id)
DB 스키마를 그림으로 작성해놓지 않아 이해를 도우지 못하는 점은 죄송합니다.
간단히 설명드리자면
상점을 조회하는데, 조회할 상점 id를 서브 쿼리로 뽑아온다.
Shop과 Review는 1 : N 관계로 inner join 하여 where절에 맞는 review들의 shop_id를 뽑아온다.
shop_id는 중복이 될 수 있으므로 distinct 처리
where 절은 writer(작성자)가 내 친구 중에 있어야 하며 리뷰 content에는 맛(searchWord)이라는 단어가 있는 shop의 id를 뽑아온다.
Querydsl을 사용해 구현하기
MySQL에 쿼리를 짜 보면서 어떤 방향으로 구현해야 하는지 감을 잡았다.
-> 내 친구가 작성한 리뷰에서 shop id를 서브 쿼리로 빼오고 페이징은 Cursor로 구현하자!
따라서, Cursor를 통한 페이징을 합쳐 Querydsl로 구현해내면 된다.
Cursor 기반 페이지네이션(Cursor-based Pagination)
- 클라이언트가 가져간 마지막 row의 순서상 다음 row들을 n개 요청/응답하게 구현
- n개의 row를 skip 한 다음 10개 주세요가 아니라, 이 row 다음 꺼부터 10개 주세요 를 요청하는 식
따라서, full scan을 할 필요 없다
만약 3개씩 묶어서 보겠다면 Querydsl을 통한 첫 조회 결과는 다음과 같이 예상된다.
id | place_id | place_name | ... |
11 | 병천면11 | 고기국수 | ... |
10 | 병천면10 | 빽다방 | ... |
2 | 병천면2 | 빽다방2 | ... |
그다음 리스트를 가져오기 위해 custom cursor를 생성하여 마지막 row 기준 다음 리스트를 가져오게 한다.
- 상점 이름으로 정렬을 할 것이므로 custom cursor는 상점이름으로 구분한다
- 상점 이름이 unique하지 않을 수 있으므로 PK인 상점 id를 이어 붙여 유니크한 값을 가지도록 한다
private BooleanExpression customCursor(String cursor){
if(cursor==null) return null;
return StringExpressions.lpad(shop.placeName.stringValue(), 20, '0')
.concat(StringExpressions.lpad(shop.id.stringValue(), 10, '0'))
.gt(cursor);
}
간단하게 예를 들면 첫 리스트(cursor=null) 다음 호출 시 cursor는 0000000000000000빽다방20000000002 으로 결과는 아래와 같이 예상된다.
id | place_id | place_name | ... |
3 | 병천면3 | 빽다방3 | ... |
4 | 병천면4 | 빽다방4 | ... |
5 | 병천면5 | 빽다방5 | ... |
그렇다면 이제 Querydsl을 사용하여 쿼리를 구현해보자.
우선 Review Entity에서 Shop Entity의 id를 얻어와야 하기 때문에 서브 쿼리가 필요하다.
Querydsl에서의 sub query
- JPAExpressions를 사용하여 구현한다
- 같은 Entity를 사용했을 때, 메인 쿼리와 서브 쿼리의 별칭이 겹치면 안 되기 때문에 Q객체를 하나 더 만들어서 생성해줘야 하며, 이때 Alias를 직접 지정해서 구분해야 한다.
- ex) QReviewEntity review = QReviewEntity.review; QReviewEntity subReview = new QReviewEntity("subReview");
- Querydsl은 from절 sub query를 지원하지 않는다
- 서브 쿼리에서 결과에 대한 Alias를 지정하고 싶은 경우 ExpressionUtils.as() 메서드를 이용하면 된다.
- ex) ExpressionUtils.as( JPAExpressions.select(subBoard.views.avg()) .from(subBoard), "customAlias") )
@Override
public Page<ShopEntity> findAllShopBySearchWordInReviewWithCursor(String cursor, Long userId, String searchWord, Pageable pageable) {
List<Long> followerId = findAllFollowersId(userId);
// 팔로워들이 작성한 리뷰에 searchWord가 포함된 상점 List
List<ShopEntity> reviewShopEntities = queryFactory
.selectFrom(shop)
.where(shop.id.in(
JPAExpressions.select(review.shop.id).from(review)
.where(review.writer.id.in(followerId), review.content.contains(searchWord))
.distinct()
), customCursor(cursor))
.innerJoin(shop.shopCount, shopCount).fetchJoin()
.limit(pageable.getPageSize())
.orderBy(shop.placeName.asc(), shop.id.asc())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(shop.count())
.from(shop);
return PageableExecutionUtils.getPage(reviewShopEntities, pageable, countQuery::fetchOne);
}
private BooleanExpression customCursor(String cursor){
if(cursor==null) return null;
return StringExpressions.lpad(shop.placeName.stringValue(), 20, '0')
.concat(StringExpressions.lpad(shop.id.stringValue(), 10, '0'))
.gt(cursor);
}
쿼리 결과
// sql 출력 예시
Hibernate:
select
shopentity0_.id as id1_12_0_,
shopcount1_.shop_id as shop_id3_13_1_,
shopentity0_.created_at as created_2_12_0_,
shopentity0_.is_deleted as is_delet3_12_0_,
shopentity0_.updated_at as updated_4_12_0_,
shopentity0_.address as address5_12_0_,
shopentity0_.business_day as business6_12_0_,
shopentity0_.category_name as category7_12_0_,
shopentity0_.phone as phone8_12_0_,
shopentity0_.place_id as place_id9_12_0_,
shopentity0_.place_name as place_n10_12_0_,
shopentity0_.x as x11_12_0_,
shopentity0_.y as y12_12_0_,
shopcount1_.rating_count as rating_c1_13_1_,
shopcount1_.total_rating as total_ra2_13_1_
from
shop shopentity0_
inner join
shop_count shopcount1_
on shopentity0_.id=shopcount1_.shop_id
where
(
shopentity0_.is_deleted = 0
)
and (
shopentity0_.id in (
select
distinct reviewenti2_.shop_id
from
review reviewenti2_
where
(
reviewenti2_.is_deleted = 0
)
and (
reviewenti2_.writer_id in (
? , ? , ? , ?
)
)
and (
reviewenti2_.content like ? escape '!'
)
)
)
and concat(lpad(shopentity0_.place_name,?,'0'), lpad(cast(shopentity0_.id as char),?,'0'))>?
order by
shopentity0_.place_name asc,
shopentity0_.id asc limit ?
2022-12-11 20:45:01.489 TRACE 22264 --- [nio-8080-exec-8] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [1]
2022-12-11 20:45:01.489 TRACE 22264 --- [nio-8080-exec-8] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [BIGINT] - [3]
2022-12-11 20:45:01.489 TRACE 22264 --- [nio-8080-exec-8] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [BIGINT] - [4]
2022-12-11 20:45:01.489 TRACE 22264 --- [nio-8080-exec-8] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [BIGINT] - [5]
2022-12-11 20:45:01.489 TRACE 22264 --- [nio-8080-exec-8] o.h.type.descriptor.sql.BasicBinder : binding parameter [5] as [CLOB] - [%맛%]
2022-12-11 20:45:01.489 TRACE 22264 --- [nio-8080-exec-8] o.h.type.descriptor.sql.BasicBinder : binding parameter [6] as [INTEGER] - [20]
2022-12-11 20:45:01.489 TRACE 22264 --- [nio-8080-exec-8] o.h.type.descriptor.sql.BasicBinder : binding parameter [7] as [INTEGER] - [10]
2022-12-11 20:45:01.489 TRACE 22264 --- [nio-8080-exec-8] o.h.type.descriptor.sql.BasicBinder : binding parameter [8] as [VARCHAR] - [0000000000000000빽다방20000000002]
Swagger에서 확인하기
cursor : null, searchWord: 맛, size: 3
마지막 객체를 cursor로 한 다음 리스트
cursor : 0000000000000000빽다방20000000002, searchWord: 맛, size: 3
정리
Querydsl을 이용하여 Cursor기반 페이징 API를 구현했다. 주어진 요구조건이 까다로운 것인지 객체지향적인 요소가 부족했던 것인지, 제가 그냥 SQL을 잘 못 짜는 것인지.. Querydsl에서 Sub query를 사용하는 것은 안티 패턴으로 보인다고 한다. MySQL에서 sub query가 들어가게 되면 성능이 매우 떨어진다고 한다. 여기를 참고하자. 딱 지금 상황에 맞는.. 일단 기능 구현이 빠듯했으므로 이렇게 구현하였지만.. 성능을 한번 생각해서 쿼리 수정이 필요한 것으로 보인다 ㅠㅠ.. 다 만들고 나서 보이니 안타깝다..
sub query가 필요하다면 다음과 같은 내용을 고려해보도록 하자!
- Join으로 해결할 순 없는지
- 애플리케이션에서 처리할 순 없는지
- 쿼리를 나눠서 실행할 순 없는지
'JPA' 카테고리의 다른 글
지연 로딩과 조회 성능 최적화 (0) | 2022.07.22 |
---|---|
Entity와 DTO의 분리 (0) | 2022.07.06 |