이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


경로 표현식

JPQL(Java Persistence Query Language)에서 경로 표현식(Path Expression)은 엔터티의 속성에 접근하거나 연관된 엔터티를 탐색하기 위해 사용된다. 이는 객체 그래프를 따라갈 수 있게 하며, 다양한 쿼리에서 데이터를 필터링하고 조회하는 데 중요한 역할을 한다.

 

쉽게 말하자면 .(점)을 찍어 객체 그래프를 탐색하는 것이다.

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)

경로 탐색의 끝, 객체 그래프 탐색 불가능

  • JPQL: select m.username, m.age from Member m
  • SQL: select m.username, m.age from Member m

-단일 값 연관 경로

묵시적 내부 조인(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

 

-컬렉션 값 연관 경로

묵시적 내부 조인 발생, 객체 경로 탐색 불가능

FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능

 

명시적 조인, 묵시적 조인

  • 명시적 조인: join 키워드 직접 사용
    select m from Member m join m.team t
  • 묵시적 조인: 경로 표현식에 의해 묵시적으로 SQL 조인 발생 (내부 조인만 가능)
    select m.team from Member m
경로 탐색을 사용한 묵시적 조인 시 주의사항
-항상 내부 조인이 일어난다.
-컬렉션은 경로 탐색의 끝, 명시적 조인을 통해 별칭을 얻어야 한다.
-경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM (JOIN)절에 영향을 준다.

실무에서는 묵시적 조인을 지양하고 명시적 조인을 사용해야 한다.

 

경로 표현식 예시

  • select o.member.team from Order o -> 성공
  • select t.members from Team -> 성공
  • select t.members.username from Team t -> 실패
  • select m.username from Team t join t.members m -> 성공

 

FETCH JOIN

JPA에서의 Fetch Join은 여러 엔터티를 조인하면서 관련된 엔터티의 데이터도 함께 가져오는 방법이다.

이는 N+1 문제를 해결하기 위해 자주 사용된다.

Fetch Join을 사용하면 필요한 데이터를 한 번의 쿼리로 모두 가져올 수 있어 성능을 최적화할 수 있다.

 

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

 

엔티티 Fetch Join(Entity Fetch Join)과 컬렉션 Fetch Join(Collection Fetch Join)은 JPA에서 연관된 엔터티를 로딩하기 위해 사용되는 두 가지 페치 조인 방식이다. 이 두 방식은 사용하는 목적과 데이터 구조에 따라 차이점이 있다.

관계의 유형

  • 엔티티 Fetch Join 조인은 주로 다대일(N:1) 또는 일대일(1:1) 관계에 사용된다.
  • 컬렉션 Fetch Join 조인은 주로 일대다(1:N) 또는 다대다(N:M) 관계에 사용된다.

 

로딩되는 데이터의 구조

  • 엔티티 Fetch Join은 단일 엔터티를 로드한다. 예를 들어, Member가 Team을 로드하는 경우, 각 Member는 하나의 Team 객체를 포함한다.
  • 컬렉션 Fetch Join은 여러 개의 엔터티(컬렉션)를 로드한다. 예를 들어, Team이 여러 Member를 로드하는 경우, 각 Team 객체에는 여러 Member 객체가 포함된다.

 

데이터 중복 가능성

  • 엔티티 Fetch Join은 데이터 중복 문제가 상대적으로 적다.
  • 컬렉션 Fetch Join은 여러 행의 데이터베이스 결과가 동일한 부모 엔터티를 포함할 수 있어 중복된 데이터가 발생할 수 있다.

 

페이징 처리의 제약

  • 컬렉션 Fetch Join은 페이징 처리(setFirstResult, setMaxResults)가 올바르게 작동하지 않을 수 있다. 이는 조인 결과의 크기가 예측 불가능하기 때문에, 데이터베이스 수준에서 정확한 페이징을 적용하기 어렵기 때문이다.

 

엔티티 FETCH JOIN

  • 회원을 조회하면서 연관된 팀도 함께 조회(SQL 한 번에)
  • SQL을 보면 회원 뿐만 아니라 팀(T.*)도 함께 SELECT
    -[JPQL] select m from Member m join fetch m.team
    -[SQL]SELECT M.*, T.* FROM MEMBER M INNER JOIN TEAM T ON M.M.TEAM_ID=T.ID

 

Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);

Team teamB = new Team();
teamB.setName("teamB");
em.persist(teamB);

