JPA에서 Spring data mongo 로 페이징 마이그레이션

dasd412·2023년 1월 24일
0

MSA 프로젝트

목록 보기
24/25

기존 JPA + Querydsl 페이징 코드 (모놀리식 프로젝트)

서비스 레이어

    @Transactional(readOnly = true)
    public Page<FoodBoardDTO> getFoodByPagination(EntityId<Writer, Long> writerEntityId, FoodPageVO foodPageVO) {
        Pageable page = foodPageVO.makePageable();
        logger.info("page vo : " + page.toString());

        /* where 절 이후에 쓰이는 조건문 */
        List<Predicate> predicates = new ArrayList<>();

        if (foodPageVO.getSign() != null && foodPageVO.getEnumOfSign() != InequalitySign.NONE) {
            predicates.add(decideEqualitySignOfBloodSugar(foodPageVO.getEnumOfSign(), foodPageVO.getBloodSugar()));
        }
        
        LocalDateTime startDate;
        
        LocalDateTime endDate;
        try {
            startDate = foodPageVO.convertStartDate().orElseThrow(() -> new ConvertLocalDateException("시작 날짜 변환 실패"));
            endDate = foodPageVO.convertEndDate().orElseThrow(() -> new ConvertLocalDateException("끝 날짜 변환 실패"));

            if (isStartDateEqualOrBeforeEndDate(startDate, endDate)) {
                predicates.add(decideBetweenTimeInDiary(startDate, endDate));
            }
        } catch (ConvertLocalDateException e) {
            logger.info("date format is empty or null " + e.getMessage());
        }
        return foodRepository.findFoodsWithPaginationAndWhereClause(writerEntityId.getId(), predicates, page);
    }

리포지토리 레이어

@Override
    public Page<FoodBoardDTO> findFoodsWithPaginationAndWhereClause(Long writerId, List<Predicate> predicates, Pageable pageable) {

        /*
        List의 경우 추가 count 없이 결과만 반환한다.
        */
        List<Tuple> foodList = jpaQueryFactory.select(QFood.food.foodName, QDiet.diet.bloodSugar, QDiabetesDiary.diabetesDiary.writtenTime, QDiabetesDiary.diabetesDiary.diaryId)
                .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))
                .orderBy(QDiet.diet.bloodSugar.desc(), QDiabetesDiary.diabetesDiary.writtenTime.desc(), QFood.food.foodName.asc())
                .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), tuple.get(QDiabetesDiary.diabetesDiary.diaryId)))
                .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(ExpressionUtils.allOf(predicates));

        return PageableExecutionUtils.getPage(dtoList, pageable, countFood::fetchCount);
    }

상황 분석 및 설계하기

document json

{
    "fastingPlasmaGlucose":100,
    "remark":"test",
    "year":"2022",
    "month":"12",
    "day":"11",
    "hour":"11",
    "minute":"25",
    "second":"43",
    "dietList":[
        {
            "eatTime":"LUNCH",
            "bloodSugar":100,
            "foodList":[
{
                    "foodName":"TOAST",
                    "amount":50.0,
                    "amountUnit":"g"
                },
                {
                    "foodName":"coke",
                    "amount":100.0,
                    "amountUnit":"mL"
                }
            ]
        }

    ]
}

저장하고자 하는 document json의 형태는 위와 같다. 관계로 나타내면
일대다가 중첩된 것이라 할 수 있겠다.
예를 들어, diary : diet =1:n, diet : food =1:n이라고 할 수 있다.

페이징 조건

  1. 페이징 dto는 diary.written_time, diet.blood_sugar, food.food_name이 필요하다.
  2. diary.written_time, 즉 기간으로 조건을 줄 수 있어야 한다.
  3. diet.blood_sugar에 대해 부등호 조건을 줄 수 있어야 한다.
  4. Spring data mongo를 활용한다. (읽기 전용 서비스의 경우 mongo db를 사용하기 때문...)

테스트 코드 짜기

테스트 셋업하기

페이징의 경우, 테스트하려면 많은 데이터가 필요하다.

아래 코드를 설명하면, diary 1개에 diet 3개를 갖고 있다. 그리고 각 diet는 3개의 food를 가진다. 그리고 diary 20개를 작성했다. 수치를 결정하는 값인 someValue는 편의상 day와 같은 값을 갖도록 했다.

