JPQL 중급 문법

뚝딱이·2022년 9월 19일
0

JPA

목록 보기
11/11

경로 표현식

경로 표현식이란, 점을 찍어 객체 그래프를 탐색하는 것이다.

select m.username -> 상태 필드
	from Member m
    	join m.team t -> 단일 값 연관 필드
        join m.orders o -> 컬렉션 값 연관 필드
where t.name = '팀A'
  • 상태 필드(state field): 단순히 값을 저장하기 위한 필드
    (ex: m.username)
    • 경로 탐색의 끝이므로 더이상 탐색이 안된다.
      예를 들어 m.username를 하면 username에 점을 찍어 더 이상 탐색할 수 없다.
  • 연관 필드(association field): 연관관계를 위한 필드
    • 단일 값 연관 필드:
      @ManyToOne, @OneToOne, 대상이 엔티티(ex: m.team)
      - 묵시적 내부 조인이 발생한다. 탐색 가능하다.
      예를 들어 select m.team을 하면 점을 찍어 team의 다른 필드를 탐색할 수 있다. (m.team.id)
      select m.username from Member m 을 하면 member테이블만 조회한다.
      select m.team from Member m 을 하면 member와 team을 조인해서 값을 조회한다.
      따라서 묵시적 내부조인이 발생한다.
    • 컬렉션 값 연관 필드:
      @OneToMany, @ManyToMany, 대상이 컬렉션(ex: m.orders)
      - 묵시적 내부 조인이 발생하며 탐색이 불가능하다.
      select t.members from Team t : 일대 다 관계기 때문에 값이 여러개여서 뭘 선택해야될지 난감하다. t.members에 점을 찍어 탐색할 수 없다.
      - FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능
      select m.username from team t join t.members m 과 같이 from절에서 join을 하면 탐색이 가능하다.

묵시적 내부 조인은 운영시에 너무 힘들다. 그래서 JPQL을 작성할 때 SQL과 최대한 비슷하게 맞춰서 작성하는 것이 좋다.

실무에서 권장하는 방법은 묵시적 조인을 사용하지 않고 명시적 조인을 사용하는 것이다.

상태 필드 경로 탐색

JPQL: select m.username, m.age from Member m
SQL: select m.username, m.age from Member m

JPQL과 SQL이 똑같다.

단일 값 연관 경로 탐색

JPQL: select o.member from Order o
SQL:
select m.*
from Orders o
inner join Member m on o.member_id = m.id

묵시적 조인이 발생한다.

명시적 조인, 묵시적 조인

명시적 조인은 말 그대로 join 키워드를 직접 사용하는 것이다.
ex) select m from Member m join m.team t

묵시적 조인은 경로 표현식에 의해 묵시적으로 SQL 조인이 발생한다. (내부 조인만 가능하다. )
ex) select m.team from Member m

예제

  • select o.member.team from Order o -> 성공
    • 성공하지만, 묵시적 조인이 두번이나 일어난다. (member와 team)
    • 쿼리만 보면 심플하지만 실제 쿼리는 조인이 두번이나 일어나니 복잡해진다.
  • select t.members from Team -> 성공
    • 컬렉션이므로 더이상 탐색 금지
  • select t.members.username from Team t -> 실패
    • 컬렉션이므로 members에서 더이상 탐색이 안된다. members에서 점찍고 사용할 수 있는 것은 size밖에 없다.
  • select m.username from Team t join t.members m -> 성공
    • from 절에서 join을 사용했으므로 가능하다.

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

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

실무에선 묵시적 조인을 그냥 사용하지 않는 것이 좋다. 조인이 SQL 튜닝의 중요 포인트인데 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다.

fetch join

실무에서 굉장히 중요하다 !

