티스토리 뷰

이전 포스팅에서 커서를 사용하여 무한 스크롤을 구현해보았는데

 

https://jerecord.tistory.com/207

 

커서를 사용하여 무한 스크롤을 구현해보자.

공연 동행 구인 웹 서비스의 백엔드로 참여하며 동행 구인 게시글 목록 조회 API를 구현하며 커서 기반 페이지네이션을 적용해보았습니다😀 페이지네이션(오프셋과 커서) 페이지네이션하면 대

jerecord.tistory.com

 

검색 필터링 기능을 적용함에 따라, 사용자가 설정한 조건을 바탕으로 

검색 결과 동적 제공이 필요해졌습니다👀

 

동적 SQL 쿼리의 필요성을 느꼈고, 

네이티브 쿼리를 작성하기에는, 검색 조건도 많고

가독성도 떨어지고 오타 이슈도 많을 것 같아

 

쿼리를 문자가 아니라 자바 코드로 작성할 수 있게 해주는

QueryDsl를 적용하게 되었습니다🐾

 

 


QueryDsl 기본 설정

dependencies {
    // ...

    // QueryDsl
    implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

 


QueryDsl 사용하기 위한 레포지토리 확장

커스텀 인터페이스 생성 후, 해당 인터페이스를 구현하는 클래스 생성하여 기존 repository가 커스텀 인터페이스를 상속하도록 합니다.

 

  • AccompanyPostRepository -> 기존 JPA 레포지토리!
  • AccompanyPostCustomRepository -> AccompanyPostRepository가 추가로 상속할 레포지토리
  • AccompanyPostCustomRepositoryImpl -> AccompanyPostCustomRepository 구현체로 QueryDsl 코드 작성

 


필터 요청 항목에 따른 필터링 QueryDsl 구현

무한 스크롤 방식을 그대로 유지하기 위해

인자로 cursorId, size, 그리고 필터 조건을 저장하는 AccompanyPostFilterRequest 객체를 넘겨주었습니다.

기존에는 size를 담은 Pageable을 넘겨주었는데
인수로 size만 넘겨주면 불필요한 Pageable 객체 생성이 발생하지 않겠다는 생각이 들어
size 값만 넘겨주고 QueryDsl을 통해 limit을 걸어 필요한 갯수만 받아오도록 수정하였습니다.

 

그리고 각 조건들의 경우 함수화 하여, where 내부에 넣어주었습니다.

Querydsl의 where는 null이 파리미터로 들어오는 경우 조건문에서 제외됩니다~

 

각 조건에 대해 아래와 같이 비교하여 일치하지 않으면 null을 반환하여 조건문에서 제외되도록 하였습니다.

(➡️즉, null 인 경우 전체 검색이 됩니다.)

  • 성별, 지역, 인원 수, 공연 장소의 경우 같은지 비교
  • 나이의 경우, 시작나이와 종료나이를 기준으로 겹치는 구간이 있는지 판단
  • 목적의 경우, 필터 조건의 목적을 가지고 있는지 판단

 

다음 데이터가 있는지 여부를 제공하는 hasNext 값도 전달하기 위해

isExistByIdLessThan 함수를 생성하여 id 값보다 작은 데이터가 있는지 QueryDsl을 작성했습니다.

 

@RequiredArgsConstructor
public class AccompanyPostCustomRepositoryImpl implements AccompanyPostCustomRepository {

    private final JPAQueryFactory jpaQueryFactory;
    private final QAccompanyPost accompanyPost = QAccompanyPost.accompanyPost;

    @Override
    public Slice<AccompanyPost> findByAccompanyPostFilterRequest(Long cursorId, int size,
            AccompanyPostFilterRequest accompanyPostFilterRequest) {
        List<AccompanyPost> accompanyPosts = jpaQueryFactory
                .selectFrom(accompanyPost)
                .where(
                        genderEquals(accompanyPostFilterRequest.getGender()),
                        regionEquals(accompanyPostFilterRequest.getRegion()),
                        ageOverlap(accompanyPostFilterRequest.getStartAge(),
                                accompanyPostFilterRequest.getEndAge()),
                        totalPeopleEquals(accompanyPostFilterRequest.getTotalPeople()),
                        concertPlaceEquals(accompanyPostFilterRequest.getConcertPlace()),
                        purposesEquals(accompanyPostFilterRequest.getPurposes()),
                        lessThanCursorId(cursorId),
                        accompanyPost.isActivated.eq(true)
                ).orderBy(accompanyPost.id.desc()).limit(size).fetch();
        boolean hasNext = false;
        if (!accompanyPosts.isEmpty()) {
            Long lastIdInResult = accompanyPosts.get(accompanyPosts.size() - 1).getId();
            hasNext = isExistByIdLessThanOfAccompanyPostFilterRequest(lastIdInResult,
                    accompanyPostFilterRequest);
        }

        return new SliceImpl<>(accompanyPosts, Pageable.ofSize(size), hasNext);
    }

    private BooleanExpression genderEquals(String gender) {
        return gender != null ? accompanyPost.gender.eq(gender) : null;
    }

    private BooleanExpression regionEquals(String region) {
        return region != null ? accompanyPost.region.eq(AccompanyRegionType.getValue(region))
                : null;
    }

    private BooleanExpression ageOverlap(Long startAge, Long endAge) {
        if (startAge != null && endAge != null) {
            return accompanyPost.endAge.goe(startAge)
                    .and(accompanyPost.startAge.loe(endAge));
        }
        return null;
    }

    private BooleanExpression totalPeopleEquals(Long totalPeople) {
        return totalPeople != null ? accompanyPost.totalPeople.eq(totalPeople) : null;
    }

    private BooleanExpression concertPlaceEquals(String concertPlace) {
        return concertPlace != null ? accompanyPost.concert.place.eq(concertPlace) : null;
    }

    private BooleanExpression purposesEquals(List<String> purposes) {
        return purposes != null ? accompanyPost.purposes.any().in(
                purposes.stream()
                        .map(AccompanyPurposeType::getValue)
                        .toList()) : null;
    }

    private BooleanExpression lessThanCursorId(Long cursorId) {
        return cursorId != null ? accompanyPost.id.lt(cursorId) : null;
    }


    private boolean isExistByIdLessThanOfAccompanyPostFilterRequest(Long id,
            AccompanyPostFilterRequest accompanyPostFilterRequest) {
        return jpaQueryFactory.selectFrom(accompanyPost)
                .where(
                        genderEquals(accompanyPostFilterRequest.getGender()),
                        regionEquals(accompanyPostFilterRequest.getRegion()),
                        ageOverlap(accompanyPostFilterRequest.getStartAge(),
                                accompanyPostFilterRequest.getEndAge()),
                        totalPeopleEquals(accompanyPostFilterRequest.getTotalPeople()),
                        concertPlaceEquals(accompanyPostFilterRequest.getConcertPlace()),
                        purposesEquals(accompanyPostFilterRequest.getPurposes()),
                        accompanyPost.id.lt(id),
                        accompanyPost.isActivated.eq(true))
                .fetchFirst() != null;
    }
}

 

 


테스트 코드 작성

@Import(QueryDslConfig.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class AccompanyPostRepositoryTest {

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private AccompanyPostRepository accompanyPostRepository;

    @Autowired
    private ConcertRepository concertRepository;

    @BeforeEach
    void setUp() {
        accompanyPostRepository.deleteAll();
        memberRepository.deleteAll();
    }

    @AfterEach
    void tearDown() {
        accompanyPostRepository.deleteAll();
        memberRepository.deleteAll();
    }

    @Test
    @DisplayName("주어진 게시글 id 이후에 생성된 특정 개수의 동행 구인 게시글을 검색 필터 기반으로 조회할 수 있다.")
    void success_findByAccompanyPostFilterRequest() {
        // given
        Member member = Member.builder()
                .profileImage("image.png")
                .provider("kakao")
                .providerId("alsjkghlaskdjgh")
                .build();
        memberRepository.save(member);
        Concert concert = concertRepository.save(ConcertDataFactory.createConcert());
        AccompanyPostFilterRequest accompanyPostFilterRequest1 = AccompanyPostFilterRequest.builder()
                .gender("남")
                .region("수도권(경기, 인천 포함)")
                .startAge(13L)
                .endAge(17L)
                .totalPeople(1L)
                .concertPlace(concert.getPlace())
                .purposes(Arrays.asList("관람", "숙박", "이동"))
                .build();
        AccompanyPostFilterRequest accompanyPostFilterRequest2 = AccompanyPostFilterRequest.builder()
                .gender("여")
                .region("경상북도/경상남도")
                .startAge(13L)
                .endAge(17L)
                .totalPeople(1L)
                .concertPlace(concert.getPlace())
                .purposes(Arrays.asList("관람", "숙박"))
                .build();
        accompanyPostRepository.saveAll(
                createAccompanyPosts(member, 30, accompanyPostFilterRequest1, concert));
        accompanyPostRepository.saveAll(
                createAccompanyPosts(member, 30, accompanyPostFilterRequest2, concert));
        Long cursorId = 1000000000L;
        int size = 10;

        // when
        Slice<AccompanyPost> accompanyPostSlice = accompanyPostRepository.findByAccompanyPostFilterRequest(
                cursorId, size, accompanyPostFilterRequest1);

        // then
        assertThat(accompanyPostSlice.getContent().size(), equalTo(size));
        assertThat(
                accompanyPostSlice.getContent().stream()
                        .map(accompanyPost -> isAccompanyPostEqualsAccompanyPostFilterRequest(
                                accompanyPost, accompanyPostFilterRequest1))
                        .toList(), everyItem(equalTo(true)));
    }

    @Test
    @DisplayName("주어진 게시글 id 이후에 생성된 특정 개수의 동행 구인 게시글을 검색 필터 기반으로 조회할 수 있다. - 동행 목적 테스트")
    void success_findByAccompanyPostFilterRequest_given_purposes() {
        // given
        Member member = Member.builder()
                .profileImage("image.png")
                .provider("kakao")
                .providerId("alsjkghlaskdjgh")
                .build();
        memberRepository.save(member);
        AccompanyPostFilterRequest accompanyPostFilterRequest1 = AccompanyPostFilterRequest.builder()
                .gender("남")
                .region("수도권(경기, 인천 포함)")
                .startAge(13L)
                .endAge(17L)
                .totalPeople(1L)
                .concertPlace("KSPO DOME")
                .purposes(Arrays.asList("관람", "숙박"))
                .build();
        AccompanyPostFilterRequest accompanyPostFilterRequest2 = AccompanyPostFilterRequest.builder()
                .gender("여")
                .region("경상북도/경상남도")
                .startAge(13L)
                .endAge(17L)
                .totalPeople(1L)
                .concertPlace("KSPO DOME")
                .purposes(List.of("관람"))
                .build();
        AccompanyPostFilterRequest accompanyPostFilterRequest3 = AccompanyPostFilterRequest.builder()
                .purposes(List.of("관람"))
                .build();
        Concert concert = concertRepository.save(ConcertDataFactory.createConcert());
        accompanyPostRepository.saveAll(
                createAccompanyPosts(member, 3, accompanyPostFilterRequest1, concert));
        accompanyPostRepository.saveAll(
                createAccompanyPosts(member, 3, accompanyPostFilterRequest2, concert));
        Long cursorId = 1000000000L;
        int size = 10;

        // when
        Slice<AccompanyPost> accompanyPostSlice = accompanyPostRepository.findByAccompanyPostFilterRequest(
                cursorId, size, accompanyPostFilterRequest3);

        // then
        assertThat(accompanyPostSlice.getContent().size(), equalTo(6));
        assertThat(
                accompanyPostSlice.getContent().stream()
                        .map(accompanyPost -> isAccompanyPostEqualsAccompanyPostFilterRequest(
                                accompanyPost, accompanyPostFilterRequest3))
                        .toList(), everyItem(equalTo(true)));
    }

    @Test
    @DisplayName("주어진 게시글 id 이후에 생성된 특정 개수의 동행 구인 게시글을 검색 필터 기반으로 조회할 수 있다. - 나이 범위 테스트")
    void success_findByAccompanyPostFilterRequest_given_ages() {
        // given
        Member member = Member.builder()
                .profileImage("image.png")
                .provider("kakao")
                .providerId("alsjkghlaskdjgh")
                .build();
        memberRepository.save(member);
        AccompanyPostFilterRequest accompanyPostFilterRequest1 = AccompanyPostFilterRequest.builder()
                .gender("남")
                .region("수도권(경기, 인천 포함)")
                .startAge(13L)
                .endAge(17L)
                .totalPeople(1L)
                .concertPlace("KSPO DOME")
                .purposes(Arrays.asList("관람", "숙박"))
                .build();
        AccompanyPostFilterRequest accompanyPostFilterRequest2 = AccompanyPostFilterRequest.builder()
                .gender("여")
                .region("경상북도/경상남도")
                .startAge(13L)
                .endAge(17L)
                .totalPeople(1L)
                .concertPlace("KSPO DOME")
                .purposes(List.of("관람"))
                .build();
        AccompanyPostFilterRequest accompanyPostFilterRequest3 = AccompanyPostFilterRequest.builder()
                .startAge(11L)
                .endAge(13L)
                .purposes(List.of("관람"))
                .build();
        Concert concert = concertRepository.save(ConcertDataFactory.createConcert());
        accompanyPostRepository.saveAll(
                createAccompanyPosts(member, 3, accompanyPostFilterRequest1, concert));
        accompanyPostRepository.saveAll(
                createAccompanyPosts(member, 3, accompanyPostFilterRequest2, concert));
        Long cursorId = 1000000000L;
        int size = 10;

        // when
        Slice<AccompanyPost> accompanyPostSlice = accompanyPostRepository.findByAccompanyPostFilterRequest(
                cursorId, size, accompanyPostFilterRequest3);

        // then
        assertThat(accompanyPostSlice.getContent().size(), equalTo(6));
        assertThat(
                accompanyPostSlice.getContent().stream()
                        .map(accompanyPost -> isAccompanyPostEqualsAccompanyPostFilterRequest(
                                accompanyPost, accompanyPostFilterRequest3))
                        .toList(), everyItem(equalTo(true)));
    }

    private boolean isAccompanyPostEqualsAccompanyPostFilterRequest(AccompanyPost accompanyPost,
            AccompanyPostFilterRequest accompanyPostFilterRequest) {
        return ((accompanyPostFilterRequest.getGender() == null || accompanyPost.getGender()
                .equals(accompanyPostFilterRequest.getGender())) &&
                (accompanyPostFilterRequest.getRegion() == null || accompanyPost.getRegion()
                        .getName().equals(accompanyPostFilterRequest.getRegion())) &&
                ((accompanyPostFilterRequest.getStartAge() == null
                        && accompanyPostFilterRequest.getEndAge() == null)
                        ||
                        (accompanyPost.getEndAge() >= accompanyPostFilterRequest.getStartAge() && (
                                accompanyPost.getStartAge()
                                        <= (accompanyPostFilterRequest.getEndAge()))) &&
                                (accompanyPostFilterRequest.getTotalPeople() == null
                                        || accompanyPost.getTotalPeople()
                                        .equals(accompanyPostFilterRequest.getTotalPeople())) &&
                                (accompanyPostFilterRequest.getConcertPlace() == null
                                        || accompanyPost.getConcert().getPlace()
                                        .equals(accompanyPostFilterRequest.getConcertPlace())) &&
                                (accompanyPostFilterRequest.getPurposes() == null
                                        || accompanyPostFilterRequest.getPurposes().isEmpty() ||
                                        accompanyPost.getPurposes().containsAll(
                                                accompanyPostFilterRequest.getPurposes().stream()
                                                        .map(AccompanyPurposeType::getValue)
                                                        .toList())))
        );
    }
}

 

 이전에 적용한 커서 페이징에 검색 필터링을 위해 QueryDsl 적용하며 동행 구인글 검색 기능 구현 과정을 남겨보았습니다. QueryDsl을 통해 검색 조건이 많은 쿼리문의 where 조건들을 자바 코드로 함수화하여 작성할 수 있어 편리했습니다쿼리 관련 오류들을 컴파일 타임에 잡을 수 있다는 점에서 복잡한 쿼리 문 작성시에 활용하기에도 좋을 것 같습니다.