Post

[JPA] JPQL과 페치 조인

[JPA] JPQL과 페치 조인

JPQL (Java Persistence Query Language)

JPQL란?

테이블이 아닌 객체를 검색하는 객체지향 쿼리

1
2
3
4
5
--SQL: 테이블 대상
SELECT * FROM MEMBER WHERE USERNAME = 'kim';

--JPQL: 엔티티 객체 대상
SELECT m FROM Member m WHERE m.username = 'kim';
  • SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않음
  • 엔티티와 속성은 대소문자를 구분함 (Member, username)
  • JPQL 키워드는 대소문자를 구분하지 않음 (SELECT, FROM, WHERE)
  • 엔티티 이름을 사용 (테이블 이름이 아님)
  • 별칭(alias)은 필수 (member m)

단점

동적 쿼리를 만들기 어렵다.

1
2
3
4
5
6
7
8
// 동적 쿼리 만들기 어려움
String jpql = "select m from Member m where";
if (username != null) {
    jpql += " m.username = :username";
}
if (age != null) {
    jpql += " and m.age = :age";
}

객체지향 쿼리 언어 종류

JPQL

1
2
3
4
String jpql = "select m from Member m where m.username = :username";
List<Member> result = em.createQuery(jpql, Member.class)
    .setParameter("username", "kim")
    .getResultList();

Criteria

자바 코드로 JPQL을 작성할 수 있지만 복잡하고 실용성이 없음

QueryDSL - 오픈소스 라이브러리

자바 코드로 JPQL을 작성, 동적 쿼리 작성이 편리

1
2
3
4
5
6
7
8
// QueryDSL 사용 (간결하고 직관적!)
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = QMember.member;

List<Member> result = queryFactory
    .selectFrom(m)
    .where(m.username.eq("kim"))
    .fetch();
  • 문자가 아닌 자바 코드로 작성해 컴파일 시점에 문법 오류 발견
  • 동적 쿼리 작성이 편리
  • 단순하고 쉬워 실무에서 가장 많이 사용
  • JPQL을 알면 쉽게 사용 가능

JDBC

JDBC 커넥션 직접 사용 또는 Spring JdbcTemplate, MyBatis 등과 함께 사용 가능

1
2
em.flush();  // 플러시로 DB 동기화
// 네이티브 SQL 실행
  • 영속성 컨텍스트를 적절한 시점에 수동으로 플러시(flush) 필요

경로 표현식

. 을 찍어 그래프를 탐색

image.png

상태 필드 (State Field)

  • 값을 저장하기 위한 필드
  • 경로 탐색의 끝 더이상 탐색 불가

연관 필드 (Association Field)

  • 연관관계를 위한 필드

단일 값 연관 필드

1
2
3
4
5
select m.team from Member m
// m.team은 단일 값 연관 필드
// 묵시적 조인 발생: SELECT * FROM MEMBER M INNER JOIN TEAM T ...

select m.team.name from Member m  // 계속 탐색 가능!
  • @ManyToOne, OneToOne
  • 대상이 엔티티
  • 묵시적 내부 조인 발생
  • 탐색 O

컬렉션 값 연관 필드

1
2
3
4
5
select t.members from Team t
// t.members는 컬렉션 값 연관 필드
// 묵시적 조인 발생

select t.members.username from Team t  // 오류 컬렉션에서 탐색 불가

컬렉션에서 탐색하려면 명시적 조인 사용

1
2
select m.username from Team t
join t.members m  // 명시적 조인으로 별칭 부여
  • @OneToMany , @ManyToMany
  • 대상이 컬렉션
  • 묵시적 내부 조인 발생
  • 탐색 X (더 이상 탐색 불가)

명시적 조인 VS 묵시적 조인

명시적 조인

JOIN 키워드를 직접 사용

1
select m from Member m join m.team t

묵시적 조인

경로 표현식에 의해 묵시적으로 SQL 조인 발생(내부 조인만 가능)

1
select m.team from Member m  // team 접근 시 자동으로 조인 발생실무에서 묵시적 조인은 가급적 사용하지 말자 명시적 조인만 사용하자

