노트 정리/자바 ORM 표준 JPA 프로그래밍

[JPA이론] 11. 객체지향 쿼리 언어2 - 중급 문법

DuL2 2022. 10. 23. 00:00

경로 표현식

 경로 표현식은 JPQL문 내부에서 객체에 점을 찍어 객체 내부의 필드를 그래프 탐색할 수 있는 방식을 말한다. 경로 표현식에서 사용할 수 있는 필드는 다음 두 가지, 상태 필드연관 필드로 분류할 수 있다.

 

  • 상태 필드(state field): 단순히 값을 저장하기 위한 필드이다.
    • ex) m.username - 멤버 객체의 이름
  • 연관 필드(association field): 연관관계를 위한 필드를 말한다.
    • 단일 값 연관 필드: @ManyToOne, @OneToOne, 대상이 엔티티(ex: m.team)
    • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany, 대상이 컬렉션(ex: m.orders)

 위와 같은 분류에 따라 JPA에서 경로 표현식을 사용할 수 있다. 단순히 값을 저장하기 위한 상태 필드는 그래프에서 경로 탐색의 끝이므로 탐색을 할 수 없다. 또한, 단일 값 연관 경로에서는 엔티티를 대상이기 때문에 묵시적 내부 조인(inner join)이 발생하여 탐색을 이어나간다. 하지만, 컬렉션 값 연관 경로는 값을 저장면서 컬렉션이기 때문에 컬렉션을 채우기 위한 묵시적 내부 조인이 발생하나 탐색 가능한 객체가 여러개이므로 '.'을 사용한 탐색은 할 수 없다.(Collection.size()와 같은 함수를 사용할 순 있다.)

 

다음은 컬렉션 값 연관 필드를 탐색하도록 하여 쿼리를 보낸 결과이다. from절에 Member 탐색을 했지만 테이블에서는 Team에 접근해서 데이터를 가져와야 하므로 묵시적 내부 조인이 발생하여 쿼리가 나간 것을 확인할 수 있다.

    List<Team> teams = em.createQuery("select m.team from Member m", Team.class)
            .getResultList();

 이렇게 묵시적 이너 조인이 발생하는 쿼리는 조심하여 사용해야 하는데 복잡한 실무에서 유지보수가 힘들 수 있기 때문이다. 또한, 명시적으로 작성하는 것이 실무에서 유지 보수와 쿼리 튜닝에 도움이 되므로 명시적인 사용을 지향하자.

 

페치 조인

 페치(fetch) 조인은 SQL 조인의 종류가 아닌 JPQL에서 성능 최적화를 위해 제공하는 기술이다. 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능을 말한다. 기본적인 join 이후 fetch를 붙여 사용할 수 있으며 형태는 다음과 같다.

[ LEFT [OUTER] | INNER ] JOIN FETCH 조인 경로

 예를 들어 회원을 조회하던 중 회원 내부 필드인 팀도 함께 조인하고 싶다면 fetch 조인을 사용할 수 있다.

[JPQL]
SELECT m FROM Member m JOIN FETCH m.team

[SQL]
SELECT m.*, T.* FROM Member m INNER JOIN Team T ON m.TEAM_ID=T.ID

이렇게 되면 m과 t를 함께 가져오므로 즉시 로딩을 하는 것과 같은 효과를 볼 수 있다. 즉시 로딩으로 인한 대부분의 `N + 1` 문제를 회피하면서 우리가 필요한 값을 프록시 없이 즉시 가져올 수 있게 된다.

 

단, 일대다 관계에 있는 컬렉션 페치 조인의 경우에는 조심해야 한다. 데이터베이스에서는 일대다 관계를 조인하게 되면 데이터가 배수만큼 늘어나게 된다는 점을 인지하고 있자. (팀을 대상으로 조회할 때 팀A에 두명의 팀원이 있다고 생각해보면 팀 A.members 컬렉션의 size는 2로 찍힐 것이다.)

 

JPQL에서의 DISTINCT

 SQL에서는 DISTINCT를 적게 되면 중복인 데이터들을 제거해준다. Join할 때는 비교 대상의 두 값이 대상 테이블과 조인되는 테이블 모두에서 정확히 같아야지만 제거되는데 JPQL에서는 객체를 대상으로 보므로 객체에서도 따로 중복될 경우 제거하여 반환 할 수 있도록 추가 절차를 거치게 된다.

 

위에서 언급했던 팀 A는 테이블에서는 2명의 회원으로 인해 전혀 다른 값이었지만 객체로 변하게 되면 결국 members로 합쳐지며 같으므로 List<Team>을 반환할 때는 같은 객체는 제거(`DISTINCT`)하여 반환해준다. 즉 1개의 팀만 받을 수 있다.

 

