해당 글은 김영한 님의 자바 ORM 표준 JPA 프로그래밍을 참고하여 작성한 글입니다.

 

✅ 경로 표현식

JPQL에서는 점(.)을 사용하여 객체의 값에 접근할 수 있다. 이처럼 점(.)을 이용해 객체 그래프를 탐색하는 것을 경로 표현식이라고 한다.

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

🔽 경로 표현식 종류

  • 상태필드(state field): 단순히 값을 저장하기 위한 필드 (예: m.username)
  • 연관필드(association field): 연관관계를 위한 필드
    • 단일 값 연관 필드: 객체 간의 단일 연관 관계를 나타내는 필드 (예: m.team)
    • 컬렉션 값 연관 필드: 객체 간의 다수의 연관 관계를 나타내는 필드 (예: m.orders)

 

 

▶ 상태 필드

단순히 값을 저장하기 위해 사용하는 필드이다.

🔽 특징

  • 추가 탐색이 불가능하다.
  • 쿼리에서 단일 값에 접근할 때 사용된다.

🔽 예시 코드

//JPQL 쿼리
select m.username, m.age from Member m

//실행 시 발생하는 SQL 쿼리
SELECT M.USERNAME, M.AGE FROM MEMBER M
  • 설명: m.username과 m.age와 같은 단순 값을 포함하는 상태 필드는 경로 탐색의 끝점으로, 추가적인 탐색이 불가능하다.

 

 

▶ 단일 값 연관 필드

객체 간 단일 연관 관계를 나타내는 필드이다.

🔽 특징

  • 추가 탐색이 가능하다.
  • 연관된 객체의 속성에 접근할 수 있다.
  • 묵시적으로 내부 조인이 발생한다.
  • 연관 필드 예시: @ManyToOne, @OneToOne, 대상이 엔티티 객체 (예: m.team)

🔽 예시 코드

//JPQL 쿼리
select o.member from Order o

//실행 시 발생하는 SQL 쿼리 (묵시적 조인 발생)
SELECT M.* FROM ORDERS O INNER JOIN MEMBER M ON O.MEMBER_ID = M.ID

//추가 탐색 가능
select o.member.name from Order o
  • 설명: 단일 값 연관 필드는 o.member와 같은 필드를 사용하여 연관된 객체의 속성에 접근할 수 있으며, o.member.name과 같은 추가 탐색이 가능하다.

 

 

▶ 컬렉션 값 연관 필드

객체 간 다수의 연관 관계를 나타내는 필드이다.

🔽 특징

  • 직접적으로는 추가 탐색이 불가능하지만, FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.
  • 다른 엔티티 객체들의 컬렉션(list, set 등)을 참조하며, 경로 탐색을 통해 컬렉션 내의 각 객체에 접근할 수 있다.
  • 묵시적으로 내부 조인이 발생한다.
  • 연관 필드 예시: @OneToMany, @ManyToMany, 대상이 컬렉션 (예: m.orders)
//JPQL 쿼리
select t.members from Team t

//실행 시 발생하는 SQL 쿼리 (묵시적 조인 발생)
SELECT M.* FROM TEAM T INNER JOIN MEMBER M ON M.TEAM_ID = T.ID

//명시적 조인 시 별칭을 통해 추가 탐색 가능
select m.username from Team t join t.members m
  • 설명: 컬렉션 값 연관 필드는 t.members와 같이 컬렉션을 참조하며, 추가 탐색을 위해 명시적 조인을 통해 별칭을 사용하여 접근할 수 있다.
컬렉션 값 연관 필드 명시적 조인 동작 과정
1. 명시적 조인 시 'join t members m' 구문에서 Team 객체의 members 컬렉션과 Member 객체 간의 조인이 내부적으로 수행된다.
2. 이후 members 컬렉션 내의 각 Member 객체가 Team 객체와 연결되어, 관련된 Member의 데이터에 접근할 수 있다.

 

 

▶ 경로 표현식 예제

//가능, 문제는 내부 조인이 2번 발생
select o.member.team from Order o

