DB/JPA

[JPA] JPQL 추가 정리

꾸준함. 2021. 10. 18. 02:56

개요

지난 게시글(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 프로그래밍 - 기본편 (김영한 강사님)

반응형