[JPA 기본] 객체지향 쿼리 언어 ② - 기본 문법 👨‍💻

홍정완·2022년 11월 13일
0

JPA

목록 보기
38/38
post-thumbnail

페치 조인


페치 조인은 SQL에서 이야기하는 조인의 종류는 아니다.
페치 조인은 JPQL의 성능 최적화를 위해 제공하는 기능이다.

페치 조인은, 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능으로 join fetch 명령어로 사용할 수 있다.


엔티티 페치 조인

select m
from Member m join fetch m.team

페치 조인을 사용해서 회원 엔티티를 조회하면서 연관된 엔티티도 함께 조회하는 JPQL이다. 단순히 join 옆에 fetch를 불였을 뿐이지만 회원 엔티티와 팀 엔티티를 함께 조회한다. 참고로, m.team과 같은 코드는 alias를 사용할 수 없다.


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

엔티티 페치 조인에서 JPQL에서 select m으로 회원 엔티티만 선택했는데 실행된 SQL을 보면 SELECT M.*, T.*로 회원과 연관된 팀도 함께 조회된 것을 확인할 수 있다. 즉, 일반 JOINSELECT M.*만 했던 것과 달리 SELECT M.*, T.*로 값을 가져온다. 그리고 회원팀 객체객체 그래프를 유지하면서 조회된 것을 확인할 수 있다.


String jpql = "select m from Member m join fetch m.team"

List<Member> members = em.createQuery(jpql, Member.class).getResultList();
   
for(Member member : members) {    
    // 페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩이 발생하지 않는다.   
    System.out.println("username = " + member.getUsername() + ", " + 
        "teamname = " + member.getTeam().name());
}
username = 회원1, teamname = 팀A
username = 회원2, teamname = 팀A
username = 회원3, teamname = 팀B

회원을 지연 로딩으로 설정했다고 가정해 보자. fetch = FetchType.LAZY 회원을 조회할 때 페치 조인을 사용해서 팀도 함께 조회했으므로 연관된 팀 엔티티는 프록시가 아닌 실제 엔티티가 된다. 즉, 지연 로딩으로 설정했음에도 지연 로딩이 일어나지 않는다. (회원 엔티티가 준영속이 되어도 팀은 조회할 수 있다는 장점이 생긴다.)



컬렉션 페치 조인

1대 N 관계인 컬렉션을 페치 조인해 보자.


select t 
from Team t join t.members
where t.name = '팀A'
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.*연관된 회원도 함께 조회하고 있다.


ID(PK)NAMEID(PK)TEAM_ID(FK)NAME
1팀A11회원1
1팀A21회원2

팀A는 1개이지만, TeamMember를 함께 조인하면서 회원1의 팀A, 회원2의 팀A 이렇게 2개가 조회되었음을 알 수 있다.


✅ 참고

  • 일대다 조인은 결과가 증가할 수는 있지만, 일대일, 다대일 조인은 결과가 증가하지는 않는다.



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

for(Team team : teams) {
    System.out.println("teamname = " + team.getName() + ", team = " + team);
    for(Member member : team.getMember()) {
        // 페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안 함
        System.out.println(
            "-> username = " + member.getUserName() + ", member = " + member);
    }
}
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
teamname

출력 결과를 보면 같은 팀A가 2건 조회된 것을 확인할 수 있다.



페치 조인과 DISTINCT

SQLDISTINCT는 중복된 결과를 제거하는 명령어다.
JPQLDISTINCTSQL은 물론 애플리케이션 단에서도 DISTINCT를 추가한다.


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

컬렉션 페치 조인팀A가 중복으로 조회된다. 이를 해결하기 위해서 위와 같이 DISTINCT 구문을 사용하면 된다.



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


일반 조인

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

SELECT M.* 
FROM MEMBER M
INNER JOIN TEAM T ON T.ID=M.TEAM_ID
WHERE TEAM.NAME = '팀A'

