BackEnd/JPA

[JPA] 실무 활용 - Spring Data JPA와 Querydsl

샤아이인 2022. 5. 15.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

이번 시간에는 이전에 순수 JPA로 만들었던 repository들을 Spring Data JPA를 통해 만들어 보자.

 

1. Spring Data JPA 리포지토리로 변경

▶ Spring Data JPA - MemberRepository 코드 작성

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsername(String username);
}

기존의 순수 JPA를 활용한 MemberJpaRepository 와 비교해볼때, 상당히 많은 기본 CRUD코드가 제거된것을 확인할 수 있다.

 

▶ 테스트 코드 작성

@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired EntityManager em;
    @Autowired MemberRepository memberRepository;

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

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

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

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

테스트가 정상적으로 통과하는 것 을 확인할 수 있다.

 

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

Spring Data JPA는 interface로 동작하기 때문에 내가 원하는 QueryDsl 구현 코드를 추가하려면 사용자 정의 Repository를 만들어 줘야 한다.

 

  1. 사용자 정의 인터페이스 작성
  2. 사용자 정의 인터페이스 구현
  3. 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속

 

출처 - 인프런 김영한 QueryDsl

다만 위 그림과 조금 다르게,

MemberRepositoryCustom -> MemberCustomRepository 로 이름 변경

MemberRepositoryImpl -> MemberCustomRepositoryImpl 로 이름 변경

 

2-1) 사용자 정의 인터페이스 작성

우선 내가 원하는 기능을 명세한 사용자 정의 interface를 다음과 같이 만들자.

public interface MemberCustomRepository {
    List<MemberTeamDto> search(MemberSearchCondition condition);
}

 

2-2) 사용자 정의 인터페이스 구현

public class MemberCustomRepositoryImpl implements MemberCustomRepository{

    private final JPAQueryFactory queryFactory;

    public MemberCustomRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                                member.id,
                                member.username,
                                member.age,
                                team.id,
                                team.name
                        )
                )
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .fetch();
    }

    private BooleanBuilder usernameEq(String username) {
        if(StringUtils.hasText(username)) {
            return new BooleanBuilder(member.username.eq(username));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder teamNameEq(String teamName) {
        if(StringUtils.hasText(teamName)) {
            return new BooleanBuilder(team.name.eq(teamName));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder ageGoe(Integer ageGoe) {
        if(ageGoe != null) {
            return new BooleanBuilder(member.age.goe(ageGoe));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder ageLoe(Integer ageLoe) {
        if(ageLoe != null) {
            return new BooleanBuilder(member.age.loe(ageLoe));
        }
        return new BooleanBuilder();
    }
}

 

2-3) Spring Data Repository에 사용자 정의 인터페이스 상속

다음과 같이 MemberCustomRepository를 상속하게 되었다.

public interface MemberRepository extends JpaRepository<Member, Long>, MemberCustomRepository {
    List<Member> findByUsername(String username);
}

 

정상적으로 잘 동작하는지 테스트 코드를 통해서 확인해보자. 지난 번 글에서 사용한 테스트를 재사용할 것 이다.

@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired EntityManager em;
    @Autowired MemberRepository memberRepository;

    @Test
    public void search_builder_test() {
        // given
        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);

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

        MemberSearchCondition searchCondition2 = new MemberSearchCondition();
        searchCondition2.setTeamName("teamB");


        // then
        List<MemberTeamDto> result = memberRepository.search(searchCondition1);
        assertThat(result).extracting("username").containsExactly("member4");

        List<MemberTeamDto> result2 = memberRepository.search(searchCondition2);
        assertThat(result2).extracting("username").containsExactly("member3", "member4");

    }
}

정상적으로 테스트를 통과하게 된다.

 

예전 JPA활용 2편 에서도 설명했듯, API나 화면에 너무 특화되어 있다면 따로 MemberQueryRepository를 만들어서 사용하길 권장한다. 다음 글의 마지막 쳅터를 읽어보자!

https://blogshine.tistory.com/370

 

[JPA] 지연 로딩과 조회 성능 최적화 - 2

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다. 간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화 " data-ke

blogshine.tistory.com

 

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

  • Spring Data JPA의 Page, Pageable을 활용해보자.
  • 전체 카운트를 한번에 조회하는 단순한 방법
  • 데이터 내용과 전체 카운트를 별도로 조회하는 방법

우선 인터페이스에 다음과 같이 page 메서드를 추가해주자.

public interface MemberCustomRepository {
    List<MemberTeamDto> search(MemberSearchCondition condition);
    Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);
    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
}

 

3-1) 전체 카운트를 한번에 조회하는 단순한 방법

searchPageSimple이라는 메서드를 다음과 같이 만들게 되었다.

public class MemberCustomRepositoryImpl implements MemberCustomRepository{

    private final JPAQueryFactory queryFactory;

