1. 객체지향 쿼리 언어 소개
1.1. JPA의 다양한 쿼리 방법
JPA는 여러 가지 쿼리 방법을 제공하여 다양한 상황에 대응할 수 있도록 설계되었다:
// 1. JPQL (Java Persistence Query Language)
String jpql = "SELECT m FROM Member m WHERE m.age > 20";
List<Member> result = em.createQuery(jpql, Member.class).getResultList();
// 2. Criteria API (타입 안전 쿼리)
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> cq = cb.createQuery(Member.class);
Root<Member> m = cq.from(Member.class);
cq.select(m).where(cb.gt(m.get("age"), 20));
List<Member> result = em.createQuery(cq).getResultList();
// 3. QueryDSL (실무 권장)
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = QMember.member;
List<Member> result = queryFactory.selectFrom(m)
.where(m.age.gt(20))
.fetch();
// 4. 네이티브 SQL
String sql = "SELECT * FROM MEMBER WHERE AGE > 20";
List<Member> result = em.createNativeQuery(sql, Member.class).getResultList();
// 5. JDBC 직접 사용 또는 MyBatis, SpringJdbcTemplate
Connection conn = em.unwrap(Connection.class);
// JDBC 직접 사용 또는 다른 매퍼와 연동
1.2. JPQL 소개와 필요성
단순 조회의 한계
// 1. 기본 조회 방법
Member member = em.find(Member.class, 1L); // 단일 조회
// 2. 객체 그래프 탐색
Member member = em.find(Member.class, 1L);
Team team = member.getTeam(); // 연관 엔티티 조회
// 문제: 나이가 18살 이상인 모든 회원을 조회하려면?
// -> SQL이 필요하지만, 객체를 대상으로 하는 쿼리가 필요
JPQL의 등장
// JPQL: 객체지향 쿼리 언어
String jpql = "SELECT m FROM Member m WHERE m.age >= 18";
List<Member> adults = em.createQuery(jpql, Member.class).getResultList();
// 실행된 SQL (추상화됨)
// SELECT m.id, m.name, m.age, m.team_id
// FROM member m
// WHERE m.age >= 18
JPQL vs SQL
- JPQL: 객체(엔티티)를 대상으로 쿼리, SELECT m FROM Member m
- SQL: 테이블을 대상으로 쿼리, SELECT * FROM member
- 공통점: SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN 지원
- 차이점: JPQL은 데이터베이스 독립적
2. JPQL 기본 문법과 기능
2.1. JPQL 문법 구조
기본 문법
-- SELECT 문 구조
SELECT [DISTINCT] {프로젝션}
FROM {엔티티} [AS] {별칭}
[WHERE {조건식}]
[GROUP BY {그룹화} [HAVING {그룹조건}]]
[ORDER BY {정렬}]
-- UPDATE 문 구조
UPDATE {엔티티} [AS] {별칭}
SET {수정할_속성} = {값}
[WHERE {조건식}]
-- DELETE 문 구조
DELETE FROM {엔티티} [AS] {별칭}
[WHERE {조건식}]
대소문자 구분 규칙
// 엔티티와 속성: 대소문자 구분 O
String jpql1 = "SELECT m FROM Member m WHERE m.age > 18"; // 정상
String jpql2 = "SELECT m FROM member m WHERE m.age > 18"; // 오류: member (소문자)
// JPQL 키워드: 대소문자 구분 X
String jpql3 = "select m from Member m where m.age > 18"; // 정상 (소문자)
String jpql4 = "SELECT m FROM Member m WHERE m.age > 18"; // 정상 (대문자)
// 별칭: 필수 (AS는 생략 가능)
String jpql5 = "SELECT m FROM Member AS m"; // AS 명시적
String jpql6 = "SELECT m FROM Member m"; // AS 생략 가능
2.2. 집합 함수와 정렬
집합 함수
// 다양한 집합 함수 사용
String jpql = """
SELECT
COUNT(m), -- 회원 수
SUM(m.age), -- 나이 합계
AVG(m.age), -- 평균 나이
MAX(m.age), -- 최대 나이
MIN(m.age) -- 최소 나이
FROM Member m
""";
TypedQuery<Object[]> query = em.createQuery(jpql, Object[].class);
Object[] result = query.getSingleResult();
Long count = (Long) result[0];
Long sum = (Long) result[1];
Double avg = (Double) result[2];
Integer max = (Integer) result[3];
Integer min = (Integer) result[4];
GROUP BY와 HAVING
// 팀별 통계
String jpql = """
SELECT t.name, COUNT(m), AVG(m.age)
FROM Member m
JOIN m.team t
GROUP BY t.name
HAVING AVG(m.age) >= 20
""";
List<Object[]> results = em.createQuery(jpql, Object[].class).getResultList();
for (Object[] row : results) {
String teamName = (String) row[0];
Long count = (Long) row[1];
Double avgAge = (Double) row[2];
System.out.println(teamName + ": " + count + "명, 평균나이: " + avgAge);
}
// ORDER BY 정렬
String jpql2 = """
SELECT m
FROM Member m
ORDER BY
m.age DESC, -- 나이 내림차순
m.name ASC NULLS FIRST -- 이름 오름차순, NULL 먼저
""";
2.3. 결과 조회 API
TypeQuery와 Query
// TypeQuery: 반환 타입이 명확할 때
TypedQuery<Member> typedQuery =
em.createQuery("SELECT m FROM Member m", Member.class);
List<Member> members = typedQuery.getResultList();
// Query: 반환 타입이 명확하지 않을 때
Query query = em.createQuery("SELECT m.name, m.age FROM Member m");
List<Object[]> results = query.getResultList();
for (Object[] row : results) {
String name = (String) row[0];
Integer age = (Integer) row[1];
}
// 단일 결과 조회
try {
Member member = em.createQuery(
"SELECT m FROM Member m WHERE m.id = :id", Member.class)
.setParameter("id", 1L)
.getSingleResult(); // 결과가 없으면 NoResultException 발생
} catch (NoResultException e) {
// 결과가 없을 때 처리
}
결과 조회 메서드 비교
- getResultList(): 결과가 0개 이상일 때 사용, 결과 없으면 빈 리스트 반환
- getSingleResult(): 결과가 정확히 1개일 때 사용
- 결과 없음: NoResultException
- 결과 2개 이상: NonUniqueResultException
2.4. 파라미터 바인딩
이름 기준 파라미터 (권장)
// 이름 기준 파라미터
String jpql = "SELECT m FROM Member m WHERE m.username = :username AND m.age > :age";
List<Member> members = em.createQuery(jpql, Member.class)
.setParameter("username", "kim")
.setParameter("age", 20)
.getResultList();
// IN 절 파라미터
String jpql2 = "SELECT m FROM Member m WHERE m.username IN :names";
List<Member> members2 = em.createQuery(jpql2, Member.class)
.setParameter("names", Arrays.asList("kim", "lee", "park"))
.getResultList();
// 날짜/시간 파라미터
String jpql3 = "SELECT m FROM Member m WHERE m.createdDate > :date";
List<Member> members3 = em.createQuery(jpql3, Member.class)
.setParameter("date", LocalDateTime.now().minusDays(7))
.getResultList();
위치 기준 파라미터 (비권장)
// 위치 기준 파라미터 (가독성 떨어짐)
String jpql = "SELECT m FROM Member m WHERE m.username = ?1 AND m.age > ?2";
List<Member> members = em.createQuery(jpql, Member.class)
.setParameter(1, "kim")
.setParameter(2, 20)
.getResultList();
파라미터 바인딩 장점
- SQL 인젝션 방지: 쿼리와 데이터 분리
- 성능 최적화: 같은 쿼리에 대해 재사용 가능
- 가독성 향상: 이름 기준 파라미터 사용 시
2.5. 프로젝션
프로젝션 대상 종류
// 1. 엔티티 프로젝션
String jpql1 = "SELECT m FROM Member m"; // 전체 엔티티
String jpql2 = "SELECT m.team FROM Member m"; // 연관 엔티티
// 2. 임베디드 타입 프로젝션
String jpql3 = "SELECT m.address FROM Member m";
// 3. 스칼라 타입 프로젝션
String jpql4 = "SELECT m.username, m.age FROM Member m";
// 4. DISTINCT로 중복 제거
String jpql5 = "SELECT DISTINCT m.team FROM Member m";
여러 값 조회 방법
String jpql = "SELECT m.username, m.age FROM Member m";
// 방법 1: Query 타입으로 조회 (Object[])
List<Object[]> results = em.createQuery(jpql).getResultList();
for (Object[] row : results) {
String username = (String) row[0];
Integer age = (Integer) row[1];
}
// 방법 2: TypeQuery 사용 (명시적 타입)
TypedQuery<Object[]> query = em.createQuery(jpql, Object[].class);
List<Object[]> results = query.getResultList();
// 방법 3: DTO로 조회 (가장 권장)
public class MemberDto {
private String username;
private int age;
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
// getters
}
String jpql2 = "SELECT new com.example.MemberDto(m.username, m.age) FROM Member m";
TypedQuery<MemberDto> query2 = em.createQuery(jpql2, MemberDto.class);
List<MemberDto> dtos = query2.getResultList();
2.6. 페이징 API
표준 페이징 API
// 페이징 쿼리
String jpql = "SELECT m FROM Member m ORDER BY m.name DESC";
List<Member> resultPage1 = em.createQuery(jpql, Member.class)
.setFirstResult(0) // 시작 위치 (0-based)
.setMaxResults(10) // 페이지 크기
.getResultList();
List<Member> resultPage2 = em.createQuery(jpql, Member.class)
.setFirstResult(10) // 두번째 페이지
.setMaxResults(10)
.getResultList();
// 전체 카운트 쿼리
Long totalCount = em.createQuery("SELECT COUNT(m) FROM Member m", Long.class)
.getSingleResult();
int totalPages = (int) Math.ceil((double) totalCount / 10);
데이터베이스별 페이징 SQL 변환
-- MySQL
SELECT m.* FROM MEMBER m ORDER BY m.name DESC LIMIT 0, 10
-- PostgreSQL
SELECT m.* FROM MEMBER m ORDER BY m.name DESC LIMIT 10 OFFSET 0
-- Oracle (11g 이전)
SELECT * FROM (
SELECT ROWNUM AS rn, m.* FROM (
SELECT * FROM MEMBER ORDER BY name DESC
) m WHERE ROWNUM <= 20
) WHERE rn > 10
-- SQL Server
SELECT * FROM MEMBER
ORDER BY name DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY
실무 페이징 패턴
public class Page<T> {
private List<T> content;
private int page;
private int size;
private long totalElements;
private int totalPages;
// 생성자, getters
}
public Page<Member> findMembers(int page, int size) {
// 데이터 조회
List<Member> content = em.createQuery(
"SELECT m FROM Member m ORDER BY m.createdDate DESC", Member.class)
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
// 전체 카운트 조회 (성능 최적화 필요시 별도 최적화)
Long total = em.createQuery("SELECT COUNT(m) FROM Member m", Long.class)
.getSingleResult();
return new Page<>(content, page, size, total);
}
// Count 최적화 (복잡한 쿼리일 때)
public Long countMembers() {
return em.createQuery(
"SELECT COUNT(m) FROM Member m WHERE m.status = :status", Long.class)
.setParameter("status", MemberStatus.ACTIVE)
.getSingleResult();
}
2.7. 조인
조인 종류
// 1. 내부 조인 (INNER JOIN)
String jpql1 = "SELECT m FROM Member m JOIN m.team t";
String jpql2 = "SELECT m FROM Member m INNER JOIN m.team t";
// 2. 외부 조인 (LEFT OUTER JOIN)
String jpql3 = "SELECT m FROM Member m LEFT JOIN m.team t";
String jpql4 = "SELECT m FROM Member m LEFT OUTER JOIN m.team t";
// 3. 세타 조인 (연관관계 없는 조인)
String jpql5 = """
SELECT COUNT(m)
FROM Member m, Team t
WHERE m.username = t.name
""";
// 4. 페치 조인 (성능 최적화)
String jpql6 = "SELECT m FROM Member m JOIN FETCH m.team";
// 5. 다중 조인
String jpql7 = """
SELECT m
FROM Member m
JOIN m.team t
LEFT JOIN t.company c
""";
ON 절 조인 (JPA 2.1+)
// 1. 조인 대상 필터링
String jpql1 = """
SELECT m, t
FROM Member m
LEFT JOIN m.team t ON t.name = '개발팀'
""";
// SQL: ... LEFT JOIN Team t ON m.team_id = t.id AND t.name = '개발팀'
// 2. 연관관계 없는 엔티티 외부 조인 (하이버네이트 5.1+)
String jpql2 = """
SELECT m, t
FROM Member m
LEFT JOIN Team t ON m.username = t.name
""";
// SQL: ... LEFT JOIN Team t ON m.username = t.name
// 3. 복잡한 ON 조건
String jpql3 = """
SELECT m, t
FROM Member m
JOIN m.team t ON t.active = true AND t.createdDate > :date
""";
2.8. 서브쿼리
서브쿼리 지원 함수
// 1. 기본 서브쿼리
String jpql1 = """
SELECT m
FROM Member m
WHERE m.age > (SELECT AVG(m2.age) FROM Member m2)
""";
// 2. EXISTS
String jpql2 = """
SELECT m
FROM Member m
WHERE EXISTS (
SELECT t
FROM m.team t
WHERE t.name = '팀A'
)
""";
// 3. IN
String jpql3 = """
SELECT m
FROM Member m
WHERE m.team IN (
SELECT t
FROM Team t
WHERE t.active = true
)
""";
// 4. ALL
String jpql4 = """
SELECT o
FROM Order o
WHERE o.amount > ALL (
SELECT p.stock
FROM Product p
)
""";
// 5. ANY/SOME
String jpql5 = """
SELECT m
FROM Member m
WHERE m.age > ANY (
SELECT m2.age
FROM Member m2
WHERE m2.team.name = '개발팀'
)
""";
JPA 서브쿼리 제한사항
- 지원: WHERE, HAVING 절
- 조건부 지원: SELECT 절 (하이버네이트 지원)
- 미지원: FROM 절의 서브쿼리 (하이버네이트 6부터 지원)
- 대안: 조인 또는 네이티브 SQL 사용
2.9. 조건식과 함수
CASE 식
// 1. 기본 CASE 식
String jpql1 = """
SELECT
CASE
WHEN m.age <= 18 THEN '청소년'
WHEN m.age <= 30 THEN '청년'
WHEN m.age <= 65 THEN '중장년'
ELSE '노년'
END
FROM Member m
""";
// 2. 단순 CASE 식
String jpql2 = """
SELECT
CASE m.grade
WHEN 'VIP' THEN '우수회원'
WHEN 'GOLD' THEN '골드회원'
ELSE '일반회원'
END
FROM Member m
""";
// 3. COALESCE (첫번째 null 아닌 값)
String jpql3 = "SELECT COALESCE(m.nickname, m.username, '이름없음') FROM Member m";
// 4. NULLIF (두 값이 같으면 null)
String jpql4 = "SELECT NULLIF(m.username, 'admin') FROM Member m";
JPQL 기본 함수
// 문자열 함수
String jpql1 = """
SELECT
CONCAT(m.firstName, ' ', m.lastName),
SUBSTRING(m.email, 1, 10),
TRIM(m.username),
LOWER(m.username),
UPPER(m.username),
LENGTH(m.username),
LOCATE('@', m.email)
FROM Member m
""";
// 수학 함수
String jpql2 = """
SELECT
ABS(m.score),
SQRT(m.score),
MOD(m.age, 10)
FROM Member m
""";
// JPA 용도 함수
String jpql3 = """
SELECT
SIZE(t.members), -- 컬렉션 크기
TYPE(e) -- 엔티티 타입 (상속에서 사용)
FROM Team t, Employee e
""";
사용자 정의 함수
// 1. 방언 확장 (사용자 정의 함수 등록)
public class MyH2Dialect extends H2Dialect {
public MyH2Dialect() {
registerFunction(
"group_concat",
new StandardSQLFunction("group_concat", StandardBasicTypes.STRING)
);
}
}
// persistence.xml
<property name="hibernate.dialect" value="com.example.MyH2Dialect"/>
// 2. JPQL에서 사용
String jpql = "SELECT function('group_concat', m.username) FROM Member m";
// 3. 하이버네이트의 표준 함수 사용
String jpql2 = "SELECT group_concat(m.username) FROM Member m"; // Hibernate 6+
3. JPQL 고급 기능
3.1. 경로 표현식
경로 표현식 종류
@Entity
public class Member {
@Id private Long id;
private String username; // 상태 필드
@ManyToOne private Team team; // 단일 값 연관 필드
@OneToMany private List<Order> orders; // 컬렉션 값 연관 필드
}
// 1. 상태 필드 경로 탐색 (탐색 끝)
String jpql1 = "SELECT m.username FROM Member m"; // 정상
// m.username.username (X) - 상태 필드는 더 이상 탐색 불가
// 2. 단일 값 연관 경로 탐색 (묵시적 내부 조인 발생)
String jpql2 = "SELECT m.team.name FROM Member m"; // 정상, 탐색 가능
// SQL: SELECT t.name FROM Member m INNER JOIN Team t ON m.team_id = t.id
// 3. 컬렉션 값 연관 경로 탐색 (묵시적 내부 조인 발생, 탐색 불가)
String jpql3 = "SELECT t.members FROM Team t"; // 정상
String jpql4 = "SELECT t.members.username FROM Team t"; // 오류! 탐색 불가
// 4. 명시적 조인으로 탐색 가능
String jpql5 = """
SELECT m.username
FROM Team t
JOIN t.members m -- 명시적 조인
"""; // 정상
묵시적 조인 vs 명시적 조인
// 묵시적 조인 (가급적 사용하지 말 것)
String jpql1 = "SELECT m.team.name FROM Member m";
// 문제점: 조인이 발생하는지 한눈에 파악 어려움, 항상 내부 조인만 가능
// 명시적 조인 (권장)
String jpql2 = """
SELECT t.name
FROM Member m
JOIN m.team t
WHERE t.name = '개발팀'
""";
// 장점: 조인 종류 명확, 외부 조인 가능, 쿼리 튜닝 용이
// 복잡한 조인
String jpql3 = """
SELECT o
FROM Order o
LEFT JOIN o.member m
LEFT JOIN FETCH o.items i -- 페치 조인
INNER JOIN i.product p
WHERE m.username = :username
""";
경로 표현식 실무 조언
- 가급적 묵시적 조인 대신 명시적 조인 사용
- 컬렉션은 경로 탐색 불가 → 명시적 조인으로 별칭 획득
- SELECT, WHERE 절에서 경로 탐색 가능
- 묵시적 조인은 SQL의 FROM 절에 영향을 줌
3.2. 페치 조인 (Fetch Join) ⭐

