10. 객체지향 쿼리 언어 JPQL (2)

2025. 6. 23. 21:02Spring/JPA

5. 경로 표현식
 1) 용어 정리
 2) 실무 조언

 

6. 페치 조인(fetch join)
 6-1. 개념
 6-2. 사용 예시 

1) em.find()로 Member 조회

2) JPQL로 Member 리스트 조회

3) JPQL의 페치 조인으로 Member 리스트 조회 
 6-3. 컬렉션 페치 조인시 주의사항

1) 중복 데이터 문제 

2) 컬렉션 2개 이상 페치 조인 금지

3) 컬렉션 페치 조인 + 페이징 API 금지
 6-4. 실무 조언

 

7. 다형성 쿼리

 

8. 엔티티 객체를 조건절에 직접 사용
 1) 기본 키 기준 비교
 2) 외래 키 기준 비교

 

9. Named 쿼리
 1) NamedQuery

2) 어노테이션 방식

3) 장단점 및 실무조건 

 

10. 벌크 연산
 1) 벌크연산
 2) 예시

 3) 주의사항


5. 경로 표현식

1) 용어 정리

표현식 종류 특징 탐색 가능 여부  추천 사용 방식 
상태 필드 값 저장 필드 X 그대로 사용
단일 값 연관 필드 @ManyToOne 등 O 묵시적 조인 가급적 명시적 조인으로 전환
컬렉션 연관 필드 @OneToMany 등 X 반드시 명시적 조인 필요
-- 경로 표현식: 객체 그래프를 탐색할 때 사용하는 점(.) 표기법 
SELECT m.username          -- 상태 필드,  String, int 등 기본 타입
SELECT m.team.name         -- 단일 연관 필드, @ManyToOne, @OneToOne
SELECT t.members           -- 컬렉션 연관 필드, @OneToMany, @ManyToMany

-- 1. 상태 필드
SELECT m.username FROM Member m       -- O 조회 가능
SELECT m.username.name FROM Member m  -- X 내부 탐색 불가능! (String은 더 탐색 못 함)

-- 2. 단일 연관 필드
SELECT m.team FROM Member m  	  -- 단일 값 연관 필드
SELECT m.team.name FROM Member m  -- 상태 필드 탐색까지 가능! (묵시적 조인) 

-- 3. 컬렉션 연관 필드
SELECT t.members FROM Team t           -- O 가능 (컬렉션 자체는 가져올 수 있음)
SELECT t.members.username FROM Team t  -- X 내부 탐색 안 됨! (컬렉션 내부는 탐색 불가)
SELECT m.username FROM Team t JOIN t.members m  -- O 명시적 JOIN으로 조회 가능

2) 실무 조언

  • 묵시적 조인보다 명시적 조인 권장 (실무에서는 거의 다 명시적 조인만 사용)
  • 명시적 조인을 사용해야 하는 이유  
    • SQL에 JOIN이 생기는데도 코드엔 안 보임 → 성능 튜닝 어려움
    • 쿼리 최적화/페이징/조건 분리 하려면 명시적으로 JOIN 해야 함
-- 묵시적 조인: 점(.)으로 탐색하면서 자동 조인 발생 
-- JOIN 문이 안 보이지만, SQL은 내부적으로 조인됨 → 유지보수/성능 분석 때 혼란
SELECT m.team.name FROM Member m

-- 명시적 조인: JOIN이 명확하게 보여서 직관적이고 안전함!
SELECT t.name FROM Member m JOIN m.team t

 

6. 페치 조인(fetch join)

6-1. 개념

  • SQL 조인과 다른 개념
  • 지연 로딩을 한 번의 SQL로 처리 (즉시 로딩 효과)
조인 방식 의미 DB에서 가져오는 범위
일반 JOIN 단순히 연관된 데이터를 조회만 연관된 엔티티는 프록시 (지연 로딩)
JOIN FETCH 연관된 엔티티까지 즉시 조회하고 영속성 컨텍스트에 등록 완전한 엔티티 상태로 함께 로딩

6-2. 사용 예시

// Member - Team (N:1)
@Entity
public class Member {
    @Id
    Long id;
    