@RunWith(SpringRunner.class)
@SpringBootTest
@TestPropertySource(locations = "/application-test.properties")
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public class FoodBoardPagingTest {

    @Autowired
    private ReadDiaryService readDiaryService;

    @Autowired
    private DiaryDocumentRepository diaryDocumentRepository;

    private long dietId = 1;

    private long foodId = 1;

    @Before
    public void setup() {
        this.dietId = 1;

        this.foodId = 1;

        IntStream.range(0, 20).forEach(
                i -> {
                    saveDiaryDocument((long) (i + 1), i + 1);
                }
        );
    }

    //일지 1개에 식단 3개, 식단 1개당 음식 3개. 따라서 일지 1개는 음식 9개를 갖는다.
    private void saveDiaryDocument(Long diaryId, int day) {

        List<DietDocument> dietDocuments = makeDietDocumentList(diaryId, day);

        DiabetesDiaryDocument diaryDocument = DiabetesDiaryDocument.builder()
                .diaryId(diaryId)
                .writerId(1L)
                .fastingPlasmaGlucose(100)
                .remark("test")
                .writtenTime(LocalDateTime.of(2023, 1, day, 0, 0))
                .dietDocuments(dietDocuments).build();

        diaryDocumentRepository.save(diaryDocument);
    }

    private List<DietDocument> makeDietDocumentList(Long diaryId, int someValue) {
        return Arrays.asList(
                makeDietDocument(diaryId, EatTime.BREAK_FAST, someValue),
                makeDietDocument(diaryId, EatTime.LUNCH, someValue),
                makeDietDocument(diaryId, EatTime.DINNER, someValue)
        );
    }

    private DietDocument makeDietDocument(Long diaryId, EatTime eatTime, int someValue) {
        List<FoodDocument> meal = makeFoodDocumentList(dietId, someValue);

        DietDocument dietDocument = DietDocument.builder()
                .dietId(dietId).diaryId(diaryId).writerId(1L)
                .eatTime(eatTime).bloodSugar(100 + someValue)
                .foodList(meal).build();

        dietId += 1;

        return dietDocument;
    }

    private List<FoodDocument> makeFoodDocumentList(Long dietId, int someValue) {
        return Arrays.asList(
                makeFoodDocument(dietId, someValue),
                makeFoodDocument(dietId, someValue),
                makeFoodDocument(dietId, someValue)
        );
    }

    private FoodDocument makeFoodDocument(Long dietId, int someValue) {
        FoodDocument food = FoodDocument.builder()
                .foodId(foodId).dietId(dietId).writerId(1L)
                .foodName(String.valueOf(foodId)).amount(someValue).amountUnit(AmountUnit.g).build();

        foodId += 1;

        return food;
    }
}

테스트 코드 작성하기

    @Test
    public void testDefaultPaging() {
        FoodPageVO vo = new FoodPageVO();
        Page<FoodBoardDTO> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getTotalPages()).isEqualTo(18);
        assertThat(dtoPage.getContent().size()).isEqualTo(10);
    }

실제 코드 짜기

@Service
@Transactional(readOnly = true)
public class ReadDiaryServiceImpl implements ReadDiaryService {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private final DiaryDocumentRepository diaryDocumentRepository;

    private final QDiabetesDiaryDocument qDocument = new QDiabetesDiaryDocument("diabetesDiaryDocument");

    public ReadDiaryServiceImpl(DiaryDocumentRepository diaryDocumentRepository) {
        this.diaryDocumentRepository = diaryDocumentRepository;
    }


	@Override
    public Page<FoodBoardDTO> getFoodByPagination(String writerId, FoodPageVO foodPageVO) {
        List<Predicate> predicates = new ArrayList<>();

        //1. id에 해당하는 작성자이고
        predicates.add(qDocument.writerId.eq(Long.parseLong(writerId)));

        //2. 작성 기간 안에 쓰여진
        addPredicateForTimeSpan(predicates, foodPageVO);

        //3. 일지를 모두 찾는다.
        List<DiabetesDiaryDocument> diaryDocumentList = (List<DiabetesDiaryDocument>) diaryDocumentRepository.findAll(ExpressionUtils.allOf(predicates));

        //4. 식사 혈당 필터 조건이 있다면 필터링 한다. 그리고 필터링한 식단과 음식을 모아 DTO로 만든다.
        List<FoodBoardDTO> foodBoardDTOList = filterWithEqualitySignAndBloodSugar(diaryDocumentList, foodPageVO.getEnumOfSign(), foodPageVO.getBloodSugar());

        //5. DTO를 조건 순으로 정렬한다.
        foodBoardDTOList.sort(Comparator
                .comparing(FoodBoardDTO::getBloodSugar, Comparator.reverseOrder())
                .thenComparing(FoodBoardDTO::getWrittenTime, Comparator.reverseOrder())
                .thenComparing(FoodBoardDTO::getFoodName, Comparator.naturalOrder()));

        //6. 5.의 결과를 페이징한다.
        Pageable pageable = foodPageVO.makePageable();


        return new PageImpl<>(foodBoardDTOList, pageable, foodBoardDTOList.size());
    }

