dasd412 / RemakeDiabetesDiaryAPI

혈당일지 api 리메이크
https://www.diabetes-diary.tk/
1 stars 0 forks source link

음식 게시물 페이징 처리하기 #48

Closed dasd412 closed 2 years ago

dasd412 commented 2 years ago

일단, 참고 자료부터 올린다.

[참고 서적] 스타트 스프링 부트

[참고 링크] https://ttl-blog.tistory.com/228

dasd412 commented 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;
dasd412 commented 2 years ago

위 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
}
dasd412 commented 2 years ago

맨 위의 링크를 참고해서 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 쿼리를 실행하지 않지만, Page의 경우 count 쿼리를 실행하기 때문에 비효율적이라고 한다. 따라서 List로 결과들을 받아 온다.

그리고 count 쿼리는 countFood라는 변수로 지정해 놓는다. 해당 변수를 PageableExecutionUtils.getPage()의 세 번째 파라미터로 놓는다. 이렇게 하면, 첫 번째 페이지인데 컨텐츠 개수가 페이지 사이즈보다 작거나 마지막일 경우 count 쿼리를 실행하지 않게 된다.

dasd412 commented 2 years ago

테스트 코드는 다음과 같다. 참고로 페이지의 경우 '첫' 페이지만 가져온다.

    @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도 맞다.

dasd412 commented 2 years ago

좀 더 조건을 세부화 해야겠다. 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

dasd412 commented 2 years ago

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절 조건문을 "가변적"으로 변경할 수 있다.

dasd412 commented 2 years ago

사용된 테스트 코드에서 수정된 부분만 발췌하면 다음과 같다.

        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);
dasd412 commented 2 years ago

다음은 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로 먼저 전체가 정렬 되는게 문제다.

dasd412 commented 2 years ago

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에 의해 정렬이 된 것을 확인할 수 있었다.