SQL 조인 종류가 아니다. JPQL에서 성능 최적화를 위해 제공하는 JPQL 전용 기능이다.
연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다.

  • join fetch 명령어 사용
  • 페치 조인 ::= [ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로

Entity fetch join

회원을 조회하면서 연관된 팀도 함께 조회해보자. 즉 SQL 한번으로 회원과 팀을 같이 조회해보자.

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

JPQL에선 분명이 m만 조회했는데, SQL을 보면 t와 m 모두 조회했다.


            String query = "select m from Member m";

            List<Member> resultList = em.createQuery(query, Member.class)
                    .getResultList();

            for (Member member : resultList) {
                System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
            }
Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.id as id1_0_,
            member0_.age as age2_0_,
            member0_.TEAM_ID as TEAM_ID5_0_,
            member0_.type as type3_0_,
            member0_.username as username4_0_ 
        from
            Member member0_
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
member = member1, teamA
member = member2, teamA
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
member = member3, teamB

먼저 member를 가져오고, for문을 돌면서 team 이름을 가져와야 하니, team에 대한 쿼리가 나간다.

member1 의 teamA를 조회할 땐 DB에서 가져오므로 쿼리가 나간다. 하지만 member2의 teamA를 조회할 땐 1차 캐시에 teamA가 존재하므로 쿼리가 나가지 않고, 1차 캐시에서 가져온다. 그리고 member의 teamB는 1차 캐시에 없으니 조회하기 위해 쿼리가 나간다.

위의 예시에선 예시가 얼마 되지않아 쿼리를 3번 날렸지만, 데이터가 많을 수록 쿼리가 너무 많이 나간다.

그렇다면 fetch join을 사용해보자.

            String query = "select m from Member m join fetch m.team";

            List<Member> resultList = em.createQuery(query, Member.class)
                    .getResultList();

            for (Member member : resultList) {
                System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
            }
Hibernate: 
    /* select
        m 
    from
        Member m 
    join
        fetch m.team */ select
            member0_.id as id1_0_0_,
            team1_.id as id1_3_1_,
            member0_.age as age2_0_0_,
            member0_.TEAM_ID as TEAM_ID5_0_0_,
            member0_.type as type3_0_0_,
            member0_.username as username4_0_0_,
            team1_.name as name2_3_1_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id
member = member1, teamA
member = member2, teamA
member = member3, teamB

member를 조회할 때 team의 프록시를 가져오는게 아니라 진짜 team을 가져온다. 그래서 위의 쿼리의 결과로 team이 모두 영속성 컨텍스트에 올라가있다.
따라서 데이터가 이미 다 채워져 있으므로 1차 캐시에서 조회한다. 그래서 추가적인 쿼리가 더 나가지 않는다. 지연 로딩을 설정해도 fetch join이 우선순위를 갖는다.

Collection fetch join

일대다 관계, 컬렉션 페치 조인

  • JPQL
    select t
    from Team t join fetch t.members
    where t.name = ‘팀A'
  • SQL
    SELECT T., M.
    FROM TEAM T
    INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
    WHERE T.NAME = '팀A
            String query = "select t from Team t join fetch t.members";

            List<Team> resultList = em.createQuery(query, Team.class)
                    .getResultList();

            for (Team team : resultList) {
                System.out.println("team.getName() = " + team.getName());
                System.out.println("team.getMembers().size() = " + team.getMembers().size());
            }
Hibernate: 
    /* select
        t 
    from
        Team t 
    join
        fetch t.members */ select
            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_.TEAM_ID as TEAM_ID5_0_1_,
            members1_.type as type3_0_1_,
            members1_.username as username4_0_1_,
            members1_.TEAM_ID as TEAM_ID5_0_0__,
            members1_.id as id1_0_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
team.getName() = teamA
team.getMembers().size() = 2
team.getName() = teamA
team.getMembers().size() = 2
team.getName() = teamB
team.getMembers().size() = 1
result.size() = 3

데이터는 맞는데 teamA가 두번 출력됐다.

team에서 member를 join해서 가져오면 member 수 만큼 team을 가져온다.
그래서 teamA를 두번 출력하는 것이다.
member가 얼마나 있는지 미리 알 수 없으니, db가 주는대로 받는 수 밖에 없다.

            String query = "select t from Team t join fetch t.members";

            List<Team> resultList = em.createQuery(query, Team.class)
                    .getResultList();

            for (Team team : resultList) {
                System.out.println("team.getName() = " + team.getName());
                System.out.println("team.getMembers().size() = " + team.getMembers().size());

                for (Member member : team.getMembers()) {
                    System.out.println("member = " + member);
                }
            }
team.getName() = teamA
team.getMembers().size() = 2
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
team.getName() = teamA
team.getMembers().size() = 2
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
team.getName() = teamB
team.getMembers().size() = 1
member = Member{id=5, username='member3', age=0}

select t from Team t
의 사이즈를 출력하면 2가 나온다.
하지만 select t from join fetch t.members의 사이즈를 출력하면 3이 나온다.
이를 주의하자.

DISTINCT

중복이 싫을 땐 어떻게 해야할까

  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령, DISTINCT 만으로는 중복을 제거하지 못한다.
  • JPQL의 DISTINCT 2가지 기능 제공
    1. SQL에 DISTINCT를 추가
    2. 애플리케이션에서 엔티티 중복 제거
select distinct t
from Team t join fetch t.members
where t.name = ‘팀A’ 

위와 같이 distinct 를 추가했다고 해보자. 그럼 중복을 제거할 수 있을까?

SQL의 distinct는 위의 그림에 있는 두개의 데이터가 완전히 일치해야 중복을 제거한다. 하지만 위의 데이터는 team의 이름과 id만 같을 뿐 member의 id와 이름이 다르다. 따라서 쿼리만으론 중복을 제거하지 못한다.

그럼 어떻게 하느냐
JPA의 DISTINCT는 추가로 애플리케이션에서 중복 제거를 시도 한다. 따라서 같은 식별자를 가진 Team 엔티티를 제거한다. 아래의 그림과 같다.



            String query = "select distinct t from Team t join fetch t.members";

            List<Team> resultList = em.createQuery(query, Team.class)
                    .getResultList();

            for (Team team : resultList) {
                System.out.println("team.getName() = " + team.getName());
                System.out.println("team.getMembers().size() = " + team.getMembers().size());

                for (Member member : team.getMembers()) {
                    System.out.println("member = " + member);
                }
            }

실행해보자.

team.getName() = teamA
team.getMembers().size() = 2
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
team.getName() = teamB
team.getMembers().size() = 1
member = Member{id=5, username='member3', age=0}

teamA가 한번만 출력되는 것을 확인할 수 있다.

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

일반 조인 실행시 연관된 엔티티를 함께 조회하지 않음

  • JPQL
    select t
    from Team t join t.members m
    where t.name = ‘팀A'
  • SQL
    SELECT T.*
    FROM TEAM T
    INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
    WHERE T.NAME = '팀A'

즉 t만 조회된다. 하지만 fetch join을 사용하면 t와 m이 모두 조회된다.

  • JPQL은 결과를 반환할 때 연관관계 고려X
  • 단지 SELECT 절에 지정한 엔티티만 조회할 뿐
  • 여기서는 팀 엔티티만 조회하고, 회원 엔티티는 조회X
  • 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)
  • 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념

