BOOK 3 - 자바 ORM 표준 JPA 프로그래밍(10)-1

객체지향 쿼리 언어 - JPQL

  • JPA는 복잡한 검색 조건을 사용해서 엔티티 객체를 조회할 수 있는 다양한 쿼리 기술을 지원한다.

객체지향 쿼리 소개

  • 데이터베이스에서 SQL을 사용해 필요한 데이터를 최대한 걸러서 조회해야 한다.
  • ORM을 사용하면 데이터베이스 테이블이 아닌 엔티티 객체를 대상으로 개발하므로 검색도 테이블이 아닌 엔티티 객체를 대상으로 하는 방법이 필요하다.
    • 이런 문제를 해결하기 위해 JPQL이 만들어졌다.
  • JPQL은 다음과 같은 특징이 있다.
    • 테이블이 아닌 객체를 대상으로 검색하는 객체지향 쿼리이다.
    • SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다.
  • JPA는 JPQL을 분석한 다음 적절한 SQL을 만들어 데이터베이스를 조회한다.
    • 조회한 결과로 엔티티 객체를 생성해서 반환한다.

JPQL 소개

  • JPQL ( Java Persistence Query Language )은 엔티티 객체를 조회하는 객체지향 쿼리다.
  • JPQL은 SQL을 추상화해서 특정 데이터베이스에 의존하지 않으므로 데이터베이스 dialect만 변경하면 JPQL을 수정하지 않아도 데이터베이스를 변경할 수 있다.
String jpql = "select m from Member as m where m.username= 'kim'";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList(0);
  • 위 코드는 회원 이름이 ‘kim’ 인 엔티티를 조회한다.
  • JPQL에서 Member 는 엔티티 이름이고, m.username 은 테이블 컬럼명이 아니라 엔티티 객체의 필드명이다.
  • em.createQuery() 메소드에 실행할 JPQL과 반환할 엔티티의 클래스 타입을 넘겨주고 getResultList() 메소드를 호출한다.
    • JPA는 JPQL을 SQL로 변환해서 데이터베이스를 조회하고 결과로 Member 엔티티를 생성해서 반환한다.

Criteria 쿼리 소개

  • Criteria는 JPQL을 생성하는 빌더 클래스이다.
  • Criteria는 문자가 아닌 프로그래밍 코드로 JPQL을 작성할 수 있다는 장점이 있다.
  • 문자 기반 쿼리는 오타가 있어도 컴파일은 성공하고 어플리케이션을 배포할 수 있다.
    • 해당 쿼리가 런타임 시점에 오류가 발생하여 문제가 된다.
  • Criteria는 문자가 아닌 코드로 JPQL을 작성하기 때문에 컴파일 시점에 오류를 발견할 수 있다.
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(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();
  • m.get("username") 에서 필드명을 문자로 작성했다.
    • 이 부분도 문자가 아닌 코드로 작성하고 싶으면 MetaModel을 사용해야 한다.
      • 자바가 제공하는 Annotation Processor 기능을 사용하면 어노테이션을 분석해서 클래스를 생성할 수 있다.
      • JPA는 이 기능을 사용해서 Member 엔티티 클래스로부터 Criteria 전용 클래스를 생성하는데 이것을 메타 모델이라 한다.
  • Criteria는 코드로 쿼리를 작성할 수 있어서 동적 쿼리를 작성할 때 유용하다.

QueryDSL 소개

  • QueryDSL도 Criteria처럼 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도 어노테이션 프로세서를 사용해서 쿼리 전용 클래스를 만들어야 한다.
    • QMemberMember 엔티티 클래스를 기반으로 생성한 QueryDSL 쿼리 전용 클래스 이다.

네이티브 SQL 소개

  • JPA는 SQL을 직접 사용할 수 있는 기능을 지원하는데 이것을 네이티브 SQL 이라 한다.
  • 네이티브 SQL은 데이터베이스에 의존하는 SQL을 작성하기 때문에 데이터베이스를 변경하면 함께 수정해야 한다는 단점이 있다.
String sql = "SELECT ID,AGE,TEAM_ID,NAME FROM MEMBER WHERE NAME = 'kim'";
List<Member> resultList = em.createNativeQuery(sql, Member.class).getResultList();
  • 네이티브 SQL은 em.createNativeQuery() 를 사용하면 나머지는 JPQL과 동일하다.

JDBC 직접 사용, MyBatis 같은 SQL Mapper 프레임워크 사용

Session session = entityManager.unwrap(Session.class);
session.doWork(new Work() {
  @Override
  public void execute(Connection connection) throws SQLException {
    // work...
  }
})
  • JDBC 커넥션에 직접 접근하고 싶으면 JPA 구현체가 제공하는 Session 을 구해서 doWork() 를 호출하면 된다.
  • JDBC나 MyBatis를 JPA와 함께 사용하면 영속성 컨텍스트를 적절한 시점에 강제로 플러시해야 한다.
    • JDBC나 MyBatis 같은 SQL 매퍼는 JPA를 우회해서 데이터베이스를 접근한다.
    • JPA를 우회하는 SQL에 대해서 JPA가 전혀 인식하지 못하기 때문에 영속성 컨텍스트와 데이터베이스를 불일치 상태로 만들어 데이터 무결성을 훼손할 수 있다.
    • 이런 이슈를 해결하기 위해 JPA를 우회해서 SQL을 실행하기 직전에 영속성 컨텍스트를 수동으로 플러시해서 데이터베이스와 영속성 컨텍스트를 동기화해야 한다.

JPQL

  • 다음 도메인 모델을 가정해보자.

새 파일 2019-11-06 20 21 35_3

새 파일 2019-11-06 20 21 35_4

기본 문법과 쿼리 API

  • JPQL도 SQL과 비슷하게 SELECT , UPDATE , DELETE 문을 사용할 수 있다.

SELECT 문

SELECT m FROM Member AS m where m.username = 'Hello'
  • 대소문자 구분
    • 엔티티와 속성은 대소문자를 구분한다.
    • 반면에 SELECT , FROM , AS 같은 JPQL 키워드는 대소문자를 구분하지 않는다.
  • 엔티티 이름
    • JPQL에서 사용한 Member 는 클래스 명이 아니라 엔티티 명이다.
    • 엔티티 명은 @Entity(name="XXX") 로 지정할 수 있다.
      • 따로 지정하지 않으면 클래스명을 기본 값으로 사용한다.
  • alias는 필수
    • Member AS m 식으로 Member 엔티티에 m 이라는 별칭을 주었다.
      • as 는 생략 가능하다.
    • JPQL은 별칭을 필수로 사용해야 한다.

TypedQuery, Query

  • 작성한 JPQL을 실행하려면 쿼리 객체를 만들어야 한다.
    • 쿼리 객체는 TypedQueryQuery 가 있다.
  • 반환할 타입을 명확하게 지정할 수 있으면 TypedQuery 객체를 사용하고, 반환 타입을 명확하게 지정할 수 없으면 Query 객체를 사용하면 된다.
TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);
List<Member> resultList = query.getResultList();
  • em.createQuery() 의 두번째 파라미터에 반환할 타입을 지정하면 TypedQuery 를 반환한다.