//가능, 컬렉션이지만 경로 탐색의 끝이기 때문에 가능
select t.members from Team t

//불가능, 추가 탐색 불가(컬렉션 값 연관 필드)
select t.members.username from Team t

//가능, 컬렉션이지만 명시적 조인을 통해 탐색 가능
select m.username from Team t join t.members m

 

 

▶ 명시적 조인과 묵시적 조인

  • 명시적 조인: JOIN 키워드를 쿼리문에 직접 작성하여 조인을 수행한다.
  • 묵시적 조인: 경로 표현식을 통해 묵시적으로 조인이 발생한다. (INNER JOIN만 가능)

🔽 예시 코드

//명시적 조인
select m from Member m join m.team t

//묵시적 조인
select m.team from Member m

🔽 경로 탐색을 사용한 묵시적 조인 시 주의 사항

  • 묵시적 조인은 항상 내부 조인(INNER JOIN)만 가능하다. 외부 조인은 명시적으로 JOIN 키워드를 사용해야 한다.
  • 컬렉션 값 연관 필드는 추가 탐색이 불가능하기 때문에, 추가 탐색을 하려면 명시적 조인을 통해 별칭을 얻어서 사용해야 한다.
  • 경로 탐색은 주로 SELECT, WHERE 절에서 사용되지만, 내부적으로 SQL의 FROM 절에 영향을 미친다. 즉, SQL 쿼리의 FROM 절에 묵시적 조인이 자동으로 추가된다.

🔽 묵시적 조인을 사용하지 않는 이유

실무에서는 일반적으로 묵시적 조인 대신에 명시적 조인을 사용하는 것이 좋다.

  1. 추가 탐색의 어려움: 컬렉션 값 연관 필드는 묵시적 조인을 통한 직접적인 추가 탐색이 어렵다. 이는 컬렉션의 개별 객체 필드에 직접 접근할 수 없기 때문이다.
  2. 조인의 가시성: 묵시적 조인은 경로 탐색을 통해 자동으로 조인을 수행하므로, 쿼리가 실행 시 어떤 조인이 내부적으로 수행되는지 명확히 파악하기 어렵다.
  3. 쿼리 성능 및 최적화: 명시적 조인을 사용하면 쿼리의 조인 구조와 조건을 명확하게 정의할 수 있어 SQL 튜닝과 최적화가 비교적 용이하다.

 

 

▶ 경로 표현식 정리

  상태 필드 단일 값 연관 필드 컬렉션 값 연관 필드
추가 탐색 여부 X O X, 명시적 조인 시 가능해짐
묵시적 내부 조인 X O O

 

 

 

✅ 패치 조인(Fetch Join)

패치 조인은 JPQL에서 성능 최적화를 위해 제공하는 기능이다.

🔽 특징

  • SQL 조인의 종류가 아니며, 연관된 엔티티나 컬렉션을 한 번의 SQL 쿼리로 함께 조회할 수 있게 해주는 기능이다.
  • 패치 조인을 사용하면 엔티티가 지연로딩(LAZY)으로 설정되어 있어도, 실제 데이터가 즉시 로드된다.
  • N+1 문제를 해결할 수 있다.
  • 명령어: JOIN FETCH
  • 구문: [ LEFT [OUTER] | INNER ] JOIN FETCH 조인_경로
패치 조인은 즉시 로딩과 유사하지만, 객체 그래프를 동적으로 원하는 타이밍에 조정할 수 있다는 점에서 다르다.

 

 

▶ 엔티티 패치 조인

엔티티 패치 조인은 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 경우에 사용한다.

 

🔽 예시 코드

Member 엔티티를 조회하면서, 연관된 Team 엔티티도 함께 조회하는 경우

//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

 

🔽 예시 상황 및 사용 코드

  • 팀이 있는 회원을 조회하면서, 각 회원의 팀 정보도 함께 조회하고 싶은 상황

1️⃣ 문제 상황: 패치 조인을 사용하지 않는 코드

String query = "select m from Member m";

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

