Spring Data JPA의 가장 큰 장점은 간편함입니다.
기본적인 CRUD 메서드, 쿼리 메서드를 사용해서 엔티티의 필드와 연관된 데이터를 쉽게 가져올 수 있습니다.
기본적인 CRUD 메서드는 굳이 Repository에서 정의를 하지 않아도 서비스 단에서 사용할 수 있는 메서드이고 대표적으로 save() 메서드가 있습니다.
쿼리 메서드는 Repository에서 간단한 네이밍 룰을 사용해서 메서드를 작성하여 특정 조건에 해당하는 쿼리를 실행할 수 있습니다.
기본적인 CRUD 메서드 예시
BoardService.class
boardRepository.save(board);
Board board = boardRepository.findById(id).orElseThrow();
쿼리 메서드 예시
PostRespository.interface
List<Post> findByBoardName(String boardName);
Long countPostsByBoardId(long BoardId);
이러한 이점에도 불구하고, 복잡한 조건의 데이터를 가져오기 위해서는 필연적으로 JPQL을 사용해야 합니다.
JPQL은 SQL을 추상화하여 엔티티 객체를 조회하는 객체지향 쿼리입니다.
회원 엔티티에서 회원이름이 일치하는 엔티티를 조회하는 JPQL을 사용해보겠습니다.
String jpql = "select m from Member as m where m.username = 'kim'";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();
하지만 JPQL은 단점이 존재합니다. 복잡해질수록 코드의 길이가 길어진다는 점입니다. 또한, JPQL 문자열의 오타나 문법 오류를 컴파일 시점에 확인할 수 없는 경우가 발생할 수 있습니다.
이 문제를 해결하기 위해 자바에는 Criteria라는 API가 존재합니다. Criteria는 JPQL을 자바 코드로 작성하도록 도와주는 빌더 클래스입니다.
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);
Root<Member> m = query.from(Member.calss);
// 쿼리 생성하기
CriteriaQuery<Member> cq = query.select(m).where(cb.equal("username"),"kim"));
// 쿼리에 해당하는 데이터 가져오기
List<Member> resultList = em.createQuery(cq).getResultList();
확실히 JPQL에 비해 체계가 있고 조금 더 정형화된 느낌이 있습니다.
Criteria의 장점은 문법 오류를 컴파일 단계에서 확인할 수 있고 문자 기반의 JPQL 보다 동적 쿼리를 안전하게 생성할 수 있습니다.
하지만, 코드가 직관적이지 못하다는 단점을 가지고 있습니다. 너무 장황하고 코드가 한눈에 들어오지 않습니다.. ㅠ ㅠ
QueryDSL도 Criteria 처럼 JPQL 빌더 역할을 합니다. QueryDSL의 장점은 코드 기반에 직관적이라는 점입니다.
QueryDSL은 프로젝트의 @Entity 어노테이션을 선언한 클래스를 탐색하여 Q클래스를 생성합니다.
Q클래스를 사용하면서 가질 수 있는 장점은 쿼리를 Type-Safe하게 작성할 수 있습니다.
Repository에서 JPQL과 QueryDSL의 코드의 차이에 대해서 알아보겠습니다.
JPQL
@Query("select p from Post p join fetch p.comments");
List<Post> findAllInnerFetchJoin();
@Query("select distinct p from Post p join fetch p.comments")
List<Post> findAllInnerFetchJoinWithDistinct();
QueryDSL
QueryDSL을 Repository에서 사용할 때는 보통 CustomRepository와 CustomRepositoryImpl 클래스를 같이 생성해줍니다.
CustomRespository.java
// 메소드를 커스텀 인터페이스에 정의
public interface PostCustomRepository{
List<Post> findAllInnerFetchJoin();
List<Post> findAllInnerFetchJoinWithDistinct();
}
CustomRepositoryImpl.java
@Repository
public class PostCustomRepositoryImpl implements PostCustomRepository{
@Autowired
private final JPAQueryFactory jpaQueryFactory;
QPost qPost = QPost.post;
@Override
public List<Post> findAllInnerFetchJoin(){
return jpaQueryFactory.selectFrom(qPost)
.innerJoin(qPost.comments)
.fetchJoin()
.fetch();
}
@Override
public List<Post> findAllInnerFetchJoinWithDistinct(){
return jpaQueryFactory.selectFrom(qPost)
.distinct()
.innerJoin(qPost.comments)
.fetchJoin()
.fetch();
}
}
커스텀 인터페이스를 구현하는 클래스에 QueryDSL을 작성합니다.
Q클래스인 QPost를 사용해서 타입 안전성을 확보할 수 있습니다.
또한 메서드의 이름이 직관적이기 때문에 어떤 쿼리를 실행시킬지 한눈에 보기 쉽습니다.
그러면 본격적으로 QueryDSL을 사용해보겠습니다.
기본적인 CRUD 기능은 Spring Data JPA의 쿼리 메소드로 구현이 가능하기 때문에 조금 복잡한 쿼리 위주로 사용했습니다.
- QueryDSL을 사용해서 Paging 사용하기
@Override
public Page<Post> findAllWithBoardId(Pageable pageable, Long id) {
List<Post> postList = queryFactory
.select(qPost)
.from(qPost)
.where(qPost.board.id.eq(id)) // 조건문 id 일치 여부 확인
.offset(pageable.getOffset()) // pageable 시작 인덱스를 지정
.limit(pageable.getPageSize()) // pageable의 PageSize 만큼 limit
.fetch(); // 컬렉션 반환
return new PageImpl<>(postList, pageable, postList.size());
}
Pageable을 매개변수로 가져와서 Pageable에서 설정한 offset과 pagesize에 맞게 게시물 리스트를 반환하는 QueryDSL 메소드입니다.
- 현재 날짜와 가장 가까운 날짜의 공연 반환하기
@RequiredArgsConstructor
public class CalendarRepositoryImpl implements CalendarRepositoryCustom{
private final JPAQueryFactory queryFactory;
QCalendar qCalendar = QCalendar.calendar;
@Override
public Calendar findNextCalendarByConStart(LocalDate nowDate){
return queryFactory.select(qCalendar)
.from(qCalendar)
.where(qCalendar.concertTime.conStart.after(nowDate))
.orderBy(qCalendar.concertTime.conStart.asc())
.fetchFirst();
};
}
where 절에 after()
메소드를 사용해서 현재 날짜보다 뒷 공연인지 확인하고
orderBy 절에서 공연 시작 날짜 오름차순으로 정렬하고
fetchFirst()
로 맨 앞 데이터를 반환합니다.
- Boolean Expression을 사용해서 QueryDSL 동적 쿼리로 메소드 개수 1개로 줄이기
package com.example.concalendar.calendar.repository;
@RequiredArgsConstructor
public class CalendarBookmarkRepositoryImpl implements CalendarBookmarkRepositoryCustom{
private final JPAQueryFactory queryFactory;
QCalendar qCalendar = QCalendar.calendar;
QCalendarBookmark qCalendarBookmark = QCalendarBookmark.calendarBookmark;
@Override
public List<CalendarBookmark> findCalendarBookmarksByCalendar(Long calendar_id, Long user_id){
return queryFactory.select(qCalendarBookmark)
.from(qCalendarBookmark)
.where(calendarIdEq(calendar_id), userIdEq(user_id))
.fetch();
};
private BooleanExpression calendarIdEq(Long calendar_id){
if (calendar_id == null){
return null;
}
return qCalendarBookmark.calendar.conNo.eq(calendar_id);
}
private BooleanExpression userIdEq(Long user_id){
if (user_id == null){
return null;
}
return qCalendarBookmark.user.userId.eq(user_id);
}
}
calendarId
가 일치하는지 여부를 확인하는 caelndarIdEq
메소드와 userId
가 일치하는지 여부를 확인하는 userIdEq
메소드를 선언해줍니다.
두 개의 매개변수 중 하나의 매개변수가 null
이든 null
이 아니든 결과를 도출할 수 있고 매개변수의 null
여부에 따라 다른 결과를 도출하는 동적 쿼리를 구현할 수 있습니다.
- 유저가 즐겨찾기한 개수에 따라 공연 즐겨찾기 랭킹 구현하기
@Override
public List<Calendar> findCalendarBookmarkRanking(){
return queryFactory.select(qCalendarBookmark.calendar)
.from(qCalendarBookmark)
.groupBy(qCalendarBookmark.calendar.conNo)
.orderBy(qCalendarBookmark.user.userId.sum().desc())
.limit(7) // 7개까지 가져오기
.fetch();
}
콘서트No
를 GroupBy
하고 해당 콘서트를 즐겨찾기한 userId
의 총합을 역순으로 정렬한 공연정보 리스트를 가져옵니다.
이러한 기능 말고도 QueryDSL을 사용해서 서브쿼리를 사용하거나 DTO를 반환하는 메소드도 사용할 수 있습니다.
이후 위 기능을 사용한 QueryDSL 방식도 포스팅 하도록 하겠습니다.
JPQL의 문제를 해결하기 위한 Criteria가 있다는 사실을 새롭게 알게됐습니다🌱