Member member1 = new Member();
member1.setUsername("member1");
member1.setAge(10);
member1.setTeam(teamA);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member2");
member2.setAge(10);
member2.setTeam(teamA);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("member3");
member3.setAge(10);
member3.setTeam(teamB);
em.persist(member3);

em.flush();
em.clear();

String jpql = "select m from Member m join fetch m.team"; //페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩X

List<Member> members = em.createQuery(jpql, Member.class).getResultList();

for (Member m : members) {
    System.out.println("username = " + m.getUsername() + ", " + "teamName = " + m.getTeam().getName());
}

 

컬렉션 FETCH JOIN

일대다 관계에서 JOIN(FETCH JOIN도 마찬가지)을 사용하면 데이터 중복 문제가 발생한다.

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

Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);

Team teamB = new Team();
teamB.setName("teamB");
em.persist(teamB);

Member member1 = new Member();
member1.setUsername("member1");
member1.setAge(10);
member1.setTeam(teamA);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member2");
member2.setAge(10);
member2.setTeam(teamA);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("member3");
member3.setAge(10);
member3.setTeam(teamB);
em.persist(member3);

em.flush();
em.clear();

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

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

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

String jpql = "select t from Team t join fetch t.members where t.name = 'teamA'";//페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
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.getMembers()) {
        System.out.println("-> username = " + member.getUsername()+ ", member = " + member);
    }
}

필자의 개발 환경에서는 데이터 중복 문제가 발생하지 않았다.(Member 엔티티 지연로딩 확인함)

아마 하이버네이트가 버전이 업되면서 데이터 중복 문제를 해결한 것으로 보인다.

하이버네이트6 부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용

 

어찌되었든간에 데이터 중복 문제를 해결하려면 DISTINCT를 사용해야 한다.

String query = "select distinct t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class).getResultList();

DISTINCT가 추가로 애플리케이션에서 중복 제거시도를 하게되어서 같은 식별자를 가진 Team 엔티티 제거한다.

애플리케이션 레벨에서 중복제거
쿼리 실행 후 애플리케이션 레벨에서 중복된 엔터티를 제거할 수도 있다. 이는 Fetch Join을 사용하지 않고, 필요한 데이터를 가져온 후 Java 코드에서 중복을 필터링하는 방식이다. 예를 들어, Set 컬렉션을 사용하여 중복된 엔터티를 자동으로 제거할 수 있다.
List<Team> result = em.createQuery(query, Team.class).getResultList();
Set<Team> uniqueTeams = new HashSet<>(result);​

 

 

JOIN 과 FETCH JOIN의 차이점

일반 조인 실행시 연관된 엔티티를 함께 조회하지 않음
[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은 결과를 반환할 때 연관관계 고려X
단지 SELECT 절에 지정한 엔티티만 조회할 뿐임
여기서는 팀 엔티티만 조회하고, 회원 엔티티는 조회X
FETCH JOIN을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)
FETCH JOIN은 객체 그래프를 SQL 한번에 조회하는 개념

 

FETCH JOIN의 특징과 한계

1. 페치 조인 대상에는 별칭을 줄 수 없다.

하이버네이트는 가능, 가급적 사용X

select t from Team t join t.members m where m.age > 10 //t.members에 별명 부여(m)

 

2. 둘 이상의 컬렉션은 FETCH JOIN 할 수 없다.

 

3. 컬렉션을 FETCH JOIN하면 페이징 API(setFirstResult,setMaxResults)를 사용할 수 없다.

  • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능
  • 하이버네이트는 경고 로그를 남기고 메모리에서 페이징(매우 위험)

 

4. 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화

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

@BatchSize는 JPA와 Hibernate에서 지연 로딩(Lazy Loading)을 최적화하기 위한 어노테이션이다. 이 어노테이션은 엔터티나 컬렉션 필드에 적용되어, 한 번에 로드할 데이터의 개수를 설정할 수 있다. 이를 통해 N+1 문제를 완화하고, 데이터베이스 접근 횟수를 줄여 성능을 향상시킬 수 있다.

1. 엔티티 클레스에 사용
엔터티 클래스에 @BatchSize를 적용하면, 해당 엔터티가 지연 로딩될 때 한 번에 로드할 엔터티의 개수를 지정할 수 있다.
@Entity
@BatchSize(size = 10)
public class Team {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}​
위 예제에서는 Team 엔터티에 @BatchSize(size = 10)을 적용하였다. 이는 Team 엔터티가 지연 로딩될 때, 한 번에 최대 10개의 Team 엔터티를 로드하라는 의미이다.