Query query = em.createQuery("SELECT m.username, m.age from Member m");
List resultList = query.getResultList();

for(Object o : resultList) {
  Object[] result = (Obejct[]) o;
  System.out.println("username = " + result[0]);
  System.out.println("age = " + result[0]);
}
  • 위 코드처럼 여러 엔티티나 컬럼을 선택할 때는 조회 대상의 타입이 명확하지 않으므로 Query 객체를 사용한다.
  • Query 객체는 SELECT 절의 조회 대상이 둘 이상이면 Object[] 를 반환하고, 하나면 Object 를 반환한다.

결과 조회

  • 다음 메소드들을 호출하면 실제 쿼리를 실행해서 데이터베이스를 조회한다.
    • query.getResultList() : 결과를 반환한다. 결과가 없으면 빈 컬렉션을 반환한다.
    • query.getSingleResult() : 결과가 정확히 하나일 때 사용한다.
      • 결과가 없으면 NoResultException 예외가 발생한다.
      • 결과가 1개보다 많으면 NonUniqueResultException 예외가 발생한다.

파라미터 바인딩

  • JPQL은 이름 기준 파라미터 바인딩도 지원한다.

이름 기준 파라미터

  • 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();
  • JPQL API는 대부분 메소드 체인 방식으로 설계되어 있어서 다음과 같이 연속해서 작성할 수 있다.

    List<Member> members = em.createQuery("SELECT m FROM Member m where m.username = :username", Member.class)
      .setParameter("username", usernameParam)
      .getResultList();
    

위치 기준 파라미터

  • Positional paramters를 사용하려면 ? 다음에 위치 값을 지정해준다.
    • 위치 값은 1부터 시작한다.
List<Member> members = em.createQuery("SELCT m FROM Member m where m.username = ?1", Member.class)
  .setParameter(1, usernameParam)
  .getResultList();

파라미터 바인딩 방식을 사용하면 파라미터의 값이 달라도 같은 쿼리로 인식해서 JPA는 JPQL을 SQL로 파싱한 결과를 재사용할 수 있어 성능이 개선된다.

데이터베이스 내부에서도 실행한 SQL을 파싱해서 사용하는데 같은 쿼리는 파싱한 결과를 재사용할 수 있기 때문에 어플리케이션과 데이터베이스 모두 해당 쿼리의 파싱 결과를 재사용할 수 있어서 전체 성능이 향상된다.

프로젝션

  • SELECT 절에 조회할 대상을 지정하는 것을 projection 이라고 한다.
  • 프로젝션 대상은 엔티티, 임베디드 타입, 스칼라 타입이 있다.

엔티티 프로젝션

