JPQL 중급 문법

김민우·2022년 9월 19일
0

JPA

목록 보기
9/10

JPQL의 중급 문법을 살펴보자.

경로 표현식

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

어떤 필드를 참조하는지에 따라 2가지로 구분된다.

  • 상태 필드
  • 연관 필드
    • 단일 값 연관 필드
    • 컬렉션 값 연관 필드

하나 씩 알아보자.

상태 필드(state field)

단순히 값을 저장하기 위한 필드이다. 따라서, 경로 탐색의 끝이므로 더이상 탐색이 안된다.

[JPQL]

"select m.username from Member m";

[SQL]

select m.username, m.age from Member m
  • m.username에서 더이상 점(.)을 찍어서 객체 그래프를 탐색할 수 없다.
    상태필드를 만나면 이와같이 더이상 탐색을 할 수 없다.

연관 필드(association field)

연관관계를 위한 필드이다. 이 연관필드는 대상이 엔티티인지 컬렉션인지에 따라 두 가지로 구분된다.

1. 단일 값 연관 필드

  • @ManyToOne, @OneToOne, 대상이 엔티티(ex: m.team)
  • 탐색이 가능하다.

2. 컬렉션 값 연관 필드

  • @OneToMany, @ManyToMany, 대상이 컬렉션(ex: m.orders)
  • 더 이상 탐색이 불가능하다.
    • FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능

연관필드의 특징은 묵시적 내부 조인(inner join)이 발생한다는 것이다.

[JPQL]

select o.member from Order o

[SQL]

select m.* 
from Orders o 
inner join Member m on o.member_id = m.id
  • 묵시적 내부 조인(inner join)이 발생하고 더 탐색할 수 있다.
    (묵시적 내부 조인이 발생한다는 것이 매우 중요하다.)

묵시적 내부조인이 뭘까? 다음을 보자.

  1. 상태 필드 조회시 쿼리
Hibernate: 
    /* select
        m.username 
    from
        Member m */ select
            member0_.username as col_0_0_ 
        from
            Member member0_
  • 단순히 의도대로 쿼리가 나간것을 확인할 수 있다.
  1. 단일 값 연관 경로 탐색시 쿼리
Hibernate: 
    /* select
        m.team 
    from
        Member m */ select
            team1_.id as id1_3_,
            team1_.name as name2_3_ 
        from=
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id
  • from 절에서 MemberTeam 을 조인한 후 select 절에 나열한다. 내부 조인(inner join)이 발생했다.

객체 입장에서는 단순히 점(.)을 찍어서 나열하면 되지만 DB입장에서는 조인을 해야한다. 이를 묵시적 내부 조인이 발생했다라 한다. 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM (JOIN) 절에 영향을 준다.

이는 편해보이지만 실무를 많이 하다보면 매우 위험하다는 것을 알 수 있다. 조심히 다뤄야한다. 왠만하면 묵시적 내부 조인이 발생하지 않도록 쿼리를 작성해야 한다. 조인의 경우 성능에 큰 영향을 미치는데 이러한 조인은 성능적인 면에서도 별로 좋지 않다. 또한 SQL과 JPQL을 비슷하게 맞추는게 좋다. 안그러면 직관적으로 튜닝하는게 매우 어렵다.

그럼 외부조인은 할 수 있을까? 할 수 없다. 하고 싶으면 명시적 조인 (left join)을 해야 한다.

지금까지 알아본 경로 표현식을 예제로 간단히 확인해보자.

(성공)
select o.member.team from Order o
(성공)
select t.members from Team
(성공)
select m.username from Team t join t.members m
(실패)
select t.members.username from Team t
  • 컬렉션 값 연관 필드이므로 더 이상 탐색이 불가능한데 .username으로 추가 탐색을 할 수 없다.

앞서 언급했지만 컬렉션 값 연관 필드 또한 묵시적 내부 조인이 발생한다. 단일 값 연관 필드와 차이점은 탐색을 할 수 없다는 것이다. 처음에 JPA 사용할 때 맞닥뜨리면 매우 당황하는 부분이다. 다음 예시를 보자.

String query2 = "select t.members from Team t";
Collection result = em.createQuery(query2, Collection.class).getResultList();
  • 복잡하므로 보통 이렇게 사용하진 않는다.