엔티티 페치 조인
// N+1 문제 발생 예시
String jpql = "SELECT m FROM Member m";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();
for (Member member : members) {
System.out.println(member.getTeam().getName()); // N+1 문제!
// member.getTeam() 호출 시마다 추가 쿼리 실행
}
// 페치 조인으로 해결
String jpql2 = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members2 = em.createQuery(jpql2, Member.class).getResultList();
for (Member member : members2) {
System.out.println(member.getTeam().getName()); // 추가 쿼리 없음
// 이미 team이 로딩됨
}
컬렉션 페치 조인

// 1:N 관계에서의 페치 조인 (주의: 데이터 뻥튀기 가능성)
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.getName());
for (Member member : team.getMembers()) {
System.out.println(" -> 회원: " + member.getUsername());
}
}
DISTINCT로 중복 제거
// 데이터 뻥튀기 문제
// 팀A: 회원1, 회원2
// 팀B: 회원3
// 페치 조인 결과: 팀A, 팀A, 팀B (팀A가 2번 나옴)
// DISTINCT로 해결
String jpql = "SELECT DISTINCT t FROM Team t JOIN FETCH t.members";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();
// DISTINCT 동작 방식
// 1. SQL에 DISTINCT 추가 (데이터가 다르면 효과 없음)
// 2. 애플리케이션에서 같은 식별자를 가진 엔티티 제거 (JPA 레벨)