    @ManyToOne(fetch = FetchType.XXX)
    Team team;
}

1) em.find()로 Member 조회

Member member = em.find(Member.class, 1L);
  • 1. FetchType.LAZY → member만 조회
    select m.id, m.username, m.team_id from member m where m.id = 1;
  • 2. FetchType.EAGER → JPA가 자동으로 join fetch 또는 서브쿼리 형태로 쿼리를 생성함.
    select m.id, m.username, m.team_id, t.id, t.name
    from member m left outer join team t on m.team_id = t.id where m.id = 1;

2) JPQL로 Member 리스트 조회

List<Member> members = em.createQuery(
			"SELECT m FROM Member m", Member.class)
			.getResultList();
  • 1. FetchType.LAZY → member만 조회
    하지만! 결과 리스트 members를 반복문으로 순회하면서 Team에 접근한다면? → N+1 문제 발생
    • getTeam() 호출하면서 지연 로딩된 Team을 초기화하려고 함
    • 즉, Member이 N개면 추가로 N개의 Team 조회 쿼리가 발생된다. 
  • 2. FetchType.EAGER → 바로 N+1 문제 발생! 
    SELECT * FROM Member (1번)
    SELECT * FROM Team WHERE id = ?  (N번, member 수만큼)
    • 왜 여기선 JPA가 JOIN문을 안 만들어줄까?
      JPA는 JPQL 쿼리를 그대로 SQL로 변환해서 실행하기 때문에, EAGER 설정이 있어도 JOIN 쿼리는 자동 생성되지 않음.
    • 그러니 JPQL에 JOIN이 없고 EAGER이면? 
      → 일단 JPQL에 그대로 Member만 가져오는 쿼리 1번 실행하고 
      → 그리고 EAGER 설정 때문에 각 Member의 Team을 즉시 로딩하기 위해
      Member 수만큼 Team에 대한 SELECT 쿼리 N번이  추가로 실행됨
      그래서 N+1 문제 발생

3) JPQL의 페치 조인으로 Member 리스트 조회 

List<Member> members = em.createQuery(
    			"SELECT m FROM Member m JOIN FETCH m.team", Member.class)
    			.getResultList();
-- SQL문 실행
select m.id, m.username, m.team_id, t.id, t.name
from member m 
left outer join team t 
on m.team_id = t.id;
  • 페치 조인은 FetchType(LAZY/EAGER) 설정과는 완전히 별개로 동작한다.
    → 즉, 엔티티에 LAZY로 설정되어 있어도, JPQL에서 fetch join을 쓰면 즉시 로딩된다.
  • 페치 조인을 사용하면 회원과 팀을 한 번의 Join 쿼리로 다 가져오므로 추가 쿼리 없이 team.getName() 사용 가능
  • 따라서 FetchType을 LAZY로 사용하고, 정말 필요한 상황에서만 fetch join을 사용하는 전략이  
    N+1 문제를 막고 성능도 최적화할 수 있는 실무적인 접근이다.

6-3. 컬렉션 페치 조인시 주의사항 

@Entity
public class Team {
    @Id
    private Long id;

    @OneToMany(mappedBy = "team")
    private List<Member> members;
}
  • 컬렉션 페치 조인이란?
    • OneToMany 관계(컬렉션)를 페치 조인하는 것 
    • SELECT t FROM Team t JOIN FETCH t.members 
      → Team을 조회하면서, 각 팀에 소속된 멤버들(members)도 한 번에 전부 로딩하겠다! 
  • 단, 컬렉션 페치 조인 시에는 아래와 같은 사항들을 주의해야한다. 

1) 중복 데이터 문제 

//1. 중복 데이터 문제 
//컬렉션이기 때문에 하나의 Team이 여러 줄로 중복됨
List<Team> result = em.createQuery("SELECT t FROM Team t JOIN FETCH t.members", Team.class)
                      .getResultList();
//생성되는 SQL문 
SELECT t.*, m.*
FROM team t
LEFT OUTER JOIN member m ON t.id = m.team_id
//SQL 결과 (3줄)
TeamA, Member1
TeamA, Member2
TeamA, Member3
//이대로 자바에서 List로 받으면? TeamA가 members 수만큼 중복되어 리스트에 반복됨