for (Member member : members) {
		System.out.println("username = " + member.getUsername() + ", " +
                                   "teamname = " + member.getTeam().name());
}
//출력
username = 회원1, teamname = 팀A    //SQL 쿼리로 가져옴
username = 회원2, teamname = 팀A    //영속성 컨텍스트에 있으므로 1차 캐시에서 가져옴
username = 회원3, teamname = 팀B    //SQL 쿼리로 가져옴
  • 설명: 쿼리가 총 3번 실행된다. 만약 회원이 100명이라면 총 100번의 쿼리가 실행되는 아찔한 상황이 벌어지는데, 이를 N+1 문제라고 한다.
N+1 문제와 해결법과 관련된 자세한 설명은 여기를 참고하면 좋을 것 같다.

2️⃣ 해결 방법: 패치 조인을 사용하는 코드

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());
}
  • 설명:
    • 패치 조인을 사용하면 회원과 팀을 지연 로딩(LAZY)으로 설정하더라도, 패치 조인에 의해 데이터가 즉시 로드되므로 지연 로딩이 발생하지 않는다.
    • 루프 구문에서 getTeam() 메서드는 더 이상 프록시 객체가 아니다. 패치 조인으로 인해 실제 Team 엔티티가 이미 로드되어 있으므로, getTeam() 호출 시 실제 데이터에 직접 접근할 수 있다.

 

 

▶ 컬렉션 패치 조인

컬렉션 패치 조인은 엔티티를 조회할 때 연관된 컬렉션도 함께 조회하는 경우에 사용되며, 특히 일대다 관계의 컬렉션을 패치 조인할 때 사용된다.

 

🔽 예시 코드

Team 엔티티를 조회하면서, 연관된 Member 컬렉션(t.members)도 함께 조회하는 경우

//JPQL 쿼리
select t from Team t 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️⃣ 문제 상황: DISTINCT를 사용하지 않는 패치 조인 코드

String query = "select t from Team t join fetch t.members where t.name = '팀A'";

List<Team> teams = em.createQuery(query, 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);
    }
}
//출력
teamname = 팀A, team = hellojpa.jpql.Team@1e60b459
->username = 회원1, member = Member{id=1, username='회원1', age=10}
->username = 회원2, member = Member{id=2, username='회원2', age=10}
teamname = 팀A, team = hellojpa.jpql.Team@1e60b459
->username = 회원1, member = Member{id=1, username='회원1', age=10}
->username = 회원2, member = Member{id=2, username='회원2', age=10}
  • 설명:
    • 컬렉션 패치 조인을 사용하면, 일대다 관계인 Team과 Member를 조인할 때, Team 객체는 하나임에도 불구하고 Member와 조인하면서 결과가 중복되어 출력된다. 이는 Team과 관련된 Member가 각각 별도로 조인되면서 발생하는 현상이다. 즉, 하나의 Team 객체가 여러 개의 Member 객체와 조인되어, Team 객체가 중복되어 리스트에 나타나게 된다. 따라서 같은 Team이 여러 번 출력되며, 그에 따른 Member도 중복되어 나타난다.
    • 또한, 패치 조인을 사용하면 Team과 Member를 지연 로딩(LAZY)으로 설정하더라도, 패치 조인으로 인해 관련 데이터가 즉시 로드되기 때문에 지연 로딩이 일어나지 않는다.

2️⃣ 해결 방법: DISTINCT를 사용하는 패치 조인 코드

  • 해결 방법은 JPQL 쿼리에서 DISTINCT 키워드를 사용하여 중복 엔티티를 제거하는 것이다. 이를 통해 중복된 결과가 출력되지 않도록 할 수 있다.
  • 이 방법에 대한 자세한 내용은 바로 아래에서 설명하겠다.

 

 

▶ 패치 조인과 DISTINCT

SQL에서의 DISTINCT는 중복된 데이터 행을 제거하는 명령어이지만, JPQL에서의 DISTINCT는 SQL에서의 기능을 넘어 두 가지 기능을 제공한다.

  1. SQL 쿼리에 DISTINCT를 추가하여 중복된 데이터 행을 제거
  2. 애플리케이션 레벨에서 엔티티 중복을 제거
    • 동일한 식별자를 가진 엔티티 제거

 

