현재 필자는 연구실에서 진행하는 연구실 공식 기술 블로그 개발을 진행중이다.
Repository Link : Wasabi
프로젝트는 Spring Data JPA를 사용하고 있고, JPQL을 이용해서 복잡하진 않지만 나름 프로젝트에서는 복잡한 편에 속하는 쿼리들을 작성중이다.
그러던 와중 동적 쿼리에 강하고.. 컴파일 시점에 에러도 알 수 있고.. 메서드 체인 방식으로 편리하게 사용할 수 있는 Query DSL을 알게되었고, 학습하고 변경해본 기록을 남기려 한다.
우선 Query DSL의 자세한 장점들은 다른 좋은 블로그들에 많으니 생략하고, Wasabi의 요구사항에 대해 간략하게 알아보자!
이번 리팩토링에 관련된 요구사항들은 다음과 같다.
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 의존성을 추가하고 설정부터 해보자.
기본적인 다른 설정은 다 되어있다고 가정하고 진행한다.
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 디렉토리를 삭제하고, 재생성하게 작성해놓았다.
정상적으로 작성하고 빌드했다면, 다음 이미지와 같이 생성이 되어있다.
첫 번째로,
EntityManager
와JPAQueryFactory
를 빈으로 등록해주자.
@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을 사용하기 위한 공장이라고 생각하면 된다.
그리고 위에서 언급한 요구사항 같은 경우는 게시글, 좋아요 두 엔티티가 필요하므로, QBoard
와 QLike
를 선언한다.
@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();
};
}
}
limit
를 페이징 사이즈보다 1 크게 만들어주어 다음 게시물까지 불러올 수 있게 만든다.
SliceImpl
을 리턴해야 하는데, 파라미터에는 List, Pageable, hasNext
세 개가 들어간다!
우선 List
로 만들고, Pageable
도 위에서 받으니 두 개는 해결.
hasNext
는 간단하게 검증하는 과정을 추가하여, 더 불러올 게시물이 있다면 아까 1 크게 만들어 주었으니 해당 게시물을 삭제한다.
hasNext
를 true
를 반환해주고, 만약 더 불러올 게시물이 없다면 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은 처음 설정.. 의존성.. 개념.. 등 접근하기 쉽지는 않다.
하지만, 한 번 학습해놓으면 동적 쿼리에 정말 유용하고, 반복적인 쿼리들을 대체할 수 있는 강력한 기능들을 사용가능하다!
3개의 쿼리 메서드를 하나의 로직으로 사용할 수 있다니, 정말 유용한데요?