실무 권장사항

묵시적 조인은 가급적 사용하지 말고, 명시적 조인만 사용하자

  • 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어려움
  • 성능 튜닝이 어려움
  • 명시적 조인을 사용하면 쿼리를 예측하기 쉬움

페치 조인 (Fetch Join)

JPQL에서 성능 최적화를 위해 제공하는 기능

연관된 엔티티나 컬렉션을 SQL 한번에 조회하는 기능

1
2
3
4
5
// 일반 조인
select m from Member m join m.team t

// 페치 조인
select m from Member m join fetch m.team

엔티티 페치 조인

회원을 조회하면서 연관된 팀도 함께 조회(SQL 한 번에)

1
2
3
4
5
6
7
8
9
String jpql = "select m from Member m join fetch m.team";
List<Member> members = em.createQuery(jpql, Member.class)
    .getResultList();

for (Member member : members) {
    // 페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩 X
    System.out.println("username = " + member.getUsername() 
        + ", teamname = " + member.getTeam().getName());
}
1
2
3
SELECT M.*, T.* 
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID = T.ID
  • 페치 조인으로 Member와 Team을 함께 조회
  • 지연 로딩 설정이 있어도 페치 조인이 우선 (LAZY 무시)
  • 실제 엔티티를 조회(프록시 아님)

컬렉션 패치 조인

일대다 관계에서 컬렉션 페치 조인

1
2
3
4
5
6
7
8
9
10
11
12
String jpql = "select t from Team t join fetch t.members";
List<Team> teams = em.createQuery(jpql, Team.class)
    .getResultList();

for (Team team : teams) {
    System.out.println("team = " + team.getName() 
        + " | members = " + team.getMembers().size());
    
    for (Member member : team.getMembers()) {
        System.out.println("-> member = " + member);
    }
}
  • 일대다 조인은 결과가 증가할 수 있다.

페치 조인과 DISTINT

JPQL의 DISTINCT는 2가지 기능 제공

  • SQL에 DISTINCT 추가
  • 애플레케이션에서 엔티티 중복제거
1
2
3
String jpql = "select distinct t from Team t join fetch t.members";
List<Team> teams = em.createQuery(jpql, Team.class)
    .getResultList();
1
2
3
SELECT DISTINCT T.*, M.* 
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
  • SQL의 DISTINCT는 데이터가 완전히 같아야 중복 제거
  • JPQL의 DISTINCT는 SQL + 애플리케이션에서 같은 식별자를 가진 엔티티 중복 제거

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

일반조인

1
select t from Team t join t.members m
  • 일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않음
  • SELECT 절에 지정한 엔티티만 조회
1
2
SELECT T.* FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
  • Team만 조회, Member는 조회안됨 (지연 로딩)

페치 조인

1
select t from Team t join fetch t.members
  • 연관된 엔티티를 함께 조회(즉시로딩)
  • 페치조인은 객체 그래프를 SQL 한번에 조회하는 개념
1
2
SELECT T.*, M.* FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID

→ Team과 Member 모두 조회

페치조인의 한계

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

1
2
3
4
5
--안됨
select t from Team t join fetch t.members m where m.age > 10

--이렇게 해야 함
select m from Member m where m.age > 10 and m.team = :team

둘 이상의 컬렉션은 페치 조인할 수 없다

1
2
3
4
--안됨
select t from Team t 
join fetch t.members 
join fetch t.orders
  • 데이터 정합성 문제 발생 가능
  • 카테시안 곱으로 데이터 폭발

컬렉션을 패치조인하면 페이징 API를 사용할 수 없다

1
2
3
4
5
6
--경고 발생 (메모리에서 페이징)
String jpql = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(jpql, Team.class)
    .setFirstResult(0)
    .setMaxResults(10)
    .getResultList();
  • 일대다 페치 조인에서 페이징하면 메모리에서 페이징 처리
  • 데이터가 많으면 메모리 초과 위험

해결방법 →

  • BatchSize 사용
  • 다대일로 방향 전환
