10장. 객체지향 쿼리 언어
1. 객체지향 쿼리 소개
모든 엔티티를 메모리에 올려두고 애플리케이션에서 특정 조건으로 검색하는 것은 현실성이 없다. 결국 데이터는 데이터베이스에 있으므로 SQL로 필요한 내용을 최대한 걸러서 조회해야 한다.
하지만 ORM을 사용하면 데이터베이스 테이블이 아닌 엔티티 객체를 대상으로 개발하므로, 검색도 테이블이 아닌 엔티티 객체를 대상으로 하는 방법이 필요하다.
종류 | 설명 |
---|---|
SQL | 데이터베이스 테이블을 대상으로 하는 데이터 중심의 쿼리 |
JPQL | - 엔티티 객체를 대상으로 하는 객체지향 쿼리. - SQL을 추상화해서 특정 DB 의 SQL에 의존하지 않는다. |
JPQL을 사용하면 JPQ는 이 JPQL 을 분석한 다음 적절한 SQL을 만들어 데이터베이스를 조회한다. 그리고 조회한 결과로 엔티티 객체를 생성해서 반환한다.
JPA가 공식 지원하는 기능
객체지향 쿼리 | 설명 |
---|---|
JPQL | Java Persistence Query Language |
Criteria 쿼리 | JPQL을 편하게 작성하도록 도와주는 API, 빌더 클래스 모음 |
네이티브 SQL | JPA 에서 JPQL 대신 직접 SQL을 사용 |
다른 객체지향 쿼리
객체지향 쿼리 | 설명 |
---|---|
QueryDSL | JPQL을 편하게 작성하도록 도와주는 빌더 클래스 모음. 비표준 오픈소스 프레임워크 |
JDBC 직접 사용, MyBatis 등 | 필요 시 JDBC 직접 사용 가능 |
가장 중요한 건 JPQL 이다. Criteria
나 QueryDSL
은 JPQL을 편하게 작성하도록 도와주는 빌더 클래스일 뿐이다.
1.1 JPQL 소개
JPQL(Java Persistence Query Language)
은 엔티티 객체를 조회하는 객체지향 쿼리다.
1. JPQL은 SQL을 추상화해서 특정 데이트베이스에 의존하지 않는다.
- 데이터베이스 방언만 변경하면 JPQL을 수정하지 않아도 자연스럽게 데이터베이스를 변경할 수 있다.
2.JPQL은 SQL보다 간결하다.
- 엔티티 직접 조회, 묵시적 조인, 다형성 지원으로 SQL보다 코드가 간결하다.
1.1.1 회원 엔티티
@Entity(name = "Member")
public class Member {
@Column(name = "name")
private String username;
// ...
}
1.1.2 JPQL 사용 예
// 쿼리 생성
String jpql = "select m from Member as m where m.username = 'kim'";
List<Member> resultList =
em.createQuery(jpql, Member.class).getResultList();
getResultList()
메소드를 실행하면 JPA는 JPQL을 SQL로 변환해서 데이터베이스를 조회한다.
그리고 조회한 결과로 Member
엔티티를 생성해서 반환한다.
1.1.3 실행된 SQL
select
member.id as id,
member.age as age,
member.team_id as team,
member.name as name
from
Member mbmer
where
member.name = 'kim'
1.2 Criteria 쿼리 소개
Criteria는 JPQL을 생성하는 빌더 클래스다.
JPA 2.0 부터 Criteria를 지원한다.
1.2.1 장점
- 프로그래밍 코드로 JPQL을 작성할 수 있다.
- ex) query.select(m).where(...)
- 컴파일 시점에 오류를 발견할 수 있다.
- 문자기반 쿼리는 런타임 시점에 발견 가능 -> 문제
- IDE를 사용하면 코드 자동완성을 지원한다.
- 동적 쿼리를 작성하기 편하다.
1.2.2 Criteria 쿼리 예
// Criteria 사용 준비
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.creatQuery(Member.class);
//루크 클래스(조회를 시작할 클래스)
Root<Member> m = query.from(Member.class);
//쿼리 생성
CriteriaQuery<Member> cq =
query.select(m).where(cb.equal(m.get("username"), "kim));
List<Member> resultList = em.createQuery(cq).getResultList();
1.2.3 메타모델 API
자바가 제공하는 어노테이션 프로세서 기능을 사용하면 어노테이션을 분석해서 클래스를 생성할 수 있다.
JPA는 이 기능을 사용해서 엔티티 클래스로부터 Criteria 전용 클래스를 생성하는데, 이를 메타 모델
이라 한다.
ex) Member 엔티티 클래스로부터 Member_ 라는 Criteria 전용 클래스 생성
메타모델 사용 전 | 사용 후 |
---|---|
m.get("username") | m.get(Member_.username) |
1.2.4 단점
- 모든 장점을 상쇄할 정도로 복잡하고 장황하다.
- 사용하기 불편하다.
- 코드를 읽기 어렵다.
1.3 QueryDSL 소개
QueryDSL 도 Criteria 처럼 JPQL 빌더 역할을 한다. JPA 표준은 아니다.
1.3.1 장점
- 코드 기반이면서 단순하고 사용하기 쉽다.
- 작성한 코드도 JPQL과 비슷해서 한눈에 들어온다.
- 스프링 데이터 프로젝트에서 지원할 정도로 기대되는 프로젝트다.
영한님께서는 QueryDSL을 매우 선호하신다고 하셨다.
//준비
JPAQuery query = new JPAQuery(em);
QMember member = QMember.member;
//쿼리, 결과조회
List<Member> members =
query.from(member)
.where(member.username.eq("kim"))
.list(member);
QueryDSL
도 어노테이션 프로세서
를 사용해서 쿼리 전용 클래스를 만들어야 한다. QMember
는 Member 엔티티 클래스를 기반으로 생성한 QueryDSL 쿼리 전용 클래스.
1.4 네이티브 SQL 소개
JPQ는 SQL을 직접 사용할 수 있는 기능을 지원하는데 이것을 네이티브 SQL 이라 한다.
SQL은 지원하지만 JPQL이 지원하지 않는 기능을 사용할 때는 네이티브 SQL
을 사용하면 된다.
1.4.1 네이티브 SQL의 단점
- 특정 데이터베이스에 의존하는 SQL 작성
- => 데이터베이스 변경 시 네이티브 SQL도 수정해줘야 한다.
1.4.2 네이티브 SQL 코드
String sql = "SELECT ID, AGE, ITEM_ID, NAME FROM MEMBER WHERE NAME = 'kim'";
List<Member> resultList =
em.createNativeQuery(sql, Member.class).getResultList();
em.createNativeQuery()
를 사용하여 직접 작성한 SQL을 데이터베이스에 전달한다.
1.5 JDBC직접 사용, MyBatis 같은 SQL 매퍼 프레임워크 사용
JPA는 JDBC 커넥션을 얻는 API를 제공하지 않기 때문에 JPA 구현체가 제공하는 방법을 사용해야 한다.
1.5.1 하이버네이트 JDBC 획득
Session session = entityManager.unwrap(Session.class);
session.doWork(new Work() {
@Override
public void execute(Connection connection) throws SQLExcetion {
// work ...
}
});
JPA를 우회해서 데이터베이스에 접근하기 때문에 JPA가 전혀 인식하지 못한다.
JDBC
나 마이바티스
를 사용하는 것은 모두 JPA를 우회해서 데이터베이스에 접근한다.
그럼 영속성 컨텍스트와 데이터베이스를 불일치 상태로 만들어 데이터 무결성을 훼손하는 경우가 발생할 수 있다.
따라서 JPA와 함께 사용하려면 영속성 컨텍스트를 적절한 시점에 강제로 플러시를 해줘야 한다.
이런 이슈를 해결하기 위해 SQL을 실행하기 직전에 영속성 컨텍스트를 수동으로 플러시해서
데이터베이스와 영속성 컨텍스트를 동기화해준다.
스프링 프레임워크의 AOP를 적절히 활용해서, JPA를 우회하여 데이터베이스에 접근하는 메소드를 호출할 때마다
영속성 컨텍스트를 플러시하면 깔끔하게 해결 가능하다!
2. JPQL
어떤 방법을 사용하든 JPQL(Java Persistence Query Language)에서 모든 것이 시작한다.
- JPQL은 객체지향 쿼리 언어이다. 테이블 대상이 아닌
엔티티 객체
를 대상. - JPQL은 SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다.
- JPQL은 결국 SQL로 변환된다.
기본 샘플 모델 UML 과 ERD
2.1 기본 문법과 쿼리 API
- SQL과 비슷하게
SELECT
,UPDATE
,DELETE
문을 사용할 수 있다.UPDATE
,DELETE
문은 벌크 연산이라고 한다.
- INSERT 문은 없다. ->
EntityManager.persist()
메소드 사용.
2.1.1 JPQL 문법
select_문 :: =
select_절
from_절
[where_절]
[groupby_절]
[having_절]
[orderby_절]
update_문 :: = update_절 [where_절]
delete_문 :: = delete_절 [where_절]
2.1.2 SELECT 문
SELECT m FROM Member AS m where m.username = 'Hello'
2.1.3 특징
1. 대소문자 구분
- 엔티티와 속성은 대소문자를 구분한다.
SELECT
,FROM
,AS
같은 JPQL 키워드는 대소문자를 구분하지 않는다.
2. 엔티티 이름
- JPQL에서 사용한 Member는 클래스 명이 아니라 엔티티명이다.
- 엔티티명은 @Entity(name="xxx")로 지정할 수 있다.
- 지정하지 않으면 클래스명이 기본값.
- 기본값인 클래스명을 엔티티명으로 사용하는 것을 추천
3. 별칭은 필수
- JPQL은 별칭을 필수로 사용해야 한다.
- 'AS'는 생략 가능.
잘못된 문법
SELECT username FROM Member m // username -> m.username으로 바꿔줘야 한다.
하이버네이트의 HQL(Hibernate Query Language) 에서는 위와 같이 사용 가능
=> Member m
처럼 사용해도 된다.
2.1.4 TypeQeury, Query
JPQL을 실행하려면 쿼리 객체를 만들어야 한다.
쿼리 객체
1. TypeQuery
- 반환 타입이 명확한 경우
em.createQuery()
메소드의 두 번째 파라미터에 반환할 타입을 지정해준다.
TypeQuery<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);
}
2. Query
- 반환 타입이 명확하지 않은 경우
- SELECT 절의 조회 대상이 하나면
Object
를 반환
String jpql1 = "select m.username from Member m";
Query result = em.createQuery(jpql1);
List resultList = result.getResultList();
for (Object object : resultList) {
String username = (String) object;
System.out.println("1 = " + username);
}
- SELECT 절의 조회 대상이 둘 이상이면
Object[]
반환
String jpql1 = "select m.username, m.age from Member m";
Query result = em.createQuery(jpql1);
List resultList = result.getResultList();
for (Object object : resultList) {
Object[] objects = (Object[]) object;
System.out.println("1 = " + objects[0]);
System.out.println("2 = " + objects[1]);
}
=> 타입 변환이 필요없는 TypeQuery
를 사용하는 것이 더 편리하다.
2.1.5 결과 조회
query.getResultList()
- 만약 결과가 없으면 빈 컬렉션 반환
query.getSingleResult()
- 결과가 정확히 하나일 때 사용
- 결과가 없으면 예외 발생 :
NoResultException
- 결과가 1개보다 많으면 예외 발생 :
NonUniqueResultException
2.2 파라미터 바인딩
JDBC 는 위치 기준 파라미터 바인딩만 지원하지만, JPQL은 위치 기준 파라미터 바인딩
+ 이름 기준 파라미터 바인딩
지원한다.
2.2.1 이름 기준 파라미터 (메소드 체인 방식)
파라미터 이름 앞에
:
를 사용한다.
String usernameParam = "User1";
List<Member> members =
em.createQuery("SELECT m FROM Member m where m.username = :username", Member.class)
.setParameter("username", usernameParam)
.getResultList();
2.2.2 위치 기준 파라미터 (메소드 체인 방식)
?
다음에 위치값을 주면 된다. 위치 값은 1부터 시작한다.
List<Member> members =
em.createQuery("SELECT m FROM Member m where m.username = ?1", Member.class)
.setParameter(1, usernameParam)
.getResultList();
위치기준 파라미터 방식보다는 이름 기준 파라미터 바인딩 방식을 사용하는 것이 더 명확하다.
다음과 같이 파라미터 바인딩 방식을 사용하지 않고 JPQL을 수정해서 직접 문자를 더해 만들어 넣으면 SQL 인젝션 공격을 당할 수 있다.
"select m from Member m where m.username = '" + usernameParam + "'"
또한 파라미터 바인딩 방식을 사용 시 성능 이슈도 존재한다.
- 파라미터의 값이 달라도 같은 쿼리로 인식해서 JPQ는 JPQL을 SQL로 파싱한 결과를 재사용할 수 있다.
- 데이터베이스도 내부에서 실행한 SQL을 파싱해서 사용하는데 같은 쿼리는 파싱한 결과를 재사용할 수 있다.
결과적으로 애플리케이션과 데이터베이스 모두 해당 쿼리의 파싱 결과를 재사용할 수 있어서 전체 성능이 향상된다.
=> 파라미터 바인딩 방식은 선택이 아닌 필수다.
2.3 프로젝션
SELECT
절에 조회할 대상을 지정하는 것을 프로젝션(Projection)이라 한다.
프로젝션 대상
- 엔티티
- 임베디드 타입
- 스칼라 타입(숫자, 문자 등 기본 데이터)
2.3.1 엔티티 프로젝션
원하는 객체를 바로 조회 가능 => SQL에선 컬럼 하나하나 나열해서 조회해야 한다.
조회한 엔티티는 영속성 컨텍스트
에서 관리된다.
SELECT m FROM Member m // 멤버
SELECT m.team FROM Member.m // 팀
2.3.2 임베디드 타입 프로젝션
JPQL 에서 임베디드 타입은 엔티티와 거의 비슷하게 사용된다.
조회의 시작점이 될 수 없다는 제약이 있다.
// 잘못된 쿼리
String query = "SELECT a FROM Address a";
// Order 엔티티가 시작점인 올바른 쿼리
String query = "SELECT o.address FROM Order o";
List<Address> address = em.createQuery(query, Address.class).getResultList();
임베디드 타입은 엔티티 타입이 아닌 값 타입이다. 따라서 영속성 컨텍스트 관리되지 않는다.
2.3.3 스칼라 타입 프로젝션
기본 데이터 타입들을 스칼라 타입이라 한다.
// 전체 회원의 이름 조회
List<String> usernames =
em.createQuery("SELECT username FROM Member m", String.class)
.getResultList();
중복제거 : DISTINCT
SELECT DISTINCT username FROM Member m
통계 쿼리 가능
Double orderAmountAvg = em.createQuery("SELECT AVG(o.orderAmount) FROM Order o", Double.class)
.getSingleResult();
2.3.4 여러 값 조회
프로젝션에 여러 값을 선택하면 TypeQuery
를 사용할 수 없고 대신 Query
를 사용해야 한다.
조회한 엔티티는 영속성 컨텍스트에서 관리된다.
List<Object[]> resultList =
em.createQuery("SELECT o.member, o.product, o.orderAmount FROM Order o")
.getResultList();
for (Object[] row : resultList) {
Member member = (Member) row[0]; //엔티티
Product product = (Product) row[1]; //엔티티
int orderAmount = (Integer) row[2]; //스칼라
}
2.3.5 NEW 명령어
실제 개발에서 Object[]
를 직접 사용하지 않고 DTO 형태
의 의미있는 객체로 변환해서 사용.
샘플 UserDTO
public class UserDTO {
private String username;
private int age;
public UserDTO(String username, int age) {
this.username = username;
this.age = age;
}
// ...
}
NEW 명령어 사용
NEW 명령어를 사용하면 반환받을 클래스를 지정해줄 수 있는데 이 클래스의 생성자에 JPQL 조회 결과를 넘겨줄 수 있다.
NEW 명령어를 통해 지정해준 클래스로 TypeQuery
를 사용할 수 있어 지루한 객체 변환 작업을 줄일 수 있다.
TypeQuery<UserDTO> query =
em.createQuery("SELECT new jpabook.jpql.UserDTO(m.username, m.age)
FROM Member m", UserDTO.class);
List<UserDTO> resultList = query.getResultList();
NEW 명령어 주의사항
- 패키지 명을 포함한 전체 클래스 명을 입력해야 한다.
순서
와타입
이 일치하는 생성자가 필요하다.
2.4 페이징 API
페이징 처리용 SQL은 지루하고 반복적이다.
그리고 더 큰 문제는 데이터베이스마다 페이징 처리 SQL 문법이 다르다는 점이다.
JPA는 페이징을 두 API로 추상화
setFirstResult(int startPosition)
: 조회 시작 위치(0부터 시작)setMaxResults(int maxResult)
: 조회할 데이터 수
2.4.1 페이징 사용
TypeQuery<Member> query =
em.createQuery("SELECT m FROM Member m ORDER BY m.username DESC",
Member.class);
// 11번째부터 20건의 데이터 조회, 11~30
query.setFirstResult(10);
query.setMaxResult(20);
query.getResultList();
데이터베이스 방언을 이용해 데이터베이스마다 다른 페이징 처리를 같은 API로 처리할 수 있다.
2.5 집합과 정렬
집합은 집합함수와 함께 통계 정보를 구할 때 사용한다.
select
COUNT(m), // 회원수
SUM(m.age), // 나이 합
AVG(m.age), // 나이 평균
MAX(m.age), // 최대 나이
MIN(m.age), // 최소 나이
from Member m
2.5.1 집합 함수 사용 시 주의 사항
- NULL 값은 무시, 통계에 잡히지 않는다.
- 값이 없는데 SUM, AVG, MAX, MIN 함수를 사용하면 NULL 값이 된다. 단, COUNT는 0이 된다.
DISTINCT
를 집합 함수에 사용해서 중복된 값을 제거하고 나서 집합을 구할 수 있다.select COUNT( DISTINCT m.age) from Member m
- DISTINCT를 COUNT에 사용시 임베디드 타입은 지원하지 않는다.
2.5.2 GROUP BY, HAVING
- GROUP BY : 특정 그룹끼리 묶어준다.
- HAVING : GROUP BY와 함께 사용, GROUP BY로 그룹화한 통계 데이터를 기준으로 필터링.
예제코드
// 평균 나이가 10살 이상인 그룹을 조회
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
HAVING AVG(m.age) >= 10
결과가 아주 많다면 통계 결과만 저장하는 테이블을 별도로 만들어두고 사용자가 적은 새벽에 통계 쿼리를 실행해서 그 결과를 보관하는 것이 좋다.
2.5.3 정렬(ORDER BY)
결과 정렬 시 사용한다.
select m from Member m order by m.age DESC, m.username ASC
- ASC : 오름차순(기본값)
- DESC : 내림차순
2.6 JPQL 조인
SQL 조인과 기능은 같고 문법만 약간 다르다.
2.6.1 내부 조인
내부 조인은 INNER JOIN
을 사용. INNER는 생략 가능.
연관 필드를 사용해서 조인한다.
연관 필드
다른 엔티티와 연관관계를 가지기 위해 사용하는 필드
String teamName = "팀A";
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", teamName)
.getResultList();
잘못된 경우
FROM Member m JOIN Team t // 오류!
2.6.2 외부 조인
SELECT m
FROM Member m LEFT [OUTER] JOIN m.team t
OUTER
는 생략 가능. 보통 LEFT JOIN
으로 사용한다.
2.6.3 컬렉션 조인
일대다 관계, 다대다 관계처럼 컬렉션을 사용하는 곳에 조인.
[회원 ->팀]
으로의 조인은 다대일 조인, 단일 값 연관 필드(m.team) 사용.[팀 -> 회원]
은 반대로 일대다 조인, 컬렉션 값 연관 필드(t.members) 사용.
SELECT t, m FROM Team t LEFT JOIN t.members m
2.6.4 세타 조인
세타 조인은 내부 조인만 지원한다.
전혀 관계없는 엔티티도 조회할 수 있다.
전혀 관계없는 Member.username과 Team.name을 조인
//JPQL
select count(m) from Member m, Team t
where m.username = t.name
//SQL
SELECT COUNT(M.ID)
FROM
MEMBER M CROSS JOIN TEAM T
WHERE
M.USERNAME = T.NAME
2.6.5 JOIN ON 절 (JPA2.1)
- JPA 2.1부터 조인할 때 ON 절을 지원. 조인 대상을 필터링하고 조인할 수 있다.
- 내부 조인의 ON 절은
WHERE 절
과 결과가 같음. - 따라서 보통
ON 절
은 외부 조인에 사용.
//JPQL
select m, t from Member m
left join m.team t on t.name = 'A'
//SQL
SELECT m.*, t.*
FROM Member m
LEFT JOIN Team t ON m.team_id = t.id and t.name = 'A'
2.7 페치 조인
SQL에서 이야기하는 조인의 종류는 아니고, JPQL에서 성능 최적화를 위해 제공하는 기능이다.
연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능, join fetch
명령어로 사용할 수 있다.
페치 조인 ::= [ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로
2.7.1 엔티티 페치 조인
회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회
select m
from Member m join fetch m.team
일반적인 JPQL 조인과 다르게 페치 조인은 별칭을 사용할 수 없다.
하이버네이트는 페치 조인에도 별칭을 허용한다.
2.7.2 실행된 SQL
SELECT
M.*, T.*
FROM MEMBER T
INNER JOIN TEAM T ON M.TEAM_ID = T.ID
2.7.3 JPQL 을 사용하는 페치 조인
String jpql = "select m from Member m join fetch m.team";
List<Member> members = em.createQuery(jpql, Member.class)
.getResultList();
for (Member member : members) {
//페치조인으로 회원과 팀을 함께 조회 -> 지연로딩 발생 안 함.
System.out.println("username = " + memrber.getUserName() + ", " +
"teamname = " + member.getTeam().name());
...
// 출력결과
username = 회원 1, teamname = 팀A
username = 회원 2, teamname = 팀A
username = 회원 3, teamname = 팀B
}
회원과 팀을 지연 로딩 설정
- 회원 조회 시 페치 조인을 사용해서 팀을 함께 조회
- 연관된 팀 엔티티는 프록시가 아닌 실제 엔티티.
- 지연 로딩이 일어나지 않는다.
- 실제 엔티티이므로 회원 엔티티가 준영속 상태가 되어도 팀 조회 가능
2.7.4 컬렉션 페치 조인
select t
from Team t join fetch t.members
where t.name = '팀A'
2.7.5 실행된 SQL
SELECT
T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'
팀을 조회하면서 페치 조인을 사용해서 연관된 회원 컬렉션(t.members)도 함께 조회한다. -> 지연 로딩이 발생하지 않는다.
하지만 select t
로 팀만 선택했는데, 실행된 SQL을 보면 T., M. 로 팀과 연관된 회원도 함께 조회한 것을 확인할 수 있다.
그리고 팀 A
는 하나지만 MEMBER
테이블과 조인하면서 결과가 증가해서 같은 팀 A
가 2건 조회되었다.
2.7.6 컬렉션 페치 조인 사용
String jpql = "select t from Team t join fetch t.members where t.name = `팀A`";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();
for(Team team: teams){
System.out.println("teamname = " + team.getName() + ", team = " + team);
for(Member member : team.getMembers()){
System.out.println("->username = " + member.getUsername() + ", member = " + member);
}
}
//출력결과
teamname = 팀A, team = Team@0x100
->username = 회원1, member = Member@0X200
->username = 회원2, member = Member@0X300
teamname = 팀A, team = Team@0x100
->username = 회원1, member = Member@0X200
->username = 회원2, member = Member@0X300
위와 같이 같은 팀A
가 2건 조회된 것을 확인할 수 있다.
내 요약
페치 조인을 하면서 Team의 이름이 팀A
인 Team 에 속한 Member도 함께 조회하였다.
따라서 팀A
내에 회원1
과 회원2
가 포함되어 있어서 두 개의 row가 조회된다.
그런데 조회 결과에서 em.createQuery(jpql, Team.class)
를 통해 Team 엔티티를 리스트로 뽑아낸다.
따라서 리스트 내에 팀A
만 2건이 존재해서, 위의 출력 결과와 같이 하나의 팀A
에 회원1
과 회원2
가 각각 포함되어 있는 현상이 발생하는 것이다.
일대다 조인은 결과가 증가할 수 있지만 일대일, 다대일 조인은 결과가 증가하지 않는다.
2.7.7 페치 조인과 DISTINCT
중복된 결과를 제거하기 위해 SQL에서는 DISTINCT
명령어를 사용한다.
JPQL의 DISTINCT
명령어는 SQL에 DISTINCT
를 추가하는 것은 물론이고, 애플리케이션에서 한 번 더 중복을 제거한다.
select distinct t
from Team t join fetch t.members
where t.name = '팀A'
- SQL 에
SELECT DISTINCT
가 추가된다. - -> 하지만 SQL 단에서 조회한 각 로우의 데이터가 다르므로 SQL 의 DISTINCT는 효과가 없다.
- 애플리케이션에서
distinct
명령어를 보고 중복된 데이터를 걸러낸다. - ->
select distinct t
의 의미는 팀 엔티티의 중복을 제거하라는 것이다.
//출력결과
teamname = 팀A, team = Team@0x100
->username = 회원1, member = Member@0X200
->username = 회원2, member = Member@0X3
2.7.8 일반 조인
select table
from Team t join t.members m
where t.name = '팀A'
2.7.9 실행된 SQL
SELECT T.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'
일반 조인 사용 시 실행된 SQL의 SELECT 절을 보면 팀만 조회하고 조인했던 회원은 전혀 조회하지 않는다.
=> JPQL은 결과 반환 시 연관관계까지 고려하지 않는다.
이때 연관된 컬렉션은 지연 로딩
또는 즉시 로딩
을 설정해서 조회할 수 있다.
만약 지연 로딩
을 사용한다면 연관된 컬렉션에 대해 프록시나 아직 초기화하지 않은 컬렉션 래퍼를 반환한다.
만약 즉시 로딩
을 사용한다면 연관된 컬렉션을 즉시 로딩하기 위해 쿼리를 한 번 더 실행한다.
2.7.10 페치 조인의 특징과 한계
특징
- 페치 조인을 사용하면 SQL 한 번으로 연관된 엔티티들을 함께 조회할 수 있어서 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.
- 글로벌 로딩 전략보다 우선한다.
- 예를 들어 글로벌 로딩 전략을 지연 로딩으로 설정해도 JPQL에서 페치 조인을 사용하면 페치 조인을 적용해서 함께 조회한다.
- ex)
OneToMany(fetch = FetchType.LAZY)
// 글로벌 로딩 전략
최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에서 항상 즉시 로딩이 일어난다.
일부는 빠를 수 있지만 전체로 보면 사용하지 않는 엔티티를 자주 로딩하므로 오히려 성능에 악영향을 미칠 수 있다.
따라서 글로벌 전략은 될 수 있으면 지연 로딩을 사용하고, 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다.
- 연관된 엔티티를 쿼리 시점에 조회하므로 준영속 상태에서도 객체 그래프를 탐색할 수 있다.
페치조인의 한계
1. 별칭을 줄 수 없다.
- 따라서
SELECT
,WHERE 절
,서브 쿼리
에 페치 조인 대상을 사용할 수 없다.
2. 둘 이상의 컬렉션을 페치할 수 없다.
3. 컬렉션 페치 조인 시 페이징 API를 사용할 수 없다.
- 컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API 사용 가능
- 하이버네이트에서 컬렉션 페치 조인하고 페이징 API 를 사용하면 경고 로그 남기며 메모리에서 페이징 처리
- => 데이터가 많으면 성능 이슈, 메모리 초과 예외 발생 가능해서 위험
페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수있어서 성능 최적화에 상당히 유용(N+1 문제 해결)
페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.여러 테이블을 조인해서 다른 결과를 내야 한다면, 억지로 페치 조인을 사용하는 것보다
여러 테이블에서 필요한 필드만을 조회해서 DTO로 변환하는 것이 더 효과적일 수 있다.
2.8 경로 표현식
경로 표현식이란 .(점) 을 찍어 객체 그래프를 탐색하는 것
select m.username
from Member m
join m.team t
join m.orders o
where t.name = '팀A'
여기서 m.username
, m.team
, m.orders
, t.name
모두 경로 표현식이다.
2.8.1 용어
1. 상태 필드 : 단순히 값을 저장하기 위한 필드 (필드 or 프로퍼티)
- ex) member.username, member.age
2. 연관 필드 : 연관관계를 위한 필드, 임베디드 타입 포함(필드 or 프로퍼티)
- 단일 값 연관 필드:
@ManyToOne
,@OneToOne
, 대상이 엔티티 - ex) member.team
- 컬렉션 값 연관 필드:
@OneToMany, @ManyToMany
, 대상이 컬렉션 - ex) member.orders
2.8.2 특징
- 상태 필드 경로 : 경로 탐색의 끝이다. 더는 탐색이 불가능하다.
- 단일 값 연관 경로 : 묵시적으로 내부 조인 발생. 단일 값 연관 경로는 계속 탐색 가능
- 컬렉션 값 연관 경로 : 묵시적으로 내부 조인 발생. 더 이상 탐색이 불가능하다.
- 단,
FROM 절
에서 조인을 통해 별칭을 얻으면 별칭으로 탐색 가능
- 단,
명시적 조인 :
JOIN
을 직접 적어주는 것묵시적 조인 : 경로 표현식에 의해 묵시적으로 조인이 발생. 내부 조인만 할 수 있다.
2.8.3 QUIZ. 다음 JPQL에서 몇 번의 내부 조인이 발생할까
select o.member.team
from Order o
where o.product.name = 'productA' and o.address.city = 'YEONGJU'
실행된 SQL
select t.*
from Order o
inner join Member m on o.member_id=m.id
inner join Team t on m.team_id=t.id
inner join Product p on o.product_id=p.id
where p.name='productA' and o.city='YEONGJU'
=> 총 3번의 조인이 발생하였다!
o.address
처럼 임베디드 타입에 접근하는 것도 단일 값 연관 경로 탐색
이지만, 주문 테이블에 이미 포함되어 있으므로 조인이 발생하지 않는다.
2.8.4 많이 하는 실수 - 컬렉션 값에서 경로 탐색 시도
select t.members from Team t //성공
select t.members.username from Team t //실패
컬렉션에서 경로 탐색을 하려면
// from 절에서, join t.members m으로 컬렉션에 새로운 별칭을 얻는다.
select m.username from Team t join t.members m
SIZE
라는 특별한 기능 사용 가능
select t.members.size from Team t
=> COUNT 함수를 사용하는 SQL 로 적절히 변환된다.
2.8.5 묵시적 조인 시 주의사항
1. 컬렉션은 경로 탐색의 끝이다.
- 컬렉션에서 경로 탐색을 하려면 명시적으로 조인해서 별칭을 얻어야 한다.
2. 경로 탐색은 주로 SELECT
, WHERE
절에서 사용
- 하지만 묵시적 조인으로 인해 SQL의 FROM 절에 영향을 준다.
경로 탐색을 사용하면 묵시적 조인 발생 -> SQL 에서 내부 조인 발생
조인은 성능상에 큰 영향을 미친다. 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다.
=> 따라서 성능이 중요하면 분석하기 쉽도록 묵시적 조인보다는 명시적 조인을 사용하는 것이 좋다.
2.9 서브 쿼리
WHERE
, HAVING
절에서만 사용할 수 있고, SELECT
, FROM
절에서는 사용할 수 없다.
2.9.1 예시 1: 나이가 평균보다 많은 회원
select m from Member m
where m.age > (select avg(m2.age) from Member m2)
2.9.2 예시 2: 한 건이라도 주문한 고객
select m from Member map
where (select count(o) from Order o where m = o.member) > 0
위의 where 절은 m.orders.size > 0
을 사용해도 같은 결과가 나온다.
2.9.3 서브 쿼리 함수
1. {NOT} EXISTS (subquery)
- 서브쿼리에 결과가 존재하면 참이다. NOT은 반대
- ex) 팀 A 소속인 회원
select m from Member m where exists (select t from m.team t where t.name = '팀A')
2. {ALL | ANY | SOME} (subquery)
- 비교 연산자와 같이 사용
- ALL : 조건을 모두 만족해야 참
- ANY 혹은 SOME : 조건을 하나라도 만족하면 참
- ex) 전체 상품 각각의 재고보다 주문량이 많은 주문들
select o from Order o where o.orderAmount > ALL (select p.stockAmount from Product p)
- ex) 어떤 팀이든 소속된 회원
select m from Member m where m.team = ANY (select t from Team t)
3. {NOT} IN (subquery)
- 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참.
IN
은 서브쿼리가 아닌 곳에서도 사용한다. - ex) 20세 이상 회원을 보유한 팀
select t from Team t where t IN (select t2 from Team t2 JOIN t2.members m where m.age >= 20) )
2.10 조건식
2.10.1 타입 표현
종류 | 예제 |
---|---|
문자 | 'HELLO', 'Hi' |
숫자 | 10L, 10D, 10F |
날짜 | {d'2012-03-24'} |
타임 | {t'10-11-11'} |
DATETIME | [ts'2014-03-24 10-11-11.123'} |
Enum | jpabook.MemberType.Admin |
엔티티 타입 | TYPE(m) = Member , 주로 상속과 관련해 사용 |
2.10.2 BETWEEN
select m from Member m
where m.age between 10 and 20
2.10.3 IN 식
select m from Member m
where m.username in ('회원1', '회원2')
2.10.4 LIKE 식
%
: 아무 값들이 입력되어도 됨. 값이 없어도 됨_
: 한 글자는 아무 값이 입력되어도 되지만, 값이 있어야함.
// 중간에 '원'이 들어간 회원
select m from Member m
where m.username like '%원%'
where m.username like '회원%'
where m.username like '%회원'
//회원A, 회원1
where m.username like '회원_'
//회원3
where m.username like '__3'
2.10.5 NULL 비교식
NULL은 =
으로 비교하면 안되고, 반드시 IS NULL
을 사용해서 비교해야한다.
where m.username is null
where null = null //거짓
where 1 = 1 //참
2.10.6 컬렉션 식
컬렉션에서만 사용하는 특별한 기능
빈컬렉션 비교
//JPQL : 주문이 하나라도 있는 회원 조회
select m from Member m
where m.orders is not empty
// 오류
select m from Member m
where m.orders is null // (오류!)
컬렉션 멤버 식
엔티티나 값이 컬렉션에 포함되어 있으면 참
- 문법 : {엔티티나 값} {NOT}
MEMBER
{OF} {컬렉션 값 연관 경로}
select t from Team t
where :memberParam member of t.members
2.10.7 스칼라 식
숫자, 문자, 날짜, case, 엔티티 타입같은 기본적인 값
문자함수
함수 | 설명 | 예제 |
---|---|---|
CONCAT(문자1, 문자2, ..) | 문자를 합한다. | CONCAT('A', 'B') = AB |
SUBSTRING(문자, 위치, [길이]) | 위치부터 시작해 길이만큼 문자를 구한다 | SUBSTRING('ABCDEF', 2) = BCDEF |
TRIM(문자) | 문자의 양 끝 공백을 지워준다 | TRIM(' ABC ') = ABC |
LOWER(문자) | 소문자로 변경 | LOWER('ABC') = 'abc' |
UPPER(문자) | 대문자로 변경 | UPPER('abc') = 'ABC' |
LENGTH(문자) | 문자 길이 | LENGTH('ABC') = 3 |
LOCATE(찾을 문자, 원본 문자, [검색시작위치]) | 검색위치부터 문자 검색, 1부터 시작 | LOCATE('DE', 'ABCDEFG') = 4 |
수학함수
함수 | 설명 | 예제 |
---|---|---|
ABS(수학식) | 절대값 | ABS(-10) = 10 |
SQRT(수학식) | 제곱근 | SQRT(4) = 2.0 |
MOD(수학식, 나눌 수) | 나머지 | MOD(4, 3) = 1 |
SIZE(컬렉션 값 연관 경로식) | 컬렉션 크기 | SIZE(t.members) |
날짜함수
- CURRENT_DATE : 현재 날짜
- CURRENT_TIME : 현재 시간
- CURRENT_TIMESTAMP : 현재 날짜 시간
- 날짜 타입에서 년, 월, 일, 시간, 분, 초 값을 구하는 기능
- YEAR, MONTH, DAY, HOUR, MINUTE, SECOND
2.10.8 CASE 식
특정 조건에 따라 분기할 때 CASE 식 사용
- 기본 CASE
select
case when m.age <= 10 then '학생요금'
when m.age >= 60 then '경로요금'
else '일반 요금'
end
from Member m
- 심플 CASE
select
case t.name
when '팀A' then '인센티브110%'
when '팀B' then '인센티브120%'
else '인센티브105%'
end
from Team t
- COALESCE
select coalesce (m.username, 'null이면 반환할 문자열') from Member m
- NULLIF
- 두 값이 같으면 null 반환. 다르면 첫 번째 값 반환
- 보통 집합 함수는 null을 포함하지 않으므로 함께 사용
select NULLIF(m.username, '관리자') from Member m
2.11 다형성 쿼리
JPQL로 부모 엔티티 조회 시 자식 엔티티도 함께 조회한다.
2.11.1 TYPE
엔티티의 상속 구조에서 조회 대상을 자식 타입으로 한정할 때 주로 사용
ex) Item 중에 Book, Movie 를 조회
// JPQL
select i from Item i
where type(i) IN (Book ,Movie)
// SQL
SELECT i FROM Item i
WHERE i.DTYOE in ('B', 'M')
2.11.2 TREAT(JPA2.1)
자바의 타입 캐스팅과 비슷. 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용
JPA 표준은 FROM
, WHERE
절에서 사용 가능. 하이버네이트는 SELECT
절에서도 사용 가능
// JPQL
select i from Item i where treat(i as Book).author = 'kim'
// SQL
select i.* from Item i
where
i.DTYPE='B'
and i.author='kim'
2.12 기타 정리
- enum은 == 비교 연산만 지원
- 임베디드 타입은 비교를 지원하지 않는다.
- JPA 표준은 ''을 길이 0인
Empty String
으로 정했지만, DB에 따라 ''를 NULL로 사용하는 것도 있다.
- NULL == NULL 은 알 수 없는 값이고, NULL is Null 은 참이다.
2.13 엔티티 직접 사용
객체 인스턴스는 참조 값으로 식별, 테이블 로우는 기본 키 값으로 식별
JPQL에서 엔티티 객체를 직접 사용하면 SQL에서는 해다 엔티티의 기본 키값을 사용한다
2.13.1 기본 키 값
엔티티를 직접 사용하던, 식별자 값을 직접 사용하던 결과가 같다.
엔티티를 파라미터로 직접 받는 코드
String sqlString = "select m from Member m where m = :member"; // m.id = :memberId 로 해도 결과가 같음
List resultList = em.createQuery(sqlString)
.setParameter("member", member)
.getResultList();
실행된 SQL
select m.*
from Member m
where m.id=?
2.13.2 외래 키 값
외래 키 대신 엔티티를 사용하는 코드
외래 키를 직접 사용하던, 엔티티를 직접 사용하던 생성되는 SQL은 같다
Team team = em.find(Team.class, 1L);
String sqlString = "select m from Member m where m.team = :team"; // m.team.id = :teamId 도 결과는 같다.
List resultList = em.createQuery(sqlString)
.setParameter("team", team)
.getResultList();
실행된 SQL
select m.*
from Member m
where m.team_id=? (팀 파라미터의 ID값)
m.team.id 를 조회할 때
Member
와Team
간에 묵시적 조인이 일어날 것 같지만 그렇지 않다.=>
MEMBER
테이블이 이미team_id
외래 키를 가지고 있기 때문이다.만약
m.team.name
을 호출하면 묵시적 조인이 발생한다!
2.14 Named 쿼리: 정적 쿼리
- 어플리케이션 로딩 시점에 JPQL 문법을 체크하고 미리 파싱해준다.
- 오류를 빨리 확인할 수 있고, 사용하는 시점에는 파싱된 결과를 재사용.
- 성능상 이점을 가져올 수 있다.
@NamedQuery
어노테이션이나 XML에 작성 가능
2.14.1 Named 쿼리를 어노테이션에 정의
// 정의
@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();
2.14.2 2개 이상의 Named 쿼리 정의
@NamedQueries
어노테이션 사용
@Entity
@NamedQueries({
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"),
@NamedQuery(
name = "Member.count"
query = "select count(m) from Member m")
})
public class Member { ... }
3. QueryDSL
- 쿼리를 문자가 아닌 코드로 작성해도, 쉽고 간결하며 그 모양도 쿼리와 비슷하게 개발할 수 있는 오픈소스 프로젝트이다.
- JPA, JDO, JDBC, Lucnen, Hibernate Search, MongoDB, 자바 컬렉션 등을 다양하게 지원한다.
- 데이터를 조회하는 데 기능이 특화되어 있다.
- 엔티티를 기반으로 쿼리 타입이라는 쿼리용 클래스를 생성해야 한다. 쿼리 타입 생성용 플러그인 추가!
3.1 QueryDSL 설정
3.1.1 필요 라이브러리
querydsl-apt
: Querydsl 관련 코드 생성 기능 제공querydsl-jpa
: querydsl 라이브러리
3.1.2 build.gradle 환경 설정
plugins {
...
//querydsl 추가
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
...
}
dependencies {
...
//querydsl 추가
implementation 'com.querydsl:querydsl-jpa'
...
}
//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
//querydsl 추가 끝
3.1.3 결과
3.2 시작
public static void queryDSL(EntityManager entityManager) {
JPAQuery query = new JPAQuery(entityManager);
QMember qMember = new QMember("m"); // 생성되는 JPQL의 별칭이 m
List<Member> members = query
.from(qMember)
.where(qMember.username.eq("철수"))
.orderBy(qMember.username.desc())
.list(qMember);
for (Member member : members) {
System.out.println("Member : " + member.getMemberId() + ", " + member.getUsername());
}
}
3.2.1 사용 방법
com.mysema.query.jpa.impl.JPAQuery
객체 생성
EntityManager
를 인자로 넘겨준다.
- 사용할 쿼리 타입(Q)를 생성하는데 생성자에는 별칭을 준다.
list()
내부에 조회할 대상을 지정
3.2.2 JPQL과 비교
select m from Member m
where m.name = ?1
order by m.name desc
List<Member> members = query
.from(qMember)
.where(qMember.username.eq("갓영한"))
.orderBy(qMember.username.desc())
.list(qMember);
3.2.3 기본 Q 생성
쿼리 타입(Q)은 사용하기 편리하도록 기본 인스턴스를 보관하고 있다.
엔티티를 조인하거나 같은 엔티티를 서브쿼리에 사용하면 같은 별칭이 사용되므로 이때는 별칭을 직접 지정해서 사용해야 한다.
쿼리 타입 사용
QMember qMember = new QMember("m"); //직접 지정
QMember qMember = QMember.member; //기본 인스턴스
import static 활용
import static 경로.QMember.member; // 기본 인스턴스
public static void basic(){
EntityManager em=emf.createEntityManager();
JPAQuery query=new JPAQuery(em);
List<Member> members =
query.from(member)
.where(member.username.eq("철수"))
.orderBy(member.username.desc())
.list(member);
}
3.3 검색 조건 쿼리
3.3.1 기본 쿼리 기능
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<Item> list = query.from(item)
.where(item.name.eq("좋은상품").and(item.price.gt(20000)))
.list(item);
3.3.2 실행된 JPQL
select item
from Item item
where item.name =?1 and item.price >?2
QueryDSL 의 where 절에는 and 나 or를 사용할 수 있다.
,
를 사용해서 여러 검색 조건을 사용해도 된다. => and 연산 적용.
.where(item.name.eq("좋은상품"), item.price.gt(20000))
3.3.3 쿼리 타입 필드의 메소드
item.price.between(1000, 2000); // 가격이 10000원~20000원 상품
item.name.contains("상품1"); // 상품1 이름을 포함하는 상품, like '%상품1%' 검색
item.name.startsWith("고급"); // like 검색, '고급%' 검색
IDE 자동완성 기능을 이용해서 필요한 메소드를 손쉽게 찾을 수 있다.
3.4 결과 조회
- 쿼리 작성이 끝나고 결과 조회 메소드를 호출하면 실제 데이터베이스를 조회한다.
- 보통은
uniqueResult()
혹은list()
를 사용하고 파라미터로 프로젝션 대상을 넘겨준다.
메소드 | 설명 |
---|---|
uniqueResult() | 조회 결과가 한 건일 때 사용. 조회 결과가 없으면 null을 반환, 결과가 하나 이상이면 예외 발생 |
singleResult() | uniqueResult() 와 같지만 결과가 하나 이상이면 처음 데이터를 반환 |
list() | 결과가 하나 이상일 때 사용. 결과가 없으면 빈 컬렉션을 반환 |
3.5 페이징과 정렬
QItem item = QItem.item;
query.from(item)
.where(item.price.gt(20000))
.orderBy(item.price.desc(), item.stockQuantity.asc())
.offset(10).limit(20)
.list(item)
기능 | 메소드 |
---|---|
정렬 | orderBy 사용 - asc() , desc() |
페이징 | offset , limit |
페이징은
restrict()
메소드에com.mysema.query.QueryModifiers
를 파라미터로 사용해도 된다.QueryModifiers queryModifiers = new QueryModifiers(20L, 10L); // limit, offset List<Member> members = query.from(qMember) .restrict(queryModifiers) .list(qMember);
3.5.1 실제 페이징 처리
- 실제 페이징 처리를 하려면 검색된 전체 데이터 수를 알아야 한다. 이 때
listResults()
를 사용한다.
listResults()
를 사용하면 전체 데이터 조회를 위한count
쿼리를 한번 더 실행 후SearchResults
를 반환한다.- 이 객체에서 전체 데이터 수 조회가 가능하다.
SearchResult<Item> result =
query.from(item)
.where(item.price.gt(10000))
.orderBy(item.price.desc(), item.stockQuantity.asc()) // 가격 내림차순, 재고수량 오름차순
.offset(10).limit(20)
.listResults(item);
long total = result.getTotal(); // 검색된 전체 데이터 수
long limit = result.getLimit();
long offset = result.getOffset();
List<Item> results = result.getResult(); // 조회된 데이터
3.6 그룹
gorupBy
를 사용하고, 그룹화된 결과를 제한하려면having
을 사용한다.
query.from(item)
.groupBy(item.price)
.having(item.price.gt(1000))
.list(item);
3.7 조인
3.7.1 사용 가능한 조인
innerJoin(join)
leftJoin
rightJoin
fullJoin
- JPQL의 on
- 성능 최적화를 위한
fetch 조인
: join 뒤에.fetch()
를 붙여줌
fetch 조인 : 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능. SQL 호출 횟수를 줄여 성능을 최적화한다.
3.7.2 기본 문법
- 첫 번째 파라미터에 조인 대상을 지정하고, 두 번째 파라미터에 별칭으로 사용할 쿼리 타입을 지정
join(조인 대상, 별칭으로 사용할 쿼리 타입)
3.7.3 조인 예시
기본 조인 예시
QOrder order = QOrder.order;
QMember member = QMember.member;
QOrderItem orderItem = QOrderItem.orderItem;
query.from(order)
.join(order.member, member)
.leftJoin(order.orderItems, orderItem)
.list(order);
조인 on 사용 예시
query.from(order)
.leftJoin(order.orderItems, orderItem)
.on(orderItem.count.gt(2))
.list(order);
페치 조인 사용 예시
QOrder order = QOrder.order;
QMember member = QMember.member;
QOrderItem orderItem = QOrderItem.orderItem;
query.from(order)
.join(order.member, member).fetch()
.leftJoin(order.orderItems, orderItem).fetch()
.list(order);
세타 조인 사용 예시
세타 조인
: from 절에 여러 조인을 사용하는 방법
QOrder order = QOrder.order;
QMember member = QMember.member;
query.from(order, member)
.where(order.member.eq(member))
.list(order)
3.8 서브 쿼리
com.mysema.query.jpa.JPASubQuery
를 생성해서 사용한다.- 서브 쿼리의 결과가 하나면
unique()
, 여러 건이면list()
를 사용할 수 있다.
3.8.1 결과 단건 조회
QItem item = QItem.item;
QItem itemSub = new QItem("itemSub"); // 같은 엔티티를 서브 쿼리에 사용 - 별칭 사용 필수
query.from(item)
.where(item.price.eq(
new JPASubQuery().from(itemSub)
.unique(itemSub.price.max())
))
.list(item);
3.8.2 결과 여러 건 조회
QItem item = QItem.item;
QItem itemSub = new QItem("itemSub");
query.from(item)
.where(item.in(
new JPASubQuery().from(itemSub)
.where(item.name.eq(itemSub.name))
list(itemSub)
))
.list(item);
3.9 프로젝션과 결과 반환
- 프로젝션 : select 절에 조회 대상을 지정하는 것
3.9.1 하나의 프로젝션 대상
해당 타입으로 반환한다.
List<String> result = query.from(item).list(item.name);
3.9.2 여러 컬럼 반환과 튜플
프로젝션 대상으로 여러 필드 선택 -> Tuple 타입
으로 반환 (Map 과 비슷한 내부 타입)
tuple.get()
메소드에 조회한 쿼리 타입을 지정
JPAQuery query = new JPAQuery(em);
List<Tuple> result = query.from(item).list(item.name, item.price);
//.list(new QTuple(item.name, item.price)) 와 같다
for(Tuple tuple: result){
System.out.println("name = " + tuple.get(item.name));
System.out.println("price = " + tuple.get(item.price));
}
3.9.3 빈 생성
- 쿼리 결과를 엔티티가 아닌 특정 객체로 받고 싶을 때
빈 생성(Bean Population)
기능을 사용한다.
예제 ItemDTO
// DTO 에 값 채우기
public class ItemDTO{
private String username;
private int price;
public ItemDTO(){}
public ItemDTO(String username, int price){
this.username = username;
this.price = price;
}
// Getter, Setter
...
}
3.9.4 프로퍼티 접근(Setter)
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<ItemDTO> result = query.from(item).list(
Projections.bean(ItemDTO.class, item.name.as("username"), item.price));
쿼리 결과와 매핑할 프로퍼티 이름이 달라 as 를 통해 별칭을 준다.
3.9.5 필드 직접 접근
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<ItemDTO> result = query.from(item).list(
Projections.fields(ItemDTO.class, item.name.as("username"), item.price));
필드를 private 으로 설정해도 동작한다.
3.9.6 생성자 사용
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<ItemDTO> result = query.from(item).list(
Projections.constructor(ItemDTO.class, item.name, item.price));
지정한 프로젝션과 파라미터 순서가 같은 생성자가 필요
3.9.7 DISTINCT
query.distinct().from(item)...
3.10 수정, 삭제 배치 쿼리
- JPQL 배치 쿼리와 같이 영속성 컨텍스트를 무시하고 데이터베이스를 직접 쿼리한다.
3.10.1 수정, 삭제 배치 쿼리
수정 : JPAUpdateClause
삭제 : JPADeleteClause
QItem item = QItem.item;
JPAUpdateClause updateClause = new JPAUpdateClause(em, item);
long count = updateClause.where(item.name.eq("만렙개발자의 JPA 책"))
.set(item.price, item.price.add(100)); // 상품의 가격을 100원 증가
.execute();
QItem item = QItem.item;
JPADeleteClause deleteClause = new JPADeleteClause(em, item);
long count = deleteClause.where(item.name.eq("만렙개발자의 JPA 책"))
.execute();
3.11 동적 쿼리
com.mysema.query.BooleanBuilder
를 사용하면 특정 조건에 따른 동적 쿼리를 편리하게 생성 가능.
// 상품 이름과 가격 유무에 따라 동적으로 쿼리 생성
SearchParam param = new SearchParam();
param.setName("시골개발자");
param.setPrice(10000);
QItem item = QItem.item;
BooleanBuilder builder = new BooleanBuilder();
if(StringUtils.hasText(param.getName())){
builder.and(item.name.contains(param.getName()));
}
if(param.getPrice() != null){
builder.and(item.price.gt(param.getPrice()));
}
List<Item> result = query.from(item)
.where(builder)
.list(item);
3.12 메소드 위임
쿼리 타입에 검색 조건을 직접 정의할 수 있다.
3.12.1 검색 조건 정의
메소드 위임 기능을 사용하려면 정적 메소드를 만들고, @QueryDelegate
어노테이션에 속성으로 이 기능을 적용할 엔티티를 지정한다.
public class ItemExpression {
@QueryDelegate(Item.class)
public static BooleanExpression isExpensive(QItem item, Integer price){ // (대상 엔티티의 쿼리타입, 필요한 파라미터...)
return item.price.gt(price);
}
}
3.12.2 쿼리 타입에 생성된 결과
public class QItem extends EntityPathBase<Item> {
...
public com.mysema.query.types.expr.BooleanExpression isExpensive(Interger price) {
return ItemExpression.isExpensive(this, price);
}
}
3.12.3 메소드 위임 기능 사용
query.from(item.where(item.isExpensive(30000))).list(item);
String
, Date
같은 자바 기본 내장 타입에도 메소드 위임 기능을 사용할 수 있다.
@QQueryDelegate(String.class)
public static BooleanExpression isHelloStart(StringPath stringPath){
return stringPath.startsWith("Hello");
}
3.12.4 정리
QueryDSL 을 사용해서 문자가 아닌 코드로 안전하게 코드를 작성할 수 있고, 복잡한 동적 쿼리를 쉽게 작성할 수 있다.
4. NativeSQL
JPQL은 표준 SQL이 지원하는 대부분의 문법과 SQL 함수들을 지원하지만 특정 데이터베이스에 종속적인 기능은 지원하지 않는다.
ex)
- 특정 DB만 지원하는 함수, 문법, SQL 쿼리 힌트
- 인라인 뷰(From 절에서 사용하는 서브쿼리), UNION, INTERSECT
- 스토어드 프로시저
때로는 특정 DB에 종속적인 기능이 필요하다. 따라서 JPA는 특정 DB에 종속적인 기능을 사용할 수 있도록 여러 방법들을 열어두었다.
네이티브 SQL
다양한 이유로 JPQL을 사용할 수 없을 때 JPA이 SQL을 직접 사용할 수 있는 기능을 제공하는 것
JPQL을 사용하면 JPA가 SQL을 생성한다. 네이티브 SQL은 이 SQL을 개발자가 직접 정의하는 것이다.
즉, JPQL은 자동 모드, 네이티브 SQL은 수동 모드인 셈이다
네이티브 SQL을 사용하면 엔티티를 조회할 수 있고 JPA가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있다.
- 네이티브 SQL은 JPQL이 자동 생성하는 SQL을 수동으로 직접 정의하는 것이다. 따라서 JPA 가 제공하는 기능 대부분을 그대로 사용할 수 있다.
- 하지만 관리하기 쉽지 않고 자주 사용하면 특정 데이터베이스에 종속적인 쿼리가 증가해서 이식성이 떨어진다.
- 될 수 있으면 표준 JPQL을 사용하고 기능이 부족하면 차선책으로 JPA 구현체가 제공하는 기능을 사용한다.
4.1 네이티브 SQL 사용
네이티브 쿼리 API의 3가지 유형
// 결과 타입 정의
public Query createNativeQuery(String sqlString, Class resultClass);
// 결과 타입 정의할 수 없음
public Query createNativeQuery(String sqlString);
// 결과 매핑 사용
public Query createNativeQuery(String sqlString, String resultSetMapping);
4.1.1 엔티티 조회
em.createNativeQuery(SQL, 결과 클래스)
- 실제 데이터베이스 SQL을 사용하며, 위치기반 파라미터만 지원한다. 나머지는 JPQL을 사용할 때와 같다. 조회한 엔티티도 영속성 컨텍스트에서 관리된다.
- 하이버네이트는 이름 기반 파라미터도 사용 가능하다.
엔티티 조회 코드
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();
4.1.2 값 조회
스칼라 값만 조회하기 때문에 결과를 영속성 컨텍스트가 관리하지 않는다.
값조회 코드
String sql =
"SELECT ID, AGE, NAME, TEAM_ID " +
"FROM MEMBER WHERE AGE > ?";
Query nativeQuery = em.createNativeQuery(sql) // 두 번째 파라미터 사용 x
.setParameter(1, 10);
List<Object[]> resultList = nativeQuery.getResultList();
for(Object[] row: resultList){
System.out.println("id = " + row[0]);
System.out.println("age = " + row[1]);
System.out.println("name = " + row[2]);
System.out.println("team_id = " + row[3]);
}
4.1.3 결과 매핑 사용
엔티티와 스칼라 값을 함께 조회하는 것처럼 매핑이 복잡해지면 @SqlResultSetMapping
을 정의해서 결과 매핑을 사용해야 한다.
결과 매핑 사용 코드
// SQL 정의
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"); // 두 번째 파라미터 : 결과 매핑 정보의 이름
List<Object[]> resultList = nativeQuery.getResultList();
for(Object[] row: resultList){
Member member = (Member) row[0];
BigInteger orderCount = (BigInteger) row[1];
System.out.println("member = " + member);
System.out.println("orderCount = " + orderCount);
}
결과 매핑 정의 코드
@Entity
@SqlResultSetMapping(name = "memberWithOrderCount", // 결과 매핑 정의
entities = {@EntityResult(entityClass = Member.class)}, // ID, AGE, NAME, TEAM_ID 는 Member 엔티티와 매핑
columns = {@ColumnResult(name = "ORDER_COUNT")} // ORDER_COUNT는 단순히 값으로 매핑
)
public class Member { ... }
4.1.4 표준 명세 예제
Query q = em.createNativeQuery{
"SELECT o.id AS order_id, " +
"o.quantity AS order_quantity, " +
"o.item AS order_item, " +
"i.name AS item_name, " +
"FROM Order o, Item i " +
"WHERE (order_quantity > 25) AND (order_item = i.id)", "OrderResults");
}
@SqlResultSetMapping(name = "OrderResults", // 결과 매핑 정의
entities = {
@EntityResult(entityClass = com.acme.Order.class, fields={ // 여러 엔티티와 여러 컬럼을 매핑, entityClass : 결과로 사용할 엔티티 클래스를 지정
@FieldResult(name="id", column="order_id"), // name: 결과를 받을 필드명, column : 결과 컬럼명
@FieldResult(name="quantity", column="order_quantity"),
@FieldResult(name="item", column="order_item")})},
columns = {
@ColumnResult(name = "item_name")} // 결과 컬럼명
)
// 쿼리 결과를 매핑하기 위해 사용. 어떤 엔티티에 어떤 필드값에 쿼리 결과들을 어떤 컬럼명으로 매핑할지 결정!
@NamedNativeQuery
로 정적 SQL을 작성할 수 있다.createNamedQuery
메서드를 사용해서 TypeQuery를 사용할 수 있다.- 네이티브 SQL을 사용해도 Query, TypeQuery(Named 네이티브 쿼리의 경우에만) 를 반환한다. 따라서 JPQL API를 그대로 사용 가능하다. (페이징 처리 API 적용 가능!)
5. 객체지향 쿼리 심화
5.1 벌크 연산
엔티티를 수정하려면 영속성 컨텍스트의 변경 감지 기능이나 병합을 사용하고, 삭제하려면 EntityManager.remove() 메소드를 사용
하지만 이 방법으로는 수백개 이상의 엔티티를 하나씩 처리하기에는 시간이 너무 오래 걸린다.
이 때, 여러 건을 한 번에 수정하거나 삭제하는 벌크 연산을 사용하면 된다! executeUpdate()
메서드 사용
5.1.1 UPDATE 벌크 연산
벌크 연산은 executeUpdate()
메소드를 사용한다. 해당 메소드는 벌크 연산으로 영향을 받은 엔티티 건수를 반환한다.
String qlString =
"update Product p " +
"set p.price = p.price * 1.1 " +
"where p.stockAmount < :stockAmount"; // 재고가 10개 미만인 모든 상품의 가격을 10% 상승
int resultCount = em.createQuery(qlString) // 벌크 연산으로 영향을 받은 엔티티 건수를 반환
.setParameter("stockAmount", 10)
.executeUpdate();
5.1.2 DELETE 벌크 연산
String qlString =
"delete from Product p " +
"where p.price < :price";
int resultCount = em.createQuery(qlString) // 벌크 연산으로 영향을 받은 엔티티 건수를 반환
.setParameter("price", 10) // 가격이 100원 미만인 상품을 삭제하는 코드
.executeUpdate();
JPA 표준은 아니지만 하이버네이트는 INSERT 벌크 연산도 지원한다.
5.1.3 🚨 주의사항
벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리한다.
벌크 연산 주의사항 예제
// 상품 A 조회(상품A의 가격은 1000원이다.)
Product productA = em.createQuery("select p from Product p where p.name = :name",
Product.class)
.setParameter("name", "productA")
.getSingleResult();
// 출력 결과: 1000
System.out.println("productA 수정 전 = " + productA.getPrice());
// 벌크 연산 수행으로 모든 상품 가격 10% 상승
em.createQuery("update Product p set p.price=price*1.1")
.executeUpdate();
// 출력 결과: 1000
System.out.println("productA 수정 후 = " + productA.getPrice());
상품 조회를 통해 가져온 Product 객체 productA가 있다. 이후, 벌크 연산 수행으로 객체의 정보를 수정했을 때, 영속성 컨텍스트를 지나치지 않고 DB의 정보를 바로 변경한다.
하지만 영속성 컨텍스트에 저장되어 있는 productA의 정보는 변경되지 않고 그대로 남아있는다. (1100원이 아니라 1000원 출력)
DB 에는 1100원으로 저장되어 있지만, 영속 컨텍스트에는 1000원으로 저장되어 있다. => 불일치
5.1.4 🚑 해결 방법
1. em.refresh()
사용
- 벌크 연산을 수행한 직후 em.refresh()를 사용해 DB에서 productA 를 다시 조회하면 된다.
ex)em.refresh(productA)
2. 벌크 연산 먼저 실행
- 가장 실용적인 해결책, 벌크 연산을 먼저 실행하고 나서 productA를 조회하면 된다.
- 변경된 사항을 영속 컨텍스트에도 저장함.
3. 벌크 연산 수행 후 영속성 컨텍스트 초기화
- 그렇게 되면 이후 엔티티를 조회할 때 벌크 연산이 적용된 DB에서 엔티티를 조회한다.
5.2 영속성 컨텍스트와 JPQL
쿼리 후 영속 상태인 것과 아닌 것
JPQL로 엔티티를 조회하면 영속성 컨텍스트에서 관리되지만 엔티티가 아니면 영속성 컨텍스트에서 관리되지 않는다. (임베디드 타입, 값 타입은 관리 X)
조회한 엔티티만 영속성 컨텍스트가 관리한다.
JPQL로 조회한 엔티티와 영속성 컨텍스트
만약, 영속성 컨텍스트에 회원1이 이미 있는데, JPQL로 회원 1을 다시 조회하면 조회한 결과를 버리고 대신에 영속성 컨텍스트에 있던 엔티티를 반환한다.
새로 조회한 엔티티로 대체한다면, 영속성 컨텍스트에 수정 중인 데이터가 사라질 수 있으므로 위험하기 때문이다.
영속성 컨텍스트는 영속 상태인 엔티티의 동일성을 보장한다.
em.find()
또는JPQL
을 사용하면 영속성 컨텍스트가 같으면 동일한 엔티티를 반환한다.이때
식별자 값
을 사용해서 비교한다.
find() vs JPQL
둘 다 주소 값이 같은 인스턴스를 반환하고 결과도 같다.
하지만 엔티티를 조회하는 순서가 다르다.
- em.find() : 영속성 컨텍스트 -> DB
- JPQL : DB -> 영속성 컨텍스트
1. em.find()
- 엔티티를 영속성 컨텍스트에서 먼저 찾고 없으면 DB에서 찾는다. 따라서 해당 엔티티가 영속성 컨텍스트에 있으면 메모리에서 바로 찾으므로 성능상 이점 ! =>
1차 캐시
라고 부른다
//최초 조회, 데이터베이스에서 조회
Member member1 = em.find(Member.class, 1L);
//두번째 조회, 영속성 컨텍스트에 있으므로 데이터베이스를 조회하지 않음
Member member2 = em.find(Member.class, 1L);
// member1 == member2 는 주소 값이 같은 인스턴스
2. JPQL
em.find()
를 두 번 사용한 로직과 마찬가지로 주소 값이 같은 인스턴스를 반환하고 결과도 같다. 하지만 항상 데이터베이스에 SQL을 실행해서 결과를 조회한다.
- 처음 JPQL 호출 시 DB에서 엔티티를 조회하고 영속성 컨텍스트에 등록, 두 번째 JPQL 을 호출하면 DB에서 엔티티를 조회, 이미 영속성 컨텍스트에 조회한 같은 엔티티가 있으면 새로 검색한 엔티티를 버리고 영속성 컨텍스트에 있는 기존 엔티티를 반환한다.
- 특징
- JPQL은 항상 데이터베이스를 조회한다.
- JPQL로 조회한 엔티티는 영속 상태다.
- 영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환한다.
// 첫 번째 호출: 데이터베이스에서 조회
Member member1 = em.createQuery("select m from Member m where m.id = :id", Member.class)
.setParameter("id", 1L)
.getSingResult();
// 두 번째 호출: 데이터베이스에서 조회
Member member2 = em.createQuery("select m from Member m where m.id = :id", Member.class)
.setParameter("id", 1L)
.getSingResult();
// member1 == member2 는 주소값이 같은 인스턴스
5.3 JPQL과 플러시 모드
5.3.1 플러시
영속성 컨텍스트의 변경 내역을 데이터베이스에 동기화하는 것이다.
JPA는 플러시가 일어날 때, 영속성 컨텍스트에 등록, 수정, 삭제한 엔티티를 찾아 INSERT, UPDATE, DELETE SQL을 만들어 DB에 반영한다. em.flush()
플러시 모드에 따라 커밋하기 직전이나 쿼리 실행 직전에 자동으로 플러시 호출 가능.
em.setFlushMode(FlushModeType.AUTO)
커밋 또는 쿼리 실행 시(실행 직전에) 플러시(기본값)
em.setFlushMode(FlushModeType.COMMIT)
커밋 시에만 플러시.
-> 성능 최적화를 위해 꼭 필요할 때만 사용해야 한다.
JPQL은 영속성 컨텍스트에 있는 데이터를 고려하지 않고 DB에서 데이터를 조회. 따라서 JPQL 실행 전 영속성 컨텍스트의 내용을 DB에 반영(플러시)해야 한다. => 데이터 무결성
그렇지 않으면, 영속성 컨텍스트의 엔티티 정보는 변경되었지만 DB 에는 변경사항이 적용되지 않는 문제가 발생할 수 있다.
// 플러시 모드 설정
em.setFlushMode(FlushModeType.COMMIT);
product.setPrice(2000);
em.flush(); //1. 직접 호출
Product product2 =
em.createQuery("select p from Product P where p.price = 2000", Product.class)
.setFlushMode(FlushModeType.AUTO) // 2. setFlushMode() 설정
.getSingleResult();
플러시 모드를 COMMIT 으로 설정해놓아서 자동으로 플러시를 호출하지 않는다.
1번처럼 수동으로 플러시를 하거나
2번처럼 해당 쿼리에서만 AUTO 모드로 플러쉬모드를 적용하면 된다.
🤔 COMMIT 모드를 사용하는 이유
플러시가 너무 자주 일어나는 상황에 이 모드를 사용하면 쿼리할 때 발생하는 플러시 횟수를 줄여서 성능을 최적화할 수 있다. 예를 들면, 등록(), 쿼리() 가 여러 번 반복되는 경우우
- 만약 JPA를 거치지 않고 JDBC로 쿼리를 실행한다면 JPA는 JDBC가 실행한 쿼리를 인식할 수 없다. 따라서 AUTO 모드를 해도 플러시가 일어나지 않는다
- 이 때는 JDBC로 쿼리를 실행하기 전에
em.flush()
를 호출해서 영속성 컨텍스트의 내용을 데이터베이스에 동기화해주는 것이 안전하다.
6. 정리
- JPQL은 SQL을 추상화해서 특정 데이터베이스 기술에 의존하지 않는다.
- QueryDSL은 JPQL을 만들어주는 빌더 역할. 핵심은 JPQL
- QueryDSL을 사용하면 동적 쿼리를 편리하게 작성할 수 있다. + 직관적이고 편리함
- JPA도 네이티브 SQL을 제공하므로 직접 SQL 사용 가능하다. 하지만 특정 DB에 종속적인 SQL 사용 시 다른 DB로 변경이 쉽지 않다.
- 최대한 JPQL을 사용하고, 그래도 방법이 없다면 네이티브 SQL을 사용
- JPQL은 대용량 데이터 수정/삭제를 위해 벌크 연산을 지원한다.