일반 조인의 JPQL결과를 반환할 때 연관관계까지 고려하지 않는다. 단지, SELECT 절에 지정한 엔티티만 조회할 뿐이다. 따라서, 예제를 기준으로 말하면 팀 엔티티만 조회하고 연관된 회원 컬렉션은 조회하지 않는다.


❓ 여기서 연관관계는 어떤 역할을 할까

  • LAZY : 프록시로 가져오기에 연관 객체를 가져올 때 쿼리를 호출한다.

  • EAGER : jpql은 단순히 SQL로 변환하여 실행할 뿐이다. 그렇기에, 우선은 단순히 한 개의 엔티티 값만을 조인해서 가져오고 연관 관계가 비었기에 다시 select를 통해 값을 가져온다.



페치 조인

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

이에 비해, 페치조인은 연관된 객체들을 한 번에 가져온다는 특징이 있다.



페치 조인의 특징과 한계

페치 조인을 사용하면 SQL1번으로 연관된 엔티티들을 함께 조회할 수 있어서 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.


@OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략   

위와 같이, 엔티티에 직접 적용하는 로딩 전략은 글로벌 로딩 전략이라고 부른다. (애플리케이션 전체에 영향을 미치므로)


  • 페치 조인 > 글로벌 로딩 전략

페치 조인은 글로벌 로딩 전략보다 우선한다. 예를 들어, 글로벌 로딩 전략을 지연 로딩으로 설정해도 JPQL에서 페치 조인을 사용하면 페치 조인을 함께 적용해서 조회한다.

최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에서 항상 즉시 로딩이 일어난다. 물론, 일부는 빠를 수는 있지만 전체로 보면 사용하지 않는 엔티티를 자주 로딩하므로 오히려 성능에 악영향을 미칠 수 있다.


따라서 글로벌 로딩 전략은 될 수 있으면 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효율적이다. 또한, 페치 조인을 사용하면 연관된 엔티티를 쿼리 시점에 조회하므로 지연 로딩이 발생하지 않는다. 따라서, 준영속 상태에서도 객체 그래프를 탐색할 수 있다.



페치 조인의 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다. (기준 엔티티는 가능)

    • 문법상 별칭을 줄 수 없고 SELECT, WHERE, 서브 쿼리에 페치 조인 대상을 사용할 수 없다.

    • Hibernate와 같은 몇몇 JPA 구현체들은 페치 조인에 별칭을 지원하기도 한다. 단, 구현체마다 다르고 별칭을 잘못 사용하면 무결성이 깨질 수 있고 특히 연관된 데이터 수가 달라진 상태에서 2차 캐시에 저장되면 다른 곳에서 조회할 때도 연관된 데이터 수가 달라지는 문제가 발생할 수 있다.


  • 둘 이상의 컬렉션을 페치할 수 없다.

    • 구현체에 따라 사용은 가능하나, 컬렉션 1의 수 * 컬렉션 2의 수 카테시안 곱이 만들어지므로 주의해야 한다.

    • Hibernate에서는 에러가 발생한다.


  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.

    • 컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.

    • Hibernate에서 컬렉션을 페치 조인하고 페이징 API를 사용하면 경고 로그를 남기면서 메모리에서 페이징 처리를 한다. 데이터가 적으면 상관없겠지만 데이터가 많으면 성능 이슈와 메모리 초과 예외가 발생할 수 있어서 위험하다.



페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수 있어서 성능 최적화에 상당히 유용하다. 하지만, 몇몇 한계점들이 존재하기에 모든 것을 페치 조인으로 해결할 수는 없다.

페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 반면에 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 한다면 억지로 페치조인을 사용하기보다는 여러 테이블에서 필요한 필드들만 조회해서 DTO로 반환하는것이 더 효과적일 수 있다.


public class Team {
    ... // 생략 
    
    @OneToMany(mappedby="team")
    private List<Member> members = new ArrayList();
    
    ... // 생략 
}
String query = "select t From team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
    .setFirstResult(0)
    .setMaxResults(1)
    .getResultList();

