[Spring Boot] JPQL을 Query DSL로 리팩토링해보자!

윤진원·2023년 8월 28일
7

Spring Boot

목록 보기
5/6
post-thumbnail

현재 필자는 연구실에서 진행하는 연구실 공식 기술 블로그 개발을 진행중이다.
Repository Link : Wasabi

프로젝트는 Spring Data JPA를 사용하고 있고, JPQL을 이용해서 복잡하진 않지만 나름 프로젝트에서는 복잡한 편에 속하는 쿼리들을 작성중이다.

그러던 와중 동적 쿼리에 강하고.. 컴파일 시점에 에러도 알 수 있고.. 메서드 체인 방식으로 편리하게 사용할 수 있는 Query DSL을 알게되었고, 학습하고 변경해본 기록을 남기려 한다.


프로젝트 요구사항 🤲🏻

우선 Query DSL의 자세한 장점들은 다른 좋은 블로그들에 많으니 생략하고, Wasabi의 요구사항에 대해 간략하게 알아보자!

이번 리팩토링에 관련된 요구사항들은 다음과 같다.

  1. 사용자는 게시글에 좋아요를 누를 수 있다.
  2. 사용자가 게시글을 조회하면 게시글 조회수가 1 증가한다.
  3. 사용자는 게시글 목록을 슬라이싱(무한 스크롤)으로 조회할 수 있다.
  4. 조회 정렬 기준은 최신순(기본값), 조회수순, 좋아요순이 존재한다.

4번째 요구사항이 리팩토링한 부분의 핵심이다!

Query DSL을 알지 못했을 때는, 흔히 떠올릴 수 있는 방법으로 다음과 같은 방법을 선택했다.

// 최신순
@Query("SELECT board FROM Board board ORDER BY board.createdAt DESC")
Slice<Board> findAllByOrderByCreatedAtDesc(final Pageable pageable);

// 조회수
@Query("SELECT board FROM Board board ORDER BY board.views DESC")
Slice<Board> findAllByOrderByViewsDesc(final Pageable pageable);

// 좋아요 순
@Query("SELECT board FROM Board board ORDER BY SIZE(board.likes) DESC")
Slice<Board> findAllByOrderByLikesDesc(final Pageable pageable);

Spring Data JPA를 사용해서 메서드 쿼리 기능을 사용하고.. JPQL을 사용하고.. 슬라이싱을 위해 Pageable 을 파라미터로 전달받고.. 각 정렬 기준에 따라 정렬해주고..


하지만 위 코드를 보면 드는 생각이 있을 것이다.
Order By 절만 다른데, 어떻게 합쳐서 하나로 사용할 수 있는 방법이 없을까?

답은 Query DSL! 이제부터 간단한 사용 방법을 알아보며 리팩토링을 진행해보자.


Query DSL을 사용해서 리팩토링 ✅

우선, Query DSL 의존성을 추가하고 설정부터 해보자.

기본적인 다른 설정은 다 되어있다고 가정하고 진행한다.

dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

// Q타입 클래스 생성 경로
def generated = "$buildDir/generated/qclass"

