Querydsl이 존재하기 이전의 JPA를 사용하는 Spring 프로젝트에서는 검색 조건에 따라 게시물을 검색하는 등의 Dynamic Query (동적 쿼리) 를 처리하기 위해서는 JPQL을 통한 문자열 처리나, JPA Criteria 등으로 처리하였다. 하지만 JPQL 쿼리를 문자열로 처리하는 행위는 번거러울 뿐더러 문자열 오타와 같은 실수로 인한 버그 발생 우려가 높다. 뿐만 아니라 JPA Criteria 같은 경우는 JPA 표준 스펙이지만 실무에서 사용하기 복잡하고 코드의 가독성이 매우 떨어진다.
// 복잡하고 가독성이 떨어져 유지보수성이 낮은 Criteria 방식 예시
public List<Order> findAll(OrderSearch orderSearch) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> o = cq.from(Order.class);
Join<Order, Member> m = o.join("member", JoinType.INNER);
List<Predicate> criteria = new ArrayList<>();
if (orderSearch.getOrderStatus() != null) {
Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
criteria.add(status); }
if (StringUtils.hasText(orderSearch.getMemberName())) {
Predicate name = cb.like(m.<String>get("name"), "%" +
orderSearch.getMemberName() + "%");
criteria.add(name);
}
cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
TypedQuery<Order> query = em.createQuery(cq).setMaxResults(1000);
return query.getResultList();
}
오늘날 이러한 동적쿼리 작성의 문제점들을 Querydsl이 멋지게 해결해준다. 쿼리를 자바 코드로 작성할 수 있게 해주며, 문법 오류를 컴파일 시점에 체크할 수 있게 해줌으로써 개발자에게 편리함을 선사해준다.
필자는 Spring boot 프로젝트에서 Spring Data JPA 와 Querydsl을 함께 사용해 repository를 구축하고 있다.
// 예시 코드
public interface MemberRepository extends JpaRepository<Member, Long>{
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
Spring Data JPA를 사용하면 자바 인터페이스에서 Spring Data JPA의 JpaRepository을 상속받는 방식으로 기본적인 CRUD 기능들을 직접 코드를 작성할 필요없이 가져다 쓸 수 있으며, join이 필요한 복잡한 쿼리들은 위의 코드처럼 @Query annotation을 활용한 JPQL 작성 방식으로 interface안에 추가할 수 있다.
Spring Data JPA와 함께 Querydsl 코드를 작성하려면 Spring Data JPA의 Repository를 확장해야만 한다. 확장 방법으로 QuerydslRepositorySupport 클래스를 이용하는 방법을 우선 알아보자. 다음은 검색을 위한 동적쿼리를 예시 코드들이다.
public interface SearchBoardRepository {
Page<Object[]> SearchPage(String type, String keyword, Pageable pageable);
}
public interface BoardRepository extends JpaRepository<Board, Long>, SearchBoardRepository{}
우선 인터페이스를 하나 더 추가하고, 구현할 메소드를 작성한다. 또한 JpaRepository를 상속받은 repository interface에서 이를 상속받는다.
(상기 코드는 Board, Member, Reply의 3가지 Entity가 존재함 Board 와 Member 사이의 다 : 1,
Reply 와 Board 사이의 다 : 1 매핑이 되어있는 상황
type 인자는 board의 제목, 내용, 작성자중 어느 것으로 검색 조건을 할지를 나타냄
keyword 인자는 포함된 내용을 찾기위한 검색 내용임
pageable은 Spring의 페이징과 정렬을 위한 인자임)
그 후 구현 클래스를 작성한다. 제목은 반드시 인터페이스의 이름 + Impl로 작성하도록 한다. 또한 구현 클래스는 QuerydslRepositorySupport를 상속 받아야 하며, 부모의 생성자에 도메인 클래스를 인자로 넘겨주어야 한다.
public class SearchBoardRepositoryImpl extends QuerydslRepositorySupport implements SearchBoardRepository {
public SearchBoardRepositoryImpl() {
super(Board.class);
}
}
메소드를 구현한 코드는 다음과 같다.
(다음 코드는 "남가람북스" 출판사의 "코드로 배우는 스프링 부트 웹 프로젝트" 에서 참고하였다)
@Override
public Page<Object[]> SearchPage(String type, String keyword, Pageable pageable) {
QBoard board = QBoard.board;
QReply reply = QReply.reply;
QMember member = QMember.member;
JPQLQuery<Board> jpqlQuery = from(board);
jpqlQuery.leftJoin(member).on(board.writer.eq(member));
jpqlQuery.leftJoin(reply).on(reply.board.eq(board));
JPQLQuery<Tuple> tuple = jpqlQuery.select(board, member, reply.count());
BooleanBuilder booleanBuilder = new BooleanBuilder();
BooleanExpression expression = board.id.gt(0L);
booleanBuilder.and(expression);
if(type != null) {
String[] typeArr = type.split("");
BooleanBuilder conditionBuilder = new BooleanBuilder();
for (String t : typeArr) {
switch (t) {
case "t":
conditionBuilder.or(board.title.contains(keyword));
break;
case "c":
conditionBuilder.or(board.content.contains(keyword));
break;
case "w":
conditionBuilder.or(member.email.contains(keyword));
break;
default:
break;
}
}
booleanBuilder.and(conditionBuilder);
}
tuple.where(booleanBuilder);
Sort sort = pageable.getSort();
sort.stream().forEach(order -> {
Order direction = order.isAscending() ? Order.ASC : Order.DESC;
String prop = order.getProperty();
PathBuilder orderByExpression = new PathBuilder(Board.class, "board");
tuple.orderBy(new OrderSpecifier(direction, orderByExpression.get(prop)));
});
tuple.groupBy(board);
tuple.offset(pageable.getOffset());
tuple.limit(pageable.getPageSize());
List<Tuple> result = tuple.fetch();
long count = tuple.fetchCount();
return new PageImpl<Object[]>(result.stream().map(t -> t.toArray()).collect(Collectors.toList()),
pageable, count);
}
Criteria를 사용한 복잡한 동적 쿼리 코드보다는 조금 나아진 느낌이지만 여전히 복잡하고 가독성이 떨어져 보인다.
기존의 SQL query들과 다르게 from절로 시작해야만 한다는 단점도 존재한다. 이를 조금 더 가독성이 좋고, 재사용성을 늘려 유지보수성이 좋게 만드는 방법이 없을까 고민해보았다.
위의 코드를 리팩토링 해보자.
필자는 이를 JPAQueryFactory를 통한 메서드 체인 방식으로 querydsl 쿼리를 작성해 볼 것이다.
public interface BoardRepositoryCustom {
Page<Object[]> searchPage(String type, String keyword, Pageable pageable);
}
인터페이스를 추가로 만들어주고 구현할 기능을 마찬가지로 작성한다.
public interface BoardRepository extends JpaRepository<Board, Long>, BoardRepositoryCustom {}
JpaRepository를 상속받은 repository interface에서 이를 상속받는다.
Spring으로 부터 JpaQueryFactory를 주입 받는 방식을 사용할 것이다. @SpringBootApplicaton annotation이 붙은 애플리케이션 부트스트랩 클래스에 @Bean annotation을 통해 사용자 지정 의존성 주입 코드를 추가해준다.
@EnableJpaAuditing
@SpringBootApplication
public class CommentBoardApplication {
public static void main(String[] args) {
SpringApplication.run(CommentBoardApplication.class, args);
}
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em){
return new JPAQueryFactory(em);
}
}
그 후 BoardRepositoryCustom 인터페이스 구현 클래스에서 이를 주입받고 사용한다. 리팩토링한 코드는 다음과 같다.
import static HCY.CommentBoard.entity.QBoard.board;
import static HCY.CommentBoard.entity.QMember.member;
import static HCY.CommentBoard.entity.QReply.reply;
@RequiredArgsConstructor
public class BoardRepositoryImpl implements BoardRepositoryCustom{
private final JPAQueryFactory queryFactory;
@Override
public Page<Object[]> searchPage(String type, String keyword, Pageable pageable) {
QueryResults<Tuple> queryResult = queryFactory
.select(board, member, reply.count())
.from(board)
.leftJoin(member).on(board.writer.eq(member))
.leftJoin(reply).on(reply.board.eq(board))
.where(
board.id.gt(0L),
titleEq(type, keyword),
contentEq(type, keyword),
writerEq(type, keyword)
)
.orderBy(
getOrderSpecifier(pageable.getSort())
.stream().toArray(OrderSpecifier[]::new)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.groupBy(board)
.fetchResults();
List<Tuple> content = queryResult.getResults();
long totalCount = queryResult.getTotal();
return new PageImpl<Object[]>(content.stream().map(tuple -> tuple.toArray()).collect(Collectors.toList()),
pageable, totalCount);
}
}
QuerydslRepositorySupport 클래스를 사용할 때보다 searchPage 메서드가 한층 간결해 졌음을 볼 수 있다. Spring으로 부터 주입받은 JPAQueryFactory를 활용해서 query를 마치 sql query를 작성하듯 메서드 체인으로 작성 하였다. 여기서 눈여겨 볼 점은 where절과 orderBy에 존재하는 메서드들인데 이는 다른 메서드들에서 재사용하기 쉽도록 따로 메서드로 작성한다.
private BooleanExpression titleEq(String type, String keyword) {
if(type != null) return type.contains("t") ? board.title.containsIgnoreCase(keyword) : null;
else return null;
}
private BooleanExpression contentEq(String type, String keyword) {
if(type != null) return type.contains("c") ? board.content.containsIgnoreCase(keyword) : null;
else return null;
}
private BooleanExpression writerEq(String type, String keyword) {
if(type != null) return type.contains("w") ? board.writer.name.containsIgnoreCase(keyword) : null;
else return null;
}
private List<OrderSpecifier> getOrderSpecifier(Sort sort) {
List<OrderSpecifier> orders = new ArrayList<>();
// Sort
sort.stream().forEach(order -> {
Order direction = order.isAscending() ? Order.ASC : Order.DESC;
String prop = order.getProperty();
PathBuilder orderByExpression = new PathBuilder(Board.class, "board");
orders.add(new OrderSpecifier(direction, orderByExpression.get(prop)));
});
return orders;
}
BooleanExpression을 통해 여러 조건의 where절을 합칠 수 있으며, pageable 에 존재하는 Sort도 OrderSpecifier를 통해 한꺼번에 처리할 수 있다.
만약 다른 조건으로 검색하는 새로운 메서드를 작성한다면 위의 코드를 재사용 할 수 있어 개발 효율이 높아진다.
공부를 거듭하며 객체지향적인 코드가 무엇일까 고민을 많이하고 적용하려고 노력하고 있다. 이를 위한 기본은 코드의 가독성과 재사용성을 용이하게 하고, 유지보수성을 높이는 방법이라고 생각한다. 이번 포스트 작성을 통해 Querydsl을 활용해 동적 쿼리를 작성하는 법과 유연한 소프트웨어를 추구하는 방법을 정리해 볼 수 있는 계기가 된 것 같다. 블로그의 첫 단추를 채웠으니 앞으로 공부 했던 내용들을 틈틈히 포스트 해봐야겠다!
멋지십니다... 많이 배우고갑니다!!!