SQL에서의 DISTINCT
SQL 쿼리에 DISTINCT를 추가해도 데이터가 완전히 동일하지 않으면 중복 제거가 실패할 수 있다. 따라서 애플리케이션 레벨에서 엔티티의 중복을 제거까지 해주는 JPQL의 DISTINCT가 필요하다.

 

Hibernate 6 부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용된다고 한다.

 

🔽 예시 코드

//JPQL 쿼리
select distinct t from Team t fetch t.members where t.name = '팀A'

 

🔽 사용 코드

String query = "select distinct t from Team t join fetch t.members where t.name = '팀A'";

List<Team> teams = em.createQuery(query, 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);
    }
}
//출력
teamname = 팀A, team = hellojpa.jpql.Team@4fe533ff
->username = 회원1, member = Member{id=1, username='회원1', age=10}
->username = 회원2, member = Member{id=2, username='회원2', age=10}
  • 설명:
    • DISTINCT는 애플리케이션 레벨에서 엔티티의 중복을 제거한다.
    • 동일한 식별자를 가진 Team 엔티티를 제거하여 중복된 결과를 방지한다.

 

 

▶ 패치 조인과 일반 조인의 차이

🔽 패치 조인을 사용하지 않는 경우

//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'
//출력
->username = 회원1, member = Member{id=1, username='회원1', age=10}
->username = 회원2, member = Member{id=2, username='회원2', age=10}
->username = 회원1, member = Member{id=1, username='회원1', age=10}
->username = 회원2, member = Member{id=2, username='회원2', age=10}
  • 설명:
    • Team과 Member 엔티티를 조인하여 Team을 조회하지만, 연관된 Member 컬렉션(t.members)은 함께 조회되지 않는다.
    • JPQL 쿼리는 결과를 반환할 때 연관된 엔티티를 자동으로 로드하지 않으며, SELECT 절에 명시된 엔티티만 조회한다.
    • 지연 로딩(LAZY)으로 설정 시, 프록시 객체나 초기화되지 않은 컬렉션 래퍼를 반환한다.
    • 즉시 로딩(EAGER)으로 설정 시, Member 컬렉션을 즉시 로딩하기 위해 추가 쿼리가 실행될 수 있다.

 

🔽 패치 조인을 사용하는 경우

//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'
//출력
teamname = 팀A, team = hellojpa.jpql.Team@4fe533ff
->username = 회원1, member = Member{id=1, username='회원1', age=10}
->username = 회원2, member = Member{id=2, username='회원2', age=10}
teamname = 팀A, team = hellojpa.jpql.Team@4fe533ff
->username = 회원1, member = Member{id=1, username='회원1', age=10}
->username = 회원2, member = Member{id=2, username='회원2', age=10}
  • 설명: Team 엔티티와 연관된 Member 컬렉션(t.members)을 한 번의 쿼리로 함께 조회할 수 있다.

 

🔽 패치 조인과 일반 조인의 차이

  • 일반 조인
    • JPQL은 결과를 반환할 때 연관 관계를 고려하지 않는다.
    • SELECT 절에 지정한 엔티티만 조회하며, 연관된 엔티티는 자동으로 로드되지 않는다.
  • 패치 조인
    • 패치 조인을 사용하면 연관된 엔티티도 함께 조회된다. (즉시 로딩)
    • 패치 조인은 객체 그래프를 한 번의 SQL 쿼리로 조회하는 기능을 제공한다.

 

 

▶ 패치 조인의 특징과 한계

🔽 패치 조인의 특징

  1. 패치 조인을 사용하면 연관된 엔티티를 SQL 쿼리 한 번으로 함께 조회할 수 있다.
  2. 패치 조인은 글로벌 로딩 전략보다 우선적으로 실행된다.
    • 글로벌 로딩 전략: @OneToMany(fetch = FetchType.XXX)
    • 실무에서 글로벌 로딩 전략은 모두 지연 로딩이다.
  3. 최적화가 필요한 특정 상황에서 적용된다. (N+1 문제 등)

 

