[ ORM ]/JPA

[ JPA ] E-commerce 프로젝트 - 주문 검색 기능 개발 (JPQL,Criteria,Querydsl)

환이s 2025. 1. 13. 17:50
728x90


Intro

안녕하세요. 환이s입니다👋

이전 포스팅에서 주문 도메인 개발까지 알아봤습니다.

이어서 앞서 말씀드린 주문 파트의 핵심인 검색 기능 개발을 진행하면서 JPA에서 동적 쿼리를 어떻게 해결하는지 알아보겠습니다🙂

 


주문 검색 기능 개발

 

먼저 주문 목록 페이지를 확인해 보겠습니다.

 

 

위 화면을 보면 주문상태를 검색조건으로 필터링할 수 있는 기능인 것을 확인할 수 있습니다.

각 조건을 선택하면 해당 조건을 동적으로 추가하고 제거해야 하기 때문에 동적 쿼리가 필요하게 됩니다.

 

그렇다면 주문 도메인 개발 때 생성한 OrderRepository에 검색 로직을 추가해서 동적 쿼리를 생성해야 하는데, 그전에 검색 조건 파라미터 먼저 만들어 줍니다.

 

 검색 조건 파라미터 - OrderSearch

public class OrderSearch {
 
 	private String memberName; //회원 이름
 	private OrderStatus orderStatus;//주문 상태[ORDER, CANCEL]
 	
    //Getter, Setter
}

 

파라미터로는 회원 이름과 주문 상태만 추가했습니다.

다음으로 리포지토리에 검색 로직 파라미터를 매개 변수로 메서드를 생성하겠습니다.

 

OrderRepository

@Repository
public class OrderRepository {

 	@PersistenceContext
 	EntityManager em;
    
     public void save(Order order) {
     	em.persist(order);
     }
     
     public Order findOne(Long id) {
     	return em.find(Order.class, id);
     }
     
     public List<Order> findAll(OrderSearch orderSearch) {
     	//... 검색 로직
     }
}

 

해당 메서드는 검색 조건에 동적으로 쿼리를 생성해서 주문 엔티티를 조회하는 역할을 합니다.

 

그렇다면 이제 조회하는 로직을 추가해줘야 하는데, 이번 포스팅에서 JQPL, JPA 표준 Criteria 그리고 Querydsl 예시를 통해 비교하면서 풀어보겠습니다.


주문 검색 기능 개발 - JPQL

 

먼저 JPQL 예시입니다.

 

 OrderRepository - JPQL

