10. 객체지향 쿼리 언어 JPQL (2)
2025. 6. 23. 21:02ㆍSpring/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 문제 발생
- 왜 여기선 JPA가 JOIN문을 안 만들어줄까?
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
- NamedQuery는 JPQL 쿼리를 미리 정의해두고, 이름으로 호출해서 재사용하는 정적 쿼리 정의 방식
- 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() 할 것 |