[인강노트 - querydsl] 7. 실무 활용 -스프링 데이터 JPA와 Querydsl

봄도둑·2023년 1월 4일
0

김영한님의 실전! querydsl 강의 내용을 정리한 노트입니다. 블로그에 있는 자료를 사용하실 때에는 꼭 김영한님 강의 링크를 남겨주세요!

1. 스프링 데이터 JPA 리포지토리로 변경

  • 엄청 쉬움
  • save, findAll, findById는 이미 있기 때문에 만들 필요가 없음
  • 가벼운 정적 쿼리의 경우 인터페이스만 만들면 spring data jpa가 구현체를 만들어서 다 꽂아줌 → 우리는 인젝션만 받아서 쓰기만 하면 됨
public interface MemberRepository extends JpaRepository<Member, Long> {
    //spring data jpa의 경우 save, findById, findAll을 제공함 -> 즉 만들 필요가 없음

    //그러나 findByUsername은 제공하지 않음 -> 아래 처럼 만들면 됨, 메소드 이름으로 추측해서 알아서 쿼리를 쏴줌
    List<Member> findByUsername(String username);
}
  • 문제는 동적 쿼리를 사용할 때 쓰는 사용자 정의 리포지토리 → 다음 강의에서 손 볼 예정

2. 사용자 정의 리포지토리

  • 복잡한 구현이 필요하거나 사용자 정의가 필요한 경우 → 내가 원하는 구현 코드를 넣으려면 사용자 정의 리포지토리라는 복잡한 사용법을 사용해야 함
  • 사용자 정의 리포지토리 사용법
    1. 사용자 정의 인터페이스 작성
    2. 사용자 정의 인터페이스 구현
    3. 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속
  • 먼저 사용자 정의 인터페이스를 만들자 → 여기에는 우리가 정의할 메소드를 만들어서 넣어줌
public interface MemberRepositoryCustom {
    List<MemberTeamDto> search(MemberSearchCondition condition);
}
  • 그 다음, 저 search를 구현한 녀석을 만들어야 함(클래스)
    • 이 녀석을 만들 때 사용자 정의 인터페이스 이름(MemberRepositoryCustom) 뒤에 Impl을 꼭 붙여줘야 함(MemberRepositoryImpl)
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public MemberRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }
    
    @Override
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        team.id.as("teamId"),
                        team.name.as("teamName")
                ))
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .fetch();
    }

    private BooleanExpression usernameEq(String username) {
        return hasText(username) ? member.username.eq(username) : null;
    }

    private BooleanExpression teamNameEq(String teamName) {
        return hasText(teamName) ? team.name.eq(teamName) : null;
    }

    private BooleanExpression ageGoe(Integer ageGoe) {
        return ageGoe != null ? member.age.goe(ageGoe) : null;
    }

    private BooleanExpression ageLoe(Integer ageLoe) {
        return ageLoe != null ? member.age.loe(ageLoe) : null;
    }
}
  • MemberRepository에 MemberRepositoryCustom을 상속 받도록 처리(인터페이스는 여러 개 상속 받을 수 있으니까)
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    List<Member> findByUsername(String username);
}
  • 그럼 이제 만들어진 search가 동작하는지 테스트를 해보자
@SpringBootTest
@Transactional
class MemberRepositoryTest {
    @Autowired
    EntityManager em;

    @Autowired
    MemberRepository memberRepository;

    @Test
    public void basicTest() {
        Member member1 = new Member("member1", 10);
        memberRepository.save(member1);

        Member findMember = memberRepository.findById(member1.getId()).get();
        assertThat(findMember).isEqualTo(member1);

        List<Member> result1 = memberRepository.findAll();
        assertThat(result1).containsExactly(member1);

        List<Member> result2 = memberRepository.findByUsername("member1");
        assertThat(result2).containsExactly(member1);
    }

