Spring Data JPA 설정
먼저 기존 Repository클래스 말고 Spring Data JPA사용을 위해 인터페이스를 생성한다. 클래스가 아닌 인터페이스이다.
Spring Data JPA Repository로 변경
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsername(String username);
}
Spring Data JPA는 메소드 이름을 사용하여 자동으로 JPQL을 만들어주기 때문에 이전과 다르게 EntityManager를 사용하여 직접 구현할 필요가 없다. findByUsername은 [select m from Member m where m.username = ?]과 같다.
테스트
@SpringBootTest
@Transactional
class MemberRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Test
public void basicTest(){
Member member = new Member("member1",10);
memberRepository.save(member);
Member findMember = memberRepository.findById(member.getId()).get();
Assertions.assertThat(findMember).isEqualTo(member);
List<Member> members = memberRepository.findAll();
Assertions.assertThat(members).containsExactly(member);
List<Member> searchMember = memberRepository.findByUsername("member1");
Assertions.assertThat(searchMember).containsExactly(member);
}
}
기존에 사용했던 테스트를 그대로 들고와서 memberRepository로만 바꿔줬다. 기본적으로 제공해 주는 메서드와 이름을 맞췄기 때문에 문제가 없다.
사용자 정의 Repository
Spring Data JPA를 사용하며 복잡한 구현, 커스터마이징이 필요할 때 사용한다. Querydsl을 사용하려면 구현 코드를 만들어야 하는데 Spring Data JPA는 인터페이스로 동작하기 때문에 원하는 구현 코드를 넣으려면 사용자 정의 Repository라는 조금 복잡한 방법을 사용해야 한다.
사용자 정의 Repository 사용법
- 사용자 정의 인터페이스를 작성한다.
- 사용자 정의 인터페이스를 구현한다.
- Spring Data Repository에 사용자 정의 인터페이스를 상속한다.
1. 사용자 정의 인터페이스 작성하기
public interface MemberRepositoryCustom {
List<MemberTeamDto> search(MemberSearchCondition condition);
}
인터페이스 이름은 상관 없이 작성하면 된다. 내가 직접 구현해서 사용하고 싶은 기능을 작성한다.
그럼 이걸 구현할 구현체가 필요하다
2. 사용자 정의 인터페이스 구현하기
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 StringUtils.hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return StringUtils.hasText(teamName) ? member.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;
}
}
구현체는 인터페이스와 다르게 이름을 작성하는 규칙이 존재한다. Spring Data JPA 인터페이스의 이름 + Impl이다. 그래서 해당 구현 클래스 이름은 MemberRepositoryImpl이다. 주의할 점은 사용자 정의 인터페이스 이름이 아닌 Spring Data JPA 인터페이스의 이름 + Impl이다.
3. Spring Data JPA 인터페이스에 사용자 정의 인터페이스를 상속하기
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
List<Member> findByUsername(String username);
}
인터페이스는 여러개 상속받을 수 있고 MemberRepositoryCustom을 상속할 수 있다. 그럼 런타임에 해당 구현체에 의존성을 가져서 실제 기능을 사용할 수 있다.
테스트
@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();
Assertions.assertThat(findMember).isEqualTo(member);
List<Member> members = memberRepository.findAll();
Assertions.assertThat(members).containsExactly(member);
List<Member> searchMember = memberRepository.findByUsername("member1");
Assertions.assertThat(searchMember).containsExactly(member);
}
@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(20);
condition.setAgeLoe(40);
condition.setTeamName("teamB");
// List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);
List<MemberTeamDto> result = memberRepository.search(condition);
Assertions.assertThat(result).extracting("username").containsExactly("member3","member4");
}
}
이전에 사용했던 테스트를 그대로 가져와서 인터페이스와 메서드만 바꿔주었고 테스트를 통과하였다.
주의할 점
무조건 위와 같이 쓰는 것 보다는 상황에 따라서 자신에게 맞게 사용하는 것이 좋다. 만약 모든 기능들을 저렇게 만들다 보면 복잡한 쿼리를 만들어야 하는 상황이 올 텐데 그럴 땐 아래와 같이 별도의 클래스로 만들어서 해당 기능만 특화되게 만들어 사용할 수도 있다. 재사용성이 없고 하나의 API만을 위해 존재한다면 별도의 Repository를 만들어 분리하는 것도 하나의 방법이다. 설계의 관점이기 때문에 기본적으로는 커스텀 인터페이스를 사용하고 아키텍처 관점에서 각자의 상황에 따라 분리하는 것도 괜찮은 방법이다!
@Repository
public class MemberQueryRepository {
private final JPAQueryFactory queryFactory;
public MemberQueryRepository(EntityManager em){
this.queryFactory = new JPAQueryFactory(em);
}
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();
}
...
}
Spring Data JPA Querydsl 페이징
페이징 쿼리 만들기
@Override
public Page<MemberTeamDto> searchPageSimple(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 = results.size();
return new PageImpl<>(results,pageable,total);
}
Spring Data의 Pageable은 기본적으로 offset, 전체 페이지 수를 알 수 있다.
이때. org.springframework.data.domain.Pageable 을 import 해야 한다.
offset은 몇 번 데이터부터 시작할지를 지정하는 것이고
limit은 한 번 조회 시 몇 개를 가져올 것인지를 지정하는 것이다.
PageImpl은 org.springframework.data.domain.Page의 구현체이다.
테스트를 해보면
@Override
public Page<MemberTeamDto> searchPage(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
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.fetchOne();
return new PageImpl<>(results,pageable,total);
}
0번 데이터부터 3개를 가져오니 사이즈는 3이어야 하고 데이터는 member1~member3까지 순서대로 나와야 한다.
결과를 보니 페이징이 잘 된 것 같다!
그리고 예전에는 fetchResult() fetchCount()를 사용했는데 복잡한 쿼리에서 이슈가 있어 deprecated 되었다. 그래서 위와 같이 직접 count 메서드를 작성해서 호출해야 한다. PageImpl을 리턴하면 아래의 최적화 결과와 같이 예쁘게 잘 만들어서 응답을 해준다. 하지만 Count쿼리는 비용이 많이 발생하니 최적화를 해주는 것이 좋으니 아래에서 소개하는 방법을 권장한다.
Count 쿼리 최적화 하기
페이징 기능을 사용할 때 페이지가 시작이면서 전체 데이터 사이즈가 요청한 사이즈보다 작을 때나 마지막 페이지인 경우 굳이 count 쿼리를 날릴 필요가 없다. 이때 사용하는 것이 PageableExecutionUtils.getPage() 메서드이다.
JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
);
return PageableExecutionUtils.getPage(results,pageable, () ->countQuery.fetchOne());
count 쿼리를 fetchOne()으로 실행하지 않고 쿼리 그 자체로만 가지고 있으면 쿼리가 날아가지 않는다. fetch를 해야 쿼리가 날아가기 때문에 이렇게 사용한다. 이후 PageableExecutionUtils.getPage에서 람다식을 이용하여 getPage가 호출될 때 계산하여 파라미터로 값을 넘겨준다.
이 getPage() 메서드는 results와 pageable의 전체 사이즈를 보고 전체 데이터 사이즈가 작거나 마지막 페이지인 경우 뒤의 함수를 호출하지 않는다.
내부를 보니 사이즈와 오프셋 등을 보고 위에서 언급한 경우들이 아닐 때만 마지막으로 리턴해주는 것을 볼 수 있다.
잘 생각해 보면 한 페이지당 100개씩 받기를 원하는데 요청을 보내니 전체 데이터가 5개라면 굳이 쿼리를 날릴 필요가 없다. 마찬가지로 마지막 페이지 요청도 데이터를 꽉 눌러 담아도 최대 100개일 것이니 굳이 카운터 쿼리를 날릴 필요가 없다. 그런 경우들을 자체적으로 제거해 주는 기능을 스프링에서 제공해 주는 것이다.
API로 확인하기
이제 컨트롤러에 붙여서 테스트를 해보겠다.
@GetMapping("/v2/members")
public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable){
return memberRepository.searchPage(condition,pageable);
}
Pageable은 @RequestParam, @PathVariable로 각각 데이터를 받는 것이 아니라 Pageable로 바로 받는다. 여기서 주의할 점은 해당 데이터는 @RequestParam으로 받을 수 있는 형식으로 요청을 보내야 한다. 즉 http://localhost:8080/v2/members?page=0&size=5 이런 형태의 요청을 보내야만 한다. 그럼 pageable.getgetOffset()과 pageable.getPageSize()를 얻을 수 있다.
그럼 응답을 위한 DTO를 별도로 만들지 않아도 페이지 관련된 정보는 자체적으로 만들어서 응답이 된다. 페이지 번호와 각 페이지의 사이즈별로 값들이 바뀌는 것을 볼 수 있다. 여기서 또 주의할 점은 page번호는 0부터 시작인 것이다.
실제로 쿼리가 안 나가는지도 확인해 보려면 현재 100개의 데이터가 있으니 page=0&size=200으로 요청을 보내면 될 것이다.
카운트 관련 쿼리 없이 조회 쿼리만 날아간 것을 확인할 수 있다.
'Java > Querydsl' 카테고리의 다른 글
[Querydsl] 순수 JPA Repository + Querydsl (0) | 2023.07.09 |
---|---|
[Querydsl] 프로젝션, 동적 쿼리, 배치 쿼리(벌크 연산) (0) | 2023.07.08 |
[Querydsl] Querydsl 기본문법 (0) | 2023.07.08 |
[Querydsl] Querdydsl 설정하기 (0) | 2023.07.03 |