Hibernate: 
    /* select
        t.members 
    from
        Team t */ select
            members1_.id as id1_0_,
            members1_.age as age2_0_,
            members1_.TEAM_ID as team_id5_0_,
            members1_.type as type3_0_,
            members1_.username as username4_0_ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID

핵심은 더 이상 탐색이 불가능하다는 것이다. 위 코드 query2에서 t.members 뒤에 점(.)을 붙여보자. 아무것도 추천되지 않는다. 컬렉션으로 취급되므로 사용할 수 있는게 .size 밖에 없다.

탐색이 되게 하려면 어떻게 해야할까? 다음 같이 from 절에서 명시적 조인을 하면 된다. 또한, 컬렉션은 경로 탐색의 끝, 명시적 조인을 통해 별칭을 얻어야한다.

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

앞서 언급했지만 실무에서는 묵시적 내부 조인을 쓰지 말자. 명시적 내부 조인을 사용하자. 실제 쿼리 튜닝도 쉽고 여러 가지 에러 사항을 해결할 수 있다.

다음과 같이 쿼리를 작성을 한 순 있다.

select o.member.team from Order o // (성공)

하지만 이를 실행시켜보면 상상도 못한 쿼리가 나간다. 비효율적이다.
묵시적 조인은 조인이 일어나는 상황을 한 눈에 파악하기 어렵다.


페치 조인(fetch join)

실무에서 정말로 매우 중요하다. 모르면 실무를 못한다. 자세히 알아보자.

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

다음과 같이 join fetch 명령어 사용한다.

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

엔티티 페치 조인

예를 들어 회원을 조회하면서 연관된 팀도 함께 조회하고 싶다. (SQL을 한 번에) SQL을 보면 회원 뿐만 아니라 팀(T.*) 도 함께 SELECT 된다. JPQL과 SQL을 보자.

[JPQL]

select m from Member m join fetch m.team
  • 조인과 똑같은데 뒤에 fetch만 넣어주면 된다.

[SQL]

SELECT M.*, T.* FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID
  • 회원을 조회하면서 연관된 팀도 함께 조회한다.

이는 즉시 로딩(EAGER)을 통해 가져오는 것과 동일하다. 그런데 이거는 쿼리로 내가 원하는 대로 어떤 객체 그래프를 한번에 조회할 것인지를 명시적으로 동적인 타이밍에 정할 수 있다.

참고
LEFT 조인이 아니라 Inner join이므로 회원4는 조회되지 않는다.
영속성 컨텍스트에 회원1, 2, 3, 팀A, B 총 5개의 엔티티를 저장한다.

1. 다대일 관계에서 페치 조인

MemberTeam이 다대일 관계이다. 이 때, Member 엔티티를 통해 Team 엔티티를 호출하는 과정을 보자.

폐치 조인 적용 전

String query2 = "select m from Member m";

List<Member> result = em.createQuery(query2, Member.class)
                    	.getResultList();