🔽 패치 조인의 한계

  1. 패치 조인 대상에는 별칭을 사용할 수 없다.
    • 별칭을 사용하면 쿼리가 복잡해지고, 패치 조인의 기본 목적인 즉시 로딩의 효율성이 떨어질 수 있다.
    • 객체 그래프는 전체 데이터의 연관 관계를 한 번의 쿼리로 완전히 로드하는 것을 목표로 한다. 즉, 객체 그래프를 완전히 조회하는 것이 아닌 일부 데이터만 선택적으로 로드하면 연관된 데이터의 일관성이 깨질 수 있다.
    • Hibernate에서는 별칭 사용이 가능하지만, 이는 권장되지 않는다.
  2. 둘 이상의 컬렉션은 패치 조인할 수 없다.
    • 데이터가 곱하기가 되어 예기치 못한 정합성 문제를 일으킬 수 있다.
  3. 컬렉션을 패치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
    • 조인된 결과에 중복된 엔티티가 발생할 수 있는데, 이를 처리하지 않고 페이징을 하게 되면 문제가 발생할 수 있다.
    • DISTINCT를 사용하여 중복 제거를 시도할 수 있지만, 이도 적절한 방법은 아니다. DISTINCT를 적용하게 되면 데이터의 정렬과 중복 제거가 복합적으로 작용하여 페이지가 일관되지 않을 수 있기 때문이다.
    • Hibernate는 패치 조인과 페이징을 함께 사용할 때 경고 로그를 남기고 메모리에서 페이징 처리하므로 매우 위험하다. 데이터베이스에서 직접 페이징 처리를 하지 않고 모든 데이터를 메모리로 로드하면 메모리 사용량이 급격히 증가하고 성능이 저하될 수 있다.
    • 대안 방법:
      • DTO로 조회: 페이징 문제를 해결하기 위해 DTO를 사용하여 필요한 데이터만 조회한다.
      • 일대일, 다대일 관계의 페이징: 단일 값 연관 필드는 패치 조인 시에도 페이징이 가능하므로, 방향을 뒤집어서 해결할 수 있다.
      • 배치 사이즈 설정: 배치 사이즈를 사용하면 연관된 엔티티를 한 번의 쿼리로 그룹화하여 로드할 수 있다. 페이징 API와 패치 조인을 어쩔 수 없이 함께 사용해야 하는 상황에서는 배치 사이즈를 설정이 유용하다. 이는 페이징 중 연관된 엔티티를 가져올 때 발생하는 N+1 문제를 해결할 수 있다.
        • @BatchSize 어노테이션: 컬렉션 필드에 @BatchSize(size = 배치사이즈)를 사용하여 배치 사이즈를 조정할 수 있다.
        • 글로벌 배치 사이즈 설정: 배치 사이즈를 글로벌 설정으로 조정할 수 있다.
정합성(Consistency): 데이터베이스 및 정보 시스템에서 데이터의 정확성과 일관성을 유지하는 개념이다. 도메인 무결성, 참조 무결성, 엔티티 무결성 등이 있다.

 

 

▶ 패치 조인 정리

  1. 패치 조인만으로 모든 문제를 해결할 수는 없다.
  2. 패치 조인은 주로 객체 그래프를 유지할 때, 즉 연관된 엔티티를 함께 조회하고자 할 때 효과적이다.
  3. 여러 테이블을 조인하여 엔티티가 가진 모양이 아닌 전혀 다른 형태의 결과를 얻어야 할 경우에는 일반 조인이 적합하다. (예: 단순히 엔티티를 조회하는 것을 넘어서는 작업(계산 등)을 포함한 복잡한 통계 쿼리)
  4. 필요한 데이터만을 조회하여 DTO로 변환하는 것이 효과적이다.

 

 

 

✅ 다형성 쿼리

다형성 쿼리는 JPQL에서 엔티티 상속 구조를 효과적으로 다루기 위해 제공되는 기능이다.

 