    private void addPredicateForTimeSpan(List<Predicate> predicates, FoodPageVO vo) {
        LocalDateTime startDate;

        LocalDateTime endDate;

        try {
            startDate = vo.convertStartDate().orElseThrow(IllegalArgumentException::new);

            endDate = vo.convertEndDate().orElseThrow(IllegalArgumentException::new);
        } catch (IllegalArgumentException e) {
            logger.info("It is invalid date format... So, this predicate should be ignored...");
            return;
        }

        if (isStartDateEqualOrBeforeEndDate(startDate, endDate)) {
            predicates.add(qDocument.writtenTime.between(startDate, endDate));
        }
    }

    private List<FoodBoardDTO> filterWithEqualitySignAndBloodSugar(List<DiabetesDiaryDocument> diaryDocumentList, InequalitySign sign, int bloodSugar) {

        List<FoodBoardDTO> dtoList = new ArrayList<>();

        for (DiabetesDiaryDocument diaryDocument : diaryDocumentList) {
            for (DietDocument dietDocument : diaryDocument.getDietList()) {
                if (!isRightCondition(dietDocument, sign, bloodSugar)) {
                    continue;
                }
                for (FoodDocument foodDocument : dietDocument.getFoodList()) {
                    dtoList.add(new FoodBoardDTO(foodDocument.getFoodName(), dietDocument.getBloodSugar(), diaryDocument.getWrittenTime(), diaryDocument.getDiaryId()));
                }
            }
        }

        return dtoList;
    }

    private boolean isRightCondition(DietDocument dietDocument, InequalitySign sign, int bloodSugar) {
        switch (sign) {
            case GREATER:
                return dietDocument.getBloodSugar() > bloodSugar;

            case LESSER:
                return dietDocument.getBloodSugar() < bloodSugar;

            case EQUAL:
                return dietDocument.getBloodSugar() == bloodSugar;

            case GREAT_OR_EQUAL:
                return dietDocument.getBloodSugar() >= bloodSugar;

            case LESSER_OR_EQUAL:
                return dietDocument.getBloodSugar() <= bloodSugar;
        }
        // 조건이 없으면 다 dto로 담아야 하므로 true를 반환.
        return true;
    }
}

이렇게 코드를 짠 이유는 다음과 같이 추론했기 때문이다.

predicates에는 diary.written_time과 diet.blood_sugar가 쿼리 조건으로 들어 갈 수 있다.

만약 둘 다 predicates로 들어간다고 해보자. 그러면 findAll(predicates)의 결과는 해당 조건들을 만족하는 diary가 나올 것이다.
하지만, diet.blood_sugar 조건을 만족하지 않는 diet들도 딸려 나올 것이다. 왜냐하면 diet는 diary에 종속적 일대다 관계를 맺고 있기 때문이다.

만약 diary.written_time으로 findAll(predicate)를 해서 diary들을 얻고, 그 diary들을 애플리케이션 로직에서 순회하면서 diet.blood_sugar로 필터링하면 동일한 결과집합을 얻을 수 있지 않을까?

(여기서 동일한 결과집합이라 함은 JPA+Querydsl 코드의 List<Tuple> foodList을 뜻한다)

그러한 생각으로 짠 코드가 위 코드이다.


이 코드의 문제점

그런데 문제가 발생했다!!

180개를 저장했는데, content size가 180개를 나타내고 있었다. 왜 이럴까?

결론을 말하면, 이 페이징 방식은 실패다! 페이징을 비슷하게 하는 것처럼 보이지만, 페이징의 목적을 위배하기 때문이다.

스택 오버 플로우 답변을 인용하자면, 다음과 같다.