for (Member member : result) {
	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 = 회원1 팀A
member = 회원2 팀A
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
member = 회원3 팀B

MemberTeam 의 연관관계 매핑이 다대일이고 지연 로딩(LAZY) 설정이 되있으므로 Team 객체는 프록시 객체로 들어온다. 결국 지연 로딩이 일어 나고 member.getTeam().getName()을 호출하는 시점 마다 DB에 쿼리를 날리게 된다.

  • 회원1을 조회할 때 DB에 SQL을 날려서 조회.
  • 회원2를 조회할 때는 이미 팀 A가 영속성 컨텍스트(1차 캐시)에 있으므로 그대로 가져온다. 이 때, DB에 SQL을 날리지 않는다.
  • 회원3을 조회할 때는 다시 DB에 SQL을 날려서 조회

쿼리가 총 3번 나갔다. 최악의 경우에는 쿼리가 4번 나갈 것이다. 만약 회원 100명 마다 팀 정보가 다르다 생각해보자. N + 1 문제가 발생하여 매우 비효율적이다.

이를 해결하는 방법은 페치 조인이다. 페치 조인을 한다는 말은 "select m from Member m join fetch m.team"; 이렇게 뒤에 join fetch 만 붙이면 된다. 이렇게 수정 후 다시 실행해보고 결과를 확인해보자.

페치 조인 적용 후

    /* 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 = 회원1 팀A
member = 회원2 팀A
member = 회원3 팀B

잘 보면 result 에 결과가 담길 때 Team 객체가 프록시가 아닌 실제 객체임을 알 수 있다. 그래서 쿼리가 깔끔하게 찍혔다. 다시 언급하지만 실무에서 엄청 많이 쓴다.
또한, 엔티티를 지연 로딩으로 설정해도 join fetch가 우선이다.

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

이 경우 또한 위와 마찬가이다. 다음 코드를 실행해보자.

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

List<Team> result = em.createQuery(query2, Team.class)
                    .getResultList();

for (Team team : result) {

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

결과는 다음과 같다.

team = 팀A, 2
- member = Member{id=3, username='회원1', age=10}
- member = Member{id=4, username='회원2', age=10}
team = 팀A, 2
- member = Member{id=3, username='회원1', age=10}
- member = Member{id=4, username='회원2', age=10}
team = 팀B, 1
- member = Member{id=5, username='회원3', age=10}

뭔가 이상하지 않은가? 팀A가 중복으로 출력이 된다. 컬렉션일 때는 이를 조심해야 한다. DB 입장에서 일대다 매핑을 하면 데이터가 뻥튀기가 된다.

TeamMember 를 조인하게 되면 중복 데이터가 발생한다. Team 입장에선 데이터가 1개인데 Member 는 2개이므로 2줄이 생긴다. 잘 모르겠다면 다음을 보자.

String query2 = "select t from Team t";

List<Team> result = em.createQuery(query2, Team.class)
                    .getResultList();

System.out.println("result = " + result.size());
(결과)
result = 2

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

List<Team> result = em.createQuery(query2, Team.class)
                    .getResultList();

System.out.println("result = " + result.size());
(결과)
result = 3

일대다 관계 때문에 데이터가 뻥튀기가 됬음을 알 수 있다.

JPA는 이렇게 중복이 발생한 것을 알아차리지 못한다. 이것이 객체와 RDB의 차이인데 객체 입장에선 어쩔 수 없다.

이런 중복을 어떻게 없앨까? SQL의 DISTINCT를 사용하자. 사용 방법은 select 뒤에 distinct를 붙이면 된다.

select distinct t
from Team t join fetch t.members where t.name = ‘팀A’

아쉽지만 SQL의 DISTINCT를 통해 모든 중복을 제거할 수 없다. 엔티티의 모든 필드값이 동일해야 같은 엔티티라 판단하여 중복을 제거하니깐.

  • 이 두 엔티티는 모든 필드값이 동일하지 않으므로 SQL의 DISTINCT로 중복을 제거할 수 없다.

결론은 JPQL의 DISTINCT를 사용하자. JPQL의 DISTINCT 다음 2가지 기능을 제공한다.

  1. SQL에 DISTINCT를 추가
  2. 애플리케이션에서 엔티티 중복 제거
    • SQL에 DISTINCT를 추가해서 DB에 날리고 그 결과가 애플리케이션에 올라왔을 때 동일한 엔티티가 있으면 없애준다.
    • DISTINCT가 추가로 애플리케이션에서 중복 제거시도
    • 같은 식별자를 가진 Team 엔티티 제거

결국 중복이 제거된 결과 리스트를 리턴해준다.

[DISTINCT 추가시 결과]

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

참고
참고로 일대다는 뻥튀기가 되지만 다대일은 뻥튀기가 되지 않는다. 개수가 맞거나 오히려 줄어든다. 그러므로 다대일은 위와 같은 상황을 고려할 필요 없이 편안하게 조인하면 된다.

페치 조인 vs 일반 조인

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

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

페치 조인은 연관된 엔티티를 함께 조회한다.

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

JPQL은 결과를 반환할 때 연관관계 고려하지 않는다. 단지 SELECT 절에 지정한 엔티티만 조회한다. (위 예시에선 Team 엔티티만 조회하고, Member 엔티티는 조회하지 않는다.)

그러나, 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회한다. (즉시 로딩과 동일)
즉, 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념이다.

페치 조인의 특징과 한계

첫 번째, 페치 조인 대상에는 별칭을 줄 수 없다. 하이버네이트는 가능하나 가급적 사용하지 말자.

"select distinct t from Team t join fetch t.members"; (o)
// "select distinct t from Team t join fetch t.members as m"; (x)

이유는 페치 조인은 나와 연관된 모든 엔티티를 가져오겠다라는 뜻인데 별도의 별칭을 줘서 where문을 통해 추가 조건을 걸수도 있기 때문이다.

특정 조건을 통해 연관된 객체 중 일부만 가져오기 위해선 fetch join을 사용하면 안된다. 이 때는 별도의 쿼리를 날려서 조회해야 한다. (JPA의 의도 설계와 애플리케이션 입장이 달라지므로 잘못 조작될 가능성이 높아진다. 이러한 정합성 이슈와 객체 그래프의 사상과 맞지 않는다.)

두 번째, 둘 이상의 컬렉션은 페치 조인 할 수 없다.
일대다도 데이터 뻥튀기가 되는데 일대다대다 같은 경우는 정말 감당이 안된다...
따라서, 페치 조인의 컬렉션은 하나만 지정할 수 있다.

마지막으로 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.

물론, 일대일이나 다대일 같은 단일 값 연관 필드들은 페치 조인해도 데이터 뻥튀기가 되지 않으므로 페이징이 가능하다. 일대다는 데이터 뻥튀기가 되므로 페이징을 하면 매우 위험하다.

페이징 과정에서 데이터가 누락될 수 있다. 다음 예시를 보자.

  • 밑의 그림에서 팀A에 회원1만 가져올 수도 있다.

하이버네이트는 경고 로그를 남기고 메모리에서 페이징을 하므로 매우 위험하다. 다음 쿼리를 보자.

// 경고 로그
WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory! 
Hibernate: 
    /* select
        distinct t 
    from
        Team t 
    join
        fetch t.members m */ 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_.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 엔티티를 메모리로 끌고 와서 페이징을 한다. 만약 Team 엔티티가 100만개가 있다 생각해보자. 심각한 장애가 발생할 것이다.
일대다의 경우 다대일 관계로 방향을 뒤집어서 페이징하자. 아니면 fetch join을 없애자.

String query2 = "select t from Team t";

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


System.out.println("result = " + result.size());

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

	for (Member member : team.getMembers()) {

		System.out.println("- member = " + member);
	}
}
Hibernate: 
    /* select
        t 
    from
        Team t */ select
            team0_.id as id1_3_,
            team0_.name as name2_3_ 
        from
            Team team0_ limit ?