SELECT m FROM Member m
SELECT m.team FROM Member m
  • 회원과 회원과 연관된 팀을 조회했다.
    • 둘 다 엔티티를 프로젝션 대상으로 사용했다.
  • SQL 처럼 컬럼을 하나하나 나열해서 조회할 필요가 없다.
  • 조회된 엔티티는 영속성 컨텍스트에서 관리된다.

임베디드 타입 프로젝션

  • JPQL에서 임베디드 타입은 엔티티와 거의 비슷하게 사용된다.

  • 임베디드 타입은 조회의 시작점이 될 수 없다는 제약이 있다.

    • 즉, 다음과 같이 쿼리를 작성할 수 없다.

      String query = "SELECT a FROM Address a";
      
  • Address 값 타입이 속해있는 Order 엔티티가 시작점이 되어야 한다.

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
  • 임베디드 타입은 엔티티 타입이 아닌 값 타입이므로 위와 같이 직접 조회한 임베디드 타입은 영속성 컨텍스트에서 관리되지 않는다.

스칼라 타입 프로젝션

  • 숫자, 문자, 날짜와 같은 기본 데이터 타입들을 스칼라 타입이라 한다.
List<String> usernames = em.createQuery("SELECT username FROM Member m", String.class).getResultList();
  • 중복 데이터를 제거하려면 DISTINCT 를 사용한다.

    SELECT DISTINCT username FROM Member m
    

여러 값 조회

  • 엔티티 단위가 아니라 필요한 데이터들만 선택해서 조회해야 할 때도 있다.
    • Query 객체를 사용하여 조회할 수 있다.
Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
List resultList = query.getResultList();

Iterator iterator = resultList.iterator();
while(iterator.hasNext()) {
  Object[] row = (Object[]) iterator.next();
  String username = (String) row[0];
  Integer age = (Integer) row[1];
}
  • generic에 Object[] 를 사용하면 좀 더 간결하게 작성할 수 있다.

  • 스칼라 타입뿐만 아니라 엔티티 타입도 여러 값을 함께 조회할 수 있다.

    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 명령어

  • usernameage 두 필드만 프로젝션하는 경우에 UserDTO 라는 유의미한 객체로 결과를 반환받고 싶다면 new 명령어를 사용한다.
TypedQuery<UserDTO> query = em.CreateQuery("SELECT new com.jpql.UserDTO(m.username, m.age) FROM Member m", UserDTO.class);
List<UserDTO> resultList = query.getResultList();
  • SELECT 다음에 new 명령어를 사용하면 반환받을 클래스를 지정할 수 있는데, 이 클래스의 생성자에 JPQL 조회 결과를 넘겨줄 수 있다.
    • 해당 클래스에 순서와 타입이 일치하는 생성자가 선언되어 있어야 한다.
    • 클래스를 지정할 때 패키지명을 포함한 전체 클래스명을 입력해야 한다.

페이징 API

  • 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.setMaxResults(20);
query.getResultList();
  • 위 코드를 실행하면 11번째 데이터부터 20건의 데이터를 조회한다.

  • MySQL 데이터베이스에서 실행되는 쿼리는 다음과 같다. (데이터베이스마다 페이징을 처리하는 SQL 문법이 다르다.)

    SELECT 
    	M.ID AS ID,
    	M.AGE AS AGE,
    	M.TEAM_ID AS TEAM_ID,
    	M.NAME AS NAME
    FROM MEMBER M
    ORDER BY M.NAME DESC
    LIMIT ?, ?
    

집합과 정렬

  • 집합은 집합함수와 함께 통계 정보를 구할 때 사용한다.

집합 함수

함수 설명 반환 타입
COUNT 결과 수를 구한다. Long
MAX , MIN 최대, 최소 값을 구한다. 문자, 숫자, 날짜 등에 사용한다. -
AVG 평균값을 구한다. 숫자 타입만 사용할 수 있다. Double
SUM 합을 구한다. 숫자 타입만 사용할 수 있다. Long , Double , BigInteger , BigDecimal

집합 함수 사용시 참고사항

  • NULL 값은 무시하므로 통계에 잡히지 않는다.
  • DISTINCT 를 집합 함수 안에 사용해서 중복된 값을 제거하고 나서 집합을 구할 수 있다.
  • DISTINCTCOUNT 에서 사용할 때 임베디드 타입은 지원하지 않는다.

GROUP BY, HAVING

  • GROUP BY 는 통계 데이터를 구할 때 특정 그룹끼리 묶어준다.
  • HAVINGGROUP BY 와 함께 사용하는데 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 AVG(m.age) >= 10

정렬(ORDER BY)

  • ORDER BY 는 결과를 정렬할 때 사용한다.
    • ASC : 오름차순
    • DESC : 내림차순
select m from Member m order by m.age DESC, m.username ASC

JPQL 조인

  • JPQL 조인은 SQL 조인과 기능은 같고 문법만 약간 다르다.

  • JPQL은 JOIN 명령어 다음에 조인할 객체의 연관 필드를 사용한다.

내부 조인

  • 내부 조인은 INNER JOIN 을 사용한다.