▶ TYPE

  • 엔티티의 상속 구조에서 특정 자식 타입으로 조회 대상을 한정할 때 사용된다.
  • 상속 구조에서 부모 엔티티를 기반으로 하여 특정 자식 엔티티만을 조회할 수 있게 도와준다.

🔽 예시 코드

부모 타입인 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

  • 엔티티의 상속 구조에서 부모 타입을 특정 자식 타입으로 캐스팅할 때 사용된다.
  • 이를 통해 부모 타입에서 자식 타입의 속성에 접근할 수 있다.

🔽 예시 코드

//JPQL 쿼리
select i from Item i where treat(i as Book).author = 'soeun'

//실행 시 발생하는 SQL 쿼리
SELECT I.* FROM ITEM I WHERE I.DTYPE = 'B' AND I.author='soeun'
TYPE은 자식 타입을 조회하는 용도, TREAT는 자식 타입의 필드를 접근하는 용도라고 생각하면 된다.

 

 

 

✅ 엔티티 직접 사용

▶ 기본 키 값

  • 엔티티를 파라미터로 전달
  • 엔티티의 식별자를 파라미터로 전달

 

🔽 예제 코드

//JPQL 쿼리
select count(m) from Member m    //엔티티를 직접 사용하여 조회
select count(m.id) from Member m    //엔티티의 id를 사용하여 조회

//실행 시 발생하는 SQL 쿼리 (JPQL 둘 다 같은 SQL을 실행)
SELECT COUNT(M.ID) AS CNT FROM MEMBER M
  • 설명: 첫 번째 JPQL 쿼리의 count(m)은 엔티티의 별칭을 직접 사용하며, JPQL이 SQL로 변환될 때 해당 엔티티의 기본 키를 사용한다. 결과적으로 실행된 SQL 쿼리는 동일하다.

 

🔽 엔티티를 파라미터로 전달

String query = "select m from Member m where m = :member";

List<Member> resultList = em.createQuery(query, Member.class)
                    .setParameter("member", memberA) //엔티티를 파라미터로 전달
                    .getResultList();
//실행 시 발생하는 SQL 쿼리
SELECT M.* FROM MEMBER M WHERE M.ID=?
  • 설명:
    • 'm = :member'로 엔티티를 직접 파라미터로 전달하면, SQL에서는 'M.ID=?'로 변환된다.
    • 즉, 엔티티의 기본 키 값을 사용하여 조회를 수행한다.

 

🔽 엔티티의 식별자를 파라미터로 전달

String query = "select m from Member m where m.id = :memberId";

List<Member> resultList = em.createQuery(query, Member.class)
                    .setParameter("memberId", memberA.getId()) //식별자를 직접 전달
                    .getResultList();
//실행 시 발생하는 SQL 쿼리
SELECT M.* FROM MEMBER M WHERE M.ID=?
  • 설명:
    • 'm.id = :memberId'로 식별자를 직접 전달하면, SQL에서는 'M.ID=?'로 변환된다.
    • 즉, 엔티티의 기본 키 값을 사용하여 조회를 수행한다.

 

결론: 엔티티를 파라미터로 전달하는 경우와 엔티티의 식별자를 파라미터로 전달하는 경우 모두 같은 SQL 쿼리를 실행한다.

 

 

▶ 외래 키 값

  • 연관된 엔티티를 파라미터로 전달
  • 연관된 엔티티의 식별자를 직접 전달

 

🔽 연관된 엔티티를 파라미터로 전달

String query = "select m from Member m where m.team = :team";
        
List<Member> resultList = em.createQuery(query, Member.class)
                    .setParameter("team", teamA) //Team 엔티티를 파라미터로 전달
                    .getResultList();
//실행 시 발생하는 SQL 쿼리
SELECT M.* FROM MEMBER M WHERE M.TEAM_ID=?
  • 설명:
    • m.team은 TEAM_ID라는 외래 키와 매핑되어 있기 때문에, 'm.team = :team'으로 엔티티를 직접 파라미터로 전달하면, SQL에서는 'WHERE M.TEAM_ID=?'로 변환된다.
    • 즉, 연관된 엔티티를 직접 파라미터로 전달하면, JPQL은 내부적으로 해당 엔티티의 외래 키 값을 추출하여 SQL 쿼리에서 조건으로 사용한다.

 