result = 2
Hibernate: 
    select
        members0_.TEAM_ID as team_id5_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.age as age2_0_1_,
        members0_.TEAM_ID as team_id5_0_1_,
        members0_.type as type3_0_1_,
        members0_.username as username4_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
team = 팀A, 2
- member = Member{id=3, username='회원1', age=10}
- member = Member{id=4, username='회원2', age=10}
Hibernate: 
    select
        members0_.TEAM_ID as team_id5_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.age as age2_0_1_,
        members0_.TEAM_ID as team_id5_0_1_,
        members0_.type as type3_0_1_,
        members0_.username as username4_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
team = 팀B, 1
- member = Member{id=5, username='회원3', age=10}

Team 조회 쿼리 1번 + 각각 지연 로딩을 통해 2번의 쿼리 = 총 3번의 쿼리가 나간다. 이렇게 하면 Team 객체를 많이 조회하면 할수록 성능은 급격히 안좋아질 것이다.

성능을 향상시키기 위해선 어떻게 해야할까?
다음 같이 Team 클래스의 members 필드에 @BatchSize(size = 100) 어노테이션을 추가하자.

@BatchSize 어노테이션
Team 객체를 가져 올 때 members 객체는 지연 로딩 상태이다. 그런데 지연 로딩을 딱 끌고 올 때 한 번에 In Query로 size 속성값 개수 만큼 넘긴다. 만약, Team 객체를 150개 조회한다고 생각해보자. ? 가 100개가 날라가고 그 이후에 50개가 날라간다. 이걸 이용하면 N + 1 문제를 해결할 수 있다. (쿼리를 테이블 수로 맞출 수 있다.) 이를 통해 많이 최적화를 할 수 있다.

@Entity
public class Team {
	...
    
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    
    ...
    
}
  • size 값은 1000 이내의 수로 보통 준다.