If I understood your code right, then your intent is to load all records from the database and and split them into x buckets that are collected in the PageImpl, right?
Thats not how it used to work. The actual intent of the Pageable and Page abstraction is NOT having to query all the data but just the "slice" of data that is needed.

즉, 페이징은 성능을 위해서 일부분만 쿼리로 긁어와야 한다는 것이다.
따라서 다른 방식으로 코딩할 필요가 있다.

출처

https://stackoverflow.com/questions/26720768/spring-data-pageimpl-not-returning-page-with-the-correct-size
Thomas Darimont 댓글


타협안

원래는 리팩토링, 즉 기능의 결과는 그대로 두되 사용하는 DB만 바꾸려고 했었다.

하지만 spring data mongo로 List<Tuple> 형태를 만드는 것은 쉽지 않았다.
게다가 jpa에서 사용한 데이터 모델 (일대다와 같은 릴레이션이 있음)과 mongo db에서 사용한 데이터 모델 (json을 기반으로 함)은 차이가 있다.

그래서 일지 자체를 가져와서 페이징하기로 했다. 구글링해보니 이러한 것을 마이그레이션(소프트웨어를 한 시스템에서 다른 시스템으로 이동)이라 하는 듯...?

테스트 코드

셋업 코드

    @Before
    public void setup() {
        this.dietId = 1;

        this.foodId = 1;

        IntStream.range(0, 200).forEach(
                i -> {
                    saveDiaryDocument((long) (i + 1), (i % 31 + 1));
                }
        );
    }

일지 자체를 페이징할 것이므로 일지를 20개에서 200개로 개수를 늘렸다.

테스트 항목

테스트 항목은 다음과 같다.

  1. 아무 조건 없이 기본 페이징
  2. 시간 조건을 가진 채로 페이징
  3. 부등호 조건을 가진 채로 페이징
  4. 2.와 3.을 혼합한 조건으로 페이징

여기서 2. 항목의 경우, 시간을 나타내기 위해 입력된 문자열이 파싱되지 못하는 등의 예외가 발생할 경우 해당 조건을 무시하도록 했다.

	@Test
    public void testDefaultPaging() {
        FoodPageVO vo = new FoodPageVO();
        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getTotalPages()).isEqualTo(20);
        assertThat(dtoPage.getContent().size()).isEqualTo(10);
    }

    @Test
    public void testInvalidTimeFormat() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("")
                .startYear("2023").startMonth("21").startDay("1411")
                .endYear("2023").endMonth("2231").endDay("1151")
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getTotalPages()).isEqualTo(20);
        assertThat(dtoPage.getContent().size()).isEqualTo(10);
    }


    @Test
    public void testInvalidTimeOrder() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("")
                .startYear("2023").startMonth("01").startDay("31")
                .endYear("2023").endMonth("01").endDay("01")
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getTotalPages()).isEqualTo(20);
        assertThat(dtoPage.getContent().size()).isEqualTo(10);
    }

    @Test
    public void testPagingWithTimeSpan() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("")
                .startYear("2023").startMonth("01").startDay("01")
                .endYear("2023").endMonth("01").endDay("11")
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getContent())
                .allSatisfy(diaryDocument -> {
                    assertThat(diaryDocument.getWrittenTime()).isAfter(LocalDateTime.of(2023, 1, 1, 0, 0));
                    assertThat(diaryDocument.getWrittenTime()).isBefore(LocalDateTime.of(2023, 1, 11, 0, 0, 0));
                });

    }

    @Test
    public void testPagingWithSignOfGreater() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("greater")
                .bloodSugar(110)
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getContent()).allSatisfy(diaryDocument -> {
            assertThat(diaryDocument.getDietList())
                    .allMatch(dietDocument -> dietDocument.getBloodSugar() > 110);
        });
    }

    @Test
    public void testPagingWithSignOfLesser() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("lesser")
                .bloodSugar(130)
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getContent()).allSatisfy(diaryDocument -> {
            assertThat(diaryDocument.getDietList())
                    .allMatch(dietDocument -> dietDocument.getBloodSugar() < 130);
        });
    }

    @Test
    public void testPagingWithSignOfEqual() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("equal")
                .bloodSugar(120)
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getContent()).allSatisfy(diaryDocument -> {
            assertThat(diaryDocument.getDietList())
                    .allMatch(dietDocument -> dietDocument.getBloodSugar() == 120);
        });
    }

    @Test
    public void testPagingWithSignOfGreaterOrEqual() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("ge")
                .bloodSugar(120)
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getContent()).allSatisfy(diaryDocument -> {
            assertThat(diaryDocument.getDietList())
                    .allMatch(dietDocument -> dietDocument.getBloodSugar() >= 120);
        });
    }

    @Test
    public void testPagingWithSignOfLesserOrEqual() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("le")
                .bloodSugar(120)
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getContent()).allSatisfy(diaryDocument -> {
            assertThat(diaryDocument.getDietList())
                    .allMatch(dietDocument -> dietDocument.getBloodSugar() <= 120);
        });
    }

    @Test
    public void testPagingWithComplexPredicate() {
        FoodPageVO vo = FoodPageVO.builder()
                .sign("le")
                .bloodSugar(115)
                .startYear("2023").startMonth("01").startDay("01")
                .endYear("2023").endMonth("01").endDay("11")
                .build();

        Page<DiabetesDiaryDocument> dtoPage = readDiaryService.getFoodByPagination("1", vo);

        assertThat(dtoPage.getContent())
                .allSatisfy(diaryDocument -> {
                    assertThat(diaryDocument.getWrittenTime()).isAfter(LocalDateTime.of(2023, 1, 1, 0, 0));
                    assertThat(diaryDocument.getWrittenTime()).isBefore(LocalDateTime.of(2023, 1, 11, 0, 0, 0));
                    assertThat(diaryDocument.getDietList()).allMatch(dietDocument -> dietDocument.getBloodSugar() <= 115);
                });
    }