한계

  • 페치 조인 대상에는 별칭을 주면 안된다.
    String query = "select t from Team t join fetch t.members as m"; -> 불가능하다
    String query = "select t from Team t join fetch t.members as m where m.username = " " "; 와 같이 조건을 걸어서 조회할 땐 fetch join을 사용하면 안된다. 데이터의 정합성이나, 객체 그래프의 사상이 맞지 않기 때문이다. 이런 경우 따로 조회해야한다.
    join fetch를 여러번 사용하는 경우 쓰는 경우가 있기도 하다.
    • 하이버네이트는 가능, 가급적 사용X
  • 둘 이상의 컬렉션은 페치 조인 할 수 없다.
    • 데이터 정합성에 맞지 않을 수 있기 때문이다.
  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult,
    setMaxResults)를 사용할 수 없다.
    • 일대다 상황에선 데이터 뻥튀기가 되고 페이징 수에 안맞으면 데이터가 안맞을 수 있다.
    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능
    • 하이버네이트는 경고 로그를 남기고 메모리에서 페이징(매우 위험)
     String query = "select t from Team t join fetch t.members ";

            List<Team> resultList = em.createQuery(query, Team.class)
                    .setFirstResult(0)
                    .setMaxResults(1)
                    .getResultList();

실행하고 로그를 확인해보자.

9월 19, 2022 4:03:36 오후 org.hibernate.hql.internal.ast.QueryTranslatorImpl list
WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

하이버네이트의 경고 로그가 떴다.

Hibernate: 
    /* select
        t 
    from
        Team t 
    join
        fetch t.members  */ select
            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_.TEAM_ID as TEAM_ID5_0_1_,
            members1_.type as type3_0_1_,
            members1_.username as username4_0_1_,
            members1_.TEAM_ID as TEAM_ID5_0_0__,
            members1_.id as id1_0_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
team.getName() = teamA
team.getMembers().size() = 2
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
result.size() = 1

또한 쿼리를 보면 페이징 관련 쿼리가 없다.
db에서 team에 대한 데이터를 다 끌고 온것이다. '

그럼 어떻게 페이징할 수 있을까 ?
쿼리를 뒤집으면 된다.

String query = "select m from Member m join fetch m.team t";

