교재: 자바 ORM 표준 JPA 프로그래밍
10장에서 다룰 내용:
JPA는 다양한 쿼리 방법을 지원한다. 핵심은 "테이블이 아닌 엔티티 객체를 대상으로 쿼리한다"는 점이다.
JPA가 지원하는 쿼리 방식:
실무에서는 JPQL을 기본으로 쓰고, 필요에 따라 Criteria나 QueryDSL을 조합한다.
JPQL 문법은 SQL과 유사하다. SELECT, UPDATE, DELETE를 지원한다.
// 기본 조회
SELECT m FROM Member AS m WHERE m.age > 18
em.createQuery()의 반환 타입에 따라 두 가지로 나뉜다.
TypeQuery: 반환 타입이 명확할 때
TypedQuery<Member> query =
em.createQuery("SELECT m FROM Member m", Member.class);
List<Member> resultList = query.getResultList();
for (Member member : resultList) {
System.out.println("member = " + member);
}
Query: 반환 타입이 여러 개일 때
Query query =
em.createQuery("SELECT m.username, m.age FROM Member m");
List resultList = query.getResultList();
for (Object o : resultList) {
Object[] result = (Object[]) o; // 결과가 여러 타입이면 Object[]
System.out.println("username = " + result[0]);
System.out.println("age = " + result[1]);
}
query.getResultList(); // 결과가 없으면 빈 컬렉션 반환
query.getSingleResult(); // 결과가 정확히 1개일 때 사용
이름 기준 파라미터 (권장):
List<Member> members =
em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class)
.setParameter("username", usernameParam)
.getResultList();
위치 기준 파라미터 (비권장):
List<Member> members =
em.createQuery("SELECT m FROM Member m WHERE m.username = ?1", Member.class)
.setParameter(1, usernameParam)
.getResultList();
이름 기준 파라미터를 사용하는 것이 훨씬 명확하고 유지보수에 유리하다. 위치 기준은 순서가 바뀌면 버그가 생기기 쉽다.
SELECT 절에서 조회할 대상을 지정하는 것을 프로젝션이라 한다.
// 엔티티 프로젝션 - 엔티티 전체 조회
SELECT m FROM Member m // Member 엔티티
SELECT m.team FROM Member m // 연관 엔티티 Team 조회
String query = "SELECT a FROM Address a";
// 단, 임베디드 타입은 독립적으로 조회 불가 → 엔티티를 통해 조회해야 함
String query = "SELECT o.address FROM Order o";
// 단일 타입
List<String> usernames =
em.createQuery("SELECT m.username FROM Member m", String.class)
.getResultList();
// DISTINCT로 중복 제거
SELECT DISTINCT m.username FROM Member m
// 집합 함수
SELECT AVG(o.orderAmount) FROM Order o
Object[]로 받는다:// Object[] 방식
List<Object[]> resultList =
em.createQuery("SELECT m.username, m.age FROM Member m")
.getResultList();
for (Object[] row : resultList) {
String username = (String) row[0];
Integer age = (Integer) row[1];
}
Object[]보다 DTO로 바로 받는 방식이 더 깔끔하다:public class UserDTO {
private String username;
private int age;
public UserDTO(String username, int age) {
this.username = username;
this.age = age;
}
}
TypedQuery<UserDTO> query =
em.createQuery(
"SELECT new jpabook.jpql.UserDTO(m.username, m.age) FROM Member m",
UserDTO.class
);
JPA는 페이징을 두 가지 API로 추상화한다. DB마다 다른 페이징 SQL을 자동으로 생성해준다.
TypedQuery<Member> query =
em.createQuery("SELECT m FROM Member m ORDER BY m.username DESC", Member.class);
query.setFirstResult(10); // 조회 시작 위치 (0부터 시작)
query.setMaxResults(20); // 조회할 데이터 수
query.getResultList(); // 11번째부터 20개 조회
→ DB별로 생성되는 SQL이 다르다. JPA가 알아서 변환해준다.
SELECT
COUNT(m), -- 회원 수 (반환: Long)
SUM(m.age), -- 나이 합 (반환: Long)
AVG(m.age), -- 평균 나이 (반환: Double)
MAX(m.age), -- 최대 나이
MIN(m.age) -- 최소 나이
FROM Member m
SELECT t.name, COUNT(m.age), SUM(m.age), AVG(m.age), MAX(m.age), MIN(m.age)
FROM Member m LEFT JOIN m.team t
GROUP BY t.name
SELECT t.name, COUNT(m.age) AS cnt
FROM Member m LEFT JOIN m.team t
GROUP BY t.name
ORDER BY cnt
String query = "SELECT m FROM Member m INNER JOIN m.team t WHERE t.name = :teamName";
List<Member> members = em.createQuery(query, Member.class)
.setParameter("teamName", "팀A")
.getResultList();
SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
페치 조인은 JPQL에서 성능 최적화를 위해 제공하는 특별한 조인이다. 연관 엔티티를 SQL 한 번에 함께 조회한다. 지연 로딩으로 설정해도 페치 조인을 사용하면 즉시 함께 로딩된다.
// 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
일반 조인과 다르게, 페치 조인은 연관 엔티티까지 함께 SELECT 절에 포함해서 가져온다. member.getTeam()을 호출해도 추가 쿼리가 발생하지 않는다.
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'
일대다 조인이므로 팀A에 회원이 2명이면 결과 row가 2개 나온다. 즉 팀 객체가 중복으로 조회될 수 있다.
DISTINCT를 사용한다.select distinct t from Team t join fetch t.members where t.name = '팀A'
// 일반 조인 → SELECT 절에 Team만 포함, members는 미로딩
select t from Team t join t.members m where t.name = '팀A'
// 페치 조인 → SELECT 절에 Team + Member 모두 포함
select t from Team t join fetch t.members where t.name = '팀A'
페치 조인 한계
setFirstResult, setMaxResults)를 사용할 수 없다 → 전체 데이터를 메모리에 올려 페이징하므로 위험.을 통해 객체 그래프를 탐색하는 것이다.
select m.username // 상태 필드
from Member m
join m.team t // 단일 값 연관 필드
join m.orders o // 컬렉션 값 연관 필드
where t.name = '팀A'
경로 표현식의 3가지 종류:
| 종류 | 예시 | 특징 |
|---|---|---|
| 상태 필드 | m.username, m.age | 더 이상 탐색 불가 |
| 단일 값 연관 | m.team | 묵시적 내부 조인 발생, 추가 탐색 가능 |
| 컬렉션 값 연관 | m.orders | 묵시적 내부 조인 발생, 추가 탐색 불가 |
JPQL도 SQL처럼 서브쿼리를 지원한다. WHERE, HAVING 절에서 사용 가능하다 (SELECT, FROM 절에서는 불가).
JPQL에서 사용하는 기본 조건식들이다.
타입 표현:
| 종류 | 예시 |
|---|---|
| 문자 | 'Hello', 'She''s' |
| 숫자 | 10L, 10D, 10F |
| Boolean | TRUE, FALSE |
| Enum | jpabook.MemberType.Admin |
| 엔티티 타입 | TYPE(m) = Member |
// 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')
select i from Item i where treat(i as Book).author = 'kim'
DB 전용 함수나 커스텀 함수를 JPQL에서 사용하고 싶을 때:
// JPQL
select function('group_concat', i.name) from Item i
// Dialect에 등록
public class MyH2Dialect extends H2Dialect {
public MyH2Dialect() {
registerFunction("group_concat",
new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
}
}
기본 키 값 대신 엔티티를 직접 파라미터로 사용할 수 있다. JPA가 자동으로 PK 비교로 변환한다.
// 엔티티를 직접 파라미터로
String qlString = "select m from Member m where m = :member";
List resultList = em.createQuery(qlString)
.setParameter("member", member) // 엔티티 직접 전달
.getResultList();
// 실행 SQL → PK로 자동 변환
select m from Member m where m.id = ?
JPQL은 동적 쿼리와 정적 쿼리로 나뉜다.
em.createQuery("...")처럼 런타임에 문자열로 만드는 방식@NamedQuery로 정의:
@Entity
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
)
public class Member {
...
}
사용:
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", "회원1")
.getResultList();
XML로 정의하는 방법도 있다:
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings>
<named-query name="Member.findByUsername">
<query><![CDATA[
select m from Member m where m.username = :username
]]></query>
</named-query>
<named-query name="Member.count">
<query>select count(m) from Member m</query>
</named-query>
</entity-mappings>
JPQL을 자바 코드로 작성하는 API. 컴파일 시점에 오류를 잡을 수 있지만 코드가 복잡하다. 실무에서는 보통 QueryDSL을 더 선호한다.
기본 구조:
// JPQL: select m from Member m where m.username='회원1' order by m.age desc
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> cq = cb.createQuery(Member.class);
Root<Member> m = cq.from(Member.class); // FROM
cq.select(m)
.where(cb.equal(m.get("username"), "회원1")) // WHERE
.orderBy(cb.desc(m.get("age"))); // ORDER BY
List<Member> resultList = em.createQuery(cq).getResultList();
CriteriaBuilder는 em.getCriteriaBuilder()로 얻는다. Root는 조회 시작점(FROM 절의 엔티티)이다.
단일 타입 조회:
cq.select(m); // JPQL: select m
여러 값 조회 (multiselect):
// JPQL: select m.username, m.age
cq.multiselect(m.get("username"), m.get("age"));
// 또는 cb.array() 사용
cq.select(cb.array(m.get("username"), m.get("age")));
Tuple 조회 (별칭으로 접근):
CriteriaQuery<Tuple> cq = cb.createTupleQuery();
Root<Member> m = cq.from(Member.class);
cq.multiselect(
m.get("username").alias("username"),
m.get("age").alias("age")
);
for (Tuple tuple : em.createQuery(cq).getResultList()) {
String username = tuple.get("username", String.class);
Integer age = tuple.get("age", Integer.class);
}
// JPQL: select m.team.name, max(m.age), min(m.age) from Member m group by m.team.name
Expression<Integer> maxAge = cb.max(m.<Integer>get("age"));
Expression<Integer> minAge = cb.min(m.<Integer>get("age"));
cq.multiselect(m.get("team").get("name"), maxAge, minAge)
.groupBy(m.get("team").get("name"))
.having(cb.gt(minAge, 10)); // HAVING min(m.age) > 10
// JPQL: select m, t from Member m inner join m.team t where t.name = '팀A'
Root<Member> m = cq.from(Member.class);
Join<Member, Team> t = m.join("team", JoinType.INNER); // INNER / LEFT / RIGHT
cq.multiselect(m, t)
.where(cb.equal(t.get("name"), "팀A"));
m.fetch("team", JoinType.LEFT);
cq.select(m);
// JPQL: select m from Member m where m.username = :usernameParam
ParameterExpression<String> usernameParam = cb.parameter(String.class, "usernameParam");
cq.select(m).where(cb.equal(m.get("username"), usernameParam));
em.createQuery(cq).setParameter("usernameParam", "회원1").getResultList();
Criteria는 타입 안전하고 동적 쿼리 작성에 강점이 있지만, 코드가 길고 가독성이 떨어진다. 실무에서는 QueryDSL이 같은 장점을 훨씬 간결하게 제공하므로 더 많이 사용된다.
Criteria의 가장 큰 강점. 조건에 따라 쿼리를 동적으로 조립할 수 있다.
StringBuilder jpql = new StringBuilder("select m from Member m join m.team t");
List<String> criteria = new ArrayList<>();
if (age != null) criteria.add(" m.age = :age ");
if (username != null) criteria.add(" m.username = :username ");
if (teamName != null) criteria.add(" t.name = :teamName ");
if (!criteria.isEmpty()) jpql.append(" where ");
for (int i = 0; i < criteria.size(); i++) {
if (i > 0) jpql.append(" and ");
jpql.append(criteria.get(i));
}
List<Predicate> predicates = new ArrayList<>();
if (age != null) predicates.add(cb.equal(m.<Integer>get("age"), cb.parameter(Integer.class, "age")));
if (username != null) predicates.add(cb.equal(m.get("username"), cb.parameter(String.class, "username")));
if (teamName != null) predicates.add(cb.equal(t.get("name"), cb.parameter(String.class, "teamName")));
cq.where(cb.and(predicates.toArray(new Predicate[0])));
m.get("age")처럼 문자열로 필드를 참조하면 오타가 생겨도 컴파일 에러가 안 난다. 메타모델을 사용하면 Member_.age처럼 타입 안전하게 참조할 수 있다.
// 메타모델 사용 전
m.<Integer>get("age")
// 메타모델 사용 후 (타입 안전)
m.get(Member_.age)
메타모델 클래스(Member_)는 어노테이션 프로세서가 자동 생성한다. 실무에서는 빌드 도구 설정으로 자동화한다.
Criteria의 복잡함을 해결한 오픈소스 라이브러리. JPQL과 거의 같은 문법을 자바 코드로 간결하게 작성할 수 있다.
<dependency>
<groupId>com.mysema.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>3.6.3</version>
</dependency>
QueryDSL도 Q 클래스를 자동 생성해야 한다. mvn compile 후 target/generated-sources에 생성된다.
JPAQuery query = new JPAQuery(em);
QMember qMember = new QMember("m"); // Q 클래스 생성
List<Member> members = query
.from(qMember)
.where(qMember.name.eq("회원1"))
.orderBy(qMember.name.desc())
.list(qMember);
// static import 활용
import static jpabook.jpashop.domain.QMember.member;
List<Member> members = query
.from(member)
.where(member.name.eq("회원1"))
.list(member);
// where 절에 and 조건 나열
query.from(item)
.where(item.name.eq("좋은상품"), item.price.gt(20000))
.list(item);
// 다양한 조건 메서드
item.price.between(10000, 20000) // BETWEEN
item.name.contains("상품") // LIKE '%상품%'
item.name.startsWith("좋은") // LIKE '좋은%'
query.list(item); // 리스트 반환, 없으면 빈 컬렉션
query.uniqueResult(item); // 단건, 없으면 null, 2개 이상이면 예외
query.singleResult(item); // 단건, 없으면 null (uniqueResult와 유사)
query.from(item)
.where(item.price.gt(20000))
.orderBy(item.price.desc(), item.stockQuantity.asc())
.offset(10).limit(20)
.list(item);
query.from(item)
.groupBy(item.price)
.having(item.price.gt(1000))
.list(item);
CONNECT BY나 SQL 힌트 같은 건 JPQL로 표현이 안 된다.→ 이럴 때 쓰는 게 네이티브 SQL이다. JPA가 직접 SQL을 날릴 수 있도록 지원해주는 기능이다.
String sql = "SELECT ID, AGE, NAME, TEAM_ID FROM MEMBER WHERE AGE > ?";
Query nativeQuery = em.createNativeQuery(sql, Member.class)
.setParameter(1, 20);
List<Member> resultList = nativeQuery.getResultList();
엔티티가 아닌 여러 컬럼 값을 그냥 받고 싶을 때는 두 번째 파라미터 없이 호출하면 된다.
String sql = "SELECT ID, AGE, NAME FROM MEMBER WHERE AGE > ?";
Query nativeQuery = em.createNativeQuery(sql)
.setParameter(1, 20);
List<Object[]> resultList = nativeQuery.getResultList();
for (Object[] row : resultList) {
String id = (String) row[0];
int age = (Integer) row[1];
String name = (String) row[2];
}
엔티티와 스칼라 값을 함께 조회하는 복잡한 경우엔 @SqlResultSetMapping을 써서 매핑을 정의한다.
@SqlResultSetMapping(
name = "memberWithOrderCount",
entities = {
@EntityResult(entityClass = Member.class)
},
columns = {
@ColumnResult(name = "ORDER_COUNT")
}
)
String sql = "SELECT M.ID, AGE, NAME, TEAM_ID, I.ORDER_COUNT "
+ "FROM MEMBER M "
+ "LEFT JOIN (SELECT IM.ID, COUNT(*) AS ORDER_COUNT "
+ " FROM ORDERS O, MEMBER IM "
+ " WHERE O.MEMBER_ID = IM.ID) I "
+ "ON M.ID = I.ID";
Query nativeQuery = em.createNativeQuery(sql, "memberWithOrderCount");
→ 복잡한 쿼리 결과를 엔티티 + 추가 컬럼으로 나눠서 받을 수 있다.
JPQL처럼 이름을 붙여서 관리할 수 있다.
엔티티 클래스에 @NamedNativeQuery로 정의하고 em.createNamedQuery()로 사용한다.
@Entity
@NamedNativeQuery(
name = "Member.memberSQL",
query = "SELECT ID, AGE, NAME, TEAM_ID FROM MEMBER WHERE AGE > ?",
resultClass = Member.class
)
public class Member { ... }
TypedQuery<Member> nativeQuery =
em.createNamedQuery("Member.memberSQL", Member.class)
.setParameter(1, 20);
네이티브 SQL은 관리하기 쉽지 않다. 자주 쓰면 특정 DB에 종속적인 쿼리가 늘어나서 이식성이 떨어진다.
→ 최대한 JPQL을 쓰고, 방법이 없을 때만 네이티브 SQL을 쓰는 것이 맞다.
그래도 안 되면 MyBatis 같은 SQL 매퍼를 함께 도입하는 것도 방법이다.
벌크 연산은 한 번에 여러 엔티티를 수정하거나 삭제할 때 사용한다.
예를 들어 재고가 10개 미만인 모든 상품의 가격을 10% 올리고 싶다면, 엔티티를 하나씩 꺼내서 수정하는 건 비효율적이다.
// UPDATE 벌크 연산
String jpql = "update Product p "
+ "set p.price = p.price * 1.1 "
+ "where p.stockAmount < :stockAmount";
int resultCount = em.createQuery(jpql)
.setParameter("stockAmount", 10)
.executeUpdate();
// DELETE 벌크 연산
String jpql = "delete from Product p "
+ "where p.price < :price";
int resultCount = em.createQuery(jpql)
.setParameter("price", 100)
.executeUpdate();
→ executeUpdate()를 사용하고, 영향받은 엔티티 건수를 반환한다.
DB 조회 결과 → 엔티티 확인
↓
이미 영속성 컨텍스트에 있음? → YES → 기존 엔티티 반환 (DB 결과 버림)
→ NO → 새 엔티티 영속성 컨텍스트에 저장 후 반환
JPQL 실행 전에 JPA는 자동으로 플러시를 한다. 영속성 컨텍스트에 변경 사항이 있는 상태에서 JPQL로 조회하면, 아직 DB에 반영되지 않은 내용이 누락될 수 있기 때문이다.
em.persist(new Product("상품X", 1000)); // 아직 DB에 없음
// JPQL 실행 직전에 자동 flush 발생
List<Product> results = em.createQuery("select p from Product p", Product.class)
.getResultList();
// 상품X도 결과에 포함된다
→ FlushModeType.COMMIT 으로 설정하면 플러시 시점을 조정할 수 있다. 다만 이 경우 JPQL 조회 시 누락 문제가 생길 수 있으니 주의해서 사용해야 한다.
JPA 2.1부터 스토어드 프로시저 호출을 지원한다.
// 순서 기반 파라미터
StoredProcedureQuery spq =
em.createStoredProcedureQuery("proc_multiply");
spq.registerStoredProcedureParameter(1, Integer.class, ParameterMode.IN);
spq.registerStoredProcedureParameter(2, Integer.class, ParameterMode.OUT);
spq.setParameter(1, 100);
spq.execute();
Integer out = (Integer) spq.getOutputParameterValue(2);
System.out.println("out = " + out); // 결과 출력
Named 스토어드 프로시저도 정의해서 재사용할 수 있다.
@NamedStoredProcedureQuery(
name = "multiply",
procedureName = "proc_multiply",
parameters = {
@StoredProcedureParameter(name = "inParam", mode = ParameterMode.IN, type = Integer.class),
@StoredProcedureParameter(name = "outParam", mode = ParameterMode.OUT, type = Integer.class)
}
)
@Entity
public class Member { ... }
StoredProcedureQuery spq = em.createNamedStoredProcedureQuery("multiply");
spq.setParameter("inParam", 100);
spq.execute();
Integer out = (Integer) spq.getOutputParameterValue("outParam");
→ 이름 기반으로 관리하면 재사용하기 훨씬 편하다.