2. 컬렉션 필드에 사용
컬렉션 필드에 @BatchSize를 적용하면, 해당 컬렉션이 지연 로딩될 때 한 번에 로드할 엔터티의 개수를 설정할 수 있다.
@Entity
public class Team {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    @BatchSize(size = 5)
    private List<Member> members = new ArrayList<>();
}​
위 예제에서는 members 컬렉션 필드에 @BatchSize(size = 5)를 적용하였다. 이는 Team 엔터티의 members 컬렉션이 지연 로딩될 때, 한 번에 최대 5개의 Member 엔터티를 로드하라는 의미이다.

장점
지연 로딩 시 한 번에 여러 엔터티를 가져옴으로써 N+1 문제를 줄일 수 있다. 예를 들어, @BatchSize(size = 10)을 사용하면 최대 10개의 엔터티를 한 번의 쿼리로 가져온다.
필요한 경우 여러 엔터티를 한 번에 로드하여 데이터베이스 접근 횟수를 줄이고, 네트워크 트래픽을 줄일 수 있다.

단점
한 번에 많은 엔터티를 로드하면 메모리 사용량이 증가할 수 있다. 따라서 설정 값에 따라 메모리 사용을 신중히 고려해야 한다. 배치 크기를 너무 크게 설정하면 오버헤드가 증가할 수 있고, 너무 작게 설정하면 성능 향상을 기대하기 어려울 수 있다.
적절한 크기를 설정하는 것이 중요하다.

 

5. 모든 것을 페치 조인으로 해결할 수는 없음

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

 

다형성 쿼리

TYPE

TYPE은 특정 엔티티 타입을 구분하는 데 사용되는 기능이다. 부모 클래스나 상위 클래스의 쿼리를 수행하면서 특정 하위 클래스나 서브타입만 필터링하고 싶을 때 사용된다.

예를 들어, 상속 계층에서 여러 서브타입이 있을 때, 특정 서브타입의 엔티티만 조회하고 싶을 때 유용하다.

->조회 대상을 특정 자식으로 한정

예) 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’)
String jpql = "SELECT i FROM Item i WHERE TYPE(i) IN (Book, Movie)";
List<Item> items = em.createQuery(jpql, Item.class).getResultList();

 

TREAT

TREAT는 부모 클래스 타입을 하위 클래스 타입으로 변환할 때 사용된다. 이는 상속 관계에서 부모 타입으로 정의된 엔티티를 하위 타입으로 취급하여 하위 클래스에만 있는 속성에 접근하고자 할 때 유용하다. 

TREAT를 사용하면 특정 서브타입의 필드나 메서드를 사용할 수 있다.

 

->자바의 타입 캐스팅과 유사

->상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용

->FROM, WHERE, SELECT(하이버네이트 지원) 사용

 

예) Item 클래스를 상속받는 Book 클래스에서 author 필드가 “kim”인 데이터를 조회

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

상위 클래스 타입을 하위 클래스 타입으로 변환하고, 변환된 하위 클래스의 속성에 접근하는 예시

String jpql = "SELECT i FROM Item i WHERE TREAT(i AS Book).author = 'kim'";
List<Book> books = em.createQuery(jpql, Book.class).getResultList();

 

 

TYPE과 TREAT의 조합

TYPE과 TREAT을 함께 사용하여 특정 타입을 선택한 후 해당 타입의 필드를 사용하는 예시도 있다.

예를 들어, 모든 Item 중에서 Book 타입을 선택하고, 그중 특정 작가의 책을 찾는 경우는 다음과 같다.

String jpql = "SELECT b FROM Item i JOIN TREAT(i AS Book) b WHERE TYPE(i) = Book AND b.author = 'Jane Austen'";
List<Book> books = em.createQuery(jpql, Book.class).getResultList();

이 쿼리는 Item 중에서 Book 타입을 선택하고, author가 “Jane Austen”인 책들을 조회한다

 

 

엔티티 직접 사용

기본 키 값

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

 

엔티티를 파라미터로 전달

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();

 

실행된 SQL

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

 

 

외래 키 값

엔티티를 파라미터로 전달

Team team = em.find(Team.class, 1L);
String qlString = “select m from Member m where m.team = :team”;
List resultList = em.createQuery(qlString)
    .setParameter("team", team)
    .getResultList();

 

식별자를 직접 전달

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

 

실행된 SQL

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

 

Named 쿼리