앞서 언급했듯이 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
이는, 객체 그래프가 추구하는 모든 데이터를 가져와서 탐색할 수 있어야 한다.를 위반하기 때문이다. 하이버네이트에서는 사용은 가능하게 해주나, 경고 로그를 띄우고 메모리에서 작업을 하도록 한다.


❓ 이를 해결하려면 어떤 방법을 사용해야 할까


1. 일대다 -> 다대일로 코드 변경

String query = "select m From Member m join fetch m.Team";
List<Team> result = em.createQuery(query, Team.class)
    .setFirstResult(0)
    .setMaxResults(1)
    .getResultList();

컬렉션을 사용한다는 것은 일대다 관계를 말한다. 그러므로 이런 방향을 뒤집으면 해결될 문제이다.



2. @BatchSize

String query = "select t From team t"; // 조인이 없다.   
List<Team> result = em.createQuery(query, Team.class)
    .setFirstResult(0)
    .setMaxResults(1)
    .getResultList();
for(Team team : result) {
    for(Member member : team.getMembers()) { <- 여기서 select 발생 
        .. // 
    }
}

이 상태로만 코드를 실행하면, fetch join이 아니므로 limit 쿼리가 나온다. 하지만, 우리가 원하는 건 호출했을 때, N+1을 막고 연관된 객체들을 가져오는 것이다. 이 상태로 SQL 실행을 보면, Team 조회 -> member 조회 -> limit 만큼 반복 이렇게 동작한다. 또한, 이 같은 상태를 Lazy에서의 N+1문제라고 말한다. 이를 해결하기 위해서 @BatchSizeTeam 클래스 List<Member> 연관 관계 매핑에 붙여 넣을 것이다.


@BathSize(100) // 일반적으로 1000 이하로 준다.
@OneToMany(mappedby="team")
private List<Member> members = new ArrayList();

@BatchSize는 Member의 건수만큼 추가 SQL을 날리지 않고, 조회한 Member의 id들을 모아서 SQL IN 절을 날린다.


// 레이지 로딩 구문
select
  members1_.TEAM_ID as TEAM_ID5_0_0__,
  members1_.id as id1_0_1_,
  members1_.id as id1_0_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_
from
  Member members0_
where
  members0_. TEAM_ID in (
      ?, ?
  )

조인을 하지 않지만, IN 절을 이용해서 한 번에 연관된 Member들을 가져온 것이다. List<Member> membersLazy loading이다. 여기서 @BatchSize를 사용하면 Lazy loading 시점(사용하는 시점)에 결과로 얻은 List<Team>IN 쿼리로 넘기는 것이다.


즉, 레이지 로딩 시 List<Team>에 담긴 모든 Team 을 사이즈만큼 IN 쿼리로 넘겨서 레이지 로딩을 한번에 가져오는 것이다. 그래서 N+1이 아니라 테이블 수만큼만 가져오는 최적화를 이룰 수 있다.


@BatchSize는 글로벌 세팅을 할 수 있다. <property name="hibernate.default_batch_fetch_size" value="5" />



경로 표현식


.(점)을 찍어 객체 그래프를 탐색하는 것

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

경로 표현식 용어 정리

  • 상태 필드(state field) : 단순히 값을 저장하기 위한 필드 (ex: m.usernmae)
  • 연관 필드(association field) : 연관관계를 위한 필드
    • 단일 값 연관 필드 : XToOne, 대상이 엔티티 (ex: m.team)
    • 컬렉션 값 연관 필드 : XToMany, 대상이 컬렉션 (ex m.orders)



경로 표현식 특징

  • 상태 필드(state field) : 경로 탐색의 끝, 탐색 x

  • 단일 값 연관 경로 : 묵시적 내부 조인(inner join) 발생, 탐색 O

select m.team.name from Member m; //team에서 경로탐색이 더 가능하다. (name)

  • 컬렉션 값 연관 경로 : 묵시적 내부 조인 발생, 탐색 X

    • FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능
select m.username from Team t join t.members m;



✅ 실무에서는 명시적 조인을 사용하도록 하자

  • 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다.

✅ 조인은 SQL 튜닝에 중요 포인트



다형성 쿼리