페치 조인 vs 일반 조인
// 일반 조인: 연관 엔티티를 함께 조회하지 않음
String jpql1 = "SELECT t FROM Team t JOIN t.members m";
// SQL: SELECT t.* FROM Team t INNER JOIN Member m ON t.id = m.team_id
// 결과: Team만 조회, Member는 조회 안됨
// 페치 조인: 연관 엔티티를 함께 조회
String jpql2 = "SELECT t FROM Team t JOIN FETCH t.members";
// SQL: SELECT t.*, m.* FROM Team t INNER JOIN Member m ON t.id = m.team_id
// 결과: Team과 Member 함께 조회
페치 조인의 특징과 한계
// 1. 별칭 사용 주의 (가급적 사용하지 말 것)
String jpql1 = "SELECT t FROM Team t JOIN FETCH t.members m WHERE m.age > 20";
// 비권장: 페치 조인 대상에 별칭을 사용하여 필터링
// 2. 둘 이상의 컬렉션 페치 조인 불가
String jpql2 = """
SELECT t
FROM Team t
JOIN FETCH t.members
JOIN FETCH t.projects // 오류!
""";
// 3. 컬렉션 페치 조인과 페이징 API 사용 불가
String jpql3 = "SELECT t FROM Team t JOIN FETCH t.members";
List<Team> teams = em.createQuery(jpql3, Team.class)
.setFirstResult(0)
.setMaxResults(10) // 경고: 메모리에서 페이징 (매우 위험!)
.getResultList();
// 4. 단일 값 연관 필드는 페이징 가능
String jpql4 = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql4, Member.class)
.setFirstResult(0)
.setMaxResults(10) // 정상
.getResultList();
페치 조인 실무 적용 패턴
// 글로벌 로딩 전략: 모두 지연 로딩
@Entity
public class Member {
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
private Team team;
}
// 필요할 때만 페치 조인 사용
public Member findMemberWithTeam(Long memberId) {
String jpql = """
SELECT m
FROM Member m
JOIN FETCH m.team
WHERE m.id = :id
""";
return em.createQuery(jpql, Member.class)
.setParameter("id", memberId)
.getSingleResult();
}
public List<Order> findOrdersWithItems(Long memberId) {
String jpql = """
SELECT DISTINCT o
FROM Order o
JOIN FETCH o.member m
JOIN FETCH o.items i
JOIN FETCH i.product
WHERE m.id = :memberId
ORDER BY o.orderDate DESC
""";
return em.createQuery(jpql, Order.class)
.setParameter("memberId", memberId)
.getResultList();
}
// 페치 조인 대신 DTO 조회 (복잡한 요구사항)
public List<OrderSummaryDto> findOrderSummaries(Long memberId) {
String jpql = """
SELECT new com.example.OrderSummaryDto(
o.id, o.orderDate, o.status,
m.username,
COUNT(i), SUM(i.totalPrice)
)
FROM Order o
JOIN o.member m
JOIN o.items i
WHERE m.id = :memberId
GROUP BY o.id, o.orderDate, o.status, m.username
""";
return em.createQuery(jpql, OrderSummaryDto.class)
.setParameter("memberId", memberId)
.getResultList();
}
3.3. 다형성 쿼리
TYPE (상속 관계에서 타입 필터링)

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {
@Id private Long id;
private String name;
private int price;
}
@Entity
@DiscriminatorValue("B")
public class Book extends Item {
private String author;
private String isbn;
}
@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
private String director;
private String actor;
}
@Entity
@DiscriminatorValue("A")
public class Album extends Item {
private String artist;
}
// TYPE 사용
String jpql1 = "SELECT i FROM Item i WHERE TYPE(i) IN (Book, Movie)";
// SQL: SELECT i.* FROM Item i WHERE i.DTYPE IN ('B', 'M')
// 모든 자식 타입 포함 조회
String jpql2 = "SELECT i FROM Item i";
// 자동으로 모든 자식 타입 포함하여 조회
TREAT (타입 캐스팅)
// 부모 타입을 특정 자식 타입으로 다룸
String jpql1 = """
SELECT i
FROM Item i
WHERE TREAT(i AS Book).author = '김영한'
""";
// SQL: SELECT i.* FROM Item i WHERE i.DTYPE = 'B' AND i.author = '김영한'
// 여러 자식 타입으로 TREAT
String jpql2 = """
SELECT i
FROM Item i
WHERE
(TREAT(i AS Book).author = '김영한') OR
(TREAT(i AS Movie).director = '크리스토퍼 놀란')
""";
// FROM 절에서 TREAT
String jpql3 = """
SELECT b
FROM Item i, TREAT(i AS Book) b
WHERE b.price > 10000
""";
3.4. 엔티티 직접 사용
기본 키 값으로 비교
// 엔티티 직접 사용
String jpql1 = "SELECT m FROM Member m WHERE m = :member";
List<Member> result1 = em.createQuery(jpql1, Member.class)
.setParameter("member", member)
.getResultList();
// SQL: SELECT m.* FROM Member m WHERE m.id = ?
// 기본 키 값 사용
String jpql2 = "SELECT m FROM Member m WHERE m.id = :memberId";
List<Member> result2 = em.createQuery(jpql2, Member.class)
.setParameter("memberId", member.getId())
.getResultList();
// SQL: SELECT m.* FROM Member m WHERE m.id = ?
외래 키 값으로 비교
// 엔티티 직접 사용
Team team = em.find(Team.class, 1L);
String jpql1 = "SELECT m FROM Member m WHERE m.team = :team";
List<Member> result1 = em.createQuery(jpql1, Member.class)
.setParameter("team", team)
.getResultList();
// SQL: SELECT m.* FROM Member m WHERE m.team_id = ?
// 외래 키 값 사용
String jpql2 = "SELECT m FROM Member m WHERE m.team.id = :teamId";
List<Member> result2 = em.createQuery(jpql2, Member.class)
.setParameter("teamId", 1L)
.getResultList();
// SQL: SELECT m.* FROM Member m WHERE m.team_id = ?
엔티티 직접 사용 장점
- 가독성 향상: 객체지향적 표현
- 타입 안전성: 컴파일 타임 체크 가능
- 리팩토링 용이: 필드명 변경 시 자동 반영
3.5. Named 쿼리
어노테이션 기반 Named 쿼리
// 엔티티에 정의
@Entity
@NamedQueries({
@NamedQuery(
name = "Member.findByUsername",
query = "SELECT m FROM Member m WHERE m.username = :username",
hints = @QueryHint(name = "org.hibernate.readOnly", value = "true")
),
@NamedQuery(
name = "Member.count",
query = "SELECT COUNT(m) FROM Member m"
),
@NamedQuery(
name = "Member.findByAgeGreaterThan",
query = "SELECT m FROM Member m WHERE m.age > :age ORDER BY m.age"
)
})
public class Member {
// ...
}
// 사용
public Member findByUsername(String username) {
return em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", username)
.setHint("javax.persistence.cache.storeMode", "REFRESH")
.getSingleResult();
}
public Long countAll() {
return em.createNamedQuery("Member.count", Long.class)
.getSingleResult();
}
public List<Member> findAdults(int minAge) {
return em.createNamedQuery("Member.findByAgeGreaterThan", Member.class)
.setParameter("age", minAge)
.getResultList();
}
Named 쿼리 장점
- 애플리케이션 로딩 시점에 쿼리 검증: 오류 조기 발견
- 재사용성: 동일 쿼리 여러 곳에서 사용
- 캐싱 가능: 하이버네이트 쿼리 캐시 활용
- 관리 용이성: 쿼리 중앙 집중 관리
3.6. 벌크 연산
벌크 업데이트
// 재고 10개 미만 상품 가격 10% 인상
String jpql = """
UPDATE Product p
SET p.price = p.price * 1.1
WHERE p.stock < :stock
""";
int updatedCount = em.createQuery(jpql)
.setParameter("stock", 10)
.executeUpdate();
System.out.println(updatedCount + "개의 상품 가격이 인상되었습니다.");
// 조건이 복잡한 벌크 업데이트
String jpql2 = """
UPDATE Order o
SET o.status = :newStatus
WHERE o.status = :oldStatus
AND o.orderDate < :date
""";
int cancelledCount = em.createQuery(jpql2)
.setParameter("newStatus", OrderStatus.CANCELLED)
.setParameter("oldStatus", OrderStatus.PENDING)
.setParameter("date", LocalDateTime.now().minusDays(30))
.executeUpdate();
벌크 삭제
// 30일 이상된 로그 삭제
String jpql = """
DELETE FROM AccessLog l
WHERE l.accessTime < :date
""";
int deletedCount = em.createQuery(jpql)
.setParameter("date", LocalDateTime.now().minusDays(30))
.executeUpdate();
// 연관관계 없는 데이터 삭제
String jpql2 = """
DELETE FROM TemporaryData t
WHERE NOT EXISTS (
SELECT 1 FROM User u WHERE u.id = t.userId
)
""";
int orphanCount = em.createQuery(jpql2).executeUpdate();
벌크 연산 주의사항
// 문제: 영속성 컨텍스트와 데이터베이스 불일치
Member member = em.find(Member.class, 1L);
member.setAge(25); // 영속성 컨텍스트에만 반영
// 벌크 연산 실행
String jpql = "UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId";
int updated = em.createQuery(jpql)
.setParameter("teamId", 1L)
.executeUpdate();
// SQL: UPDATE member SET age = age + 1 WHERE team_id = 1
// 주의: 영속성 컨텍스트의 member.age는 여전히 25
// 해결 방법 1: 벌크 연산 먼저 실행
@Transactional
public void bulkUpdateThenProcess(Long teamId) {
// 1. 벌크 연산 먼저 실행
String jpql = "UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId";
em.createQuery(jpql)
.setParameter("teamId", teamId)
.executeUpdate();
// 2. 영속성 컨텍스트 초기화
em.clear();
// 3. 이후 작업 수행
List<Member> members = em.createQuery(
"SELECT m FROM Member m WHERE m.team.id = :teamId", Member.class)
.setParameter("teamId", teamId)
.getResultList();
}
// 해결 방법 2: 벌크 연산 후 플러시와 초기화
@Transactional
public void processThenBulkUpdate(Long teamId) {
// 1. 필요한 작업 먼저 수행
List<Member> members = em.createQuery(
"SELECT m FROM Member m WHERE m.team.id = :teamId", Member.class)
.setParameter("teamId", teamId)
.getResultList();
// 2. 작업 후 플러시
em.flush();
// 3. 벌크 연산 실행
String jpql = "UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId";
em.createQuery(jpql)
.setParameter("teamId", teamId)
.executeUpdate();
// 4. 영속성 컨텍스트 초기화
em.clear();
}
벌크 INSERT (하이버네이트)
// INSERT INTO ... SELECT (하이버네이트 지원)
String jpql = """
INSERT INTO MemberHistory(id, username, age, createdDate)
SELECT m.id, m.username, m.age, CURRENT_TIMESTAMP
FROM Member m
WHERE m.createdDate < :date
""";
int insertedCount = em.createQuery(jpql)
.setParameter("date", LocalDateTime.now().minusYears(1))
.executeUpdate();
4. JPQL 성능 최적화
4.1. N+1 문제와 해결책
N+1 문제 예시
// 문제: 각 회원의 팀을 조회할 때마다 추가 쿼리 발생
List<Member> members = em.createQuery(
"SELECT m FROM Member m", Member.class)
.getResultList(); // 쿼리 1번: 회원 조회
for (Member member : members) {
System.out.println(member.getTeam().getName()); // N번의 추가 쿼리!
// member.getTeam() 호출 시마다 SELECT * FROM team WHERE id = ?
}
해결책 1: 페치 조인
// 페치 조인으로 한 번에 조회
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql, Member.class)
.getResultList(); // 쿼리 1번: 회원과 팀 함께 조회
for (Member member : members) {
System.out.println(member.getTeam().getName()); // 추가 쿼리 없음
}
해결책 2: 배치 사이즈 조정
// 엔티티에 배치 사이즈 설정
@Entity
@BatchSize(size = 100) // 팀을 100개씩 모아서 조회
public class Member {
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
// 또는 어노테이션으로 설정
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100)
private Team team;
// XML 설정
<property name="hibernate.default_batch_fetch_size" value="100"/>
해결책 3: 엔티티 그래프
// 엔티티 그래프 정의
@Entity
@NamedEntityGraph(
name = "Member.withTeam",
attributeNodes = @NamedAttributeNode("team")
)
public class Member {
// ...
}
// 사용
EntityGraph<Member> graph = em.createEntityGraph(Member.class);
graph.addAttributeNodes("team");
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.fetchgraph", graph);
Member member = em.find(Member.class, 1L, hints);
4.2. 쿼리 힌트
JPA 표준 힌트
// 읽기 전용 쿼리
String jpql = "SELECT m FROM Member m WHERE m.team.id = :teamId";
List<Member> members = em.createQuery(jpql, Member.class)
.setParameter("teamId", 1L)
.setHint("javax.persistence.readOnly", true) // 읽기 전용
.setHint("javax.persistence.query.timeout", 5000) // 타임아웃 5초
.getResultList();
// 캐시 관련 힌트
List<Member> cached = em.createQuery(jpql, Member.class)
.setParameter("teamId", 1L)
.setHint("javax.persistence.cache.retrieveMode", CacheRetrieveMode.USE)
.setHint("javax.persistence.cache.storeMode", CacheStoreMode.REFRESH)
.getResultList();
하이버네이트 힌트
// 페치 사이즈
String jpql = "SELECT m FROM Member m";
List<Member> members = em.createQuery(jpql, Member.class)
.setHint("org.hibernate.fetchSize", 100) // JDBC 페치 사이즈
.setHint("org.hibernate.readOnly", true) // 읽기 전용
.setHint("org.hibernate.comment", "회원 전체 조회") // 쿼리 코멘트
.getResultList();
// 캐시 모드
List<Member> cached = em.createQuery(jpql, Member.class)
.setHint("org.hibernate.cacheable", true) // 쿼리 캐시 사용
.setHint("org.hibernate.cacheRegion", "memberQueries") // 캐시 영역
.getResultList();
// 플러시 모드
List<Member> members2 = em.createQuery(jpql, Member.class)
.setHint("org.hibernate.flushMode", "MANUAL") // 플러시 수동 모드
.getResultList();
4.3. 네이티브 SQL 최적화
네이티브 SQL 사용
// 복잡한 통계 쿼리
String sql = """
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') as month,
COUNT(DISTINCT o.member_id) as active_members,
SUM(oi.quantity * oi.price) as total_sales,
AVG(oi.quantity * oi.price) as avg_order_value
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.order_date >= :startDate
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
ORDER BY month DESC
""";
Query nativeQuery = em.createNativeQuery(sql);
nativeQuery.setParameter("startDate", LocalDateTime.now().minusMonths(6));
List<Object[]> results = nativeQuery.getResultList();
// 결과 매핑
String sql2 = """
SELECT
m.id as member_id,
m.username,
COUNT(o.id) as order_count,
SUM(oi.quantity * oi.price) as total_spent
FROM member m
LEFT JOIN orders o ON m.id = o.member_id
LEFT JOIN order_items oi ON o.id = oi.order_id
GROUP BY m.id, m.username
""";
Query query = em.createNativeQuery(sql2, "MemberStatisticsMapping");
List<MemberStatistics> stats = query.getResultList();
// SQL 결과 세트 매핑 정의
@SqlResultSetMapping(
name = "MemberStatisticsMapping",
classes = @ConstructorResult(
targetClass = MemberStatistics.class,
columns = {
@ColumnResult(name = "member_id", type = Long.class),
@ColumnResult(name = "username", type = String.class),
@ColumnResult(name = "order_count", type = Long.class),
@ColumnResult(name = "total_spent", type = BigDecimal.class)
}
)
)
5. 실무 적용 패턴
5.1. 동적 쿼리 빌더
QueryDSL 기반 동적 쿼리
public List<Member> searchMembers(MemberSearchCondition condition) {
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = QMember.member;
BooleanBuilder builder = new BooleanBuilder();
if (condition.getUsername() != null) {
builder.and(m.username.contains(condition.getUsername()));
}
if (condition.getAgeGoe() != null) {
builder.and(m.age.goe(condition.getAgeGoe()));
}
if (condition.getAgeLoe() != null) {
builder.and(m.age.loe(condition.getAgeLoe()));
}
if (condition.getTeamName() != null) {
builder.and(m.team.name.eq(condition.getTeamName()));
}
return queryFactory.selectFrom(m)
.where(builder)
.orderBy(m.createdDate.desc())
.fetch();
}
Criteria API 기반 동적 쿼리
public List<Member> searchMembers(MemberSearchCondition condition) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> cq = cb.createQuery(Member.class);
Root<Member> m = cq.from(Member.class);
List<Predicate> predicates = new ArrayList<>();
if (condition.getUsername() != null) {
predicates.add(cb.like(m.get("username"), "%" + condition.getUsername() + "%"));
}
if (condition.getAgeGoe() != null) {
predicates.add(cb.ge(m.get("age"), condition.getAgeGoe()));
}
if (condition.getAgeLoe() != null) {
predicates.add(cb.le(m.get("age"), condition.getAgeLoe()));
}
if (condition.getTeamName() != null) {
predicates.add(cb.equal(m.get("team").get("name"), condition.getTeamName()));
}
cq.where(cb.and(predicates.toArray(new Predicate[0])));
cq.orderBy(cb.desc(m.get("createdDate")));
return em.createQuery(cq).getResultList();
}
5.2. DTO 프로젝션 패턴
레코드 사용 (Java 16+)
// 레코드 정의
public record MemberDto(Long id, String username, int age, String teamName) {}
// JPQL에서 사용
String jpql = """
SELECT new com.example.MemberDto(
m.id, m.username, m.age, t.name
)
FROM Member m
JOIN m.team t
""";
List<MemberDto> dtos = em.createQuery(jpql, MemberDto.class).getResultList();
인터페이스 기반 프로젝션
// 인터페이스 정의
public interface MemberProjection {
Long getId();
String getUsername();
int getAge();
String getTeamName();
// 디폴트 메서드
default String getInfo() {
return getUsername() + " (" + getAge() + ") - " + getTeamName();
}
}
// QueryDSL에서 사용
public List<MemberProjection> findMemberProjections() {
QMember m = QMember.member;
QTeam t = QTeam.team;
return queryFactory
.select(Projections.constructor(MemberProjection.class,
m.id, m.username, m.age, t.name))
.from(m)
.join(m.team, t)
.fetch();
}
5.3. 복잡한 통계 쿼리 패턴
CTE (Common Table Expressions) 사용
// 네이티브 SQL로 CTE 사용
String sql = """
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', o.order_date) as month,
SUM(oi.quantity * oi.price) as monthly_total
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
GROUP BY DATE_TRUNC('month', o.order_date)
)
SELECT
month,
monthly_total,
SUM(monthly_total) OVER (ORDER BY month) as running_total,
AVG(monthly_total) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) as moving_avg
FROM monthly_sales
ORDER BY month
""";
List<Object[]> stats = em.createNativeQuery(sql).getResultList();
윈도우 함수 사용
String sql = """
SELECT
m.username,
o.order_date,
o.total_amount,
RANK() OVER (
PARTITION BY m.id
ORDER BY o.total_amount DESC
) as rank_in_member,
SUM(o.total_amount) OVER (
PARTITION BY m.id
ORDER BY o.order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) as cumulative_total
FROM member m
JOIN orders o ON m.id = o.member_id
ORDER BY m.username, o.order_date
""";
List<Object[]> results = em.createNativeQuery(sql).getResultList();
6. 정리
6.1. JPQL 사용 원칙
- 객체지향적으로 생각하라: 테이블이 아닌 엔티티를 대상으로 쿼리
- 페치 조인을 이해하라: N+1 문제 해결의 핵심
- 명시적 조인을 사용하라: 묵시적 조인보다 가독성과 성능이 좋음
- 파라미터 바인딩을 사용하라: SQL 인젝션 방지와 성능 최적화
- 페이징을 활용하라: 대량 데이터 조회 시 필수
- 적절한 조인 전략 선택: 상황에 맞는 조인 방식 선택
6.2. 성능 최적화 체크리스트
✅ 해야 할 것
- N+1 문제는 페치 조인으로 해결
- 필요 없는 컬럼은 SELECT 절에서 제외
- 인덱스를 고려한 WHERE 절 작성
- 파라미터 바인딩 사용
- 적절한 페이징 적용
- 읽기 전용 쿼리는 힌트 사용
❌ 하지 말 것
- 엔티티를 DTO처럼 사용 (불필요한 컬럼 조회)
- 컬렉션 페치 조인에 페이징 적용
- 무분별한 즉시 로딩 사용
- 네이티브 SQL 남용
- 동적 쿼리 문자열 결합 (SQL 인젝션 위험)
6.3. 도구 선택 가이드
| 상황 | 권장 도구 | 이유 |
| 정적 쿼리 | Named 쿼리 | 검증, 재사용, 캐싱 |
| 동적 쿼리 | QueryDSL | 타입 안전성, 가독성 |
| 복잡한 통계 | 네이티브 SQL | 데이터베이스 기능 활용 |
| 간단한 조회 | JPQL | 간결함, 객체지향적 |
| 타입 안전성이 중요한 경우 | QueryDSL/Criteria | 컴파일 타임 체크 |
JPQL은 JPA의 핵심 기능으로, 객체지향적인 사고로 데이터베이스를 조작할 수 있게 해준다. 복잡한 비즈니스 요구사항에 대응하기 위해 다양한 기능들을 조합하여 사용하는 것이 중요하다. 특히 페치 조인, 페이징, 벌크 연산 같은 고급 기능들을 올바르게 이해하고 활용하면 성능과 유지보수성을 크게 향상시킬 수 있다.
'Spring > JPA' 카테고리의 다른 글
| [Advanced-2] QueryDSL(1): 기본 (0) | 2026.01.06 |
|---|---|
| [Advanced-1] Spring Data Jpa (0) | 2026.01.06 |
| [Basic-8] 값 타입 (0) | 2026.01.06 |
| [Basic-7] 프록시와 연관관계 관리 (0) | 2026.01.06 |
| [Basic-6] 고급 관계 매핑 (0) | 2026.01.06 |
