📢 안내
해당 글은
- 김영한 님의 JPA 프로그래밍 [8장 프록시와 연관관계 관리, 10장 객체지향 쿼리 언어] 중 일부
를 발췌하여 정리한 것입니다.
JPQL(Java Persistence Query Language)
- JPQL은 객체지향 쿼리 언어이며, 테이블을 대상으로 쿼리하는 것이 아니라 엔티티 객체를 대상으로 쿼리
- JPQL은 SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다.
- JPQL은 결국 SQL로 변환된다.
기본 문법과 쿼리 API
SELECT문
SELECT m FROM Member AS m WHERE m.username = 'Hello'
- 대소문자 구분
- 엔티티와 속성은 대소문자를 구분한다.
- SELECT, FROM, AS, WHERE 같은 JPQL 키워드는 대소문자를 구분하지 않는다.
- 엔티티 이름
- JPQL에서 사용하는 Member는 엔티티 이름이다.
- 엔티티 이름은 @Entity(name="XXX")로 지정할 수 있으며, 엔티티 이름을 지정하지 않으면 클래스명을 기본값으로 사용한다.
- 별칭은 필수
- JPQL은 별칭을 필수로 사용해야한다.
- 단, AS m에서 AS는 생략할 수 있다.
TypeQuery, Query
작성한 JPQL을 실행하려면 쿼리 객체를 만들어야 한다.
- 반환 타입을 명확하게 지정할 수 있다 👉 TypeQuery 객체 사용
- 반환 타입을 명확하게 지정할 수 없다 👉 Query 객체 사용
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);
}
- 조회 대상이 Member 엔티티이므로, 조회 대상 타입이 명확하다.
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]);
}
- 조회 대상이 String 타입인 회원 이름과 Integer 타입인 나이이므로 조회 대상 타입이 명확하지 않다.
- 이처럼 SELECT 절에서 여러 엔티티나 컬럼을 선택할 때는 반환할 타입이 명확하지 않으므로 Query 객체를 사용해야 한다.
두 코드를 비교해 보면 타입을 변환할 필요가 없는 TypeQuery를 사용하는 것이 더 편리한 것을 알 수 있다.
결과 조회
- query.getResultList(): 결과를 리스트로 반환한다. 만약 결과가 없으면 빈 컬렉션을 반환한다.
- query.getSingleResult(): 결과가 정확히 하나일 때 사용한다.
- 결과가 없으면 javax.persistence.NoResultException 예외 발생
- 결과가 1개보다 많으면 javax.persistence.NonUniqueResultException 예외 발생
파라미터 바인딩
이름 기준 파라미터 Named parameters
String usernameParam = "User1";
TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class);
query.setParameter("username", usernameParam);
List<Member> resultList = query.getResultList();
List<Member> resultList =
em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class)
.setParameter("username", usernameParam);
.getResultList();
위치 기준 파라미터 Positional parameters
- 위치 기준 파라미터를 사용하려면 ? 다음에 위치 값을 주면 된다.
- 위치 값은 1부터 시작한다.
List<Member> resultList =
em.createQuery("SELECT m FROM Member m WHERE m.username = ?1", Member.class)
.setParameter(1, usernameParam);
.getResultList();
위치 기준 파라미터 방식보다는
이름 기준 파라미터 바인더 방식을 사용하는 것이 더 명확하다.
프로젝션(Projection)
- SELECT 절에 조회할 대상을 지정하는 것
- [SELECT {프로젝션 대상} FROM]으로 대상을 선택한다.
- 프로젝션 대상은 엔티티, 임베디드 타입, 스칼라 타입(숫자, 문자 등 기본 데이터 타입)이 있다.
엔티티 프로젝션
SELECT m FROM Member m // 회원
SELECT m.team FROM Member m // 팀
- 처음은 회원을 조회했고, 두 번째는 회원과 연관된 팀을 조회했는데 둘 다 엔티티를 프로젝션 대상으로 사용했다.
- 쉽게 생각하면 원하는 객체를 바로 조회한 것이다.
- 다만 컬럼을 하나하나 나열해서 조회해야 하는 SQL과 차이가 있다.
- 이렇게 조회한 엔티티는 영속성 컨텍스트에서 관리된다.
임베디드 타입 프로젝션
- 임베디드 타입은 조회의 시작점이 될 수 없다.
- 따라서, 임베디드 타입인 Address는 조회의 시작점이 될 수 없다.
- Order 엔티티가 시작점이므로, 엔티티를 통해서 임베디드 타입을 조회할 수 있다.
- 임베디드 타입은 엔티티 타입이 아닌 값 타입이므로, 영속성 컨텍스트에서 관리되지 않는다.
// JPQL
String query = "SELECT o.address FROM Order o";
List<Address> addresses = em.createQuery(query, Address.class).getResultList();
// SQL
SELECT order.city, order.street, order.zipcode FROM Orders order;
엔티티와 값 타입
해당 글은김영한 님의 JPA 프로그래밍 [9장 값 타입] 중 일부최범균 님의 도메인 주도 개발 시작하기 [1.6 엔티티와 밸류] 부분을 발췌하여 정리한 것입니다. 0. 들어가며Entity와 DTO*, VO*를 구분
progfrog.tistory.com
스칼라 타입 프로젝션
숫자, 문자, 날짜와 같은 기본 데이터 타입들을 스칼라 타입이라고 한다.
예를 들어, 전체 회원의 이름을 조회하려면 다음처럼 쿼리하면 된다.
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();
여러 값 조회
엔티티를 대상으로 조회하면 편리하겠지만, 꼭 데이터들만 선택해서 조회해야 할 때도 있다. 이때는 TypeQuery를 사용할 수 없고 대신에 Query를 사용해야 한다.
Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
List resultList = query.getResultList();
Iterator it = resultList.iterator();
while (it.hasNext()) {
Object[] row = (Object[])it.next();
String username = (String)row[0];
Integer age = (Integer)row[1];
}
제네릭에 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];
}
스칼라 타입 뿐만 아니라 엔티티 타입도 여러 값을 함께 조회할 수 있다.
(조회한 엔티티는 영속성 컨텍스트에서 관리된다!)
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]; // 스칼라
}
New 명령어
실제 애플리케이션 개발 시에는 Object[]를 직접 사용하지 않고, UserDTO처럼 의미 있는 객체로 변환해서 사용할 것이다.
List<Object[]> resultList =
em.createQuery("SELECT m.username, m.age FROM Member m")
.getResultList();
// 객체 변환 작업
List<UserDTO> userDTOs = new ArrayList<>();
for (Object[] row : resultList) {
UserDTO userDTO = new UserDTO((String)row[0], (Integer)row[1]);
userDTOs.add(userDTO);
}
return UserDTOs;
이런 객체 변환 작업을 지루하다! 이번에는 NEW 명령어를 사용해 보자.
TypedQuery<UserDTO> query =
em.createQuery("SELECT NEW jpabook.jpql.UserDTO(m.username, m.age) FROM Member m", UserDTO.class);
List<UserDTO> resultList = query.getResultList();
- SELECT 다음에 NEW 명령어를 사용하면 반환받을 클래스를 지정할 수 있는데, 이 클래스의 생성자에 JPQL 조회 결과를 넘겨줄 수 있다.
- NEW 명령어를 사용한 클래스로 TypeQuery를 사용할 수 있어서 지루한 객체 변환 작업을 줄일 수 있다.
NEW 명령어를 사용할 때는 다음 2가지를 주의해야 한다.
- 패키지 명을 포함한 전체 클래스 명을 입력해야 한다.
- 순서와 타입이 일치하는 생성자가 필요하다.
페이징 API
- 페이징 처리용 SQL을 작성하는 일은 지루하고 반복적이다.
- 더 큰 문제는 데이터베이스마다 페이징을 처리하는 SQL 문법이 다르다는 점이다.
- JPA는 페이징을 다음 두 API로 추상화했다.
- setFirstResult(int startPosition): 조회 시작 위치 (0부터 시작)
- setMaxResults(int maxResult): 조회할 데이터 수
TypedQuery<Member> query
= em.createQuery("SELECT m FROM Member m ORDER BY m.username DESC", Member.class);
query.setFirstResult(10);
query.setFirstResult(20);
query.getResultList();
- 11번째부터 시작해서 총 20건의 데이터(11번 ~ 30번)를 조회한다.
- 데이터베이스마다 다른 페이징 처리를 같은 API로 처리할 수 있는 것은 데이터베이스 방언(Dialect) 때문이다.
- 페이징 SQL을 더 최적화하고 싶다면, 네이티브 SQL을 직접 사용해야 한다.
집합과 정렬
집합은 집합 함수와 함께 통계 정보를 구할 때 사용한다.
집합 함수
- COUNT: 결과 수를 구한다, 반환 타입은 Long
- MAX, MIN: 최대, 최소값을 구한다. 문자, 숫자, 날짜 등에 사용한다.
- AVG: 평균값을 구한다. 숫자타입만 사용할 수 있으며, 반환 타입은 Double
- SUM: 합을 구한다. 숫자 타입만 사용할 수 있다.
- 반환 타입은 정수합 Long, 소수합 Double, BigInteger 합 BigInteger, BigDecimal 합 BigDecimal
집합 함수 사용 시 참고사항
- NULL 값은 무시하므로 통계에 잡히지 않는다(DISTINCT가 정의되어 있어도 무시된다).
- 만약 값이 없는데 SUM, AVG, MAX, MIN 함수를 사용하면 NULL 값이 되며, COUNT는 0이 된다.
- DISTINCT를 집합 함수 안에 사용해서 중복된 값을 제거하고 나서 집합을 구할 수 있다.
- ex) SELECT COUNT(DISTINCT m.age) FROM Member m
- DISTINCT를 COUNT에서 사용할 때 임베디드 타입은 지원하지 않는다.
GROUP BY, HAVING
GROUP BY는 통계 데이터를 구할 때 특정 그룹끼리 묶어준다.
다음은 팀 이름을 기준으로 그룹별로 묶어서 통계 데이터를 구한다.
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은 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;
ORDER BY
ORDER BY는 결과를 정렬할 때 사용한다.
다음은 나이를 기준으로 내림차순으로 정렬하고, 나이가 같으면 이름을 기준으로 오름차순으로 정렬한다.
SELECT m FROM Member m ORDER BY m.age DESC, m.username ASC
다음은 각 팀의 이름과 해당 팀의 나이의 개수를 세어서 그 개수에 따라 오름차순으로 정렬한다.
SELECT t.name, COUNT(m.age) AS cnt
FROM Member m LEFT JOIN m.team t
GROUP BY t.name
ORDER BY cnt
JPQL 조인
JPQL도 조인을 지원하는데 SQL 조인과 기능은 같고 문법만 약간 다르다.
[DB] 내부 조인(INNER JOIN)과 외부 조인(OUTER JOIN)
하나의 테이블에 원하는 데이터가 모두 있으면 좋겠지만, 두 개의 테이블을 엮어야 원하는 결과가 나오는 경우도 많다.조인을 쓰면 두 개의 테이블을 엮어서 원하는 데이터를 추출할 수 있다.내
progfrog.tistory.com
내부 조인
내부 조인은 INNER JOIN을 사용한다. 참고로 INNER는 생략할 수 있다.
String teamName = "팀A";
String query = "SELECT m FROM Member m JOIN m.team t WHERE t.name = :teamName";
List<Member> members = em.createQuery(query, Member.class)
.setParameter("teamName", teamName)
.getResultList();
- JPQL 조인의 가장 큰 특징은 연관 필드를 사용한다는 것이다.
- 여기서 m.team이 연관 필드인데, 연관 필드는 다른 엔티티와 연관관계를 가지기 위해 사용하는 필드를 말한다.
- Member m JOIN m.team t: 회원이 가지고 있는 연관 필드로 팀과 조인한다. 조인한 팀에는 t라는 별칭을 주었다.
JPQL 조인을 SQL 조인처럼 사용하면 문법 오류가 발생한다. 다음은 잘못된 예이다.
FROM Member m JOIN Team t // 잘못된 JPQL 조인, 오류!
'팀A' 소속인 회원을 나이 내림차순으로 정렬하고, 회원명과 팀명을 조회한다.
SELECT m.username, t.name
FROM Member m
JOIN m.team t
WHERE t.name = '팀A'
ORDER BY m.age DESC
만약 조인한 두 개의 엔티티를 조회하려면 다음과 같이 JPQL을 작성하면 된다.
SELECT m, t FROM Member m JOIN m.team t
서로 다른 타입의 두 엔티티를 조회했으므로 TypeQuery를 사용할 수 없고, 다음처럼 조회해야 한다.
List<Object[]> result = em.createQuery(query).getResultList();
for (Object[] row : result) {
Member member = (Member)row[0];
Team team = (Team)row[1];
}
외부 조인
- 외부 조인은 다음과 같이 사용한다.
- 외부 조인은 기능상 SQL의 외부 조인과 같다. OUTER는 생략 가능해서 보통 LEFT JOIN으로 사용한다.
SELECT m FROM Member m LEFT JOIN m.team t
참고) NULL 제약조건과 JPA 조인 전략 & 컬렉션 즉시 로딩은 항상 외부 조인을 사용
NULL 제약조건과 JPA 조인 전략
즉시 로딩을 사용하려면 @ManyToOne의 fetch 속성을 다음과 같이 지정할 수 있다.
@Entity
public class Member {
// ...
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
// ...
}
// 즉시 로딩 실행 코드
Member member = em.find(Member.class, "member1"); // 이때 팀도 함께 조회함
Team team = member.getTeam(); // 객체 그래프 탐색
이때 회원과 팀 두 테이블을 조회해야 하므로, 쿼리를 2번 실행할 것 같지만,
대부분의 JPA 구현체는 즉시 로딩을 최적화하기 위해서 가능하면 조인 쿼리를 사용한다.
이때 실행되는 SQL문을 유심히 살펴보면,
즉시 로딩 실행 SQL에서 JPA가 내부 조인(INNER JOIN)이 아닌 외부 조인(LEFT OUTER JOIN)을 사용한 것을 알 수 있다.
현재 회원 테이블에 TEAM_ID 외래 키는 NULL 값을 허용하고 있다.
따라서, 팀에 소속되지 않는 회원이 있을 가능성이 있다.
팀에 소속하지 않은 회원과 팀을 내부 조인하면 팀은 물론이고 회원 데이터도 조회할 수 없다.
JPA는 이런 상황을 고려해서 외부 조인을 사용한다.
하지만!! 외부 조인보다 내부 조인이 성능과 최적화에서 더 유리하다.
그럼...내부 조인을 사용하려면 어떻게 해야 할까?
외래 키에 NOT NULL 제약 조건을 설정하면 값이 있는 것을 보장하기 때문에, 이때는 내부 조인만 사용해도 된다.
다음 코드처럼 이 외래 키는 NULL을 허용하지 않는다고 알려주면, JPA는 외부 조인 대신에 내부 조인을 사용한다.
@Entity
public class Member {
// ...
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID", nullable = false)
private Team team;
// ...
}
- @JoinColumn(nullable = true): NULL 허용(기본값), 외부 조인 사용
- @JoinColumn(nullable = false): NULL 비허용, 내부 조인 사용
혹은 다음과 같이 작성해도 된다.
@Entity
public class Member {
// ...
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "TEAM_ID")
private Team team;
// ...
}
정리하면, JPA는 선택적 관계면 외부 조인을 사용하고 필수 관계면 내부 조인을 사용한다.
컬렉션 즉시 로딩은 항상 외부 조인을 사용
예를 들어 다대일 관계인 회원 테이블과 팀 테이블을 조인할 때,
회원 테이블의 외래 키에 NOT NULL 제약을 걸어두면 모든 회원은 팀에 소속되므로 항상 내부 조인을 사용해도 된다.
반대로 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때,
회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생한다.
데이터베이스 제약조건으로 이런 상황을 막을 수는 없다.
따라서 JPA는 일대다 관계를 즉시 로딩할 때, 항상 외부 조인을 사용한다.
FetchType.EAGER 설정과 조인 전략을 정리하면 다음과 같다.
- @ManyToOne, @OneToOne
- (optional = false): 내부 조인
- (optional = true): 외부 조인
- @OneToMany, @ManyToMany
- (optional = false): 외부 조인
- (optional = true): 외부 조인
컬렉션 조인
일대다 관계나 다대다 관계처럼 컬렉션을 사용하는 곳에 조인하는 것을 컬렉션 조인이라 한다.
- [회원 -> 팀] 으로의 조인은 다대일 조인이면서, 단일 값 연관 필드(m.team)를 사용한다.
- [팀 -> 회원] 은 반대로 일대다 조인이면서 컬렉션 값 연관 필드 (m.members)를 사용한다.
SELECT t, m FROM Team t LEFT JOIN t.members m
여기서 t LEFT JOIN t.members는 팀과 팀이 보유한 회원목록을 컬렉션 값 연관 필드로 외부 조인했다.
세타 조인
- WHERE 절을 사용해서 세타 조인을 할 수 있다.
- 세타 조인은 내부 조인만 지원한다.
- 세타 조인을 사용하면 예제처럼 전혀 관계없는 엔티티도 조인할 수 있다.
- 예제를 보면 전혀 관련이 없는 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
JOIN ON 절
- JPA 2.1부터 조인할 때 ON 절을 지원한다.
- ON 절을 사용하면 조인 대상을 필터링하고 조인할 수 있다.
- 내부 조인의 ON 절은 WHERE 절을 사용할 때와 결과가 같으므로, 보통 외부 조인에서 사용한다.
- 모든 회원을 조회하면서 회원과 연관된 팀도 조회하자. 이때 이름이 A인 팀만 조회하자.
// 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'
and t.NAME = 'A'로 조인 시점에 조인 대상을 필터링한다.
다음글
[JPA] JPQL ②
이전글 [JPA] JPQL ①📢 안내해당 글은김영한 님의 JPA 프로그래밍 [8장 프록시와 연관관계 관리, 10장 객체지향 쿼리 언어] 중 일부를 발췌하여 정리한 것입니다. JPQL(Java Persistence Query Language)JPQL
progfrog.tistory.com