//따라서 반드시 JPQL DISTINCT 추가해서 중복 제거 
List<Team> result = em.createQuery(
    "SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class)
    .getResultList();
//생성되는 SQL문
SELECT DISTINCT t.*, m.*
FROM team t
JOIN member m ON t.id = m.team_id
//SQL 결과 (3줄) 
TeamA, Member1
TeamA, Member2
TeamA, Member3
// SQL 결과는 DISTINCT 없이 실행한 것과 같지만 (ROW단위 중복제거)
// getResultList() 호출 시점에 JPA가 엔티티 식별자(id) 기준으로 Java 컬렉션에서 중복 제거해준다. 
// 즉, Java 코드에서 List<Team> 컬렉션을 만들 때, TeamA가 3번 반복되더라도 1개만 남기고 리스트에 넣는 식으로 동작     
System.out.println(result.size()); // 1 (TeamA만 들어감)
System.out.println(result.get(0).getMembers().size()); // 3 (Member 3명 다 들어감)

 

2) 컬렉션 2개 이상 페치 조인 금지

@Entity
public class Parent {
    @Id Long id;

    @OneToMany(mappedBy = "parent")
    List<Child1> child1List;

    @OneToMany(mappedBy = "parent")
    List<Child2> child2List;
}

List<Parent> result = em.createQuery(
    "SELECT p FROM Parent p 
    		JOIN FETCH p.child1 
            	JOIN FETCH p.child2" //불가! 
    , Parent.class).getResultList();
    
    
//불가능하지만 예상되는 SQL문
SELECT p.*, c1.*, c2.*
FROM parent p
LEFT OUTER JOIN child1 c1 ON p.id = c1.parent_id
LEFT OUTER JOIN child2 c2 ON p.id = c2.parent_id

  • 같은 자식 엔티티가 여러 번 중복돼서 섞여나오게 되는 곱집합 (Cartesian Product) 발생함
  • Hibernate는 한 번에 컬렉션 두 개 이상을 페치 조인 못 함
    • JPA는 이 결과를 보고 Parent, Child1, Child2 엔티티를 만들어야 하는데 
    • 그런데 Child1, Child2가 중복돼서 여러 줄에 끼어있으니, 정확히 어떤 child가 누구 것인지 판단이 어려워지고,
      컬렉션에 중복해서 add 되거나 누락될 위험이 생긴다. 
    • 이건 1차 캐시 + 영속성 컨텍스트 + 식별자 기준 중복 제거로 해결이 안 되는 문제이므로 
    • JPA 구현체(Hibernate)는 애초에 컬렉션 2개 이상의 fetch join을 금지시켜버림.

3) 컬렉션 페치 조인 + 페이징 API 금지

em.createQuery("SELECT t FROM Team t JOIN FETCH t.members")
  .setFirstResult(0).setMaxResults(2);  // ❌ 작동안 함!

//나가는 SQL문   
SELECT t.*, m.*
FROM team t
LEFT OUTER JOIN member m ON t.id = m.team_id

//SQL문 결과
TeamA	M1
TeamA	M2
TeamA	M3
TeamB	M4
TeamB	M5
TeamC	M6
...

//JPA가 이 결과를 기준으로 setFirstResult(0), setMaxResults(10)를 적용하면 
//단순히 줄 수 기준으로 잘라버리기 때문에 Team 2개가 아니라 그냥 row수 2개로 잘리는 문제가 생긴다.
//데이터 많아지면? 성능 저하

//대안 1: 쿼리 분리 
// 1단계: Team만 페이징
List<Team> teams = em.createQuery("SELECT t FROM Team t", Team.class)
                     .setFirstResult(0)
                     .setMaxResults(10)
                     .getResultList();

// 2단계: members는 Lazy로 로딩되므로 필요 시 BatchSize로 최적화

