본문 바로가기
Study/spring

자바 ORM 표준 JPA 프로그래밍(14) - 경로 표현식, 페치 조인, 다형성 쿼리, Named 쿼리, 벌크 연산

by 유경호 2021. 1. 10.
반응형

경로 표현식(Path Expression)

경로 표현식은 쉽게 표현하면 .(점)을 찍어 객체 그래프르 탐색하는 것이다.

select m.username   -> 상태 필드
from Member m
    join m.team t   -> 단일 값 연관 필드
    join m.orders o -> 컬렉션 값 연관 필드
where t.name = '팀A'

위에서 m.username, m.team, m.orders, t.name이 모두 경로 표현식을 사용한 예이다.

  • 상태 필드(state field): 단순히 값을 저장하기 위한 필드(필드 or 프로퍼티)
  • 연관 필드(association field): 연관 관계를 위한 필드, 임베디드 타입 포함(필드 or 프로퍼티)
    • 단일 값 연관 필드: @ManyToOne, @OneToOne, 대상이 엔티티
    • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany, 대상이 컬렉션

경로 표현식 특징

JPQL에서 경로 표현식을 사용해서 경로 탐색을 하려면 다음 3가지 경로에 따라 어떤 특징이 있는지 이해해야 한다.

  • 상태 필드 경로: 경로 탐색의 끝으로 더 탐색할 수 없다.
  • 단일 값 연관 경로: 묵시적 내부 조인이 일어난다. 단일 값 연관 경로는 계속 탐색할 수 있다.
  • 컬렉션 값 연관 경로: 묵시적 내부 조인이 일아난다. 더 탐색할 수 없음. 단 FROM절에서 조인을 통해 별칭을 얻으면 별칭으로 탐색할 수 있다.

예제를 통해 경로 탐색을 하나씩 알아보자.

상태 필드 경로 탐색

select m.username, m.age from Member m

위 JQPL의 m.username, m.age는 상태 필드 경로 탐색이다.

단일 값 연관 경로 탐색

select o.member from Order o

위 JPQL을 보면 o.member를 통해 주문에서 회원으로 단일 값 연관 필드로 경로 탐색을 했다. 단일 값 연관 필드로 경로 탐색을 하면 실제 SQL이 실행될 때는 내부 조인이 일어난다. 이것을 묵시적 조인이라 한다.

// 실제 싱행 SQL
select m.* from Orders o inner join Member m on o.memeber_id=m.id

묵시적 조인은 모두 내부 조인이 일어난다. 외부 조인은 명시적으로 JOIN 키워드를 사용해야 한다.

  • 명시적 조인: JOIN을 직접 적어주는 것
  • SELECT m FROM Member m JOIN m.team t
  • 묵시적 조인: 경로 표현식에 의해 묵시적으로 조인이 일어나는 것, 내부 조인만 가능
  • SELECT m.team FROM Member m

참고로 Address와 같이 임베디드 타입에 접근하는 것도 단일 값 연관 경로 탐색이다. 하지만 주문 테이블에 이미 포함된 속성들 이기 때문에 조인이 발생하지 않는다.

컬렉션 값 연관 경로 탐색

select t.memebers from Team t → 성공

select t.members.username from Team t → 실패

위와 같이 컬렉션 값에서 경로 탐색을 시도하면 실패한다. t.members처럼 컬렉션까지는 경로 탐색이 가능하다. t.members.username처럼 컬렉션에서 경로 탐색을 시작하는 것은 허락하지 않는다.

만약 컬렉션 경로 탐색을 하고자 하면 아래와 같이 조인을 사용해서 새로운 별칭을 획득해야 한다.

select m.username from Team t join t.members m

참고로 컬렉션의 크기를 구할 수 있는 size라는 특별한 기능을 사용할 수 있다. size를 사용하면 COUNT 함수를 사용하는 SQL로 적절히 변환된다.

select t.memebers.size from Team t

경로 탐색을 사용한 묵시적 조인 시 주의사항

  • 항상 내부 조인이다.
  • 컬렉션은 경로 탐색의 끝이다. 컬렉션에서 경로 탐색을 하려면 명시적으로 조인해서 별칭을 얻어야 한다.
  • 경로 탐색은 주로 SELECT, WHERE 절(다른 곳에서도 사용됨)에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM 절에 영향을 준다.

묵시적 조인을 쓰기 보다는 실무에서는 명시적 조인을 쓰는 것이 쿼리를 예상하기도 편하고 예기치 못한 쿼리 발생을 미연에 방지할 수 있다.


페치 조인

페치(fetch) 조인은 SQL에서 이야기하는 조인의 종류가 아닌 JPQL에서 성능 최적화를 위해 제공하는 기능이다. 연관된 엔티티나 컬렉션을 한 번에 조회하는 기능으로 join fetch 명령어로 사용할 수 있다.