String teamName = "teamA";
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();
  • 위 코드를 실행했을 때 생성되는 내부 조인 SQL은 다음과 같다.

    SELECT 
    	M.ID AS ID,
    	M.AGE AS AGE,
    	M.TEAM_ID AS TEAM_ID,
    	M.NAME AS NAME
    FROM MEMBER M INNER JOIN TEAM T
    ON M.TEAM_ID = T.ID
    WHERE T.NAME = ?
    
  • JPQL 조인은 연관 필드를 사용한다.

    • 연관 필드는 다른 엔티티와 연관관계를 가지기 위해 사용하는 필드를 의미한다.
    • 여기서는 m.team 이 연관 필드이다.
  • JPQL 조인을 SQL 조인처럼 사용하면 문법 오류가 발생한다.

    FROM Member m JOIN Team t // 잘못된 JPQL 조인 => 오류 발생
    

외부 조인

SELECT m
FROM Member m LEFT [OUTER] JOIN m.team t
  • 위 JPQL 쿼리를 실행하면 데이터베이스에서 다음 SQL이 실행된다.

    SELECT
    	M.ID AS ID,
    	M.AGE AS AGE,
    	M.TEAM_ID AS TEAM_ID,
    	M.NAME AS NAME
    FROM MEMBER M LEFT OUTER JOIN TEAM T
    ON M.TEAM_ID = T.ID
    WHERE T.NAME = ?
    

컬렉션 조인

  • 일대다 관계나 다대다 관계처럼 컬렉션을 사용하는 곳에 조인하는 것을 컬렉션 조인이라 한다.
    • 예를 들어 팀 -> 회원 의 경우 일대다 조인으로 컬렉션 값 연관 필드를 사용한다.
SELECT t, m FROM Team t LEFT JOIN t.members m
  • 팀과 팀이 보유한 회원 목록을 컬렉션 값 연관필드(t.members)로 외부 조인했다.

세타 조인

  • WHERE 절을 사용해서 세타 조인을 할 수 있다.
  • 세타 조인을 사용하면 전혀 관계없는 엔티티도 조회할 수 있다.
// 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 절

  • ON 절을 사용하면 조인 대상을 필터링하고 조인할 수 있다.
  • 내부조인의 ON 절은 WHERE 절을 사용할 때와 결과가 동일하므로 보통 외부조인에서만 사용한다.
// 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'

페치 조인

  • 페치 조인은 SQL에서 다루는 조인의 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능이다.
  • 연관된 엔티티나 컬렉션을 한번에 같이 조회하는 기능으로, JOIN FETCH 명령어로 사용할 수 있다.

엔티티 페치 조인

  • 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회하는 경우를 생각해보자.
select m from Member m join fetch m.team
  • 회원(m)과 연관된 팀(m.team) 을 함께 조회한다.

  • 페치 조인은 alias를 사용할 수 없다.

  • 실행되는 SQL은 다음과 같다.

    SELECT M.*, T.*
    FROM MEMBER M
    INNER JOIN TEAM T ON M.TEAM_ID = T.ID
    

image

  • 엔티티 페치 조인 JPQL에서 select m 으로 회원 엔티티만 프로젝션했지만 실행된 SQL을 보면 M.*, T.* 로 회원과 연관된 팀도 함께 조회된 것을 확인할 수 있다.
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 = " + member.getUsername() + ", teamname = " + member.getTeam().getName());
}
  • 위 코드에서 회원 엔티티와 함께 팀 엔티티도 페치 조인했기 때문에 member.getTeam().getName() 코드를 실행할 때 지연 로딩이 발생하지 않고 실제 엔티티에서 값을 조회한다.
    • Team 엔티티에 지연 로딩 설정을 했어도 페치 조인을 실행하면 쿼리 실행시에 연관된 팀 엔티티도 함께 데이터를 조회한다.

컬렉션 페치 조인

  • 일대다 관계에서 컬렉션을 페치 조인할 수도 있다.
select t from Team t join fetch t.members where t.name = 'teamA'
  • 팀(t)을 조회하면서 연관된 회원 컬렉션(t.members)도 함께 조회한다.
  • 실행되는 SQL은 다음과 같다.
SELECT T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = 'teamA'

image

  • 컬렉션을 페치 조인한 JPQL에서 select t 로 팀 엔티티만 프로젝션했지만 실행된 SQL을 보면 T.*, M.* 로 회원 엔티티도 함께 조회된 것을 확인할 수 있다.
  • 일대다 관계의 페치 조인의 경우 위와 같이 연관된 테이블과 INNER JOIN을 하면서 하나의 PK가 중복되어 결과 테이블에 조회될 수 있다.
    • 즉, 팀A 라는 이름의 팀은 TEAM 테이블에 하나 존재하지만, 연관된 회원 데이터가 여러개인 경우 여러개의 결과가 나오게 된다.
    • 영속성 컨텍스트는 식별자로 엔티티를 구분하기 때문에 팀 엔티티는 하나의 인스턴스로 생성되지만, 결과 리스트에는 같은 팀 인스턴스가 두개 포함되는 것이다.