1
2
3
4
5
6
--단일 값 연관 필드는 페이징 가능 (1:1, N:1)
String jpql = "select m from Member m join fetch m.team";
List<Member> result = em.createQuery(jpql, Member.class)
    .setFirstResult(0)
    .setMaxResults(10)
    .getResultList();

페치 조인 사용 전략

페치 조인 사용처

  • 객체 그래프를 유지할 때 효과적
  • 최적화가 필요한 곳에 적용

일반 조인 사용처

  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 할 때
  • 필요한 데이터만 조회해서 DTO로 반환하는 것이 효과적
1
2
3
--DTO로 직접 조회
select new com.example.MemberTeamDto(m.username, t.name)
from Member m join m.team t

Named 쿼리 - 정적쿼리

미리 정의해서 이름을 부여해두고 사용하는 JPQL

1
2
3
4
5
6
7
8
@Entity
@NamedQuery(
    name = "Member.findByUsername",
    query = "select m from Member m where m.username = :username"
)
public class Member {
    ...
}
1
2
3
4
// 사용
List<Member> result = em.createNamedQuery("Member.findByUsername", Member.class)
    .setParameter("username", "kim")
    .getResultList();
  • 정적 쿼리만 가능
  • 애플리케이션 로딩 시점에 초기화 후 재사용 (성능 이점)
  • 애플리케이션 로딩 시점에 쿼리 검증 (오류를 미리 잡음)
  • 어노테이션 또는 XML에 정의 가능

장점→

  • 로딩 시점에 JPQL 문법을 체크하고 파싱해둠
  • 오류를 빨리 확인 가능
  • 미리 파싱해두므로 성능 이점

벌크 연산

쿼리 한 번으로 여러 테이블 로우를 변경하는 연산

1
2
3
4
5
6
7
8
// 변경 감지(Dirty Checking)로 하면?
List<Member> members = em.createQuery("select m from Member m")
    .getResultList();

for (Member member : members) {
    member.setAge(member.getAge() + 1);  // 각각 UPDATE 쿼리
}
// 100명이면 100번의 UPDATE 쿼리 실행

UPDATE 벌크 연산

1
2
3
4
5
6
String qlString = "update Member m set m.age = m.age + 1";
int resultCount = em.createQuery(qlString)
    .executeUpdate();

System.out.println("resultCount = " + resultCount);
// 한 번의 쿼리로 모든 Member의 age 증가!

DELETE 벌크 연산

1
2
3
String qlString = "delete from Member m where m.age < 18";
int resultCount = em.createQuery(qlString)
    .executeUpdate();

벌크 연산 주의 사항

영속성 컨텍스트를 무시하고 DB에 직접 쿼리

1
2
3
4
5
6
7
8
9
10
11
Member member = new Member();
member.setUsername("kim");
member.setAge(20);
em.persist(member);

// 벌크 연산 실행
em.createQuery("update Member m set m.age = 30")
    .executeUpdate();

System.out.println(member.getAge());  // 20 (영속성 컨텍스트 값)
// DB에는 30, 영속성 컨텍스트에는 20 → 불일치

해결 방법

벌크연산을 먼저 실행

1
2
3
4
5
6
7
// 벌크 연산 먼저
em.createQuery("update Member m set m.age = 30")
    .executeUpdate();

// 그 다음 조회
Member member = em.find(Member.class, 1L);
System.out.println(member.getAge());  // 30

벌크연산 수행 후(flush()) 영속성 컨텍스트 초기화(clear)

1
2
3
4
5
6
7
8
9
10
Member member = em.find(Member.class, 1L);

// 벌크 연산
em.createQuery("update Member m set m.age = 30")
    .executeUpdate();

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

Member findMember = em.find(Member.class, 1L);  // DB에서 다시 조회
System.out.println(findMember.getAge());  // 30

Spring Data JPA @Modifying

1
2
3
@Modifying(clearAutomatically = true)  // 자동으로 clear
@Query("update Member m set m.age = :age where m.id = :id")
int bulkAgeUpdate(@Param("age") int age, @Param("id") Long id);
This post is licensed under CC BY 4.0 by the author.