🔽 연관된 엔티티의 식별자를 파라미터로 전달

String query = "select m from Member m where m.team.id = :teamId";

List<Member> resultList = em.createQuery(query, Member.class)
                    .setParameter("teamId", teamA.getId()) //Team 엔티티의 식별자를 직접 전달
                    .getResultList();
//실행 시 발생하는 SQL 쿼리
SELECT M.* FROM MEMBER M WHERE M.TEAM_ID=?
  • 설명:
    • m.team.id는 TEAM_ID라는 외래 키 값이며, 이를 기준으로 Member 엔티티를 필터링하기 때문에, 'm.team.id = :teamId'로 식별자를 직접 파라미터로 전달하면, sQL에서는 'WHERE M.TEAM_ID=?'로 변환된다.
    • 즉, 식별자 값을 직접 전달하면, JPQL은 해당 식별자를 기반으로 SQL 쿼리의 조건으로 사용한다.
    • Member와 Team 간에 묵시적 조인이 일어날 것 같으나 MEMBER 테이블이 TEAM_ID를 외래 키로 가지고 있어 묵시적 조인이 일어나지 않는다.
      • m.team.name 등은 호출하면 묵시적 조인이 일어난다.

 

결론: 연관된 엔티티를 파라미터로 전달하는 경우와 연관된 엔티티의 식별자를 파라미터로 전달하는 경우 모두 같은 SQL 쿼리를 실행한다.

 

 

 

✅ Named 쿼리

▶ 동적 쿼리 VS Named 쿼리 (정적 쿼리)

JPQL 쿼리는 크게 동적 쿼리와 정적 쿼리로 나눌 수 있다.

  • 동적 쿼리
    • 정의: 런타임 시점에 쿼리의 조건이나 구조가 동적으로 변경될 수 있는 쿼리
    • 사용 방법: em.createQuery("select ...")와 같이 JPQL을 문자열로 직접 작성하여 쿼리를 생성한다.
  • Named 쿼리(정적 쿼리)
    • 정의: 한 번 정의하면 변경할 수 없는 정적인 쿼리
    • 사용 방법: @NamedQuery 어노테이션을 사용하거나, XML 설정을 통해 정의한다.

 

 

▶ Named 쿼리 (정적 쿼리)

🔽 특징

  • 한 번 정의하면 변경할 수 없는 정적인 쿼리이다.
  • 쿼리를 미리 정의하고 이름을 부여하여, 필요할 때 사용할 수 있다.
  • @NameQuery 어노테이션을 사용하거나, XML 설정을 통해 정의한다.
  • 애플리케이션 로딩 시점에 초기화되며, 이후 재사용된다.
  • 애플리케이션 로딩 시점에 JPQL 문법을 검증한다. 문법에 오류가 있으면 애플리케이션 실행 시 에러가 발생한다.

 

🔽 예시 코드

//Named 쿼리를 어노테이션에 정의
@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.username = :username"
)
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String username;
    
    ...
}
//사용 코드
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
                            .setParameter("username", "memberA")
                            .getResultList();

 

🔽 Named 쿼리 환경에 따른 설정 (XML)

  • XML이 항상 우선권을 가진다.
  • 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.

 

🔽 Spring Data JPA에서의 Named 쿼리

Spring Data JPA에서는 Named 쿼리를 인터페이스 메서드에 직접 선언할 수 있다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("select m from Member m where m.emailAddress = ?1")
    Member findByEmailAddress(String emailAddress);
}
어노테이션을 통한 쿼리 정의보다 Spring Data JPA를 사용하는 방식이 실무에서 더 깔끔하게 관리된다고 한다.

 

 

 

✅ 벌크 연산