String jpql = "select t from Team t join fetch t.members where t.name = 'teamA'";
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 = teamA, team = Team@0x100
    -> username = member1, member = Member@0x200
    -> username = member2, member = Member@0x300
    teamname = teamA, team = Team@0x100
    -> username = member1, member = Member@0x200
    -> username = member2, member = Member@0x300
    
    • 같은 teamA 가 2건 조회된 것을 확인할 수 있다.

페치 조인과 DISTINCT

  • JPQL의 DISTINCT 명령어는 SQL에 DISTINCT 를 추가하는 것은 물론이고 어플리케이션에서 한 번 더 중복을 제거한다.
  • 바로 직전에 컬렉션 페치 조인에서 teamA 가 중복으로 조회되는 문제를 해결하기 위해 DISTINCT 를 추가할 수 있다.
select distinct t from Team t join fetch t.members where t.name = 'teamA'
  • DISTINCT 를 사용하면 SQL에 SELECT DISTINCT 가 추가되지만 위와 같은 경우는 각 로우의 데이터가 다르므로 효과가 없다.
  • 다음으로 어플리케이션에서 distinct 명령어를 보고 중복된 데이터를 걸러낸다.
    • select distinct t 의 의미는 팀 엔티티의 중복을 제거하라는 의미이다.
    • 따라서 결과 리스트에서 중복된 teamA 결과는 지우고 하나의 결과만 나오게 된다.

페치 조인과 일반 조인의 차이

  • 페치 조인을 사용하지 않고 일반 조인을 사용하면 어떻게 될까?
select t from Team t join t.members m where t.name = 'teamA'
  • 실행되는 SQL은 다음과 같다.
SELECT T.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = 'teamA'
  • 실행된 SQL의 SELECT 절을 보면 팀만 조회하고 회원은 조회하지 않는다.
  • JPQL은 결과를 반환할 때 연관관계까지 고려하지 않는다.
    • 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다.
  • 만약 회원 엔티티에 지연 로딩을 설정했다면 프록시나 컬렉션 래퍼를 반환하고, 즉시 로딩을 설정했다면 회원 엔티티를 조회하기 위해 쿼리를 한번 더 실행한다.
  • 반면에 페치 조인을 사용하면 연관된 엔티티도 함께 조회하기 때문에 쿼리를 한번 더 실행하는 일이 없다.

페치 조인의 특징과 한계

  • 페치 조인을 사용하면 SQL 한번으로 연관된 엔티티들을 함께 조회할 수 있어서 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.

  • 페치 조인은 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선하기 때문에 엔티티를 지연 로딩으로 설정해도 페치 조인을 사용하면 함께 조회한다.

    @OneToMany(fetch=FetchType.LAZY) // 글로벌 로딩 전략
    
  • 글로벌 로딩 전략을 즉시 로딩으로 설정하면 어플리케이션 전체에서 항상 즉시 로딩이 일어난다.

    • 사용하지 않는 연관 엔티티까지 자주 로딩하므로 성능에 악영향을 미칠 수 있다.
  • 글로벌 로딩 전략은 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다.

한계
  • 페치 조인 대상에는 alias를 줄 수 없다.
    • 따라서 SELECT , WHERE , 서브 쿼리에 페치 조인 대상을 사용할 수 없다.
  • 둘 이상의 컬렉션을 페치할 수 없다.
  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
    • 컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.

경로 표현식

  • 경로 표현식은 . 을 찍어 객체 그래프를 탐색하는 것이다.
  • m.username , m.team , t.name 등이 경로 표현식을 사용한 예이다.

경로 표현식의 용어 정리

  • 상태 필드 : 단순히 값을 저장하기 위한 필드
  • 연관 필드 : 연관관계를 위한 필드, 임베디드 타입 포함
    • 단일 값 연관 필드 : @ManyToOne , @OneToOne , 대상이 엔티티
    • 컬렉션 값 연관 필드 : @OneToMany , @ManyToMany , 대상이 컬렉션

경로 표현식과 특징

  • 상태 필드 경로 : 경로 탐색의 끝이다. 더이상 탐색할 수 없다.
  • 단일 값 연관 필드 경로 : 묵시적으로 내부 조인이 일어난다. 계속 탐색할 수 있다.
  • 컬렉션 값 연관 필드 경로 : 묵시적으로 내부 조인이 일어난다. 더이상 탐색할 수 없다.
    • 단, FROM 절에서 조인을 통해 alias를 얻으면 별칭으로 탐색할 수 있다.
단일 값 연관 필드 경로 탐색
select o.member from Order o
  • 이 JPQL을 실행한 SQL은 다음과 같다.
SELECT m.*
FROM Orders o
INNER JOIN Member m ON o.MEMBER_ID = m.ID
  • 단일 값 연관 필드로 경로 탐색을 하면 SQL에서 내부 조인이 일어나는데 이것을 묵시적 조인이라 한다.
    • 묵시적 조인은 모두 내부 조인이다.
  • 좀 더 복잡한 경우를 생각해보자.