    @Test
    public void searchTest() {
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);

        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);

        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        //
        MemberSearchCondition condition = new MemberSearchCondition();
        condition.setAgeGoe(35);
        condition.setAgeLoe(40);
        condition.setTeamName("teamB");

        List<MemberTeamDto> result = memberRepository.search(condition);

        for (MemberTeamDto memberTeamDto : result) {
            System.out.println("memberTeamDto : " + memberTeamDto);
        }
    }
}
  • querydsl을 쓸 경우 어쩔 수 없이 사용자 정의 리포지토리를 써야 함 → 동작에 대한 구체적인 명세 코드를 인터페이스에 작성할 수 없으니까
  • 너무 조회가 복잡하다면 설계에 대해 다시 고민할 필요가 있음 → 재사용성보다는 특정 기능을 수행하기 위해 사용하는 조회 쿼리일 경우 굳이 spring data jpa를 쓰는 게 아니라 구현체를 직접 만들어서 쓰는 것에 대해 고민할 필요가 있음(특정 api, 특정 기능에 너무 특화된 경우) ⇒ 그러니까 기존에 JPAQueryRepository 썼을 경우 → 모든 걸 custom에 때려 박는 것도 좋은 설계는 아님
  • 핵심 비즈니스 로직으로 재사용 가능성이 있는 것들 혹은 엔티티를 검색할 경우 일반적인 repository에 넣어놓고 쓰고, 공용성이 없고 특정 기능에 종속되어 있는 경우 별도로 조회용 repository를 쓰는 게 좋을 수 있음

3. 스프링 데이터 페이징 활용1 - Querydsl 페이징 연동

  • spring data jpa에서 제공하는 페이징 기능과 querydsl을 조합해보자!
  • Page, Pageeable을 사용할 것
  • 전체 카운트를 한번에 조회하는 방법
  • 데이터의 내용과 전체 카운트를 별도로 조회하는 방법
  • 바로 코드로 보자!
  • 먼저 커스텀 레포지토리 인터페이스에 searchPageSimple과 searchPageComplex를 추가하고 파라미터로 Pageable(data.domain)을 넘겨주자(반환 타입은 Page)
public interface MemberRepositoryCustom {
    List<MemberTeamDto> search(MemberSearchCondition condition);
    Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);
    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
}
  • searchPageSimple을 구현해보자
public class MemberRepositoryImpl implements MemberRepositoryCustom {
		//..

    @Override
    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
        QueryResults<MemberTeamDto> results = queryFactory
                .select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        team.id.as("teamId"),
                        team.name.as("teamName")
                ))
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetchResults();

        List<MemberTeamDto> content = results.getResults();
        long total = results.getTotal();

        return new PageImpl<>(content, pageable, total);
    }

    //..
}
  • offset과 limit를 추가 해주는데 여기에 Pageable에서 가져온 getOffset과 getPageSize를 추가하면 됨
  • FetchResult를 쏴주는 것 보다 fetch로 받은 데이터의 수를 자바 레벨에서 카운트를 해주자
  • 테스트를 한 번 해보자
@Test
public void searchPageSimpleTest() {
    //data 넣는 로직 생략

    MemberSearchCondition condition = new MemberSearchCondition();

    PageRequest pageRequest = PageRequest.of(0, 3);

    Page<MemberTeamDto> result = memberRepository.searchPageSimple(condition, pageRequest);

    assertThat(result.getSize()).isEqualTo(3);
    assertThat(result.getContent()).extracting("username").containsExactly("member1", "member2", "member3");
}
  • fetchResult에 의해 쿼리가 두 방 나감
  • 참고로 fetchResults의 경우 orderBy가 들어가 있다면 알아서 지워져서 나감
  • 데이터의 내용과 전체 카운트를 별도로 조회하는 searchPageComplex를 만들어보자
public class MemberRepositoryImpl implements MemberRepositoryCustom {
		//..

		@Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        //특정 조건에 걸리는 데이터와 전체 데이터 수를 조회할 때
        List<MemberTeamDto> results = queryFactory
                .select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        team.id.as("teamId"),
                        team.name.as("teamName")
                ))
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        long total = queryFactory
                .selectFrom(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .fetchCount();
        
        return new PageImpl<>(results, pageable, total);
    }    

    //..
}
  • 그런데 이렇게 쿼리를 나눠서 쏘는 이유는 뭐야??? → 어떤 경우에서 데이터를 조회하는 쿼리는 복잡한데 count 쿼리는 쉽게 작성해서 나갈 수 있는 경우가 있음. fetchResults의 경우 count 쿼리를 최적화 할 수 없음
  • 전체 카운트를 최적화 하기 위해선 따로 날려주는 것이 좋은 경우가 있음 → 단, 데이터의 규모가 클 때
  • 그리고 이렇게 로직이 길어지는 경우 리펙토링을 통해 가독성을 올릴 수 도 있음(리펙토링 단축키 : ctrl + alt + m )
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
    //특정 조건에 걸리는 데이터와 전체 데이터 수를 조회할 때
    List<MemberTeamDto> results = getMemberTeamDtos(condition, pageable);

    long total = getTotal(condition);

    return new PageImpl<>(results, pageable, total);
}