실제 코드

대부분 Querydsl을 사용하지 않고 spring data mongo가 제공하는 코드들(Query, Criteria 등)을 사용했다.

Criteria.where()은 document의 @Field()를 참고한다.

    @Override
    public Page<DiabetesDiaryDocument> getFoodByPagination(String writerId, FoodPageVO foodPageVO) {

        Pageable pageable = foodPageVO.makePageable();

        Query query = new Query(Criteria.where("writer_id").is(Long.parseLong(writerId)))
                .with(pageable)
                .with(Sort.by(Sort.Direction.DESC, "diet_list.blood_sugar", "written_time"));

        addCriteriaForTimeSpan(query,foodPageVO);

        addCriteriaForBloodSugar(query,foodPageVO);

        List<DiabetesDiaryDocument> diaryDocumentList = mongoTemplate.find(query, DiabetesDiaryDocument.class);

        return PageableExecutionUtils.getPage(
                diaryDocumentList,pageable,()->mongoTemplate.count(query.skip(-1).limit(-1),DiabetesDiaryDocument.class)
        );
    }

    private void addCriteriaForTimeSpan(Query query, FoodPageVO vo) {
        LocalDateTime startDate;

        LocalDateTime endDate;

        try {
            startDate = vo.convertStartDate().orElseThrow();

            endDate = vo.convertEndDate().orElseThrow();
        } catch (DateTimeException | NoSuchElementException e) {
            logger.info("It is invalid date format... So, this predicate should be ignored...");
            return;
        }

        if (isStartDateEqualOrBeforeEndDate(startDate, endDate)) {
            query.addCriteria(Criteria.where("written_time").gte(startDate).lt(endDate));
        }
    }

    private void addCriteriaForBloodSugar(Query query, FoodPageVO foodPageVO) {
        InequalitySign sign = foodPageVO.getEnumOfSign();

        int bloodSugar = foodPageVO.getBloodSugar();

        switch (sign) {
            case GREATER:
                query.addCriteria(Criteria.where("diet_list.blood_sugar").gt(bloodSugar));
                break;

            case LESSER:
                query.addCriteria(Criteria.where("diet_list.blood_sugar").lt(bloodSugar));
                break;

            case EQUAL:
                query.addCriteria(Criteria.where("diet_list.blood_sugar").is(bloodSugar));
                break;

            case GREAT_OR_EQUAL:
                query.addCriteria(Criteria.where("diet_list.blood_sugar").gte(bloodSugar));
                break;

            case LESSER_OR_EQUAL:
                query.addCriteria(Criteria.where("diet_list.blood_sugar").lte(bloodSugar));
                break;
        }
    }

profile
아키텍쳐 설계와 테스트 코드에 관심이 많음.

0개의 댓글