페치 조인 ::= [ LEFT [OUTER] | INEER ] JOIN FETCH 조인경로

실무에서 아주 많이 사용되며 대부분의 N + 1 문제는 페치 조인으로 해결이 된다.

엔티티 페치 조인

아래 예제는 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회하는 JPQL이다.

select m from Member m join fetch m.team

위와 같이 join fetch를 사용하면 회원(m)과 팀(m.team)을 함께 조회한다. 일반적인 JPQL 조인과는 다르게 m.team 다음 별칭을 지정하지 않았다. 페치 조인은 이렇게 별칭을 사용할 수 없다. (하이버네이트 구현체는 페치 조인에도 별칭을 허용함)

회원과 팀을 지연 로딩(LAZY LOADING)으로 설정했다고 하면, 페치 조인을 사용하면 연관된 팀 엔티티는 프록시가 아닌 실제 엔티티로 반환된다. 따라서 팀 엔티티를 즉시 로딩(EAGER JOIN)으로 조회한다는 뜻이다. 그리고 프록시가 아닌 실제 엔티티이므로 회원 엔티티가 영속성 컨텍스트에서 분리되어 준영속 상태가 돼도 연관된 팀을 조회할 수 있다.

실제 실행되는 SQL은 아래와 같다.

SELECT 
    M.*, T.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID

  • JPQL에서 select m으로 회원 엔티티만 프로젝션하는데, 실행된 SQL을 보면 SELECT M.*, T.*로 회원과 연관된 팀도 함께 조회됨
  • 회원과 팀 객체가 객체 그래프를 유지하면서 조회 된다.
  • 페치 조인을 사용해서 팀도 함께 조회했으므로 m.team은 프록시가 아닌 엔티티이다.
  • 팀도 엔티티이기 때문에 연관된 팀을 사용해도 지연 로딩(Lazy Loading)이 일어나지 않는다.
  • 회원 엔티티가 영속성 컨텍스트에서 분리되어 준영속 상태가 되어도 연관된 팀을 조회할 수 있다.

컬렉션 페치 조인

일대다 관계인 컬렉션 페치 조인을 알아보자.

select t from Team t join fetch t.members where t.name = '팀A'

팀(t)를 조회하면서 연관된 t.members도 함께 조회한다.

// 실제 실행 SQL
SELECT
    T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME='팀A'

JPQL에서는 select t로 팀만 프로젝션한다 명시했는데, 실제 실행된 SQL을 보면 T.*, M.*로 팀과 연간된 회원도 함께 조회한 것을 확인할 수 있다. 그리고 세번째 그림을 보면 TEAM 테이블에서 '팀A'는 하나지만 MEMBER 테이블과 조인하면서 결과 row가 증가하기 때문에 '팀A'는 두 건이 조회되었다.

따라서 컬렉션 페치 조인 결과 객체에서 teams의 결과 예제를 보면 주소가 0x100으로 같은 '팀A'를 2건 가지게 된다.

String query = "select t from Team t join fetch t.members where t.name = '팀A'";
List<Team> teams = em.createQuery(query, Team.class).getResultList();

for(Team team : teams) {
    System.out.println("teamname = " + team.getName() + ", team = " + team);
    for(Member member : team.getMembers()) {
        System.out.println("->username = " + member.getUsername() + ", member = " + member);
    }
}

출력 결과

teamname = 팀A, team = Team@0x100
->username = 회원1, memeber = Member@0x200
->username = 회원2, memeber = Member@0x300
teamname = 팀A, team = Team@0x100
->username = 회원1, memeber = Member@0x200
->username = 회원2, memeber = Member@0x300

페치 조인과 DSTINCT

SQL의 DISTINCT는 중복된 결과를 제거하는 명령어이다. JPQL의 DISTINCT 명령어는 SQL에 DISTINCT를 추가하는 것은 물론이고 어플리케이션에서 한 번 더 중복을 제거한다.

select distinct t from Team as t join fetch t.members where t.name = '팀A'

// 실제 실행 SQL

                select
            distinct team0_.id as id1_3_0_,
            members1_.id as id1_0_1_,
            team0_.name as name2_3_0_,
            members1_.age as age2_0_1_,
            members1_.name as name3_0_1_,
            members1_.team_id as team_id4_0_1_,
            members1_.team_id as team_id4_0_0__,
            members1_.id as id1_0_0__ 
        from
            team team0_ 
        inner join
            member members1_ 
                on team0_.id=members1_.team_id 
        where
            team0_.name='팀A'

먼저 DISTINCT를 사용하면 SQL에 SELECT DISTINCT가 추가된다. 하지만 각 로우의 데이터가 다르므로 아래와 같이 SQL의 DISTINCT는 효과가 없다.