select o.member.team
from Order o
where o.product.name = 'productA' and o.address.city = 'JINJU'
  • 실행되는 SQL은 다음과 같다.
SELECT t.*
FROM Orders 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 = 'JINJU'
  • 실행된 SQL을 보면 총 3번의 묵시적 조인이 발생했다.
  • o.address 같이 임베디드 타입에 접근하는 것도 단일 값 연관 경로 탐색이지만 ORDERS 테이블에 이미 포함되어 있으므로 조인은 일어나지 않는다.
컬렉션 값 연관 경로 탐색
  • t.members.username 처럼 컬렉션에서 경로 탐색을 시작하는 것은 허용되지 않는다.

  • 컬렉션에서 경로 탐색을 하고 싶으면 조인을 사용해서 새로운 별칭을 획득해야 한다.

    select m.username from Team t join t.members m
    
  • 컬렉션은 컬렉션의 크기를 구할 수 있는 size 라는 특별한 기능을 사용할 수 있다.

    • SQL에서 COUNT 라는 함수로 적절히 변환된다.
    select t.members.size from Team t
    

경로 탐색을 사용한 묵시적 조인 시 주의사항

  • 항상 내부 조인이다.
  • 컬렉션은 경로 탐색의 끝이다. 컬렉션에서 경로 탐색을 하려면 명시적으로 조인해서 별칭을 얻어야 한다.

서브 쿼리

  • JPQL도 SQL처럼 서브 쿼리를 지원한다.
  • 서브 쿼리는 WHERE , HAVING 절에만 사용할 수 있고, SELECT , FROM 절에서는 사용할 수 없다.

서브 쿼리 사용 예제

  • 나이가 평균보다 많은 회원을 찾는다.

    select m from Member m
    where m.age > (select avg(m2.age) from Member m2)
    
  • 한 건이상 주문한 고객을 찾는다.

    select m from Member m
    where (select count(o) from Order o where m = o.member) > 0
    
    • 이 쿼리는 컬렉션 값 연관 필드의 size 기능을 사용해도 같은 결과를 얻을 수 있다.

      select m from Member m where m.orders.size > 0
      

서브 쿼리 함수

  • [NOT] EXISTS (subquery)

    • 서브 쿼리에 결과가 존재하면 참 (NOT은 반대)
    select m from Member m
    where exists (select t from m.team t where t.name = 'teamA')
    
  • {ALL ANY SOME} (subquery)
    • 비교 연산자와 같이 사용한다.
    • ALL : 조건을 모두 만족하면 참이다.
    • ANY / SOME : 조건을 하나라도 만족하면 참이다.
    select o from Oerder o
    where o.orderAmount > ALL (select p.stockAmount from Product p)
    
    select m from Member m
    where m.team = ANY (select t from Team t)
    
  • [NOT] IN (subquery)

    • 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참이다.
    select t from Team t
    where t IN (select t2 from Team t2 JOIN t2.members m2 where m2.age >= 20)
    

조건식

타입 표현

종류 설명 예제
문자 작은 따옴표 사이에 표현
작은 따옴표를 표현하고 싶으면 작은 따옴표 연속 두개(‘’) 사용
‘HELLO’
‘She’’s’
숫자 L (Long 타입)
D (Double 타입)
F (Float 타입)
10L
10D
10F
날짜 DATE {d ‘yyyy-mm-dd’}
TIME {t ‘hh-mm-ss’}
DATETIME {ts ‘yyyy-mm-dd hh:mm:ss.f’}
{d ‘2020-10-31’}
{t ‘14-31-20’}
{ts ‘2020-10-31 14:31:20.123’}
Boolean TRUE / FALSE  
Enum 패키지명을 포함한 전체 클래스명을 사용 com.jpa.MemberEnum.Admin
엔티티 타입 엔티티 타입을 표현한다. 주로 상속과 관련해서 사용 TYPE(m) = Member

연산자 우선 순위

  1. 경로 탐색 연산(.)
  2. 수학 연산 : + , - , * , /
  3. 비교 연산 : = , > , >= , < , <= , [NOT] BETWEEN , [NOT] LIKE , [NOT] IN , IS [NOT] NULL , IS [NOT] EMPTY , [NOT] MEMBER [OF] , [NOT] EXISTS
  4. 논리 연산 : NOT , AND , OR
    • AND : 둘 다 만족하면 참
    • NOT : 조건식의 결과 반대
    • OR : 둘 중 하나만 만족해도 참