아니면 fetch join을 없애는 방법도 있다.

            String query = "select t from Team t ";

            List<Team> resultList = em.createQuery(query, Team.class)
                    .setFirstResult(0)
                    .setMaxResults(2)
                    .getResultList();

            for (Team team : resultList) {
                System.out.println("team.getName() = " + team.getName());
                System.out.println("team.getMembers().size() = " + team.getMembers().size());

                for (Member member : team.getMembers()) {
                    System.out.println("member = " + member);
                }
            }

하지만 위의 코드를 실행하면, 쿼리가 member 수만큼 더 나가게 된다. 성능면에서 굉장히 좋지 못하다.
그럼 어떻게 해야할까
@BatchSize라는 걸 사용할 수 있다.

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

Team 클래스의 members를 위와 같이 설정하고 실행하면 아래와 같다.

Hibernate: 
    /* select
        t 
    from
        Team t  */ select
            team0_.id as id1_3_,
            team0_.name as name2_3_ 
        from
            Team team0_ limit ?
team.getName() = teamA
Hibernate: 
    /* load one-to-many jpql.Team.members */ select
        members0_.TEAM_ID as TEAM_ID5_0_1_,
        members0_.id as id1_0_1_,
        members0_.id as id1_0_0_,
        members0_.age as age2_0_0_,
        members0_.TEAM_ID as TEAM_ID5_0_0_,
        members0_.type as type3_0_0_,
        members0_.username as username4_0_0_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID in (
            ?, ?
        )

Team을 조회 후 Member를 모두 한꺼번에 조회하는 것이다. Member는 LAZY 로딩 상태다. 그래서 member가 쓰일 때까지 기다렸다가 쓰일 때, Batchsize만큼 in 쿼리를 날린다.

<property name="hibernate.default_batch_fetch_size" value="100"/>

persistence에 설정할 수 있다.

  • 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화

  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함

    • @OneToMany(fetch = FetchType.LAZY) //글로벌 로딩 전략
  • 실무에서 글로벌 로딩 전략은 모두 지연 로딩

  • 최적화가 필요한 곳은 페치 조인 적용

  • 모든 것을 페치 조인으로 해결할 수 는 없다.

  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적

  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다

다형성 쿼리

TYPE

  • 조회 대상을 특정 자식으로 한정
  • 예) Item 중에 Book, Movie를 조회해라
  • JPQL
    select i from Item i
    where type(i) IN (Book, Movie)
  • SQL
    select i from i
    where i.DTYPE in (‘B’, ‘M’)

TREAT

  • 자바의 타입 캐스팅과 유사
  • 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용
  • FROM, WHERE, SELECT(하이버네이트 지원) 사용
  • 예) 부모인 Item과 자식 Book이 있다.
  • JPQL
    select i from Item i
    where treat(i as Book).auther = ‘kim’
  • SQL
    select i.* from Item i
    where i.DTYPE = ‘B’ and i.auther = ‘kim’

엔티티 직접 사용

기본 키 값

  • JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값을 사용한다.
    즉, 아래에서 COUNT(m)을 했어도 sql은 m.id로 기본 키 값을 사용한다.
  • JPQL
    select count(m.id) from Member m //엔티티의 아이디를 사용
    select count(m) from Member m //엔티티를 직접 사용
  • SQL(JPQL 둘다 같은 다음 SQL 실행)
    select count(m.id) as cnt from Member m

보통 sql에선 식별자나, 컬럼을 count에 넣지 member 자체를 넣지 않기 때문에 조금 어색할 수 있다. 하지만 JPA에선 객체를 사용하기 때문에 가능하다.

String query = "select m from Member m where m = :member ";

Member findMember = em.createQuery(query, Member.class)
                    .setParameter("member", member1)
                    .getSingleResult();

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

실행해보면 아래와 같다.

Hibernate: 
    /* select
        m 
    from
        Member m 
    where
        m = :member  */ select
            member0_.id as id1_0_,
            member0_.age as age2_0_,
            member0_.TEAM_ID as TEAM_ID5_0_,
            member0_.type as type3_0_,
            member0_.username as username4_0_ 
        from
            Member member0_ 
        where
            member0_.id=?
findMember = Member{id=3, username='member1', age=0}

where 절에 member대신 member_id가 들어간 것을 확인할 수 있다.

직접 식별자를 넣어도 같다.

String query = "select m from Member m where m.id = :memberId ";

Member findMember = em.createQuery(query, Member.class)
                    .setParameter("memberId", member1.getId())
                    .getSingleResult();