//대안 2: @BatchSize 사용
// Team을 한 번에 여러 개 조회하고, Lazy로 되어 있는 members는 한 번에 모아서 IN 쿼리로 조회하도록 JPA가 해줌
@OneToMany(mappedBy = "team")
@BatchSize(size = 100)
List<Member> members;

//대안 3: DTO로 직접 조회
SELECT new com.example.TeamDto(t.id, t.name, m.name)
FROM Team t
JOIN t.members m

6-4. 실무 조언

  • 연관된 엔티티가 꼭 필요한 상황에만 사용할 것
    → 모든 쿼리에 무조건 fetch join 넣으면 과도한 조인 + 성능 저하 발생
  • N+1 문제 발생하는 쿼리에만 전략적으로 사용
  • 전역적으로 FetchType.LAZY 설정해두고, 꼭 필요한 경우에만 select fetch로 한 방에 가져오자

 

7. 다형성 쿼리

기능 설명 사용 예시
TYPE() 자식 타입을 조건으로 필터링 WHERE TYPE(i) = Book
TREAT() 부모 → 자식으로 캐스팅하여 자식 필드 사용 JOIN TREAT(i AS Book) b
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
public abstract class Item {
    @Id @GeneratedValue
    private Long id;

    private String name;
}

@Entity
@DiscriminatorValue("Book")
public class Book extends Item {
    private String author;
}

@Entity
@DiscriminatorValue("Movie")
public class Movie extends Item {
    private String director;
}

// 1. TYPE() 사용: 자식의 타입을 비교할 수 있게 해줌 
// Book만 조회 (JPQL에서 Book은 엔티티명이다. 문자열 아니므로 쌍따옴표 안씀)
String jpql = "SELECT i FROM Item i WHERE TYPE(i) = Book"; 
List<Item> books = em.createQuery(jpql, Item.class).getResultList();

// 2. TREAT() 사용: 타입 캐스팅처럼 작동해서 자식 타입의 필드에 접근 가능하게 해줌
// Order가 Item을 참조하고 있는데, 그 Item이 실제로는 Book일 때 Book의 필드 조회하고 싶을 때
String jpql = "SELECT b.author FROM Order o JOIN TREAT(o.item AS Book) b";
List<String> authors = em.createQuery(jpql, String.class).getResultList();
  • 상속 구조 잘 안 쓰는 경우도 많지만, 이미 설계돼 있다면 알아두면 좋음.
  • SINGLE_TABLE 전략에서는 dtype 컬럼에 따라 구분되므로, TYPE() 잘 동작함
  • TREAT()는 JPA 2.1 이상, 구현체(Hibernate)마다 지원 정도 다르므로 테스트 필수

 

8. 엔티티 객체를 조건절에 직접 사용

1) 기본 키 기준 비교 (엔티티 vs ID)

  • JPQL에서 엔티티 객체를 조건에 사용하면, 자동으로 그 객체의 @Id 값으로 비교해준다
Member member = em.find(Member.class, 1L);

String jpql = "SELECT o FROM Order o WHERE o.member = :member";
List<Order> result = em.createQuery(jpql, Order.class)
                       .setParameter("member", member)
                       .getResultList();

// SQL문 
SELECT * FROM orders o WHERE o.member_id = 1

// 즉, o.member = :member 이렇게 엔티티 객체를 직접 조건에 넣어도,
// JPA가 자동으로 해당 객체의 ID 값 (member.getId())으로 비교해준다.

2) 외래 키 기준 비교

  • 엔티티를 외래키 기준으로 비교할 때도 마찬가지로, 자동으로 외래키의 기본 키를 꺼내서 비교해준다. 
Team team = em.find(Team.class, 1L);

String jpql = "SELECT m FROM Member m WHERE m.team = :team";
List<Member> result = em.createQuery(jpql, Member.class)
                        .setParameter("team", team)
                        .getResultList();
//SQL문 
SELECT * FROM member m WHERE m.team_id = 1

 

3) 정리

JPQL JPQL 내부 처리
WHERE m = :member WHERE m.id = :member.getId()
WHERE m.team = :team WHERE m.team_id = :team.getId()
  • 엔티티를 직접 사용하면 코드가 더 직관적이고 깔끔해지지만 
  • 너무 복잡한 조건에서 쓸 때는 .id로 명시하는 게 가독성이 더 좋을 수도 있다. 
  • 단, 단일 엔티티 객체를 조건으로 비교할 때만 가능 (컬렉션 불가)

 