BETWEEN , IN , LIKE , NULL 비교

  • BETWEEN

    • X [NOT] BETWEEN A AND B
    • X는 A~B 사이의 값이면 참 (A, B 값 포함)
    select m from Member m where m.age between 10 and 20
    
  • IN

    • X [NOT] IN
    • X와 같은 값이 하나라도 있으면 참. 서브 쿼리를 사용할 수 있다.
    select m from Member m where m.username in ('회원1', '회원2')
    
  • LIKE

    • 문자표현식 [NOT] LIKE 패턴값 [ESCAPE 이스케이프문자]
    • 문자표현식과 패턴식을 비교한다.

    • % : 아무 값들이 입력되어도 된다.(공백 포함)
    • _ : 아무 값이 입력되어도 되지만 값이 있어야 한다.(한 글자)
    // 중간에 '원'이라는 단어가 들어간 회원
    select m from Member m where m.username like '%원%'
      
    // 처음에 회원이라는 단어가 포함
    where m.username like '회원%'
      
    // 회원3
    where m.username like '__3'
    
  • NULL 비교

    • {단일값 경로 입력 파라미터} IS [NOT] NULL
    • NULL인지 비교한다.
      • NULL은 = 으로 비교하면 안되고 IS NULL 을 사용해야 한다.

컬렉션 식

  • 컬렉션은 컬렉션 식만 사용할 수 있다.

  • EMPTY

    • {컬렉션 값 연관 필드 경로} IS [NOT] EMPTY
    • 컬렉션에 값이 비었으면 참
    select m from Member m where m.orders is not empty
    
    • 실행되는 SQL

      SELECT m.* FROM MEMBER m
      WHERE EXISTS (
      	SELECT o.ID
        FROM ORDERS o
        WHERE m.ID = o.MEMBER_ID
      )
      
  • MEMBER

    • {엔티티나 값} [NOT] MEMBER [OF] {컬렉션 값 연관 필드 경로}
    • 엔티티나 값이 컬렉션에 포함되어 있으면 참
    select t from Team t
    where :memberParam member of t.members
    

스칼라 식

  • 스칼라는 숫자, 문자, 날짜, case, 엔티티 타입(타입 정보) 같은 가장 기본적인 타입들을 말한다.

  • 수학 식 : + , - , * , /

  • 문자함수

    함수 설명 예제
    CONCAT(문자1, 문자2) 문자를 합한다. CONCAT(‘A’, ‘B’) = ‘AB’
    SUBSTRING(문자, 위치, [길이]) 위치부터 시작해 길이만큼 문자를 자른다.
    길이 값이 없으면 나머지 전체 길이를 뜻한다.
    SUBSTRING(‘ABCDEF’, 2, 3) = ‘BCD’
    TRIM([ [ LEADING | TRAILING | BOTH ] [트림 문자] FROM ] 문자) LEADING : 왼쪽만
    TRAILING : 오른쪽만
    BOTH : 양쪽 다
    트림 문자를 제거한다.
    트림 문자의 기본값은 공백이다.
    TRIM(‘ ABC ‘) = ‘ABC’
    LOWER(문자) 소문자로 변경 LOWER(‘ABC’) = abc
    UPPER(문자) 대문자로 변경 UPPER(‘abc’) = ABC
    LENGTH(문자) 문자 길이 LENGTH(‘ABC’) = 3
    LOCATE(찾을 문자, 원본 문자, [검색 시작 위치]) 검색위치부터 문자를 검색하여 위치를 반환
    1부터 시작, 못 찾으면 0 반환
    LOCATE(‘DE’, ‘ABCDEF’) = 4
  • 수학 함수

    함수 설명 예제
    ABS(수학식) 절대값을 구한다. ABS(-10) = 10
    SQRT(수학식) 제곱근을 구한다. SQRT(4) = 2.0
    MOD(수학식, 나눌 수) 나머지를 구한다. MOD(4, 3) = 1
    SIZE(컬렉션 값 연관 필드 경로식) 컬렉션의 크기를 구한다. SIZE(t.members)
    INDEX(별칭) LIST 타입 컬렉션의 위치값을 구한다.
    (@OrderColumn 을 사용하는 LIST 타입만 가능)
    t.members m where INDEX(m) > 3
  • 날짜 함수

    • CURRENT_DATE : 현재 날짜
    • CURRENT_TIME : 현재 시간
    • CURRENT_TIMESTAMP : 현재 날짜 시간
    select e from Event e where e.endDate < CURRENT_DATE
    
    • 하이버네이트는 날짜 타입에서 년, 월, 일, 시간, 분, 초 값을 구하는 기능을 지원한다.

      • YEAR , MONTH , DAY, HOUR , MINUTE , SECOND
      select year(CURRENT_TIMESTAMP), month(CURRENT_TIMESTAMP), day(CURRENT_TIMESTAMP)
      from Member
      

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 'teamA' then '인센티브 110%'
    		when 'teamB' then '인센티브 120%'
    		else '인센티브 105%'
    	end
    from Team t
    
  • COALESCE

    • 스칼라식을 차례대로 조회해서 null이 아니면 반환한다.
    select coalesce(m.username, '이름 없는 회원') from Member m
    
  • NULLIF

    • 두 값이 같으면 null 을 반환하고, 다르면 첫번째 값을 반환한다.
    select NULLIF(m.username, '관리자') from Member m
    

다형성 쿼리

  • JPQL로 부모 엔티티를 조회하면 그 자식 엔티티도 함께 조회한다.
@Entity
@Inheritance(strategy=InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="DTYPE")
public abstract class Item { ... }

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
  ...
  private String author;
}