    public MemberCustomRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }
    // 일부 생략
    
    @Override
    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
        QueryResults<MemberTeamDto> results = queryFactory
                .select(new QMemberTeamDto(
                                member.id,
                                member.username,
                                member.age,
                                team.id,
                                team.name
                        )
                )
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .offset(pageable.getOffset()) // offset, limit 지정해주기
                .limit(pageable.getPageSize())
                .fetchResults();

        List<MemberTeamDto> contents = results.getResults();
        long totalCount = results.getTotal();

        return new PageImpl<>(contents, pageable, totalCount);
    }

    private BooleanBuilder usernameEq(String username) {
        if(StringUtils.hasText(username)) {
            return new BooleanBuilder(member.username.eq(username));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder teamNameEq(String teamName) {
        if(StringUtils.hasText(teamName)) {
            return new BooleanBuilder(team.name.eq(teamName));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder ageGoe(Integer ageGoe) {
        if(ageGoe != null) {
            return new BooleanBuilder(member.age.goe(ageGoe));
        }
        return new BooleanBuilder();
    }

    private BooleanBuilder ageLoe(Integer ageLoe) {
        if(ageLoe != null) {
            return new BooleanBuilder(member.age.loe(ageLoe));
        }
        return new BooleanBuilder();
    }
}

offset 과 limit을 지정해준 후, fetchResults()를 호출해주고 있다. 내부적으로는 count 쿼리또한 나가게 된다.

(참고로 orderBy 절은 count 쿼리를 만들때 자동 제거된다)

 

이를 테스트 하는 코드는 다음과 같다.

@Test
public void search_page_simple_test() {
    // given
    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);

    // when
    MemberSearchCondition searchCondition1 = new MemberSearchCondition();
    PageRequest pageRequest = PageRequest.of(0, 3);

    // then
    Page<MemberTeamDto> result = memberRepository.searchPageSimple(searchCondition1, pageRequest);
    assertThat(result.getSize()).isEqualTo(3);
    assertThat(result.getContent()).extracting("username").containsExactly("member1", "member2", "member3");
}

 

3-2) 데이터 내용과 전체 카운트를 별도로 조회하는 방법

이번에는 searchPageComplex를 구현해 보자!

@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
    List<MemberTeamDto> contents = queryFactory
            .select(new QMemberTeamDto(
                            member.id,
                            member.username,
                            member.age,
                            team.id,
                            team.name
                    )
            )
            .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();

    // count query 자체를 분리
    JPAQuery<Long> countQuery = queryFactory
            .select(Wildcard.count)
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            );

    return PageableExecutionUtils.getPage(contents, pageable, countQuery::fetchOne);
}

이런식으로 count 쿼리를 분리하면 최적화 하기가 쉽다. (현 코드는 최적화를 적용한 코드이다. 이에 대하여 다음 단락에서 알아보자)

 

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

위에서는 contents, count 를 둘가 가져오는 함수를 만들었었다.

 

다음과 같은 경우 count 쿼리를 만들지 않아도 된다.

  • 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
  • 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함)

위와 같은 경우에는 count 쿼리를 만들지 않도록 작성해보자. (사실 이미 위에서 이렇게 작성하기는 했다)

 

return PageableExecutionUtils.getPage(contents, pageable, countQuery::fetchOne);

위 코드가 핵심이다.

 

countQuery.fetchOne()을 호출해야 count query가 추가로 나가게 된다.

하지만 getPage() 메서드 같은 경우, 위에서 말한 count 쿼리를 만들지 않아도 되는 상황에서는 fetchOne()을 호출하지 않는다.

 

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

5-1) Controller API 개발

이번시간에는 방금까지 만든 내용들을 controller를 만들어서 적용시켜보자.

@RestController
@RequiredArgsConstructor
public class MemberController  {

    private final MemberJpaRepository memberJpaRepository;
    private final MemberRepository memberRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMemberV1(@ModelAttribute MemberSearchCondition condition) {
        return memberJpaRepository.searchByWhereParams(condition);
    }

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

    @GetMapping("/v3/members")
    public Page<MemberTeamDto> searchMemberV3(@ModelAttribute MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageComplex(condition, pageable);
    }
}

다음과 같이 API에 요청을 해보자.

http://localhost:8080/v2/members?page=0&size=2

결과는 다음과 같다.

v3 api를 호출해도 동일하게 결과가 나온다.

 

지금 전체 데이터는 100개가 들어있는 상황이다.

이때 페이징 처리로 데이터를 101개 정도 요청하면(데이터는 100건뿐) totalCount 쿼리가 나가지 않는다)

select 쿼리만 나오게 된다.

 

5-2) Spring Data Sorting

스프링 데이터 JPA는 자신의 정렬(Sort)을 Querydsl의 정렬(OrderSpecifier)로 편리하게 변경하는 기능을 제공한다.

이 부분은 뒤에 스프링 데이터 JPA가 제공하는 Querydsl 기능에서 살펴보겠다.

 

스프링 데이터의 정렬을 Querydsl의 정렬로 직접 전환하는 방법은 다음 코드를 참고하자.

JPAQuery<Member> query = queryFactory.selectFrom(member);

for (Sort.Order o : pageable.getSort()) {
    PathBuilder pathBuilder = new PathBuilder(member.getType(), member.getMetadata());
    
    query.orderBy(new OrderSpecifier(o.isAscending() ? Order.ASC : Order.DESC,
            pathBuilder.get(o.getProperty())));
}

List<Member> result = query.fetch();

정렬( Sort )은 조건이 조금만 복잡해져도 Pageable Sort 기능을 사용하기 어렵다.


루트 엔티티 범위를 넘어가는(즉, join을 걸기 시작하면) 동적 정렬 기능이 필요하면 스프링 데이터 페이징이 제공하는 Sort 를 사용하기 보다는 파라미터를 받아서 직접 처리하는 것을 권장한다.

 

댓글