// QueryDSL QClass 파일 생성 위치 설정
tasks.withType(JavaCompile).configureEach {
    options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// java source set에 QueryDSL QClass 위치 추가
sourceSets {
    main.java.srcDirs += [generated]
}

// gradle clean 시 QClass 디렉토리 삭제
clean {
    delete file(generated)
}

다른 블로그의 설정과 조금 다를 것이다!

QClass의 파일 생성 위치를 변경하고, gradle clean build시 QClass 디렉토리를 삭제하고, 재생성하게 작성해놓았다.

정상적으로 작성하고 빌드했다면, 다음 이미지와 같이 생성이 되어있다.


첫 번째로, EntityManagerJPAQueryFactory 를 빈으로 등록해주자.

@Configuration
public class QueryDslConfig {

    private final EntityManager em;

    public QueryDslConfig(final EntityManager em) {
        this.em = em;
    }

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

설정을 진행했다면, 쿼리를 위한 전용 레퍼지토리를 하나 생성하자.

그리고, Q타입들을 사용하기 위한 기본적인 선언을 해준다.
JPAQueryFactory 는 단순하게 생각하면, Query DSL을 사용하기 위한 공장이라고 생각하면 된다.

그리고 위에서 언급한 요구사항 같은 경우는 게시글, 좋아요 두 엔티티가 필요하므로, QBoardQLike 를 선언한다.

@Repository
public class BoardQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QBoard board = QBoard.board;
    private final QLike like = QLike.like;

    public BoardQueryRepository(final JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }
}

그리고, 여기서 고려해봐야 할 또 다른 문제가 있다.

현재는 응답 DTO가 클래스명만 다르고 모두 똑같다는 점!

public record MyLikeBoardsResponse(
        Long id,
        String title,
        LocalDateTime createdAt,
        int likeCount,
        int views) {
}
public record MyBoardsResponse(
        Long id,
        String title,
        LocalDateTime createdAt,
        int likeCount,
        int views) {
}
public record SortBoardResponse(
        Long id,
        String title,
        LocalDateTime createdAt,
        int likeCount,
        int views) {
}

엔티티를 이 응답으로 변환해주는 매퍼 또한, 로직이 모두 같다!

public Slice<SortBoardResponse> entityToSortBoardResponse(final Slice<Board> boards) {
        
        return boards.map(board -> new SortBoardResponse(
                board.getId(),
                board.getTitle(),
                board.getCreatedAt(),
                board.getLikes().size(),
                board.getViews()
        ));
    }

public Slice<MyBoardsResponse> entityToMyBoardsResponse(final Slice<Board> myBoards) {

        return myBoards.map(board -> new MyBoardsResponse(
                board.getId(),
                board.getTitle(),
                board.getCreatedAt(),
                board.getLikes().size(),
                board.getViews()
        ));
    }

public Slice<MyLikeBoardsResponse> entityToMyLikeBoardsResponse(final Slice<Board> boards) {

        return boards.map(board -> new MyLikeBoardsResponse(
                board.getId(),
                board.getTitle(),
                board.getCreatedAt(),
                board.getLikes().size(),
                board.getViews()
        ));
    }

이 응답 DTO들을 공통적인 하나로 묶고, 매퍼또한 사용을 안할 순 없을까?

있다! Query DSL이 이 문제점을 해결해준다.

우선, 응답 DTO를 SimpleBoardResponse 라는 하나의 클래스로 만들자.

public record SimpleBoardResponse(
    Long id,
    String title,
    Long views,
    Long likeCount,
    LocalDateTime createdAt) {
}

그리고, 다시 BoardQueryRepository 로 돌아가자.


공통 응답 DTO까지 생성했으면, 이젠 Query DSL을 사용해서 위에서 언급한 코드를 리팩토링 해보자.

매퍼를 사용해서 변경해줘야 하는 점을 다음과 같이 리팩토링하면 개선할 수 있다!

@Repository
public class BoardQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QBoard board = QBoard.board;
    private final QLike like = QLike.like;

    public BoardQueryRepository(final JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    public Slice<SimpleBoardResponse> boardList(final Pageable pageable,
                                                final SortType sortType) {

        final ConstructorExpression<SimpleBoardResponse> simpleBoardResponse =
                Projections.constructor(
                        SimpleBoardResponse.class,
                        board.id,
                        board.title,
                        board.views,
                        like.count(),
                        board.createdAt
                );
}

우리는 select 절에 다음과 같이 Projections 를 사용해서 엔티티의 컬럼중, 내가 원하는 컬럼들만 넣을 것이다!

위와 같이 작성한다면 평소에 JPA에 제약사항때문에 엔티티만 반환해야 했던 점을 Query DSL을 통해 해결할 수 있다.

컬럼들이 자동으로 SimpleBoardResponse 라는 클래스로 매핑이 되어 응답으로 나간다.


다음으로는, 실제 쿼리를 작성해보자.

위에서 언급했듯이 Query DSL은 메서드 체이닝 방식으로 편리하게 쿼리를 작성할 수 있다.

@Repository
public class BoardQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QBoard board = QBoard.board;
    private final QLike like = QLike.like;

    public BoardQueryRepository(final JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    public Slice<SimpleBoardResponse> boardList(final Pageable pageable,
                                                final SortType sortType) {

        final ConstructorExpression<SimpleBoardResponse> simpleBoardResponse =
                Projections.constructor(
                        SimpleBoardResponse.class,
                        board.id,
                        board.title,
                        board.views,
                        like.count(),
                        board.createdAt
                );

        final List<SimpleBoardResponse> result = queryFactory
                .query()
                .select(simpleBoardResponse)
                .from(board)
                .leftJoin(like)
                .on(like.board.eq(board))
                .groupBy(board.id)
                .orderBy(ordering(sortType))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
    }

    private OrderSpecifier ordering(final SortType sortType) {

        return switch (sortType) {

            case VIEWS -> board.views.desc();
            case LATEST -> board.createdAt.desc();
            case LIKES -> like.count().desc();
            default -> board.id.desc();
        };
    }
}

위에서 생성한 queryFactory 를 사용하는데, 위와 같이 SQL을 작성하는것과 매우 유사하게 메서드 형식으로 쿼리를 작성할 수 있다.

게시글에 좋아요가 없는 경우도 출력되게 하려고 Left Join 을 사용했다.

추가적으로 Order By 절에 정렬 조건을 넣어주려면, 반드시 OrderSpecifier 를 사용하여 메서드를 만들어서 넣어주어야 한다.

결과적으로는 3개의 쿼리 메서드를 단 한개의 메서드로 동적으로 처리할 수 있다!


하지만, 빠뜨린게 있다!

요구사항은 슬라이싱이 가능해야 하는데, 위 코드를 넣으면 오류가 날 것이다.

다음 게시물이 있는지 알아보는 검증 과정을 추가하여, 슬라이싱을 마무리해준다.

@Repository
public class BoardQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QBoard board = QBoard.board;
    private final QLike like = QLike.like;

    public BoardQueryRepository(final JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    public Slice<SimpleBoardResponse> boardList(final Pageable pageable,
                                                final SortType sortType) {

        final ConstructorExpression<SimpleBoardResponse> simpleBoardResponse =
                Projections.constructor(
                        SimpleBoardResponse.class,
                        board.id,
                        board.title,
                        board.views,
                        like.count(),
                        board.createdAt
                );

        final List<SimpleBoardResponse> result = queryFactory
                .query()
                .select(simpleBoardResponse)
                .from(board)
                .leftJoin(like)
                .on(like.board.eq(board))
                .groupBy(board.id)
                .orderBy(ordering(sortType))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        boolean hasNext = false;
        if (result.size() > pageable.getPageSize()) {
            result.remove(pageable.getPageSize());
            hasNext = true;
        }

        return new SliceImpl<>(result, pageable, hasNext);
    }

    private OrderSpecifier ordering(final SortType sortType) {

        return switch (sortType) {

            case VIEWS -> board.views.desc();
            case LATEST -> board.createdAt.desc();
            case LIKES -> like.count().desc();
            default -> board.id.desc();
        };
    }
}
  1. limit 를 페이징 사이즈보다 1 크게 만들어주어 다음 게시물까지 불러올 수 있게 만든다.

  2. SliceImpl 을 리턴해야 하는데, 파라미터에는 List, Pageable, hasNext 세 개가 들어간다!

  3. 우선 List 로 만들고, Pageable 도 위에서 받으니 두 개는 해결.

  4. hasNext 는 간단하게 검증하는 과정을 추가하여, 더 불러올 게시물이 있다면 아까 1 크게 만들어 주었으니 해당 게시물을 삭제한다.

  5. hasNexttrue 를 반환해주고, 만약 더 불러올 게시물이 없다면 false 가 들어간다!


추가적으로, 서비스와 컨트롤러 코드를 첨부한다.


public class BoardService {

	private final BoardQueryRepository queryRepository;
    
    public BoardServiceImpl(final BoardQueryRepository queryRepository) {
        this.queryRepository = queryRepository;
    }

    public Slice<SimpleBoardResponse> readBoardList(final SortType sortType,
                                                   final Pageable pageable) {
        return this.queryRepository.boardList(pageable, sortType);
    }
@RestController
public class BoardController {

    private final BoardService boardService;

    public BoardController(final BoardService boardService) {
        this.boardService = boardService;
    }

    @GetMapping
    public ResponseEntity<Slice<SimpleBoardResponse>> readBoardList(
        @RequestParam(name = "sortBy", defaultValue = "default") @Valid final String sortBy,
        @PageableDefault(size = 3) final Pageable pageable) {
        return ResponseEntity.ok(this.boardService.readBoardList(SortType.valueOf(sortBy.toUpperCase()), pageable));
    }
}

마무리 👍🏻

위 과정을 거쳐 3개의 쿼리 메서드를 하나의 로직으로 리팩토링해보았다.

Query DSL은 처음 설정.. 의존성.. 개념.. 등 접근하기 쉽지는 않다.

하지만, 한 번 학습해놓으면 동적 쿼리에 정말 유용하고, 반복적인 쿼리들을 대체할 수 있는 강력한 기능들을 사용가능하다!

profile
기억보단 기록을

3개의 댓글

comment-user-thumbnail
2023년 8월 28일

3개의 쿼리 메서드를 하나의 로직으로 사용할 수 있다니, 정말 유용한데요?

답글 달기
comment-user-thumbnail
2023년 8월 29일

흥미로운 내용이네요.

답글 달기
comment-user-thumbnail
2023년 8월 30일

QueryDSL의 장점이 잘 돋보이는 글이네요. 다만 모든 것에는 트레이드 오프가 있을텐데, QueryDSL의 단점도 추가해서 적어주시면 좋을 것 같아요.

답글 달기