백엔드/JPA

[JPA] [11] 객체지향 쿼리 언어 - 중급 문법 (상편)

RE-Heat 2023. 8. 19. 16:24

https://www.inflearn.com/course/ORM-JPA-Basic

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런 | 강의

초급자를 위해 준비한 [웹 개발, 백엔드] 강의입니다. JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자

www.inflearn.com

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

[1] 경로 표현식

.(점)을 찍어 객체 그래프를 탐색하는 것을 일컬음
select m.username // 상태 필드
from Member m
    join m.team t // 단일 값 연관 필드
    join m.orders o // 컬렉션 값 연관 필드
where t.name = '팀A'

 

■ 경로 표현식 용어 정리

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

■ 경로 표현식 특징

  • 상태 필드(state field): 경로 탐색의 끝, 탐색 X
  • 단일 값 연관 경로: 묵시적 내부 조인(inner join) 발생, 탐색 O
String query = "select m.team From Member m";

select m.team.name From Member m처럼 team에서 경로 탐색을 더 들어갈 수 있다.

  • 컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 탐색 X
    • FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능
String query = "select t.members From Team t";
// 명시적 조인: "select m.username From Team t join t.members m"

Collection result = em.createQuery(query, Collection.class)
        .getResultList();

컬렉션 값은 쓸 수 있는 게 size() 정도밖에 없다. 그래서 별칭을 얻어 탐색하려면 명시적 조인을 써야 한다.

 

결론 : 실무에선 묵시적 내부조인을 쓰지 말고 명시적 조인을 사용하자!

  • 묵시적 내부조인은 성능 튜닝에 지대한 영향을 준다.
  • 묵시적 내부 조인은 조인이 일어나는 상황을 파악하기 어렵다.

 

[2] 페치 조인(fetch join) 1 - 기본

연관된 엔티티나 컬렉션을 한 번에 함께 조회하는 기능 - 지연로딩의 N + 1 문제 대부분 해결 가능
  • SQL 조인 종류 X
  • JPQL에서 성능 최적화를 위해 제공하는 기능
  • join fetch 명령어 사용
  • 페치 조인 ::= [ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로

 

■ 엔티티 페치 조인

① 개념

  • 회원을 조회하면서 연관된 팀도 함께 조회(SQL 한 번에)
    • 팀(T.*)도 함께 조회되는 것을 확인할 수 있다.
-- [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;

② 도식화

팀이 있는 회원 조회가 목적. fetchjoin을 하면 내부적으로 inner join을 쓴다. inner join이라 null인 회원 4값은 누락됨.

 

③ 예시

1. fetch 조인을 쓰지 않을 때

  • 지연 로딩이므로 Member 조회(Team은 프록시 객체 상태)한 뒤, Team의 정보를 조회한다.
  • 이후 팀 A를 SQL로 불러온 뒤 같은 팀 A에 소속된 회원 2는 1차 캐시에서 값을 가져온다.
  • 팀 B는 아직 조회하지 않았으므로 select 쿼리를 한 번 더 실행한다. 다시 말해 N + 1 문제가 발생한다.

 

2. 페치 조인 - N+1 문제 해결

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

  • fetch가 지연로딩보다 우선이다. 그리고 페치 조인은 조회 당시 실제 엔티티가 담기므로 지연로딩 없이 바로 사용가능

 

■ 컬렉션 페치 조인

① 일대다 관계, 컬렉션 페치 조인 - DB가 뻥튀기되는 게 문제

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

 

1. 그림으로 설명

팀A는 하나이나 멤버가 회원1, 회원2 두 개이므로 조회 결과는 두 개로 불어난다.(영한님은 이를 뻥튀기라고 표현)

2. 예시

로그에 팀A가 두 번  찍히는 걸 확인할 수 있다.

 

■ 페치 조인과 DISTINCT (하이버네이트 6 버전 이전 기준)

DISTINCT로 페치 조인 중복 해결 가능.
(그런데 하이버네이트 6부턴 이 명령어를 안 써도 애플리케이션에서 자동으로 중복을 제거한다.)
  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령
  • JPQL의 DISTINCT 2가지 기능 제공
    • 1. SQL에 DISTINCT를 추가
    • 2. 애플리케이션에서 엔티티 중복 제거
String query = "select distinct t From Team t join fetch t.members";

위 코드를 추가하면 SQL에도 반영되지만, 데이터가 다르므로 중복이 제거되지 않는다.

 

따라서 JPA는 애플리케이션에서도 중복 제거를 시도한다.

  • 같은 식별자를 가진 Team 엔티티를 제거

 

참고] 일대다에선 데이터가 뻥튀기되지만, 다대일·일대일에선 뻥튀기되지 않는다.

 

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

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

String query = "select t From Team t join t.members";

• 반면 페치 조인은 실행 시 연관관계도 같이 조회한다.(즉시 로딩 역할 - but 쿼리를 join해 한 번만 날려 성능 UP!!! )

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

 

일반 조인(좌)와 페치 조인(우)

 

[3] 페치 조인 2 - 한계

■ 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다.
    • 하이버네이트는 가능, 가급적 사용 X

JPA는 연관된 엔티티를 전부 다 조회하는 것으로 설계돼 있기 때문, 조인관계를 몇 단계 거쳐야할 때만 사용된다.

  • 둘 이상의 컬렉션은 페치 조인 할 수 없다.
    • 예를 들어 일대다 - 다대다면 
  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능
    • 하이버네이트는 경고 로그를 남기고 메모리에서 페이징(매우 위험)

fetch join시 페이징을 써도 SQL문엔 변화가 없어 백만 건을 다 가져와 버림.

 

■ 해결 방안

1. 일대다를 다대일로 방향을 전환한다.

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

2. BatchSize를 쓴다.

  • @BatchSize를 쓰면 size만큼 조회해 온다. (where memebrs0_TEAM_Id in( ?, ?) ) 

  • BatchSize()는 글로벌 설정도 가능하다.
//persistence.xml
<property name="hibernate.default_batch_fetch_size" value="100"/>

이러면 @BatchSize를 따로 해주지 않아도 알아서 적용된다.

 

■ 특징

  • 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화
  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함
    • @OneToMany(fetch = FetchType.LAZY) //글로벌 로딩 전략
  • 실무에서 글로벌 로딩 전략은 모두 지연 로딩
  • 최적화가 필요한 곳은 페치 조인 적용

 

※ 페치 조인 - 정리

  • 모든 것을 페치 조인으로 해결할 수는 없음
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적
  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적

 

[4] 다형성 쿼리

■ TYPE

  • 조회 대상을 특정 자식으로 한정
  • ex: 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');

참고] [JPA] [10] 객체지향 쿼리 언어 - 기본 문법 (하편)

