본문 바로가기
Framework and Tool/JPA

JPA - 객체지향 쿼리 언어 - Criteria 집합, 정렬, 조인

by ocwokocw 2021. 8. 9.

- 참조: 자바 ORM 표준 JPA 프로그래밍

- 집합

Criteria 에서 집합(group by) 과 집합의 조건(having) 을 어떻게 사용하는지 예제를 통해 알아보자. group by 를 사용해보기 위해 기존 Member 엔티티에 age 속성을 더해주고, Address 를 함께 사용한다. Embedded 타입에 equals 와 hasCode 재정의 해 주는것도 잊지말자.

 

@Entity
public class Member extends DateMarkable{

	@Id @GeneratedValue
	@Column(name = "MEMBER_ID")
	private Long id;
	
	private int age;
	
	private String name;
	
	@Embedded
	private Address address;
	
	@OneToMany(mappedBy = "member")
	private List<Order> orders = new ArrayList<>();

...............

@Embeddable
public class Address {

	private String city;
	private String street;
	private String zipcode;
	
	public Address() {
		super();
	}
	
	public Address(String city, String street, String zipcode) {
		super();
		this.city = city;
		this.street = street;
		this.zipcode = zipcode;
	}

 

위의 엔티티 정보로 group by 를 사용해서 만들어볼 JPQL 은 각 도시(city) 마다 최고 나이와 최소 나이를 산출해보는것이다. JPQL 로는 "select m.address.city, min(m.age), max(m.age) from Member m group by m.address.city" 와 같은 형태가 되겠다.

  

이를 Criteria 로 만들어보기 위해 예제 데이터부터 영속화시켜보자.

 

Member member1 = new Member();

member1.setName("Name#1");
member1.setAge(10);
member1.setAddress(
		new Address("City#1", "Street#1", "Zipcode#1"));
em.persist(member1);

Member member2 = new Member();

member2.setName("Name#2");
member2.setAge(13);
member2.setAddress(
		new Address("City#1", "Street#2", "Zipcode#2"));
em.persist(member2);

Member member3 = new Member();

member3.setName("Name#3");
member3.setAge(32);
member3.setAddress(
		new Address("City#2", "Street#3", "Zipcode#3"));
em.persist(member3);

Member member4 = new Member();

member4.setName("Name#4");
member4.setAge(22);
member4.setAddress(
		new Address("City#2", "Street#4", "Zipcode#4"));
em.persist(member4);

 

group by 사용을 위해 member 1과 2의 City를 City#1 으로 member 3과 4의 City를 City#2 로 설정했다. 나이는 4 명의 Member 모두 다르게 할당하였다.

 

CriteriaBuilder cb = em.getCriteriaBuilder();

CriteriaQuery<Tuple> cq = cb.createTupleQuery();

Root<Member> m = cq.from(Member.class);

Expression<Integer> maxAge = cb.max(m.<Integer>get("age"));
Expression<Integer> minAge = cb.min(m.<Integer>get("age"));

cq.multiselect(m.get("address").get("city").alias("city"),
		maxAge.alias("maxAge"),
		minAge.alias("minAge"));
cq.groupBy(m.get("address").get("city"));

List<Tuple> statCities = em.createQuery(cq)
	.getResultList();

statCities.forEach(statCity -> {
	System.out.println("City name: " + statCity.get("city") + 
			", maxAge: " + statCity.get("maxAge") + 
			", minAge: " + statCity.get("minAge"));
});

 

위의 예제에서 Root<Member> 까지는 우리가 앞에서 살편 코드들이다. min 과 max 표현식은 CriteriaBuilder 로 부터 만든다. age 속성은 Integer 형으로 반환받을 수 있다.

 

multiselect 에서 도시마다 최대나이 최소나이를 조회해야 하므로 m.address.city 와 min(m.age), max(m.age) 표현식을 인자로 넘겨준다. 여기까지 하면 JPQL 로 따졌을 때 select 절과 from 절까지 완성했다고 볼 수 있다.

 

group by 절은 select 절에서 city 를 표현한것과 같은 표현을 해주면 된다. 이때에는 grouping 정보이지 Tuple 조회시 alias 를 사용할 필요는 없으므로 alias 를 제외한 경로표현식을 작성해준다.

 

Hibernate: 
    /* select
        generatedAlias0.address.city,
        max(generatedAlias0.age),
        min(generatedAlias0.age) 
    from
        Member as generatedAlias0 
    group by
        generatedAlias0.address.city */ select
            member0_.city as col_0_0_,
            max(member0_.age) as col_1_0_,
            min(member0_.age) as col_2_0_ 
        from
            member member0_ 
        group by
            member0_.city
City name: City#1, maxAge: 13, minAge: 10
City name: City#2, maxAge: 32, minAge: 22

 

위의 SQL 로그는 실행결과이다. 주석처리된 부분을 보면 우리가 의도하려고 했던 group by SQL 로 잘 변환되었고, 결과도 올바르게 출력된것을 확인할 수 있다.

 

각 도시의 최소 나이가 20 이상인 건만 조회하려고 한다면 having 절을 사용해야 한다.

 

..............

cq.multiselect(m.get("address").get("city").alias("city"),
		maxAge.alias("maxAge"),
		minAge.alias("minAge"));
cq.groupBy(m.get("address").get("city"));
cq.having(cb.ge(minAge, 20));

..............

 

having 절은 위와 같이 CriteriaBuilder 에서 비교연산 표현식을 사용한다.

 

Hibernate: 
    /* select
        generatedAlias0.address.city,
        max(generatedAlias0.age),
        min(generatedAlias0.age) 
    from
        Member as generatedAlias0 
    group by
        generatedAlias0.address.city 
    having
        min(generatedAlias0.age)>=20 */ select
            member0_.city as col_0_0_,
            max(member0_.age) as col_1_0_,
            min(member0_.age) as col_2_0_ 
        from
            member member0_ 
        group by
            member0_.city 
        having
            min(member0_.age)>=20
City name: City#2, maxAge: 32, minAge: 22

 

having 절이 적절하게 추가되어 최소 나이가 10 살이던 도시의 데이터가 조회되지 않는다.

 

Criteria 는 직관적이지 않기 때문에 헷갈릴 수 있다. JPQL의 예약어들에 해당하는 select, from, where, order by, group by, having 절은 CriteriaQuery 로 설정한다. 대소비교 및 표현식들은 CriteriaBuilder 로 만든다.

 

위의 원칙을 가지고 아래 단계로 천천히 생각해보면서 사용하도록 하자.