public List<Order> findAll(OrderSearch orderSearch){

        List<Order> findOrders = em.createQuery("select o from Order o left join o.member m" +
                        " where o.status = :status" +
                        " and m.name like :memberName" +
                        "", Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("memberName", orderSearch.getMemberName())
                .getResultList();

        return findOrders;
    }

 

JPQL 쿼리에서는 Order 엔티티를 조회하고 Member 엔티티와 Left Join을 통해 연관된 회원 정보를 가져옵니다.

위 코드를 보면 특정 상태의 주문과 해당 주문의 회원 이름을 기준으로 주문을 조회하는 기능을 수행하는데, 여기서 두 가지 조건을 지정한 걸 볼 수 있습니다.

 

첫 번째는 "o.status = :status" Order의 상태가 orderSearch에서 가져온 주문 상태와 일치해야 하고

두 번째는 "m.name like :memberName" Member의 이름이 orderSearch에서 제공한 이름의 패턴과 일치해야 합니다.

 

최종적으로 getResultList() 메서드를 호출하여 쿼리 실행 결과로 얻은 주문 목록을 리스트 형태로 가져오는데 여기서 요구 사항을 추가해 보겠습니다.

 

검색 결과 수를 최대 500건으로 설정하고 싶다면 어떻게 처리해야 할까요? 

JPQL도 보면 동일하게 쿼리를 작성해서 리스트 형태로 가져오니까 동일하게 where 절에 추가하면 될까?

 

 OrderRepository - JPQL(최대 500건 조회 예시 1)

public List<Order> findAll(OrderSearch orderSearch) {
    return em.createQuery("select o from Order o left join o.member m" +
                    " where o.status = :status" +
                    " and m.name like :name" +
                    " and rownum <= 500", Order.class) // 최대 500건 조회
            .setParameter("status", orderSearch.getOrderStatus())
            .setParameter("name", orderSearch.getMemberName())
            .getResultList();
}

 

위 로직처럼 작성하면 동작하지 않을까요?
정답은 아닙니다.

 

JPQL에서는 rownum과 같은 직접적인 제한 조건을 사용할 수 없습니다. 그 이유는 결과의 수를 제한하는 기능이 WHERE절이 아닌 실행 시점에 적용되기 때문입니다.

 

따라서, JPQL의 WHERE 절에 직접적으로 최대 건수를 추가하는 것은 불가능하고, 제공해 주는 메서드를 사용해야 합니다.

 

 OrderRepository - JPQL(최대 500건 조회 예시 2)

public List<Order> findAll(OrderSearch orderSearch){

        return em.createQuery("select o from Order o left join o.member m" +
                        " where o.status = :status" +
                        " and m.name like :name" +
                        "", Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("name", orderSearch.getMemberName())
                .setMaxResults(500)  // 최대 500건 조회
                .getResultList();
    }

 

위 코드처럼 setMaxResults(500) 메서드를 추가해 줘서 쿼리 실행 결과에서 최대 500건의 결과만 가져오도록 설정하면 됩니다. 추가적인 설명을 드리자면 이 기능은 성능 최적화에 유용합니다.

 

예를 들어, 데이터베이스에서 너무 많은 데이터를 가져오면 애플리케이션의 성능이 저하될 수 있습니다. 따라서 필요한 만큼의 데이터만 가져오는 것이 좋습니다.

 

그렇다면 페이징 처리는 어떻게 할까요?

 

페이칭 처리 또한 제공해 주는 메서드를 사용하면 보다 쉽게 구현할 수 있습니다.

 

 OrderRepository - JPQL(페이징 처리)

public List<Order> findAll(OrderSearch orderSearch){

        return em.createQuery("select o from Order o left join o.member m" +
                        " where o.status = :status" +
                        " and m.name like :name" +
                        "", Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("name", orderSearch.getMemberName())
                .setFirstResult(10)  // 10페이지 부터 
                .setMaxResults(500) // 최대 500건 조회
                .getResultList();
    }

 

위 코드처럼 설정하면 페이징 처리와 최대 조회 건수 제한을 통해 효율적으로 데이터를 조회할 수 있도록 설계할 수 있습니다. 이를 통해 사용자가 요청한 페이지에 해당하는 데이터만 가져오게 되어, 성능을 최적화하고 불필요한 데이터 전송을 줄일 수 있습니다.

 

만약 내가 생성해야 될 목록에는 검색 엔진이 없고 전체 조회만 해당한다면 아래처럼 처리하시면 전체 조회 처리를 할 수 있습니다.

 

 OrderRepository - JPQL(전체 조회)

 public List<Order> findAll(OrderSearch orderSearch){

        return em.createQuery("select o from Order o left join o.member m" +
                        "", Order.class)
                .setFirstResult(10)
                .setMaxResults(500)
                .getResultList();
    }

 

하지만 지금은 간단한 예시를 통해서 알아봤지만 실무에서는 절대 쉽게 넘어갈 수 있는 상황이 아닙니다.

 

따라서 JPA가 아닌 마이바티스나 아이바티스는 동적 쿼리에 대해 비교적 간편히 적용할 수 있지만, JPA에서 동적쿼리를 어떻게 해야 할지 고민에 빠지게 됩니다.

 

다음 방식은 정말 무식한 방법이지만, 동적 쿼리를 문자열로 만들어 조건에 따라 추가하고 가져올 수 있지만 너무 복잡하고 조건이 많아진다면 유지보수는 불가능에 가까워지므로 이런 방법도 있다는 것만 확인하고 넘어가겠습니다.

 

 OrderRepository - JPQL(문자 동적 쿼리 적용 - 실무 x)

   /**
     *  String
     * */
    public List<Order> findAllByString(OrderSearch orderSearch){

        String jpql = "select o from Order o left join o.member m";

        boolean isFirstCondition = true;

        //주문 상태 검색
        if(orderSearch.getOrderStatus() != null){
            if(isFirstCondition){
                jpql += " where";
                isFirstCondition = false;
            }else{
                jpql += " and";
            }
            jpql += "o.status = :status";
        }

        //회원 이름 검색
        if(StringUtils.hasText(orderSearch.getMemberName())){
            if(isFirstCondition){
                jpql += " where";
                isFirstCondition = false;
            }else{
                jpql += " and";
            }
            jpql += "m.name like :name";
        }


        TypedQuery<Order> query = em.createQuery(jpql, Order.class)
                .setMaxResults(1000);

        //주문 상태 검색 setParameter
        if(orderSearch.getOrderStatus() != null){
            query.setParameter("status", orderSearch.getOrderStatus());
        }

        //회원 이름 검색 setParameter
        if(StringUtils.hasText(orderSearch.getMemberName())){
            query.setParameter("name", orderSearch.getMemberName());
        }

        return query.getResultList();
    }

 

위 코드를 보시면 알겠지만 유지보수로는 정말 최악입니다.

마이바티스, 아이바티스를 사용하는 이유가 이러한 동적 쿼리를 편리하게 사용할 수 있기 때문입니다.

 


주문 검색 기능 개발 - JPA 표준 Criteria

 

또 다른 방법으로는 JPA 표준 Criteria를 활용하는 건데, JPA에서 동적 쿼리를 위해 표준으로 만들었지만 실무에서 사용하기 힘든 부분이 있어 권장하지 않습니다.

 

그 이유는 코드를 먼저 확인한 후에 말씀드리겠습니다.

 

 OrderRepository - Criteria

  /**
     *  JPA Criteria
     * */
    public List<Order> findAllByCriteria(OrderSearch orderSearch){

        CriteriaBuilder cb = em.getCriteriaBuilder(); //엔티티 매니저에서 CriteriaBuilder를 가져옴

        CriteriaQuery<Order> cq = cb.createQuery(Order.class); //CriteriaQuery 생성

        Root<Order> o = cq.from(Order.class); // o를 Alias로 Root 생성

        Join<Object, Object> m = o.join("member", JoinType.INNER); // m을 Alias로 join 한 Member 생성

        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(500);
        return query.getResultList();

    }

 

앞서 살펴본 JPQL과는 다른 방식인 걸 확인할 수 있습니다.

 

Criteria API를 활용하지 않는 이유는

일단 너무 복잡합니다.

Criteria API는 쿼리를 프로그래밍적으로 생성하기 때문에 코드가 복잡해지고 길어질 수 있습니다. 특히, 여러 조건과 조인을 포함하는 경우 가독성이 떨어집니다. 그렇다면 당연히 쿼리가 복잡해질수록 유지보수가 힘들어지고 작성한 코드는 시간이 지나면서 이해하고 수정하기 어려워질 수 있습니다.

 

이러한 복잡성과 가독성 문제로 인해 실무에서는 JPQL이나 다른 간단한 방법을 더 선호하는데 그중에 하나가 바로 Querydsl입니다.


주문 검색 기능 개발 - Querydsl

 

QuerydslJava 기반의 타입 안전한 SQL 및 JPQL 쿼리를 생성하는 라이브러리입니다. 이를 통해 개발자들은 쿼리를 코드로 작성하고, 컴파일 타임에 오류를 체크할 수 있어 안정성을 높일 수 있습니다.

 

Querydsl 방법으로 적용해 보겠습니다.

 

 OrderRepository - Querydsl

 /**
     *  Querydsl
     * */
    public List<Order> findAll(OrderSearch orderSearch){

        QOrder order = QOrder.order;
        QMember member = QMember.member;
        
        return query
        			.select(order)
        			.from(order)
        			.join(order.member, member)
        			.where(statusEq(orderSearch.getOrderStatus()),
        					nameLike(orderSearch.getMemberName()))
        			.limit(500)
        			.fetch();
    }
    
    private BooleanExpression statusEq(OrderStatus statusCond){
    		if(statusCond == null){
    			return null;
    		}
    		return order.status.eq(statusCond);
    }
    
    private BooleanExpression nameLike(String nameCond){
    		if(!StringUtils.hasText(nameCond)){
    			return null;
    		}
    		return member.name.like(nameCond);
    }

 

코드만 봐도 Criteria API보다 코드가 더 직관적이고 관리하기 쉽게 작성할 수 있습니다. 

 

또한, 가독성이 높고 동적 쿼리를 간편하게 작성할 수 있어서 실무에서는 Querydsl을 선호하는 경우가 많습니다. 물론 선택은 프로젝트의 요구사항과 팀의 선호도에 따라 달라질 수 있으니 다 알아가시면 좋을 거 같습니다.


마치며

 

오늘은 주문 검색 기능 개발을 JPQL, Criteria, Querydsl API 방법을 통해 알아보았습니다.

저도 이번 포스팅을 통해 다양한 쿼리 작성 방식이 어떻게 각각의 상황에 적합할 수 있는지를 다시 한번 이해하는 기회를 가졌습니다. 

 

각 방법은 특정 요구사항과 상황에 따라 장단점이 있으므로, 프로젝트의 특성과 팀의 선호도에 맞춰 적절한 방법을 선택하는 것이 중요합니다.

 

오늘 배운 내용을 바탕으로, 효과적인 쿼리 작성 및 유지보수에 도움이 되기를 바랍니다.

다음 포스팅에서 뵙겠습니다👋

 

위 포스팅 글은 김영한님의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발을 참고했습니다.

 

 

728x90