Closed dasd412 closed 2 years ago
먼저, 페이징 없이 해당 기간 동안 작성자가 먹은 음식들을 구하는 sql은 다음과 같다.(MySql)
select f.* from food f
inner join writer w
inner join diabetes_diary d
inner join diet di
on d.diary_id=di.diary_id
on f.diet_id=di.diet_id
where
w.writer_id= ?
and
d.written_time between ? and ? <- 여기까지가 findFoodsWithRelationBetweenTime()에 해당.
and <- decideEqualitySign() 으로 작성하는 조건 값
di.blood_sugar>=100;
위 SQL에서 di.blood_sugar >=100은 입력 값과 조건에 따라 다르게 설정될 수 있다. 따라서 Querydsl의 Predicate를 활용하여 파라미터에 따른 Where절 분기를 처리한다.
@Override
public Predicate decideEqualitySign(InequalitySign sign, int bloodSugar) {
BooleanBuilder booleanBuilder = new BooleanBuilder();
switch (sign) {
case GREATER:
booleanBuilder.and(QDiet.diet.bloodSugar.gt(bloodSugar));
break;
case LESSER:
booleanBuilder.and(QDiet.diet.bloodSugar.lt(bloodSugar));
break;
case EQUAL:
booleanBuilder.and(QDiet.diet.bloodSugar.eq(bloodSugar));
break;
case GREAT_OR_EQUAL:
booleanBuilder.and(QDiet.diet.bloodSugar.goe(bloodSugar));
break;
case LESSER_OR_EQUAL:
booleanBuilder.and(QDiet.diet.bloodSugar.loe(bloodSugar));
break;
}
return booleanBuilder;
}
참고로 InequalitySign은 부등호를 나타내는 Enum이다.
public enum InequalitySign {
GREATER,
LESSER,
GREAT_OR_EQUAL,
EQUAL,
LESSER_OR_EQUAL
}
맨 위의 링크를 참고해서 Querydsl로 작성한 코드는 다음과 같다.
@Override
public Page<Food> findFoodsWithPaginationBetweenTime(Long writerId, InequalitySign sign, int bloodSugar, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable) {
/*
List의 경우 추가 count 없이 결과만 반환한다.
*/
List<Tuple> foodList = jpaQueryFactory.select(QFood.food.foodName, QDiet.diet.bloodSugar, QDiabetesDiary.diabetesDiary.writtenTime)
.from(QFood.food)
.innerJoin(QFood.food.diet, QDiet.diet)
.innerJoin(QFood.food.diet.diary, QDiabetesDiary.diabetesDiary)
.on(QDiet.diet.diary.writer.writerId.eq(writerId))
.where(QDiabetesDiary.diabetesDiary.writtenTime.between(startDate, endDate)
.and(decideEqualitySign(sign, bloodSugar)))
.offset(pageable.getOffset()) /* offset = page * size */
.limit(pageable.getPageSize())
.fetch();
List<FoodBoardDTO> dtoList = foodList.stream().map(tuple -> new FoodBoardDTO(tuple.get(QFood.food.foodName), tuple.get(QDiet.diet.bloodSugar), tuple.get(QDiabetesDiary.diabetesDiary.writtenTime)))
.collect(Collectors.toList());
/*
count 쿼리를 분리하여 최적화 한다.
*/
JPAQuery<Food> countFood = jpaQueryFactory
.select(QFood.food)
.from(QFood.food)
.innerJoin(QFood.food.diet, QDiet.diet)
.innerJoin(QFood.food.diet.diary, QDiabetesDiary.diabetesDiary)
.on(QDiet.diet.diary.writer.writerId.eq(writerId))
.where(QDiabetesDiary.diabetesDiary.writtenTime.between(startDate, endDate)
.and(decideEqualitySign(sign, bloodSugar)));
return PageableExecutionUtils.getPage(dtoList, pageable, countFood::fetchCount);
}
리턴 타입이 List
그리고 count 쿼리는 countFood
라는 변수로 지정해 놓는다.
해당 변수를 PageableExecutionUtils.getPage()
의 세 번째 파라미터로 놓는다.
이렇게 하면, 첫 번째 페이지인데 컨텐츠 개수가 페이지 사이즈보다 작거나 마지막일 경우 count 쿼리를 실행하지 않게 된다.
테스트 코드는 다음과 같다. 참고로 페이지의 경우 '첫' 페이지만 가져온다.
@Transactional
@Test
public void findFoodsWithPaginationBetweenTimeWithPredicate() {
//given
Writer me = saveDiaryService.saveWriter("me", "ME@NAVER.COM", Role.User);
DiabetesDiary diary1 = saveDiaryService.saveDiaryOfWriterById(EntityId.of(Writer.class, me.getId()), 20, "test1", LocalDateTime.of(2021, 12, 1, 0, 0, 0));
Diet diet1 = saveDiaryService.saveDietOfWriterById(EntityId.of(Writer.class, me.getId()), EntityId.of(DiabetesDiary.class, diary1.getId()), EatTime.BreakFast, 100);
Diet diet2 = saveDiaryService.saveDietOfWriterById(EntityId.of(Writer.class, me.getId()), EntityId.of(DiabetesDiary.class, diary1.getId()), EatTime.Lunch, 120);
Diet diet3 = saveDiaryService.saveDietOfWriterById(EntityId.of(Writer.class, me.getId()), EntityId.of(DiabetesDiary.class, diary1.getId()), EatTime.Dinner, 140);
IntStream.range(0, 30).forEach(i -> {
switch (i % 3) {
case 0:
saveDiaryService.saveFoodAndAmountOfWriterById(EntityId.of(Writer.class, me.getId()), EntityId.of(DiabetesDiary.class, diary1.getId()), EntityId.of(Diet.class, diet1.getDietId()), "diet1 food" + i, 100 + i, AmountUnit.g);
break;
case 1:
saveDiaryService.saveFoodAndAmountOfWriterById(EntityId.of(Writer.class, me.getId()), EntityId.of(DiabetesDiary.class, diary1.getId()), EntityId.of(Diet.class, diet2.getDietId()), "diet2 food" + i, 100 + i, AmountUnit.count);
break;
case 2:
saveDiaryService.saveFoodAndAmountOfWriterById(EntityId.of(Writer.class, me.getId()), EntityId.of(DiabetesDiary.class, diary1.getId()), EntityId.of(Diet.class, diet3.getDietId()), "diet3 food" + i, 100 + i, AmountUnit.mL);
break;
}
});
LocalDateTime startDate = LocalDateTime.of(2021, 11, 1, 0, 0);
LocalDateTime endDate = LocalDateTime.of(2021, 12, 11, 0, 0);
FoodPageVO foodPageVO = new FoodPageVO();
Pageable pageable = foodPageVO.makePageable(Sort.Direction.ASC, "food_id");
//when
logger.info("select\n");
Page<FoodBoardDTO> result = foodRepository.findFoodsWithPaginationBetweenTime(me.getId(), InequalitySign.GREAT_OR_EQUAL, 120, startDate, endDate, pageable);
//then
logger.info(result.getContent().toString());
assertThat(result.getContent().size()).isEqualTo(10);
}
위 코드를 실행하게 되면 select 문 + select count 두 개가 실행된다.
만약 위 코드의 IntStream.range(0, 30)
을 IntStream.range(0, 6)
과 같이 바꾸게 되면 첫 번째 페이지인데 컨텐츠 개수가 페이지 사이즈보다 작기 때문에 select count는 실행되지 않는다.
그리고 Page<FoodBoardDTO> result
라인에 중단점을 찍고 디버그를 해보면, total=20
, content.size=10
을 확인할 수 있다.
또한 Page 1 of 2 containing com.dasd412.remake.api.controller.security.domain_rest.dto.chart.FoodBoardDTO instances
라는 디버그 로그도 확인할 수 있다.
120 혈당 이상인 것은 전체 30개 중 20개이므로 toal=20이 맞다. 그리고 20개 중 10개만 가져온 것이므로 `Page 1 of 2 containing
도 맞다.
좀 더 조건을 세부화 해야겠다. Predicate를 아예 파라미터로 받아서 처리하는 게 나을 듯.
[참고 링크] https://www.programcreek.com/java-api-examples/?api=com.querydsl.core.types.Predicate
https://stackoverflow.com/questions/27090122/spring-data-querydsl-empty-predicate-predicate-chaining
between 조건도 빼놓고 쓸 수 있기 때문에 리포지토리에 다음과 같은 Predicate 리턴 메서드를 만든다.
@Override
public Predicate decideBetween(LocalDateTime startDate, LocalDateTime endDate) {
BooleanBuilder booleanBuilder = new BooleanBuilder();
booleanBuilder.and(QDiabetesDiary.diabetesDiary.writtenTime.between(startDate, endDate));
return booleanBuilder;
}
그리고 기존 find 함수를 다음과 같이 변경한다.
@Override
public Page<FoodBoardDTO> findFoodsWithPaginationBetweenTime(Long writerId, List<Predicate> predicates, Pageable pageable) {
/*
List의 경우 추가 count 없이 결과만 반환한다.
*/
List<Tuple> foodList = jpaQueryFactory.select(QFood.food.foodName, QDiet.diet.bloodSugar, QDiabetesDiary.diabetesDiary.writtenTime)
.from(QFood.food)
.innerJoin(QFood.food.diet, QDiet.diet)
.innerJoin(QFood.food.diet.diary, QDiabetesDiary.diabetesDiary)
.on(QDiet.diet.diary.writer.writerId.eq(writerId))
.where(where(ExpressionUtils.allOf(predicates))) /* where 절에 쓰이는 조건문은 "가변적" */
.offset(pageable.getOffset()) /* offset = page * size */
.limit(pageable.getPageSize())
.fetch();
List<FoodBoardDTO> dtoList = foodList.stream().map(tuple -> new FoodBoardDTO(tuple.get(QFood.food.foodName), tuple.get(QDiet.diet.bloodSugar), tuple.get(QDiabetesDiary.diabetesDiary.writtenTime)))
.collect(Collectors.toList());
/*
count 쿼리를 분리하여 최적화 한다.
*/
JPAQuery<Food> countFood = jpaQueryFactory
.select(QFood.food)
.from(QFood.food)
.innerJoin(QFood.food.diet, QDiet.diet)
.innerJoin(QFood.food.diet.diary, QDiabetesDiary.diabetesDiary)
.on(QDiet.diet.diary.writer.writerId.eq(writerId))
.where();
return PageableExecutionUtils.getPage(dtoList, pageable, countFood::fetchCount);
}
파라미터가 List<Predicate> predicates
로 바뀐 것을 주목하자.
해당 파라미터는 where(ExpressionUtils.allOf(predicates))
과 같이 사용된다.
이렇게 하면 where절 조건문을 "가변적"으로 변경할 수 있다.
사용된 테스트 코드에서 수정된 부분만 발췌하면 다음과 같다.
List<Predicate> betweenAndSugar = new ArrayList<>();
betweenAndSugar.add(foodRepository.decideEqualitySign(InequalitySign.GREAT_OR_EQUAL, 120));
betweenAndSugar.add(foodRepository.decideBetween(startDate, endDate));
//when
Page<FoodBoardDTO> result = foodRepository.findFoodsWithPaginationBetweenTime(me.getId(), betweenAndSugar, pageable);
다음은 ChartFormController 내의 코드다.
@GetMapping("/chart-menu/chart/food-board/list")
public String showChartFoodBoard(@AuthenticationPrincipal PrincipalDetails principalDetails, FoodPageVO vo, Model model) {
Pageable page = vo.makePageable(Sort.Direction.DESC, "food_id");
logger.info("page vo : " + page.toString());
List<Predicate> predicates = new ArrayList<>();
Page<FoodBoardDTO> dtoPage = findDiaryService.getFoodByPagination(EntityId.of(Writer.class, principalDetails.getWriter().getId()), predicates, page);
List<FoodBoardDTO> dtoList =
dtoPage.getContent().
stream().sorted(Comparator.comparing(FoodBoardDTO::getBloodSugar).reversed()
.thenComparing(Comparator.comparing(FoodBoardDTO::getWrittenTime).reversed()))
.collect(Collectors.toList());
logger.info("dto : " + dtoList);
model.addAttribute("foodPage", dtoList);
return "chart/foodBoard";
}
여기서 문제가 되는 것은 Pageable page = vo.makePageable(Sort.Direction.DESC, "food_id");
이다. 혈당 순, 시간 순으로만 정렬하고 싶은데, 위 코드 때문에 음식 id로 먼저 전체가 정렬 되는게 문제다.
PageVo의 정렬 기준을 없앤 메서드 makePageable()
를 만든다.
public class FoodPageVO {
public Pageable makePageable(){
return PageRequest.of(this.page-1,this.size);
}
}
이렇게 하면 페이징에 정렬 조건이 없어진다.
그리고 나서 컨트롤러도 위 코드를 호출하도록 변경한다.
@GetMapping("/chart-menu/chart/food-board/list")
public String showChartFoodBoard(@AuthenticationPrincipal PrincipalDetails principalDetails, FoodPageVO vo, Model model) {
Pageable page = vo.makePageable();
...
}
제일 중요한 리포지토리 코드는 다음과 같이 변경한다.
List<Tuple> foodList = jpaQueryFactory.select(QFood.food.foodName, QDiet.diet.bloodSugar, QDiabetesDiary.diabetesDiary.writtenTime)
.from(QFood.food)
.innerJoin(QFood.food.diet, QDiet.diet)
.innerJoin(QFood.food.diet.diary, QDiabetesDiary.diabetesDiary)
.on(QDiet.diet.diary.writer.writerId.eq(writerId))
.where(ExpressionUtils.allOf(predicates)) /* where 절에 쓰이는 조건문은 "가변적" */
.orderBy(QDiet.diet.bloodSugar.desc(), QDiabetesDiary.diabetesDiary.writtenTime.desc(), QFood.food.foodName.asc())
.offset(pageable.getOffset()) /* offset = page * size */
.limit(pageable.getPageSize())
.fetch();
바뀐 부분은 orderBy(QDiet.diet.bloodSugar.desc(), QDiabetesDiary.diabetesDiary.writtenTime.desc(), QFood.food.foodName.asc())
이다.
이렇게 했더니 페이징 할 때 정렬하지 않고, order by에 의해 정렬이 된 것을 확인할 수 있었다.
일단, 참고 자료부터 올린다.
[참고 서적] 스타트 스프링 부트
[참고 링크] https://ttl-blog.tistory.com/228