다음으로 어플리케이션에서 distinct 명령어를 보고 중복된 데이터를 걸러낸다. select distinct t의 의미는 팀 엔티티의 중복을 제거하라는 것이다.

따라서 중복인 팀A는 위 그림과 같이 하나만 조회된다.

페치 조인과 일반 조인의 차이

  • JPQL은 결과를 반환할 때 연관관계까지 고려하지 않는다. 단지 SELECT절에 지정한 엔티티만 조회할 뿐이다.
  • 팀 엔티티 조회 시 회원 컬렉션을 지연 로딩으로 설정하면 프록시나 아직 초기화하지 않은 컬렉션 래퍼를 반환한다. 즉시 로딩으로 설정하면 회원 컬렉션을 즉시 로딩하기 위해 쿼리를 한 번 더 실행한다.
  • 페치 조인은 반면에 연관된 엔티티도 함께 조회한다.
  • 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념

페치 조인의 특징

  • 페치 조인은 SQL 한 번으로 연관된 엔티티들을 함께 조회할 수 있어 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.
  • 페치 조인은 글로벌 로딩 전략보다 우선한다.(글로벌 로딩 전략 예: @OneToMany(fetch = FetchType.LAZY)
  • 글로벌 로딩 전략은 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다.
  • 페치 조인을 사용하면 연관된 엔티티를 쿼리 시점에서 조회하므로 지연 로딩이 발생하지 않는다. 따라서 준영속 상태에서도 객체 그래프를 탐색할 수 있다.

페치 조인의 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다
    • 하이버네이트는 별칭을 줄 수 있으나 사용 하지 않는 것을 권장
  • 둘 이상의 컬렉션은 페치 조인 할 수 없다 → 어찌어찌 되더라도 쓰면 안 댐, 데이터가 배에서 배로 뻥튀기(카테시안 곱에 의한)가 될 수 있음.
  • 컬렉션 페치 조인에선 페이징 API를 사용할 수 없다.

다형성 쿼리

JPQL로 부모 엔티티를 조회하면 그 자식 엔티티도 함께 조회한다.

List resultList = em.createQuery("select i from Item i").getResultList();

단일 테이블 전략(InheritanceType.SINGLE_TABLE)을 사용할 때 실행되는 SQL은 다음과 같다.

SELECT * FROM ITEM

조인 전략(InheritanceType.JOINED)을 사용할 때 실행되는 SQL은 다음과 같다.

SELECT
    i.ITEM_ID, i.DTYPE, i.name, i.price, i.stockQuantity,
    b.author, b.isbn,
    a.artist, a.etc,
    m.actor, m.director
FROM
    Item i
left outer join
    Book b on i.ITEM_ID=b.ITEM_ID
left outer join
    Album a on i.ITEM_ID=a.ITEM_ID
left outer join
    Movie m on i.ITEM_ID=m.ITEM_ID

TYPE

TYPE은 엔티티의 상속 구조에서 조회 대상을 특정 자식 타입으로 한정할 때 주로 사용한다.

// JPQL
select i from Item i where type(i) In (Book, Movie)

// SQL
SELECT i FROM Item i WHERE i.DTYPE in ('B', 'M')

TREAT(JPA2.1)

TREAT은 JPA 2.1에 추가된 기능으로 자바의 타입 캐스팅과 유사하다. 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용함

JPA 표준은 FROM, WHERE절에서 사용할 수 있지만, 하이버네이트는 SELECT 절에서도 TREAT을 사용할 수 있다.

// 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'

위를 살펴보면 treat을 사용해 부모 타입인 Item을 자식 타입인 Book으로 다룬다. 따라서 author 필드에 접근할 수 있다.


엔티티 직접 사용

기본 키 값

  • select count(m.id) from Member m → 엔티티의 아이디를 사용
  • select count(m) from Member m → 엔티티를 직접 사용

객체의 인스턴스는 참조 값으로 식별하고 테이블 로우는 기본 키 값으로 식별한다.

JPQL에서 엔티티 객체를 직접 사용하면 SQL에서는 해당 엔티티의 기본 키 값을 사용한다.

따라서 다음 실제 싱행된 SQL은 둘 다 같다.

SELECT COUNT(m.id) AS cnt FROM Member m

다음과 같이 엔티티를 파라미터로 바인딩하여 받을 수도 있다.

String query = "select m from Member m where m = :member";
List resultList = em.createQuery(query)
    .setparameter("member", memeber)
    .getResultList();

외래 키 값

Team team = em.find(Tema.class, 1L);

String query = "select m from Member m where m.team = :team";
List resultList = em.createQuery(query)
    .setPrameter("team", team)
    .getResultList();

기본 키가 1L인 팀 엔티티를 파라미터로 사용한다. 연관된 엔티티를 직접 사용하면 외래 키 값을 사용하도록 변환된다.


Named 쿼리

JPQL 쿼리는 크게 동적 쿼리와 정적 쿼리로 나눌 수 있다.

  • 동적 쿼리: em.createQuery("select ...") 처럼 JPQL을 문자로 완성해서 직접 넘기는 것을 동적 쿼리라 한다. 런타임에 특정 조건에 따라 JPQL을 동적으로 구성할 수 있다.
  • 정적 쿼리: 미리 정의한 쿼리에 일므을 부여해 필요할 때 사용할 수 있는데 이것을 Named 쿼리라 한다. Named 쿼리는 한 번 정의하면 변경할 수 없는 정적 쿼리다.

Named 쿼리는 어플리케이션 로딩 시점에 JPQL 문법을 체크하고 미리 파싱해 둔다. 따라서 오류를 빨리 확인(어플리케이션 구동 시점)할 수 있고, 사용하는 시점에서는 파싱된 결과를 재사용하므로 성능상 이점도 있다. Named 쿼리는 변하지 안흔 정적 SQL로 미리 파싱되어 생성되기 때문에 데이터베이스의 조회 성능 최적화에도 도움이 된다.

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

@NamedQuery.name에 쿼리 이름을 부여하고 @NamedQuery.query에 사용할 쿼리를 입력한다.

사용시에는 아래와 같이 em.createNamedQuery() 메소드에 Named 쿼리 이름을 입력하면 된다.

List<Memeber> resultList = em.createNamedQuery("Member.findByUsername", Memeber.class)
    .setParameter("username", "회원1")
    .getResultList();

하나의 클래스에 2개 이상의 Named 쿼리를 정의하려면 @NamedQueries 어노테이션을 사용하면 된다.

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

XML 파일을 이용한 Named 쿼리 정의 방법도 있다. 기타 자세한 사용법은 추후에 Spring Data JPA를 사용하게 되면 인터페이스로 추상화하여 편리하게 정의하여 사용할 수 있기 때문에 지금 깊게 다루진 않겠다.


벌크 연산

엔티티를 수정하려면 영속성 컨텍스트의 변경 감지 기능이나 병합을 사용하고, 삭제 하려면 em.remove() 메소드를 사용한다. 하지만 영속성 컨텍스트를 통해 엔티티를 관리하는 식으로는 수백개 이상의 엔티티를 처리하기엔 시간이 너무 오래 걸린다. 이럴 때 여러 건을 한 번에 수정하거나 삭제하는 벌크 연산을 이용한다.

예제) 재고가 10개 미만인 모든 상품의 가격을 10% 상승 시켜라.