9. Named 쿼리

1) NamedQuery

  • NamedQueryJPQL 쿼리를 미리 정의해두고, 이름으로 호출해서 재사용하는 정적 쿼리 정의 방식
    • JPQL을 자바 코드 안에 직접 쓰는 대신,
    • 엔티티 클래스(@NamedQuery)나 XML 설정(orm.xml)에 쿼리를 선언해두고,
    • 나중에 em.createNamedQuery("이름", 클래스)로 실행하는 구조

2) 어노테이션 방식 (xml 방식은 거의 안씀) 

@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> result = em.createNamedQuery("Member.findByUsername", Member.class)
                        .setParameter("username", "hyunji")
                        .getResultList();

3) 장단점 및 실무 조언 

  • 장점 
장점 설명 
컴파일 시점에 검증 가능 NamedQuery는 애플리케이션 시작 시점에 JPQL 문법 오류를 체크함. → 일반 JPQL은 런타임 시점에 오류가 터짐
재사용성 높음 쿼리를 여러 곳에서 반복해서 쓰기 좋고, 유지보수도 쉬움
코드 깔끔함 자바 코드 안에서 JPQL 문자열이 사라지니 더 깔끔해짐
성능 최적화 일부 JPA 구현체(Hibernate)는 NamedQuery를 캐싱해서 성능 이점이 있음
  • 단점: 동적 쿼리가 불가하고 조건 많아지면 불편함 
  • 쿼리가 자주 재사용된다거나, 쿼리 오류를 미리 잡고 싶다면 NamedQuery가 괜찮지만
    요즘 실무에서는 Spring Data JPA의 @Query, Querydsl, NativeQuery 등을 더 많이 사용한다.

 

10. 벌크 연산

1) 벌크 연산 

String qlString = "update Product p set p.price = p.price * 1.1 
                   where p.stockAmount < :stockAmount";

int resultCount = em.createQuery(qlString)
    .setParameter("stockAmount", 10)
    .executeUpdate();

//executeUpdate()의 반환값은 영향을 받은 row 수(int)
System.out.println("변경된 row 수: " + resultCount);
  • INSET, UPDATE, DELETE문을 JPQL로 직접 실행하는 기능
  • 영속성 컨텍스트 무시하고 바로 DB에 SQL 쿼리 실행
  • 엔티티를 개별적으로 수정하는 것이 아니라
    조건에 맞는 많은 양의 데이터를 한 방에(=bulk로) 처리한다고 해서 벌크 연산이라고 부른다.
  • 자주 쓰이진 않지만 대량 데이터 처리, 정산, 정리, 성능 최적화 목적으로 사용한다. 

2) 예시

// UPDATE
em.createQuery("update Member m set m.age = 20").executeUpdate();

// DELETE
em.createQuery("delete from Member m where m.age > 100").executeUpdate();

// 조건 있는 UPDATE
em.createQuery("update Product p set p.price = p.price * 1.2 where p.stockAmount < :stock")
  .setParameter("stock", 5)
  .executeUpdate();

3) 주의사항

  • 벌크 연산은 영속성 컨텍스트를 무시하기 때문에 영속성 컨텍스트와 불일치 발생 가능성이 있다. 
  • 따라서 벌크 연산 이후에는 아래 둘 중 하나는 꼭 해야 이후에 조회하거나 로직 처리할 때 캐시 데이터 오류를 막을 수 있음 
    • em.clear(); → 영속성 컨텍스트 초기화
    • em.flush(); em.clear(); → 영속성 반영하고 초기화
  • 상황 별 추천 방식 
상황 추천 방식
특정 조건의 데이터만 대량 수정해야 할 때 벌크 UPDATE
데이터 삭제도 조건이 명확하면 벌크 DELETE
변경 이후 조회/사용 계획이 있다면 반드시 em.clear() 또는 flush()+clear() 할 것