// Album, Movie
  • 다음과 같이 조회하면 Item 의 자식 엔티티도 함께 조회한다.

    List resultList = em.createQuery("select i from Item i").getResultList();
    
    • SINGLE_TABLE 전략을 사용할 때 실행되는 SQL은 다음과 같다.

      SELECT * FROM ITEM
      
    • JOINED 전략을 사용할 때 실행되는 SQL은 다음과 같다.

      SELECT i.ITEM_ID, i.DTYPE, i.name, i.price, i.stockQuantity,
      			 b.author, b.isbn,
      			 a.artist, a.etc,
      			 m.actor, m.director
      FROM ITEM i
      LEFT OUTER JOIN BOOK b ON i.ITEM_ID = b.ITEM_ID
      LEFT OUTER JOIN ALBUM a ON i.ITEM_ID = a.ITEM_ID
      LEFT OUTER JOIN MOVIE m ON i.ITEM_ID = m.ITEM_ID
      

TYPE

  • TYPE 은 엔티티의 상속 구조에서 조회 대상을 특정 자식 타입으로 한정할 때 주로 사용한다.
select i from Item i where type(i) IN (Book, Movie)
  • 데이터베이스에서 실행되는 SQL은 다음과 같다.
SELECT i.* FROM ITEM i WHERE i.DTYPE in ('B', 'M')

TREAT

  • TREAT 는 자바의 타입 캐스팅과 비슷하다.
    • 상속 구조에서 부모 타입을 특정 자식 타입으로 변환하여 다룰 때 사용한다.
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'

기타 정리

  • enum= 비교 연산만 지원한다.
  • 임베디드 타입은 비교를 지원하지 않는다.

NULL 정의

  • 조건을 만족하는 데이터가 하나도 없으면 NULL이다.
  • NULL은 알수 없는 값이다.
  • NULL == NULL 은 알 수 없는 값이다.
  • NULL is NULL 은 참이다.

엔티티 직접 사용

기본 키 값

  • Entity instance는 참조 값으로 식별하고, Table row는 기본 키 값으로 식별한다.
    • JPQL에서 엔티티 객체를 직접 사용하면 SQL에서는 해당 엔티티의 기본 키 값을 사용한다.
select count(m.id) from Member m	// 엔티티의 아이디를 사용
select count(m) from Member m			// 엔티티를 직접 사용
  • 위 두개의 JPQL을 실행한 SQL은 동일하다.
SELECT COUNT(m.ID) AS cnt FROM MEMBER m
  • 엔티티를 파라미터로 직접 받아도 역시 SQL에서 기본 키 값으로 변환된다.

    String jpql = "select m from Member m where m = :member";
    List resultList = em.createQuery(jpql).setParameter("member", member)
      .getResultList();
    
    SELECT m.* FROM MEMBER m WHERE m.ID = ?
    

외래 키 값

Team team = em.find(Team.class, 1L);

String jpql = "select m from Member m where m.team = :team";
List resultList = em.createQuery(jpql).setParameter("team", team).getResultList();
  • m.team 은 현재 TEAM_ID 라는 외래 키와 매핑되어 있기 때문에 다음과 같은 SQL이 실행된다.
SELECT m.* FROM MEMBER m WHERE m.TEAM_ID = ?

Named 쿼리 : 정적 쿼리

  • JPQL 쿼리는 크게 동적 쿼리와 정적 쿼리로 나눌 수 있다.
  • 동적 쿼리
    • em.createQuery("select ...") 처럼 JPQL을 문자로 완성해서 직접 넘기는 것을 동적 쿼리라 한다.
    • 런타임에 특정 조건에 따라 JPQL을 동적으로 구성한다.
  • 정적 쿼리
    • 미리 정의한 쿼리에 이름을 부여해서 필요할 때 사용하는 것을 Named 쿼리라고 한다.
    • Named 쿼리는 한 번 정의하면 변경할 수 없는 정적 쿼리다.
    • @NamedQuery 를 사용해서 작성할 수 있다.
  • Named 쿼리는 어플리케이션 로딩 시점에 JPQL 문법을 체크하고 미리 파싱해둔다.
    • 오류를 빨리 확인할 수 있고, 사용하는 시점에는 파싱된 결과를 재사용하므로 성능상 이점도 있다.
  • Named 쿼리는 정적 SQL이 생성되므로 데이터베이스 조회 성능 최적화도 된다.

어노테이션 사용하여 정의

@Entity
@NamedQuery(
	name = "Member.findByUsername",
  query = "select m from Member m where m.username = :username")
public class Member {
  ...
}
  • name : 쿼리 이름 지정
  • query : 사용할 정적 쿼리 지정
List<member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
  .setParameter("username", "member1")
  .getResultList();
  • Named 쿼리를 사용할 때는 em.createNamedQuery() 메서드에 Named 쿼리 이름을 파라미터로 넘겨주면 된다.
  • 2개 이상의 Named 쿼리를 지정할 때는 @NamedQueries 를 사용하면 된다.