String query = "update Product p " +
    "set p.price = p.price * 1.1 " +
    "where p.stockAmount < :stockAmount";

int resultCount = em.createQuery(query)
    .setParameter("stockAmount", 10)
    .executeUpdate();

벌크 연산은 executeUpdate() 메소드를 사용한다. 이 메소드는 벌크 연산으로 영향을 받은 엔티티 건수를 반환한다.

예제) 가격이 100원 미만인 상품을 삭제해라

String query = "delete from Product p " +
    "where p.price < :price";
int resultCount = em.createQuery(query)
    .setParameter("price", 100)
    .executeUpdate();

삭제도 같은 메소드를 사용한다.

벌크 연산의 주의점

벌크 연산을 사용할 때는 벌크 연산이 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다.

기존에 영속성 컨텍스트에 남아있는 엔티티와 데이터베이스에 있는 데이터가 일치하지 않게 될 수도 있다. 이는 데이터의 무결성 문제에 직결한다. 이런 문제를 해결하는 다양한 방법을 알아보자.

  • em.refresh() 사용
  • → 벌크 연산을 수행한 후에 정확한 엔티티를 사용해야 한다면 em.refresh()를 사용해서 데이터베이스에서 다시 엔티티를 조회하면 된다.
  • 벌크 연산 먼저 실행
  • → 가장 실용적인 해결책으로 벌크 연산을 가장 먼저 실행하는 것이다. 벌크 연산을 먼저 실행하고 나서 엔티티를 조회하면 벌크 연산으로 이미 변경된 데이터를 조회하게 된다. 이 방법은 JPA와 JDBC를 함께 사용할 때도 유용하다.
  • 벌크 연산 수행 후 영속성 컨텍스트 초기화
  • → 벌크 연산을 수행한 직후에 바로 영속성 컨텍스트를 초기화하여 기존에 영속성 컨텍스트에 남아 있는 엔티티를 제거하는 것도 좋은 방법이다.

반응형