[Springboot] QueryDSL의 이해와 동적쿼리 적용

HeavyJ·2023년 4월 17일
1

자바/스프링부트

목록 보기
4/17

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을 사용해야 합니다.
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

이 문제를 해결하기 위해 자바에는 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

QueryDSL도 Criteria 처럼 JPQL 빌더 역할을 합니다. QueryDSL의 장점은 코드 기반에 직관적이라는 점입니다.
QueryDSL은 프로젝트의 @Entity 어노테이션을 선언한 클래스를 탐색하여 Q클래스를 생성합니다.


Q클래스를 사용하면서 가질 수 있는 장점은 쿼리를 Type-Safe하게 작성할 수 있습니다.

JPQL과 QueryDSL 코드 비교

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 프로젝트에 적용해보기

그러면 본격적으로 QueryDSL을 사용해보겠습니다.

기본적인 CRUD 기능은 Spring Data JPA의 쿼리 메소드로 구현이 가능하기 때문에 조금 복잡한 쿼리 위주로 사용했습니다.

  1. 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 메소드입니다.

  1. 현재 날짜와 가장 가까운 날짜의 공연 반환하기
@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() 로 맨 앞 데이터를 반환합니다.

  1. 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 여부에 따라 다른 결과를 도출하는 동적 쿼리를 구현할 수 있습니다.

  1. 유저가 즐겨찾기한 개수에 따라 공연 즐겨찾기 랭킹 구현하기
	@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();

    }

콘서트NoGroupBy하고 해당 콘서트를 즐겨찾기한 userId의 총합을 역순으로 정렬한 공연정보 리스트를 가져옵니다.


이렇게 QueryDSL을 사용하면 복잡한 쿼리를 조금 더 직관적으로 확인하고 작성할 수 있습니다. 또한 동적쿼리, 페이징도 사용할 수 있다는 이점이 있습니다.

이러한 기능 말고도 QueryDSL을 사용해서 서브쿼리를 사용하거나 DTO를 반환하는 메소드도 사용할 수 있습니다.

이후 위 기능을 사용한 QueryDSL 방식도 포스팅 하도록 하겠습니다.

profile
There are no two words in the English language more harmful than “good job”.

1개의 댓글

comment-user-thumbnail
2023년 4월 18일

JPQL의 문제를 해결하기 위한 Criteria가 있다는 사실을 새롭게 알게됐습니다🌱

답글 달기