4. 스프링 데이터 페이징 활용2 - CountQuery 최적화

  • 때에 따라서는 count 쿼리를 생략할 수 있음
    • 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
    • 마지막 페이지 일 때(offset + 컨텐츠 사이즈를 더하면 전체 사이즈를 구할 수 있음)
  • 일단 코드를 먼저 보자!
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
    //특정 조건에 걸리는 데이터와 전체 데이터 수를 조회할 때
    List<MemberTeamDto> results = queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")
            ))
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    JPAQuery<Member> countQuery = queryFactory
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            );

    return PageableExecutionUtils.getPage(results, pageable, () -> countQuery.fetchCount());
}
  • PageableExecutionUtils.getPage에 마지막 파라미터로 익명함수로 fetchCount()를 쏴주면 됨

  • 그럼 이 getPage가 어떻게 생겼는지 한 번 보자!

    • 특정 조건(붉은 색 표시)일 경우 totalSupplier.getAsLong()을 하지 않고 자체적으로 content.size()를 리턴해주거나 getOffset + content.size()를 처리함(totalSupplier.getAsLong()은 우리가 익명으로 넘겨준 함수의 실행값, 즉 fetchCount()를 가리킴)
    • 이에 해당하는 조건이 바로 페이지 시작(getOffset == 0)이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 경우(pageable.getPageSize() > content.size())
    • 마지막 페이지(content.size() ≠0 && pageable.getPageSize() > content.size()) 인 경우 count 쿼리가 별도로 나가지 않고 내부 자바 로직에 의해 바로 count 수가 리턴되게 됨

5. 스프링 데이터 페이징 활용3 -컨트롤러 개발

  • controller 코드를 살펴 보자
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("v2/members")
    public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageSimple(condition, pageable);
    }

    @GetMapping("v3/members")
    public Page<MemberTeamDto> searchMemberV3(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageComplex(condition, pageable);
    }
}
  • 기본적으로 spring data가 Pageable 인터페이스를 넘기면 컨트롤러가 바인딩 될 때 데이터를 다 바인딩해서 넘겨줌 → 우리는 그걸 받아서 그대로 Page로 return 해주기만 하면 됨

  • 이제 api 호출해보고 결과를 살펴보자! → localhost:8080/v2/members?page=0&size=5 → 참고로 0 index이기 때문에 페이지 시작은 0페이지부터 시작, 지금 호출한 건 첫 페이지를 가리킴

  • 아까 위 예제에서 만든 searchPageComplex를 호출해서 실제로 count 쿼리가 나가는지 안 나가는지 알아보자 → localhost:8080/v3/members?page=0&size=110

    • 전체 데이터가 100개인데 110개의 데이터를 가져와달라고 함 → 이미 전체 데이터를 가져왔기 때문에 굳이 count 쿼리를 날려줄 필요가 없음
  • spring data를 사용하면 정렬 조건을 넣어줄 수 있음 → 그런데 springa data의 sort를 querydsl로 맞춰 쓰기가 굉장히 어려움. 그래서 spring data의 sort를 querydsl의 OrderSpecifier로 변환해서 사용하긴 해야함. 그런데 join 같은 복잡한 쿼리의 경우 OrderSpecifier가 의도한 방향으로 동작하지 않을 수 있음. 그렇기 때문에 sort를 파라미터로 받아서 직접 처리하는 것이 나을 수 있음.

profile
Java Spring 백엔드 개발자입니다. java 외에도 다양하고 흥미로운 언어와 프레임워크를 학습하는 것을 좋아합니다.

0개의 댓글