  • 엔티티 매니저에서 CriteriaBuilder 와 CriteriaBuilder 로 부터 CriteriaQuery 를 얻어온다.
  • 얻어올 엔티티 정보를 설정하는 Root 를 선언한다. 이때에는 CriteriaQuery 로 부터 얻어온다.
  • select, group by, having 절을 CriteriaQuery 로 부터 얻어오고 해당 절 안의 경로 표현식은 Root 변수로 부터 참조한다.
  • min 또는 max 에 대한 표현식은 CriteriaBuilder 로 부터 얻어온다.

- 정렬

order by 도 위에서 설명한 원칙을 가지고 작성하면 된다.

 

cq.multiselect(m.get("address").get("city").alias("city"),
		maxAge.alias("maxAge"),
		minAge.alias("minAge"));
cq.where(cb.between(m.get("age"), 10, 50));
cq.groupBy(m.get("address").get("city"));
cq.having(cb.ge(minAge, 10));
cq.orderBy(cb.desc(m.get("address").get("city")));

 

select 부터 where, group by, having, order by 를 모두 작성해보았다. 예약어에 해당하는 order by 절도 CriteriaQuery 로 부터 얻어오며, asc 나 desc 에 해당하는 표현식은 CriteriaBuilder 로 설정한다.

 

Hibernate: 
    /* select
        generatedAlias0.address.city,
        max(generatedAlias0.age),
        min(generatedAlias0.age) 
    from
        Member as generatedAlias0 
    where
        generatedAlias0.age between 10 and 50 
    group by
        generatedAlias0.address.city 
    having
        min(generatedAlias0.age)>=10 
    order by
        generatedAlias0.address.city desc */ select
            member0_.city as col_0_0_,
            max(member0_.age) as col_1_0_,
            min(member0_.age) as col_2_0_ 
        from
            member member0_ 
        where
            member0_.age between 10 and 50 
        group by
            member0_.city 
        having
            min(member0_.age)>=10 
        order by
            member0_.city desc
City name: City#2, maxAge: 32, minAge: 22
City name: City#1, maxAge: 13, minAge: 10

 

order by 절이 의도한대로 도시의 내림차순으로 잘 변환되었다.


- 조인

Criteria 에 정의된 JoinType enum 클래스 정의는 아래와 같다.

 

public enum JoinType {