페치 조인의 한계

 페치 조인에는 몇 가지 한계점이 존재한다.

 

 먼저, JPA에서 페치 조인 대상에는 별칭을 줄 수 없다. 하지만, Hibernate는 지원하는데 사용하는 것이 좋은 것은 아니다. JPA 설계상 페치 조인을 사용하는 것은 대상 테이블을 조회할 때 객체를 로딩해오기 위함이므로 대상 전부를 조회하는 것을 전제로 되어있다. 만약 members에 대한 제약 조건이 더 필요하다면 회원에 직접 접근하여 가져오는 것이 타당할 수 있으므로 생각해보자. (간혹 fetch 조인을 타고 타고 들어가는 경우가 종종 있다고도 한다.)

 

 두번째로는 둘 이상의 컬렉션은 페치 조인이 불가능하다. 특정 환경에서는 가능한 경우가 있을 수 있으나 일대다대다의 조건이 되고 완벽한 데이터 처리가 이루어지지 않아 데이터가 맞지 않을 수 있으므로 페지 조인을 위한 컬렉션은 한 개만 지정하여 사용하도록 하자.

 

 세번째는 컬렉션을 페치 조인을 하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다. 위에서 설명했던 컬렉션에 대한 조인을 실행할 때 데이터가 배수로 복사되어 나올 수 있는 문제 때문에 사용할 수 없게 된다. 이유는 페이징을 하는 과정에서 DB에서 데이터를 보낼 때 컬렉션의 요소가 잘려 결과로 잘못된 결과값을 받아볼 수 있는 위험이 있다. 그렇다면 JPA가 정확한 결과값을 도출하기 위한 방법으로는 DB 데이터를 모두 메모리로 퍼올려 확인하는 방법 뿐이 없게 되고 실무에서 몇 백만건의 데이터를 모두 퍼올리는 매우 위험한 상황이 발생될 수 있다.

 

페치 조인 정리

 페치 조인이 모든 문제의 답이 될 수는 없지만 실무에서 7~80 퍼센트 정도의 문제를 해결하는데 도움이 될 순 있다. 보통 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이며 여러 테이블을 조인해서 엔티티가 아닌 형태의 데이터를 받아야한다면 일반 조인을 사용하여 DTO를 만들어 사용하자.

 

다형성 쿼리

 JPA에서 객체의 부모-자식 간의 관계에 따라 특정 자식을 대상으로만 한정하여 조회 쿼리를 날릴 수 있다.

[JPQL]
select i from Item i
where type(i) IN (Book, Movie)
[SQL]
select i from i
where i.DTYPE in (‘B’, ‘M’)

다른 방법으로는 자바의 다운 캐스팅 처럼 사용할 수 있는 TREAT(JPA 2.1) 문법이 있다.

[JPQL]
select i from Item i
where treat(i as Book).author = ‘kim’
[SQL]
select i.* from Item i
where i.DTYPE = ‘B’ and i.author = ‘kim’

엔티티 직접 사용

JPQL은 객체를 대상으로 쿼리를 할 수 있는 언어이므로 엔티티를 직접 쿼리에 사용할 수 있다. 다만 쿼리문에서 객체 자체는 객체의 pk값인 id를 의미하게 된다. 따라서 count(m) 이라는 함수는 count(m.id)라는 값과 같다.

 

Named 쿼리

 Named 쿼리는 미리 정의해서 이름을 부여해두고 사용하는 JPQL를 말한다. Named 쿼리는 동적 쿼리는 사용이 불가능하며 정적 쿼리로 사용할 수 있다. Named 쿼리를 사용하기 위해서는 어노테이션이나 XML에 정의를 하면 사용할 수 있다. 가장 큰 특징으로는 Named 쿼리는 애플리케이션 로딩 시점에 초기화 후 재사용할 수 있는데 애플리케이션 로딩 시점에 쿼리를 검증하게 된다는 점이다..

 

사용 방법은 다음과 같다.

 먼저, 엔티티 클래스에 `@NamedQuery`라는 이름으로 작성을 해놓는다.

@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query="select m from Member m where m.username = :username")
public class Member extends BaseEntity{
...
}

다음 위처럼 비즈니스 로직에서 `createNamedQuery()` 메서드를 이용해 사용할 수 있다.

            //Named 쿼리
            List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
                            .setParameter("username", "lee")
                            .getResultList();

 -> 이 방식을 통해 Spring Data JPA에서는 개발자가 쿼리에 대한 추상화만 해놓게 되면 JPA가 알아서 Named Query로 등록해놓게 되고 이로써 로딩시점에 파싱하는 과정에서 문법 오류를 모두 잡을 수 있게 된다.

 

벌크 연산

   JPA는 한 번에 여러 테이블의 로우를 변경할 수 있는 벌크 연산을 제공한다. 벌크 연산은 update, delete, insert(hibernate 지원) 모두 지원하며 벌크 연산의 결과는 영향받은 엔티티 수를 반환하게 된다.

 

 다만, 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리하기 때문에 객체의 상태 변경 감지 및 추적을 못한다. 따라서 이 부분에 주의해 벌크 연산 이후 컨텍스트를 초기화하여 싱크를 맞춰주어야 한다.

 

sample code

            //벌크 연산
            int resultCount = em.createQuery("update Member m set m. age = 20")
                    .executeUpdate();

            System.out.println("resultCount = " + resultCount);

결과 화면

-> 101 건의 데이터가 변경되었다.

 

Spring Data JPA에서는 `@Modifying` 어노테이션을 통해 영속성 컨텍스트를 자동적으로 초기화 할 수 있다.