다시 실행해보자.

Hibernate: 
    /* select
        t 
    from
        Team t */ select
            team0_.id as id1_3_,
            team0_.name as name2_3_ 
        from
            Team team0_ limit ?
result = 2
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 (
            ?, ? => TeamA, TeamB id가 다 들어간다.
        )
team = 팀A, 2
- member = Member{id=3, username='회원1', age=10}
- member = Member{id=4, username='회원2', age=10}
team = 팀B, 1
- member = Member{id=5, username='회원3', age=10}
  • 성능이 이전에 비해 향상됬음을 알 수 있다.

@BatchSize는 방금처럼 일일히 클래스에 들어가서 세팅을 하지 않고 글로벌 세팅으로 많이 한다.
properties.xml에 다음과 같이 속성을 추가하면 된다.

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

실무에서 글로벌 로딩 전략은 모두 지연 로딩을 사용하며 최적화가 필요한 곳은 페치 조인을 적용한다. 이 두개만 고려하면 대부분의 성능 문제가 해결된다.

정리

모든 것을 페치 조인으로 해결할 수 는 없다. 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. (Member.team 같이 찾아가야 할 때는 효과적이다.)

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


다형성 쿼리

별로 중요하지는 않다. 조회 대상을 특정 대상으로 한정지을 수 있다.

TYPE

[JPQL]

select i from Item i
where type(i) IN (Book, Movie)

[SQL]

select i from i
where i.DTYPE in (‘B’, ‘M’)
  • 타입이 DTYPE(구분컬럼)으로 바뀐다.

TREAT(JPA 2.1 이후)

자바의 타입 캐스팅과 유사하다. 상속 구조에서 부모 타입을 특정 자식타입으로 다룰 때 사용한다(다형성). 이를 통해 다운 캐스팅같은 것을 할 수 있다.