System.out.println("findMember = " + findMember);
Hibernate: 
    /* select
        m 
    from
        Member m 
    where
        m.id = :memberId  */ select
            member0_.id as id1_0_,
            member0_.age as age2_0_,
            member0_.TEAM_ID as TEAM_ID5_0_,
            member0_.type as type3_0_,
            member0_.username as username4_0_ 
        from
            Member member0_ 
        where
            member0_.id=?
findMember = Member{id=3, username='member1', age=0}

외래 키 값

            String query = "select m from Member m where m.team = :team ";

            List<Member> members = em.createQuery(query, Member.class)
                    .setParameter("team", teamA)
                    .getResultList();

            for (Member member : members) {
                System.out.println("member = " + member);
            }            String query = "select m from Member m where m.team = :team ";

            List<Member> members = em.createQuery(query, Member.class)
                    .setParameter("team", teamA)
                    .getResultList();

            for (Member member : members) {
                System.out.println("member = " + member);
            }
Hibernate: 
    /* select
        m 
    from
        Member m 
    where
        m.team = :team  */ select
            member0_.id as id1_0_,
            member0_.age as age2_0_,
            member0_.TEAM_ID as TEAM_ID5_0_,
            member0_.type as type3_0_,
            member0_.username as username4_0_ 
        from
            Member member0_ 
        where
            member0_.TEAM_ID=?
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}

외래키인 team 또한 team으로 해도 team_id로 sql문이 나가는 것을 볼 수 있다.

Named 쿼리

  • 미리 정의해서 이름을 부여해두고 사용하는 JPQL

  • 정적 쿼리만 가능하다. 따라서 문자열을 붙이는 등의 변경이 불가능하다.

  • 어노테이션, XML에 정의할 수 있다.

  • 애플리케이션 로딩 시점에 초기화 후 재사용
    애플리케이션 로딩 시점에 JPA나 하이버네이트가 쿼리를 SQL로 파싱해 캐시하고 있는다. 결국 JPQL은 SQL로 파싱되어 사용되어야 하기 때문에 이 과정에서 발생하는 코스트가 있다. 하지만 Named 쿼리는 애플리케이션 로딩 시점에 캐시해놓기 때문에 이런 코스트가 없다.

  • 애플리케이션 로딩 시점에 쿼리를 검증

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

            List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
                    .setParameter("username", "member1")
                    .getResultList();

            for (Member member : resultList) {
                System.out.println("member = " + member);
            }

위와 같이 사용할 수 있는데, 실행해보자.

Hibernate: 
    /* Member.findByUsername */ select
        member0_.id as id1_0_,
        member0_.age as age2_0_,
        member0_.TEAM_ID as TEAM_ID5_0_,
        member0_.type as type3_0_,
        member0_.username as username4_0_ 
    from
        Member member0_ 
    where
        member0_.username=?
member = Member{id=3, username='member1', age=0}

의도 대로 잘 실행 된 것을 볼 수 있다.

여기서 우리가 쿼리를 작성할 때 오타를 내게 되면 애플리케이션 로딩 시점에 syntax 에러를 내는 등 쿼리를 검증해준다. 대부분의 에러는 다 잡아주기 때문에 굉장한 메리트이다.

Named 쿼리는 XML에 정의할 수도 있다.

XML이 항상 우선권을 가지기 때문에 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.

나중에 Spring Data JPA를 사용하게 되면 인터페이스 메서드에 바로 선언할 수 있다. 이름 없는 NamedQuery라고 하는데, 정말 유용하다.

이런 NamedQuery는 엔티티를 지저분하게 만들 수 있다. 그래서 실무에선 Data JPA를 섞어 사용하게 되는데 이때 이름 없는 NamedQuery를 사용하자

벌크 연산

벌크연산이란, 일반적으로 아는 update, delete문을 말한다. pk 하나를 찍어 update, delete하는 걸 제외한 나머지 update, delete문이라고 생각하면된다.

예를 들어 재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면 어떻게 해야될까

JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL 실행해야한다.
1. 재고가 10개 미만인 상품을 리스트로 조회한다.
2. 상품 엔티티의 가격을 10% 증가한다.
3. 트랜잭션 커밋 시점에 변경감지가 동작한다.
변경된 데이터가 100건이라면 100번의 UPDATE SQL 실행

JPA는 단건에 더 최적화되어있다. 하지만 이런 벌크연산을 쓰지 않을 수 없으므로 벌크 연산을 지원한다.

