개요
지난 게시글(https://jaimemin.tistory.com/1997)에 이어 아래의 JPQL 개념에 대해 정리해보겠습니다.
- 경로 표현식
- Fetch Join
- 엔티티 파라미터
- Named 쿼리
- 벌크 연산
1. 경로 표현식
- 엔티티의 getter와 동일한 개념
- ex) SELECT e.id FROM Employee e
- e.id와 같이 .을 찍어 객체 그래프를 탐색하는 것을 경로 표현식이라고 함
- 경로 표현식은 3가지 종류가 존재
- 상태 필드
- 단일 값 연관 필드
- 컬렉션 값 연관 필드
1.1 상태 필드(state field)
- 단순히 값을 저장하기 위한 필드 (e.name과 같은 필드)
- 경로 탐색의 끝 즉, 이후에 더 이상 점을 찍을 수 없음
- ex) SELECT e.name, e.age FROM Employee e
1.2 연관 필드(association field)
- 연관관계를 위한 필드
- 단일 값 연관 필드
- 컬렉션 값 연관 필드
- 단일 값 연관 필드
- @ManyToOne, @OneToOne 관계에 있는 엔티티가 대상
- 경로 탐색을 이어나갈 수 있음
- ex) SELECT e.company.location FROM Employee e
- 직원이 속해있는 회사의 위치를 조회 가능 (핵심은 company에서 더 이어서 점을 찍어 탐색을 할 수 있음)
- JPQL로 작성하는 쿼리 자체는 간단하지만 묵시적 Inner Join이 발생하기 때문에 쿼리 튜닝 어려움
- JPQL: SELECT e.company.location FROM Employee e
- 실제 실행되는 SQL: SELECT c.location FROM Member m INNER JOIN Company c ON m.company_id = c.id
- 이처럼 묵시적으로 JOIN이 발생하기 때문에 쿼리 튜닝하기가 힘들고 묵시적 JOIN 횟수가 늘어남에 따라 치명적인 성능 이슈가 발생할 수 있음 (묵시적 Join은 Inner Join만 가능)
- 컬렉션 값 연관 필드
- @OneToMany, @ManyToMany 관계에 있는 컬렉션이 대상
- 컬렉션이므로 경로 탐색을 이어나갈 수 없음
- 단, FROM 절에서 명시적 Join을 통해 Alias 획득 시 Alias를 통해 탐색 이어나갈 수 있음
- 단일 값 연관 필드와 마찬가지로 묵시적 Inner Join이 발생하므로 쿼리 튜닝 어려움
1.3 경로 표현식 올바른 예
- SELECT e.company.location FROM Employee e
- 성공이지만 묵시적 Join이 발생하므로 좋은 예시는 아님
- SELECT e.collections FROM Employee e
- 컬렉션 값 연관 필드
- 이 또한, 묵시적 Join 발생 (collections 값을 fetch 할 때 JOIN문 사용해야 함)
- SELECT m.username FROM Team t JOIN t.members m
- 컬렉션 값 연관 필드
- 원래는 컬렉션 값 연관 필드는 추가 경로 탐색이 안됨
- 하지만, Alias를 통해 별칭을 줬으므로 추가 탐색이 가능
- 또한, 명시적 Join을 했기 때문에 Join이 발생한다는 것을 확인할 수 있어 가독성이 좋음
1.4 경로 표현식 정리
- 묵시적 조인을 항상 조심
- 묵시적 조인은 Inner Join만 가능함
- 컬렉션에서 추가 탐색을 원한다면 Alias를 통해 별칭을 부여해야 함
- 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM절에 영향 끼침
- 결론은 가급적 명시적 Join만 사용 (Join은 SQL 튜닝의 핵심)
2. Fetch Join
- SQL의 조인 종류는 Inner Join, Outer Join, 그리고 Cross Join
- Fetch Join은 SQL 조인 종류가 아닌 JPQL에서 성능 최적화를 위해 제공하는 기능
- Fetch Join은 연관된 엔티티나 컬렉션을 SQL을 통해 한번에 함께 조회하는 기능
- 즉, 지연 로딩일지라도 명시적 Join을 통해 한번에 불러오는 기능
- N+1 문제를 해결할 수 있는 방법
- N+1에 대해서는 추후 설명 예정
- ex) JPQL: SELECT e FROM Employee e JOIN FETCH e.company
- ex) SQL: SELECT e.*, c.* FROM Employee e INNER JOIN Company c ON e.company_id = c.id
- Lazy Loading으로 설정했더라도 Fetch Join을 통해 Eager Loading처럼 작동
- 정리하자면, Fetch Join은 객체 그래프를 SQL 한번 실행에 조회하는 개념
2.1 Fetch Join 예제
- 회원이 3명 있고 이 중 두 명은 팀 A, 한 명은 팀 B에 속한다고 가정
- 회원 내 팀은 @ManyToOne 관계이고 지연 로딩
- 이런 상황에서 팀들을 조회하고 팀의 @OneToMany 연관관계에 있는 회원 목록 내 회원의 이름과 팀명을 모두 출력할 때 Fetch Join을 사용하는 것과 사용하지 않는 것의 차이점은?
2.1.1 Fetch Join을 사용하지 않을 경우
- 회원 1, 팀 A를 조회하기 위해 묵시적 Join SQL 실행
- 회원 2, 팀 A
- 이미 팀 A는 PersistenceContext로 관리되기 때문에 1차 캐시에서 불러옴
- 별도 묵시적 Join SQL을 실행할 필요 없음
- 회원 3, 팀 B를 조회하기 위해 묵시적 Join SQL 실행
- 위와 같은 상황에서 회원 100명이 존재하고 각기 다른 팀일 경우 별도 쿼리 100번 호출 (성능 이슈)
- N + 1 문제 발생 [회원을 가져오기 위한 쿼리 1번 + 별도 쿼리 N번]
- 즉시 로딩을 하던 Lazy 로딩을 하던 전부 발생 => 이는 fetch join으로 해결해야 함
2.1.2 Fetch Join을 사용하는 경우
- 명시적 Join을 통해 즉시 로딩이던 지연 로딩이던 필요한 데이터를 한 번에 가져옴
- 루프를 돌 때 프록시가 아닌 진짜 데이터가 존재
- 따라서, 지연 로딩 없이 깔끔하게 쿼리 1번으로 필요한 데이터 들고 옴
2.2 컬렉션 Fetch Join일 경우 데이터 양이 실제보다 많이 나오는 문제
- Fetch Join을 통해 [N+1] 문제를 해결한 것은 확인
- 하지만, 이상한 점은 팀은 기준으로 출력했는데도 결과가 3개였다는 점
- 회원 1, 팀 A
- 회원 2, 팀 A
- 회원 3, 팀 B
- 예상하기로는 팀 A, 팀 B 단 둘일 뿐이므로 컬렉션 사이즈가 2
- PersistenceContext에서는 팀 A가 하나로 인식이 되지만 팀 내 회원 Collection에는 팀 A가 두 개가 존재하는 것이 문제
- 팀 A, 팀 B 두 개만 출력하기를 원한다면 SQL의 Distinct 키워드를 떠올릴 수 있음
- 하지만, 회원 1, 회원 2의 이름이 다르므로 일반적인 SQL의 Distinct로는 팀 A가 두 번 출력되는 것을 방지할 수 없음
- 다행히도 JPA에서는 Distinct 키워드 부여 시 영속성 컨텍스트 내 같은 식별자를 가진 Team 엔티티 제거
- 따라서, 원하는 결과를 위해서는 Distinct 키워드를 붙여야 함
* @OneToMany 관계의 경우 위와 같은 문제가 발생할 수 있음
* 반면, @ManyToOne의 경우 데이터가 뻥튀기되는 것을 걱정하지 않아도 됨
2.3 Fetch Join의 특징과 한계
- Fetch Join 대상에는 Alias를 통해 별칭을 부여할 수 없음
- Fetch Join의 경우 연관된 모든 field를 조회
- Alias 조작하다 일부 데이터 누락한 상태로 데이터 변경이 이루어질 수 있음
- 일부만 가져오고 싶을 경우 별도 쿼리를 실행하는 것을 추천
- Hibernate에서는 Fetch Join 대상에도 Alias를 부여할 수 있도록 하지만 가급적 사용 X
- 하나의 컬렉션에 대해서만 Fetch Join 가능
- 컬렉션을 Fetch Join 할 경우 Paging API 사용 불가
- 앞서 1:다 관계의 경우 데이터 뻥튀기가 가능하므로 페이징 X
- 반면, 1:1, 다:1와 같이 단일 값 연관 필드들은 Fetch Join을 해도 페이징 API 지원
- @BatchSize 어노테이션을 적용할 경우 해결 가능
- @BatchSize는 컬렉션을 조회할 때 한 번에 몇 개의 데이터를 가져오는지 설정하는 어노테이션 (1,000 이하로 설정하는 것을 권장)
- @BatchSize 어노테이션을 통해 LAZY 로딩 시 BatchSize만큼 넘기기 때문에 쿼리 3개가 아닌 2개만 날림
- @BatchSize는 [N+1] 문제의 해결방법 중 하나
- Fetch Join은 누차 강조하지만 연관된 엔티티들을 SQL 한 번 실행으로 조회하는 성능 최적화 기능
- 실무에서는 글로벌 로딩 전략을 기본적으로 Lazy Loading으로 설정하고 최적화가 필요한 곳은 Fetch Join을 적용하여 해결하는 방식을 추천
- 대부분의 JPA 성능 이슈는 [N + 1] 문제에서 발생하므로 Fetch Join과 @BatchSize는 알고 있어야 함
2.4 Fetch Join 정리
- Fetch Join은 객체 그래프를 유지할 때 효과적
- 즉, 모든 연관 필드를 찾아갈 때 유리
- 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 할 경우, Fetch Join을 통해 모든 연관 필드를 불러오는 것은 비효율적
- 명시적 Join을 사용하여 필요한 데이터들만 조회해서 별도로 생성한 DTO로 반환하는 것이 효과적
3. 엔티티 파라미터
- JPQL은 테이블 중심이 아닌 엔티티 중심
- 따라서, 파라미터로 엔티티를 직접 사용해도 됨
- 이를 SQL로 변환하면 결국에는 엔티티의 primary key 즉, 기본키를 파라미터로 넘긴다는 것을 알 수 있음
- 엔티티를 식별하는 것이 기본키이기 때문
- ex) JPQL: SELECT COUNT(e) FROM Employee e
- ex) SQL: SELECT COUNT(e.id) FROM Employee e
- 따라서, 파라미터 바인딩을 통해 엔티티 자체를 넘길 수가 있음
4. Named Query
- 엔티티에 미리 정의해서 별도 이름을 부여해두고 사용하는 JPQL
- 정적 쿼리이며 어노테이션으로 정의
- XML로도 정의할 수 있지만 XML은 거의 사용하지 않음
- 애플리케이션 로딩 시점에 초기화 후 재사용 가능하다는 것이 장점
- 로딩 시점에 애플리케이션이 SQL을 파싱 하고 캐싱하기 때문에 성능 최적화
- 애플리케이션 로딩 시점에 쿼리를 검증
- 런타임 에러를 통해 장애를 방지 가능
- 런타임 에러기 때문에 애플리케이션을 실행했을 때 SQL grammar 에러를 파악 가능
- Entity 클래스가 지저분해지는 단점이 존재
- 이는 Spring Data JPA를 적용할 경우 해결 가능
5. 벌크 연산
- JPA는 벌크성보다는 단발성에 좀 더 최적화되어 있지만 벌크 연산도 지원
- 직원들의 연봉을 일괄 200만 원 인상하는 쿼리를 실행할 경우 직원의 수만큼 SQL을 호출해야 함
- 이는 DB에 부하를 줄 수 있음 (운영팀에서 안 좋아할 것이 분명함)
- 이때 등장하는 개념이 벌크 연산
- 벌크 연산은 쿼리 한 번으로 여러 테이블의 로우를 변경
- executeUpdate() 메서드를 통해 벌크 연산이 가능하며 리턴 값은 영향받은 엔티티 수 반환
- UPDATE 및 DELETE에 주로 쓰이며 Hibernate을 통해 INSERT에서도 가능
- 주의할 점은 벌크 연산은 PersistenceContext를 거치지 않고 DB에 직접 쿼리를 실행
- 이는 영속성 컨텍스트에 아무 작업도 하지 않고 바로 벌크 연산을 먼저 실행하면서 해결도 가능하고
- 벌크 연산 수행 후 영속성 컨텍스트를 초기화하는 방식으로도 해결 가능
출처
자바 ORM 표준 JPA 프로그래밍 - 기본편 (김영한 강사님)
반응형
'DB > JPA' 카테고리의 다른 글
[JPA] Hibernate MultipleBagFetchException (0) | 2023.06.28 |
---|---|
[JPA] 준영속(Detached) 상태 엔티티 수정하는 방법 (0) | 2023.05.07 |
[JPA] JPQL 간단 정리 (0) | 2021.10.16 |
[JPA] 값 타입 정리 (0) | 2021.09.29 |
[JPA] 프록시와 연관관계 관리 정리 (0) | 2021.09.14 |