    /** Inner join. */
    INNER, 

    /** Left outer join. */
    LEFT, 

    /** Right outer join. */
    RIGHT
}

 

위의 예제를 약간 수정해서 inner join 을 사용해보자. Member 엔티티와 연관 맵핑된 Order 엔티티를 조인한다. 각 도시의 최소 최대 나이를 구하는것과 더불어 주문건 수를 구한다.

 

CriteriaBuilder cb = em.getCriteriaBuilder();

CriteriaQuery<Tuple> cq = cb.createTupleQuery();

Root<Member> m = cq.from(Member.class);
Join<Member, Order> o = m.join("orders", JoinType.INNER);

Expression<Long> sumOrders = cb.count(o.get("id"));
Expression<Integer> maxAge = cb.max(m.<Integer>get("age"));
Expression<Integer> minAge = cb.min(m.<Integer>get("age"));

cq.multiselect(m.get("address").get("city").alias("city"),
		sumOrders.alias("sumOrders"),
		maxAge.alias("maxAge"),
		minAge.alias("minAge"));
cq.where(cb.between(m.get("age"), 10, 50));
cq.groupBy(m.get("address").get("city"));
cq.having(cb.ge(minAge, 10));
cq.orderBy(cb.desc(m.get("address").get("city")));

List<Tuple> statCities = em.createQuery(cq)
	.getResultList();

statCities.forEach(statCity -> {
	System.out.println("City name: " + statCity.get("city") + 
			", maxAge: " + statCity.get("maxAge") + 
			", minAge: " + statCity.get("minAge"));
});

 

m.join("orders", ...) 메소드를 이용하여 Member 에서 Order 를 join 한다. 2번째 인자로 JoinType 을 지정하는데 위의 예제에서는 INNER join 으로 설정하였다. 

 

위의 쿼리는 실행하면 결과가 나오지 않는다. 예제 데이터에서 Member 에 주문을 아무것도 맵핑해주지 않았기 때문이다.

 

Hibernate: 
    /* select
        generatedAlias0.address.city,
        count(generatedAlias1.id),
        max(generatedAlias0.age),
        min(generatedAlias0.age) 
    from
        Member as generatedAlias0 
    inner join
        generatedAlias0.orders as generatedAlias1 
    where
        generatedAlias0.age between 10 and 50 
    group by
        generatedAlias0.address.city 
    having
        min(generatedAlias0.age)>=10 
    order by
        generatedAlias0.address.city desc */ select
            member0_.city as col_0_0_,
            count(orders1_.order_id) as col_1_0_,
            max(member0_.age) as col_2_0_,
            min(member0_.age) as col_3_0_ 
        from
            member member0_ 
        inner join
            orders orders1_ 
                on member0_.member_id=orders1_.member_id 
        where
            member0_.age between 10 and 50 
        group by
            member0_.city 
        having
            min(member0_.age)>=10 
        order by
            member0_.city desc

 

비록 결과는 나오지 않지만 변환된 SQL을 보면 member 가 orders 테이블과 inner join 을 수행하는것을 알 수 있다. LEFT 로 바꿔서 한번 수행해보자.

 

..............

Root<Member> m = cq.from(Member.class);
Join<Member, Order> o = m.join("orders", JoinType.LEFT);

..............

 

LEFT join 을 하면 Member 는 존재하기 때문에 연관 맵핑된 Order 엔티티가 존재하지 않아도 정보가 조회된다.

 

Hibernate: 
    
	...........................
	
        from
            member member0_ 
        left outer join
            orders orders1_ 
                on member0_.member_id=orders1_.member_id 
	
	...........................
	
City name: City#2, sumOrders: 0, maxAge: 32, minAge: 22
City name: City#1, sumOrders: 0, maxAge: 13, minAge: 10

 

m.fetch 를 사용하면 Fetch Join 도 사용할 수 있다. 주의사항은 JPQL 에서 말한것과 동일하다.

댓글