쿼리 한 번으로 여러 테이블 로우 변경(엔티티) 한다.

            int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();
            System.out.println("resultCount = " + resultCount);
Hibernate: 
    /* update
        Member m 
    set
        m.age = 20 */ update
            Member 
        set
            age=20
resultCount = 3
  • executeUpdate()의 결과는 영향받은 엔티티 수 반환한다. 따라서 위에서는 member가 3명 있으므로 resultCount가 3이된 것을 확인할 수 있다.
  • UPDATE, DELETE 를 지원한다.
  • INSERT(insert into .. select, 하이버네이트 지원)

주의점

벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 날린다. 따라서 잘못하면 꼬일 수가 있는데, 이를 해결하기 위해선 벌크 연산을 먼저 실행하거나, 벌크 연산 수행 후 영속성 컨텍스트를 초기화 하면 된다. 벌크 연산 하면 어차피 쿼리가 나가는 것이기 때문에 flush는 된다. 따라서 이 부분은 고민할 필요가 없고, 영속성 컨텍스트만 초기화하면 된다.

            Team teamA = new Team();
            teamA.setName("teamA");
            em.persist(teamA);

            Team teamB = new Team();
            teamB.setName("teamB");
            em.persist(teamB);

            Member member1 = new Member();
            member1.setUsername("member1");
            member1.setTeam(teamA);
            em.persist(member1);

            Member member2 = new Member();
            member2.setUsername("member2");
            member2.setTeam(teamA);
            em.persist(member2);

            Member member3 = new Member();
            member3.setUsername("member3");
            member3.setTeam(teamB);
            em.persist(member3);

            em.flush();
            em.clear();
            
            int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();
            System.out.println("resultCount = " + resultCount);

위 예제에선 벌크 연산 전 em.flush를 해줬다. 하지만 이를 지워도 flush는 자동 호출된다.

            Team teamA = new Team();
            teamA.setName("teamA");
            em.persist(teamA);

            Team teamB = new Team();
            teamB.setName("teamB");
            em.persist(teamB);

            Member member1 = new Member();
            member1.setUsername("member1");
            member1.setTeam(teamA);
            em.persist(member1);

            Member member2 = new Member();
            member2.setUsername("member2");
            member2.setTeam(teamA);
            em.persist(member2);

            Member member3 = new Member();
            member3.setUsername("member3");
            member3.setTeam(teamB);
            em.persist(member3);

            int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();
            System.out.println("resultCount = " + resultCount);

em.createQuery에서 쿼리를 날리기 때문에 JPA는 쿼리를 날리면 기본으로 flush한다.
flush는 commit 시점이나, query가 나갈 때, 강제로 flush를 호출할 때 실행되기 때문이다.
따라서 벌크 연산시에 flush에 대한 처리는 고민하지 않아도 된다. 자동으로 flush되기 때문 !

            int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();
            System.out.println("resultCount = " + resultCount);

            
Member findMember = em.find(Member.class, member1.getId());            System.out.println("member1.getAge() = " + findMember.getAge());

위와 같은 상황일 때 member1의 age는 20살로 변경되어 출력될까 ? 아니다. 위의 persist로 인해 영속성 컨텍스트, 즉 1차 캐시에 저장된 값은 10이다.
벌크 연산으로 모든 member의 age를 20으로 변경하였으나, 이는 DB로 쿼리가 바로 날아가 영속성 컨텍스트에 반영되지 않고 DB에만 반영된다. 따라서 영속성 컨텍스트를 초기화하지 않았기 때문에 현재 member1의 age를 조회하면 1차 캐시에 있는 10을 반환한다.

따라서 이러한 문제를 해결하기 위해 영속성 컨텍스트를 초기화해야한다.

            int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();
            System.out.println("resultCount = " + resultCount);

em.clear();

Member findMember = em.find(Member.class, member1.getId());            System.out.println("member1.getAge() = " + findMember.getAge());

이렇게 em.clear()를 통해 영속성 컨텍스트를 초기화하면 find를 통해 member를 조회하면 1차 캐시가 비어있기 때문에 DB에서 값을 조회한다. 따라서 벌크 연산이 반영된 값을 가져올 수 있다.

참고로 Spring Data JPA에선 Modifying Query로 벌크 연산을 할 수 있다.


출처 : 자바 ORM 표준 JPA 프로그래밍 - 기본편

profile
백엔드 개발자 지망생

0개의 댓글