[7] JPQL 타입 표현과 기타식에서 한 차례 사용했음.

 

■ TREAT

  • 자바의 타입 캐스팅과 유사
  • 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용
  • FROM, WHERE, SELECT(하이버네이트 지원) 사용

 

예시] 부모인 Item과 자식 Book이 있다

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

 

[5] 엔티티 직접 사용

■ 기본 키 값

  • JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값을 사용

예시]

//엔티티를 파라미터로 전달
String query = "select m From Member m where m = :member";
Member findMember = em.createQuery(query, Member.class)
        .setParameter("member", member1)
        .getSingleResult();

//식별자를 파라미터로 전달
String query = "select m From Member m where m.id = :memberId";
Member findMember = em.createQuery(query, Member.class)
        .getParameter("memberId", member1.getId())
        .getSingleResult();

JPQL에 엔티티를 직접 사용해도 SQL 날릴 때 알아서 기본 키 값으로 변환해준다.

 

■ 외래 키 값

//파라미터로 엔티티를 전달
String query = "select m from Member m where m.team = :team";
List<Member> members = em.createQuery(query, Member.class)
        .getParameter("team", teamA)
        .getResultList();
        
//외래 키 전달
String query = "select m from Member m where m.team.id = :teamId";
List<Member> members = em.createQuery(query, Member.class)
        .getParameter("teamId", teamA.getId)
        .getResultList();

 

이 것도 아래와 같은 SQL이 날아간다.

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

 

[6] Named 쿼리

쿼리에 이름을 부여해서 사용. 애플리케이션 로딩 시점에 쿼리를 검증해 오류를 빠르게 확인할 수 있다.
  • 미리 정의해서 이름을 부여해 두고 사용하는 JPQL
  • 정적 쿼리만 가능, 동적 쿼리 X
  • 애노테이션, XML에 정의
  • 애플리케이션 로딩 시점에 초기화 후 재사용
  • 애플리케이션 로딩 시점에 쿼리를 검증

 

① 애노테이션

  • @NameQuery()의 name으로 이름을 지정하고 query에 SQL 문을 적어준다.
  • em.createNamedQuery로 NamedQuery를 불러온다.

 

② XML에 정의

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

//[META-INF/ormMember.xml]
<?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>
  • XML이 항상 우선권을 가진다.
  • 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.

참고] Spring Data JPA에서 쓰는 @Query엔 NamedQuery가 적용돼 있다. 따라서 @Query안 JPQL은 NamedQuery로 컴파일 시 등록된다. 단, 이름은 따로 지정해주지 않아 사실상 NoNamedQuery라 봐야 한다.

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    @Query("select u from User u where u.username = ?1")
    Member findByUsername(String username);
}

 

[7] 벌크 연산

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

 

■ 예제

  • 쿼리 한 번으로 여러 테이블 로우 변경(엔티티)
  • executeUpdate()의 결과는 영향받은 엔티티 수 반환
  • UPDATE, DELETE 지원
  • INSERT(insert into .. select, 하이버네이트 지원

 

■ 벌크 연산 주의점

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리 한다.
  • 벌크 연산을 먼저 실행
  • 벌크 연산 수행 후 영속성 컨텍스트 초기화

① 영속성 컨텍스트 초기화 X

영속성 컨텍스트 안에 있는 값을 find해 age가 0인 걸 확인할 수 있다. 반면 DB 값은 20세로 다 바뀌어 있다.

 

② 영속성 컨텍스트 초기화

em.clear() 이후 find를 하면 다시 select문을 날려 DB 수정 값으로 초기화 -> age=20으로 바뀐 걸 확인 가능

 

예시]

1. 미리 조회해 놓은 Member 객체가 영속성 컨텍스트에 있음.

2. 벌크 연산으로 연봉을 5천만 원에서 6천만 원으로 바꿈. DB는 연봉 수정이 반영됨.

3. 그러나 1차 캐시엔 5천만 원인 상태

4. 따라서 기존 5천만 원이 아닌 바뀐 값을 불러오기 위해 초기화(em.clear())를 해줘야 함.