Named 쿼리(Named Query)는 엔티티 클래스에 미리 정의된 정적 쿼리이다.

보통 애플리케이션 로딩 시점에 컴파일되므로 문법 오류를 사전에 확인할 수 있고, 코드 내에서 재사용할 수 있어 편리하다.

  • 애플리케이션 로딩 시점에 초기화 후 재사용
  • 애플리케이션 로딩 시점에 쿼리를 검증

 

@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", "member1")
                            .getResultList();
Named 쿼리
-정적 쿼리: 엔티티 클래스나 XML 파일에 미리 정의된 정적 쿼리이다.
-재사용성: 한 번 정의된 Named 쿼리는 여러 곳에서 재사용할 수 있다.
-문법 오류 사전 확인: 애플리케이션 로딩 시점에 쿼리 문법이 검증되어 런타임 오류를 줄일 수 있다.

@Query
-동적 쿼리: Spring Data JPA에서 메서드 레벨에서 사용하는 애노테이션으로, 메서드에 대한 JPQL이나 네이티브 SQL 쿼리를 정의할 수 있다.
-유연성: @Query를 사용하면 메서드마다 다르게 쿼리를 정의할 수 있으며, 동적으로 쿼리를 작성할 수 있다.
-런타임 쿼리 정의: 애플리케이션 로딩 시점에 쿼리 문법이 검증되고 런타임 시점에 쿼리가 해석되기 때문에 코드의 가독성을 높일 수 있다.

연관성
-둘 다 JPA에서 특정 엔티티나 데이터베이스 테이블의 데이터를 조회하기 위한 쿼리를 정의하는 방법이다.
-둘 다 정적 쿼리를 정의할 수 있으며, 로딩 시점에 문법 검증이 이루어진다.

-쿼리 정의 위치의 차이: Named 쿼리는 클래스 레벨에 정의되거나 XML 파일에 정의되며, 애플리케이션 전역에서 사용할 수 있다. 반면에 @Query는 특정 리포지토리 인터페이스의 메서드에 직접 정의된다.
-용도: Named 쿼리는 정적이고 전역적인 용도로 사용되며, 여러 곳에서 재사용 가능하다. @Query는 특정 메서드에 대한 동적 쿼리를 정의하는 데 적합하다.

 

 

벌크 연산

JPQL에서 벌크 연산(Bulk Operation)은 데이터베이스의 여러 행을 한 번에 업데이트하거나 삭제하는 연산을 말한다.

벌크 연산은 일반적으로 많은 데이터를 한꺼번에 처리할 때 사용되며, 데이터베이스에 대한 성능을 최적화할 수 있는 장점이 있다.

JPQL에서는 주로 UPDATE, DELETE 문을 사용하여 벌크 연산을 수행할 수 있다.

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

 

특징

  • 성능: 벌크 연산은 단일 쿼리로 여러 행을 처리하므로, 데이터베이스와의 상호작용 횟수를 줄여 성능을 향상시킬 수 있다.
  • 영속성 컨텍스트 영향: 벌크 연산은 데이터베이스에서 직접 수행되기 때문에, JPA의 영속성 컨텍스트에 있는 엔티티들의 상태를 무시하고 직접 변경을 가한다. 따라서 영속성 컨텍스트와 데이터베이스의 상태가 일치하지 않을 수 있다.

 

벌크 연산 주의

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 날린다.
  • 따라서 영속성 컨텍스트와 DB 상에 내용이 일치하지 않을 수 있다.
  • 데이터를 서로 일치시키려면 벌크 연산을 먼저 실행 하거나 벌크 연산 수행 후 연속성 컨텍스트 초기화해야 한다.

 

벌크 연산 수행 후 영속성 컨텍스트 초기화 예제

Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);

Team teamB = new Team();
teamB.setName("teamB");
em.persist(teamB);

Member member1 = new Member();
member1.setUsername("member1");
member1.setAge(10);
member1.setTeam(teamA);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member2");
member2.setAge(10);
member2.setTeam(teamA);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("member3");
member3.setAge(10);
member3.setTeam(teamB);
em.persist(member3);


int resultCount = em.createQuery("update Member m set m.age = 20").executeUpdate();//이때 FLUSH 가 일어남

Member findMember = em.find(Member.class, member1.getId());
System.out.println(findMember.getAge());

em.clear(); //영속성 컨텍스트 초기화

findMember = em.find(Member.class, member1.getId());
System.out.println(findMember.getAge());