TYPE

  • 조회 대상을 특정 자식으로 한정
  • ex) Item 중 Book, Movie를 조회해라
// 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)

  • 자바의 타입 캐스팅과 유사 (형 변환)
  • 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용
  • FROM, WHERE, SELECT (하이버네이트 지원) 사용
  • ex) 부모 Item과 자식 Book이 있다.
// JPQL
select 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에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키값을 사용
// 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은 같다.
// 엔티티를 파라미터로 전달
String jpql = "select m from Member m where m = :member";
List resultList = em.createQuery(jpql)
					.setParameter("member", member)
					.getResultList();
                    
// 식별자를 직접 전달
String jpql = "select m from Member m where m.id = :memberId";
List resultList = em.createQuery(jpql)
					.setParameter("memberId", memberId)
					.getResultList();

  • 위 두 JPQL의 실행 SQL은 아래와 같이 동일하다.
select m.* from Member m where m.id = ?



외래 키값

  • 기본 키와 로직은 동일하다. 엔티티 or 외래 키를 쓰면 실행 SQL은 동일하다.
Team team = em.find(Team.class, 1L);

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

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

실행된 SQL

select m.* from Member m where m.team_id = ?



Named 쿼리


  • 미리 정의해서 이름을 부여해두고 사용하는 JPQL
  • 정적 쿼리
  • 어노테이션, XML에 정의
@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", "회원1")
							.getResultList();

  • 애플리케이션 로딩 시점에 초기화 후 재사용
    → JPA는 결국 SQL로 parsing 되어 사용되는데 로딩 시점에 초기화가 된다면 parsing cost를 절약 가능.

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



XML에 정의

<persistence-unit name="jpabook">
		<mapping-file>META-INF/ormMember.xml</mapping-file>

<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="htt://xmlns.jcp.org/xml/ns/persistence/orm" version="2.1">
		<named-query name="Member.findByUsername">
				<query>
					<![CDATA[ select m from Member m where m.username = :username]]
				</query>
		</named-query>
</entity-mappings>

사용법은 @NamedQuery의 query를 사용방법과 같다.


  • NamedQuery와 XML에 정의된 Query 중 XML이 항상 우선권을 가진다.
    → 애플리케이션 운영 환경에 따라 다른 XML를 배포할 수 있다.



✅ SpringData JPA를 사용하는 사람은 NamedQuery를 이미 사용

@Repository
public interface MemberRepository extends JpaRepository<Member, Long>{

	@Query("select u from User u where u.username = ?1")
	Member findByUsername(String username);
}

@Repository annotation이 등록된 인터페이스에서 사용되는 @Query annotation에 있는 JPQL(or native)들이 NamedQuery로써 컴파일 시에 등록되는 것이다.



벌크 연산


  • 일반적으로 우리가 알고 있는 SQL의 update or delete 문을 생각하면 된다.

    	ex) 재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면?

  • JPA의 dirty check로 실행하기 위해서는 너무 많은 SQL이 실행돼야 한다.

    	1. 재고가 10개 미만인 상품을 리스트 조회
    	2. 상품 엔티티의 가격 10% 증가
    	3. 트랜잭션 커밋 시점에 dirty checking

  • 변경된 데이터가 100건이면 100건의 UPDATE SQL 실행



Example

  • 쿼리 한 번으로 여러 테이블 업데이트 (엔티티)
  • executeUpdate()의 결과는 영향받은 엔티티 수 반환
  • UPDATE, DELETE 지원
  • INSERT (insert into ... select, 하이버네이트 지원)
String query = "update Product p "+
			   "set p.price = p.price * 1.1 where p.stockAmount < :stockAmount";

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



벌크 연산 주의

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리

    • 벌크 연산을 먼저 실행

    • 벌크 연산 수행 후 영속성 컨텍스트 초기화
      → 엔티티 조회 후 벌크 연산으로 엔티티 업데이트가 돼버리면 DB의 엔티티와 영속성 컨텍스트의 엔티티가 서로 다른 값이 되게 된다.

profile
습관이 전부다.

0개의 댓글