벌크 연산은 데이터베이스에서 대량의 데이터를 동시에 처리하는 연산을 의미한다.

  • JPA에서는 엔티티를 수정할 때 영속성 컨텍스트의 변경 감지 기능이나 병합을 사용하고, 삭제할 때는 EntityManager의 remove() 메서드를 사용하는데, 이러한 방법으로 대량의 데이터를 처리하는 것은 효율적이지 않다.
  • 이러한 문제를 해결하기 위해, 벌크 연산을 사용하여 한 번의 쿼리로 대량의 데이터를 수정하거나 삭제할 수 있다.

🔽 예시 코드

int resultCount = em.createQuery("update Member m set m.age = 20")
                    .executeUpdate();

System.out.println("resultCount = " + resultCount); //영향 받은 엔티티 수를 반환

🔽 executeUpdate()

  • executeUpdate()의 결과는 영향 받은 엔티티 수를 반환한다.
  • 이 메서드는 UPDATE, DELETE 쿼리를 지원한다.
  • INSERT(예: INSERT INTO ... SELECT) 쿼리는 Hibernate에서 지원한다.

 

 

▶ 주의 사항(문제점)

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 실행하므로, 데이터 정합성 문제를 발생시킬 수 있다.

🔽 예시

String query = "select p from Product p where p.name = :name";

// 1. 상품 A 조회 (상품 A의 가격은 1000원이라고 가정)
Product productA = em.createQuery(query, Product.class)
                     .setParameter("name", "productA")
                     .getSingleResult();

// 1000 출력
System.out.println("상품 A 수정 전 가격 = " + productA.getPrice());

// 2. 벌크 연산을 통해 모든 상품의 가격을 10% 상승
em.createQuery("update Product p set p.price = p.price * 1.1")
        .executeUpdate();

// 3. 1000 출력, 문제 발생!!!
System.out.println("상품 A 수정 후 가격 = " + productA.getPrice());
  • 설명:
    1. 가격이 1000인 상품 A를 조회하여, 상품 A는 영속성 컨텍스트에서 관리된다.
    2. 벌크 연산으로 모든 상품의 가격을 10% 증가시키면, 상품 A의 가격도 1100으로 변경될 것으로 예상된다.
    3. 그러나, 출력 결과는 기대했던 1100이 아닌 1000으로 출력된다. 이는 벌크 연산이 영속성 컨텍스트의 상태를 업데이트하지 않기 때문에 발생하는 문제이다. 영속성 컨텍스트는 데이터베이스의 변경 사항을 반영하지 않아, 조회된 상품 A의 가격이 갱신되지 않는다.
출처: https://ittrue.tistory.com/282 [IT is True:티스토리]

 

 

▶ 해결 방법

  • 벌크 연산 먼저 실행
    • 벌크 연산을 가장 먼저 실행하고, 그 후에 데이터를 조회한다. (가장 단순한 해결 방법)
  • 벌크 연산 후 영속성 컨텍스트 새로 고침 (refresh)
    • 벌크 연산을 수행한 직후에 em.refresh() 메서드를 사용하여 영속성 컨텍스트의 엔티티를 데이터베이스의 최신 상태로 갱신한다.
em.createQuery("update Product p set p.price = p.price * 1.1")
        .executeUpdate();

em.refresh(productA); //영속성 컨텍스트 새로 고침

System.out.println("상품 A 수정 후 가격 = " + productA.getPrice());
  • 벌크 연산 후 영속성 컨텍스트 초기화 (clear) 후 다시 조회
    • 벌크 연산을 수행한 직후에 em.clear() 메서드를 사용하여 영속성 컨텍스트를 초기화하고, 이후에 데이터를 새로 조회한다.
em.createQuery("update Product p set p.price = p.price * 1.1")
        .executeUpdate();

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

Product updatedProductA = em.find(Product.class, productA.getId()); //데이터 새로 조회

System.out.println("상품 A 수정 후 가격 = " + updatedProductA.getPrice());

 

위 방법들 중에서 가능하다면 벌크 연산을 가장 먼저 수행하는 방법을 사용하는 것이 권장된다.

 

 

 

📍 참고

soeun2537