[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’

구현 전략은 싱글테이블인지 아닌지에 따라 SQL은 달라진다. 어쨌든 다운캐스팅 처럼 쓸 수 있다.


엔티티 직접 사용

SQL은 함수를 쓰든 뭘 쓰든 파리미터를 넘기든 데이터나 식별자 값을 넘기지 엔티티 자체를 넘기지 않는다. 그러나 JPQL은 이것이 가능하다.

JPQL에서 엔티티를 직접 사용하면 어떻게 될까? SQL에서 해당 엔티티의 기본키 값을 사용한다. 어떻게 보면 당연하다. 엔티티를 식별할 수 있는 값이 기본키 값이니깐.

[JPQL]

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

[SQL]

select count(m.id) as cnt from Member m

엔티티를 파리미터로 넘길 때도 마찬가지다. 엔티티를 파라미터로 넘기거나 식별자를 파라미터로 넘겨도 결과는 같다.

엔티티를 외래 키(FK) 값으로 사용하는 경우도 마찬가지이다. 이 때 기본키 값은 해당 엔티티와 연관관계인 엔티티의 @JoinColumn에서 선언한 값이다.

PK든 FK든 식별자이므로 이렇게 매핑되는 것은 당연하다고 생각하자.


Named 쿼리

엔티티 같은 곳에 @NamedQuery를 사용하여 미리 이름을 부여해놓는 것이다. 이는 정적 쿼리만 가능하다. 이렇게 선언해놓으면 @NamedQueryname 속성을 통해 불러올 수 있다. 즉, 쿼리를 재활용할 수 있다.

이게 큰 메리트가 있을까? 엄청난 메리트가 있다.

첫 번째는 바로 어플리케이션 로딩 시점에 초기화 후 재사용할 수 있다는 것이다. 정적 쿼리이므로 변하지 않는다. 어플리케이션 로딩 시점에 JPA나 하이버네이트가 이를 SQL로 파싱하여 캐싱한다. 결국 JPQL은 SQL로 파싱되어 실행되기 때문에 거기서 오는 비용이 있다. 이러한 비용이 로딩 시점에 이미 파싱이 되므로 거의 없다고 보면 된다.

또한, 어플리케이션 로딩 시점에 쿼리를 검증할 수 있다. 이것이 제일 막강하다. 쿼리에 오류가 있는 경우 실행하는 시점에 컴파일 오류가 발생한다. 컴파일 오류는 세상에서 제일 좋은 오류라는 것을 다시 한번 기억하자.

어노테이션 외에 XML으로도 정의할 수 있다. 이 때, XML이 항상 우선권을 가지며 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.

나중에 스프링 데이터 JPA를 사용하면 이 기능을 @Query 어노테이션을 사용하여 인터페이스 메서드 위에 바로 선언할 수 있다. 사실 스프링 데이터 JPA는 JPA의 껍데기 역할만 한다. JPA를 편하게 사용하기 위해 추상화 역할정도만 한다. 따라서, @Query 어노테이션을 JPA는 @NamedQuery로 파싱한다. (구현체정도로 생각하자.) 덕분에 문법오류가 있으면 다 잡아준다. 이게 정말 어마어마하다.

지금 @NamedQuery를 보면 형태가 매우 복잡하다. 엔티티에 이러한 복잡한 어노테이션을 쓰는 것은 별로 좋지 않다. 실무에선 스프링 데이터 JPA를 함께 사용하므로 @Query 어노테이션을 사용하면 된다.


벌크 연산

벌크 연산은 일반적으로 잘 아는 SQL의 UPDATE 문과 DELETE 문이라 생각하면 된다. PK 한 건을 찍어서 UPDATE와 DELETE하는 걸 제외한 나머지 모든 UPDATE 문과 DELETE 문이다.

다음 예시를 보자.

재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면? 이 일을 JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL 실행이 된다.

  • 재고가 10개 미만인 상품을 리스트로 조회한다.
  • 상품 엔티티의 가격을 10% 증가한다.
  • 트랜잭션 커밋 시점에 변경감지가 동작한다.

변경된 데이터가 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()의 결과는 영향받은 엔티티 수(int 타입) 반환한다.

이러한 벌크연산은 매우 편해보이나 주의사항이 있다. 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날린다. 따라서, 잘못하면 꼬인다.

이를 해결하기 위해선 영속성 컨텍스트에 관련된 작업전 벌크 연산을 먼저 실행하거나 벌크 연산을 실행한 후 영속성 컨텍스트를 초기화시키면 된다. 쿼리를 날리는 것이므로 Flush가 자동으로 된다. 즉, Flush 후 update 쿼리가 나가는 것이므로 영속성 컨텍스트 관련해선 고민을 하지 않아도 된다.

// 이 부분에서 em.flush() 자동으로 실행
int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();
  • 해당 코드 직전에 em.flush()가 자동으로 호출된다.

문제는 영속성 컨텍스트에 저장된 값을 벌크 연산을 한 후 DB가 아닌 영속성 컨텍스트에서 값을 가져올 때 발생한다. 다음을 실행해보자.

Member member1 = new Member();
member1.setUsername("회원1");
member1.setAge(10);

em.persist(member1);


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


int age = member1.getAge();
System.out.println("age = " + age);
(결과)
age = 10

분명 age 값을 20으로 바꿨는데 영속성 컨텍스트에 저장된 값을 호출했으므로 20이 아닌 10이 출력됬다. DB에는 당연히 20으로 입력되있다. Flush는 영속성 컨텍스트에 있는 값을 DB에 반영하는 것이지 영속성 컨텍스트를 초기화하지 않는다.

즉, Flush가 되고 업데이트 쿼리가 나가므로 벌크 연산시 수행한 것들은 영속성 컨텍스트에 반영되지 않고 DB에만 반영되며 영속성 컨텍스트가 초기화되지 않았으므로 이러한 결과가 나온 것이다.

따라서, 다음과 같이 벌크 연산 후 em.clear() 를 통해 영속성 컨텍스트를 초기화 해주자.

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

em.clear();

스프링 데이터 JPA의 Modifying Queries를 통해 벌크 연산을 할 수 있는데 이는 위에서 수동으로 했던 영속성 컨텍스트 초기화 같은 기능들을 자동으로 해준다. 이러한 내용을 모르고 스프링 데이터 JPA를 사용하면 이런 내용들이 아예 이해가 안갈 것이다. 이에 관련된 장애가 발생하면 고치기 매우 힘들어질 것이다. 원리를 다 알아야 되는 건 아니나 이와 같이 분명하게 알아둬야 하는 부분들은 확실히 알아두는게 좋다.

0개의 댓글