JPA(Java Persistence API)

[JPA] Part 15. JPQL

cwchoiit 2023. 10. 28. 12:35
728x90
반응형
SMALL
728x90
반응형
SMALL

JPQL(Java Persistence Query Language)는 데이터베이스의 SQL과 유사하나 객체지향 쿼리 언어이며 그렇기에 테이블을 대상으로 쿼리하는 게 아니라 엔티티 객체를 대상으로 쿼리한다는 차이점이 있다. JPQL은 SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다. 이 말은 데이터베이스마다 다른 방언(MySQL은 LIMIT, Oracle은 ROWNUM 같은)에 상관없이 동작한다는 의미이다.

 

그러나 결국, DB는 SQL만을 받기 때문에 JPQL은 결국에는 SQL로 변환된다. 

이 JPQL을 잘 이해하고 사용할 줄 알아야 기본적인 쿼리문을 사용하는데 문제가 없고 복잡한 쿼리를 처리해주는 QueryDSL도 편하게 사용할 수 있다. 그래서 JPQL을 잘 사용할 줄 알아야하는 것 같다.

 

JPQL 문법

JPQL 문법이라고 SQL과 다를 것 없다. 다만 지켜야 할 규칙이 있다.

select m from Member as m where m.username = "member"
  • 엔티티와 속성은 대소문자 구분을 한다 (Member, username)
  • JPQL 키워드는 대소문자 구분을 하지 않는다 (SELECT, FROM, where)
  • 엔티티 이름을 사용한다 (테이블 이름이 아니다)
  • 별칭은 필수(m), as는 생략 가능

 

TypedQuery와 Query 타입이 있는데 TypedQuery는 반환되는 데이터의 타입이 명확할 때를 말하고 Query는 명확하지 않을 때이다.

크게 중요한 내용은 아닌데 알아는 둬야 하니까.

TypedQuery<Member> members = em.createQuery("select m from Member m", Member.class);

이렇게 createQuery()에 두번째 인자로 타입을 지정할 수 있는데 아무 타입이나 지정할 수 있는건 아니고 거의 대부분은 엔티티를 지정한다. 이렇게 타입을 명확히 명시를 한 상태에서의 반환타입이 TypedQuery가 된다.

 

그러나 아래와 같이 id는 Long, username은 String인 데이터를 받아올 때는 타입을 명시할 수 없으므로 반환되는 데이터 타입이 Query가 된다. 이런 차이가 있다.

Query members = em.createQuery("select m.id, m.username from Member m");

 

 

그러나, 저런식으로 사용하지 않고 체인으로 사용하는게 일반적이고 그 예시는 다음과 같다.

List<Member> members = em.createQuery("select m from Member m", Member.class)
                                    .getResultList();
Member singleMember = em.createQuery("select m from Member m where m.id=1", Member.class)
                                    .getSingleResult();

이렇게 한번에 getResultList() 또는 getSingleResult()로 데이터를 받아온다. 메소드 명만 봐도 알겠지만 getResultList()는 복수의 데이터를 가져오는 방식이고 getSingleResult()는 단일값을 가져오는 방식이다.

 

그러나 주의할 점이 있다.

getResultList()는 데이터가 없는 경우 빈 리스트를 반환하기 때문에 큰 문제가 되지 않는다. 그러나 getSingleResult()는 무조건 딱 더도 덜도 말고 딱! 하나만 있어야 한다. 만약, 데이터가 없는 경우 NoResultException이 발생하고 데이터가 둘 이상인 경우 NonUniqueResultException이 발생한다. 이것을 주의해야한다. (스프링에서 제공하는 spring-data-jpa는 이 에러를 처리해서 없는 경우 null로 반환해주긴 함)

 

 

파라미터 바인딩 - 이름 기준, 위치 기준

파라미터를 던져줄 상황이 굉장히 많이 발생할텐데 사용방법은 또 간단하다.

이름 기준

List<Member> members = em
                    .createQuery("select m from Member m where m.username=:username", Member.class)
                    .setParameter("username", "Member1")
                    .getResultList();

username이라는 파라미터를 던져줄 때 setParameter()를 호출한다. 그리고 받는 쪽은 where m.username=:username 여기가 파라미터를 받는 부분이다.

 

위치 기준

위치기준은 사용하지 말자. 그러나 뭐가 위치 기준인지는 알아야 하니 일단 예시는 다음과 같다.

List<Member> members = em
                    .createQuery("select m from Member m where m.username=?1", Member.class)
                    .setParameter("1", "Member1")
                    .getResultList();

"?1" 이 부분이 파라미터를 받는 부분, 주는 부분은 setParameter("1", "value") 이 부분이다. 보면 알겠지만 코드 길어지거나 안보다가 보면 "1"이런 값들이 가시성이 확 떨어진다. 또한 중간에 추가하게 되면 순서가 달라지기 때문에 다 밀리게 되고 아주 불편하다.

 

 

프로젝션

프로젝션이란, SELECT절에 조회할 대상을 지정하는 것을 말한다.

프로젝션 대상: 엔티티, 임베디드 타입, 스칼라 타입(숫자, 문자 등 기본 데이터 타입)

// 엔티티 프로젝션
SELECT m FROM Member m

멤버 전체를 가져오는 쿼리이므로 엔티티 프로젝션을 말한다.

// 엔티티 프로젝션
SELECT m.team FROM Member m

멤버의 팀을 가져오는 쿼리로 팀이 엔티티이기 때문에 엔티티 프로젝션이라 한다.

// 임베디드 타입 프로젝션
SELECT m.address FROM Member m

멤버의 값 타입으로 임베디드한 Address를 가져오는 쿼리이므로 임베디드 타입 프로젝션이라 한다.

// 스칼라 타입 프로젝션
SELECT m.username, m.age FROM Member m

멤버가 가지고 있는 문자, 숫자 등 기본 데이터 타입을 가져오므로 스칼라 타입 프로젝션이라 한다.

 

// DISTINCT로 중복을 제거
SELECT DISTINCT m.username, m.age FROM Member m

참고로 중복제거도 역시 가능하다.

 

그래서 풀 코드로 작성하면 이렇게 쓰면 된다.

Query members = em.createQuery("select distinct m.id, m.username from Member m");
근데 여기서 한가지 의문이 발생한다. "이 경우에는 타입을 지정못하는데 가져올 땐 어떻게 가져와야 하지?"

 

방법은 여러가지가 있는데 첫번째는, 위에서 작성한 코드처럼 Query 타입으로 가져오는 방법이다.

Query query = em.createQuery("select m.username, m.id From Member m");
List<Object[]> result = query.getResultList();

Object[] data = result.get(0);
System.out.println("username: " + data[0]);
System.out.println("id: " + data[1]);

//Output:
username: Member1
id: 1

모든 데이터 타입의 상위 타입은 Object 이므로, Object[]로 받는다. []로 받는 이유는 m.username, m.id가 []에 0번, 1번으로 들어가기 때문이다. 그래서 0과 1을 찍어보면 각각 username과 id를 출력한다.

 

그러나 좀 불편한 부분이 있어보인다. 그래서 좀 더 좋은 방법은 new로 받아오는 방법이 있다.

즉, 새로운 객체를 생성해내면서 받는 방법이다. 데이터를 담을 클래스를 만들어야 하니 클래스를 먼저 만들자.

 

SelectMemberDTO Class

package org.example.entity.jpql;

public class SelectMemberDTO {
    
    private Long id;
    private String username;

    public SelectMemberDTO(Long id, String username) {
        this.id = id;
        this.username = username;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}

이렇게 받아줄 객체 클래스를 만들고 이 객체로 데이터를 받아오는 방법이다. 아래 코드를 보자.

List<SelectMemberDTO> result = em
            .createQuery(
                    "select new org.example.entity.jpql.SelectMemberDTO(m.id, m.username) From Member m",
                    SelectMemberDTO.class)
            .getResultList();

    for (SelectMemberDTO selectMemberDTO : result) {
        System.out.println("selectMemberDTO id= " + selectMemberDTO.getId());
        System.out.println("selectMemberDTO username= " + selectMemberDTO.getUsername());
    }

보면 new org.example.entity.jpql.SelectMemberDTO(m.id, m.username) 이라고 작성되어 있다. 여기가 문자열이기 때문에 패키지명까지 쭉 작성해줘야하는 번거로움이 있긴하지만 위에 Object로 받는 방법보다는 훨씬 가시적이다.

 

 

페이징

JPA에서 페이징하는 방법이 너무 간단하게 잘 되어있다.

방법은 setFirstResult(), setMaxResults()를 사용하면 끝이다.

List<Member> resultList = em
                .createQuery("select m from Member m order by m.id desc", Member.class)
                .setFirstResult(0)
                .setMaxResults(10)
                .getResultList();

setFirstResult(0), setMaxResults(10)을 주면 0부터 10개까지를 가져온다. 이게 끝이다. 결과는 다음과 같다.

Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_0_,
        member0_.TEAM_ID as team_id3_0_,
        member0_.username as username2_0_ 
    from
        Member member0_ 
    order by
        member0_.MEMBER_ID desc limit ?
member = Member99
member = Member98
member = Member97
member = Member96
member = Member95
member = Member94
member = Member93
member = Member92
member = Member91
member = Member90

 

조인

조인의 경우 크게 세 가지가 있다.

 

  • 내부조인: 내부조인은 멤버의 팀 외래키와 팀의 기본키가 같은 레코드끼리 조인했을 때 팀의 값이 있는 데이터들만 뽑아낸다.
SELECT m FROM Member m [INNER] JOIN m.team t
  • 외부조인: 외부조인은 멤버의 팀 외래키와 팀의 기본키가 같은 레코드끼리 조인했을 때 멤버를 기준으로 팀이 없는 값도 모두 뽑아낸다.
SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
  • 세타조인: 아무런 연관성이 없는 데이터들도 뽑아낸다.
SELECT count(m) FROM Member m, Team t WHERE m.username = t.name

 

그래서 조인을 사용하는 코드를 한번 작성해보면 다음과 같다.

// 내부 조인
String query = "select m from Member m join m.team t where t.name =:teamName";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setParameter("teamName", "Team1")
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();
        
// 외부 조인 
String query = "select m from Member m left join m.team t";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();
        
        
// 세타 조인
String query = "SELECT m FROM Member m, Team t WHERE m.username = t.name";
List<Member> resultList = em
        .createQuery(query, Member.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

 

조인 대상 필터링

조인 하기 전 필터링을 해서 데이터 양을 좀 줄일 수 있고 JPQL도 역시 유사한 키워드로 지원한다.

String query = "SELECT m FROM Member m LEFT JOIN m.team t ON t.name =:teamName";

연관 관계가 없는 엔티티의 외부 조인도 필터링을 걸 수 있다. (아래는 멤버와 팀이 연관 관계가 없다고 생각하고)

String query = "SELECT m FROM Member m LEFT JOIN Team t ON m.username = t.name";

 

 

서브 쿼리

SQL문에서 사용하던 서브 쿼리랑 같은 것을 말하고 JPQL도 역시 이를 지원한다. 

SELECT m FROM Member m WHERE m.age > (SELECT avg(m2.age) FROM Member m2)

이렇게 서브 쿼리를 사용할 때 위에서처럼 m과 m2로 서브 쿼리랑 본 쿼리의 멤버를 분리해야 일반적으로 SQL 성능이 좋다. 

 

 

서브 쿼리 지원 함수로 다음과 같은 것들이 있다.

  • [NOT] EXISTS (subquery): 서브 쿼리에 결과가 존재하면 참
  • {ALL | ANY | SOME} (subquery)
    • ALL: 모두 만족하면 참
    • ANY, SOME: 같은 의미, 조건을 하나라도 만족하면 참
  • [NOT] IN (subquery): 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참

 

근데 다음과 같은 제약도 있다.

- JPA는 WHERE, HAVING 절에서만 서브 쿼리 사용 가능

- SELECT 절도 가능 (하이버네이트에서 지원)

- FROM 절의 서브 쿼리는 현재 JPQL에서 불가능 (조인으로 풀 수 있으면 풀어서 해결)

 

 

 

JPQL 타입 표현

- 문자: 'HELLO', 'She"s'

- 숫자: 10L(Long), 10D(Double), 10F(Float)

- Boolean: TRUE(true), FALSE(false)

String query = "SELECT m.username, 'HELLO', TRUE, 10L FROM Member m";

List<Object[]> resultList = em
        .createQuery(query)
        .setFirstResult(0)
        .setMaxResults(1)
        .getResultList();

for (Object[] objects : resultList) {
    System.out.println("objects = " + objects[0]);
    System.out.println("objects = " + objects[1]);
    System.out.println("objects = " + objects[2]);
    System.out.println("objects = " + objects[3]);
}

//Output:
objects = Member0
objects = HELLO
objects = true
objects = 10

 

- ENUM: jpa.MemberType.ADMIN (패키지명 포함)

String query = "SELECT m.username, 'HELLO', TRUE, 10L " +
                    "FROM Member m " +
                    "WHERE m.memberType = org.example.entity.jpql.MemberType.ADMIN";

근데 보통은 파라미터를 바인딩해서 사용할테니까 저렇게까지 불편할 정도는 아니다. 파라미터 바인딩을 하면 다음과 같이 조금 더 편리해지고 보기도 좋아진다.

String query = "SELECT m.username, 'HELLO', TRUE, 10L, m.memberType " +
                    "FROM Member m " +
                    "WHERE m.memberType = :memberType";

List<Object[]> resultList = em
        .createQuery(query)
        .setParameter("memberType", MemberType.ADMIN)
        .setFirstResult(0)
        .setMaxResults(2)
        .getResultList();

- 엔티티 타입: TYPE(m) = Member (상속 관계에서 사용)

이렇게 ITEM, BOOK이 상속관계일 때 ITEM으로부터 가져온 레코드 중 DTYPE이 Book인 녀석들만 가져오는 쿼리도 할 수 있다는 것을 보여준다.

String query = "SELECT i " +
                    "FROM Item i " +
                    "WHERE TYPE(i) = Book";

List<Item> resultList = em
        .createQuery(query, Item.class);
        .getResultList();

 

 

조건식

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

이렇게 생긴 문장 많이 봤을거다. SQL하면서. JPQL에서도 동일하게 지원해준다.

 

String query = "SELECT " +
                    "       case when m.age <= 10 then '학생요금'" +
                    "       when m.age >= 60 then '경로요금'" +
                    "       else '일반요금'" +
                    "       end " +
                    "FROM Member m";
            
List<String> resultList = em.createQuery(query, String.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

for (String s : resultList) {
    System.out.println("s = " + s);
}

COALESCE, NULLIF

COALESCE는 하나씩 조회해서 NULL이 아니면 반환

SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m

그러니까 찾은 유저 각각의 유저네임이 NULL인 경우 '이름 없는 회원'으로 대체한다는 의미이다.

 

NULLIF는 두 값이 같으면 NULL반환, 다르면 첫번째 값 반환

SELECT NULLIF(m.username, '관리자') FROM Member m

찾은 유저 각각의 유저네임이 관리자인 유저는 NULL로 유저네임을 반환하고 그게 아닌경우 유저네임 그대로를 반환한다는 의미이다. 

 

 

JPQL 기본 함수 

  • CONCAT
  • SUBSTRING
  • TRIM
  • LOWER, UPPER
  • LENGTH
  • LOCATE
  • ABS, SQRT, MOD
  • SIZE, INDEX(JPA 용도)
// CONCAT
String query = "SELECT CONCAT('A', 'B') FROM Member m";

// SUBSTRING
String query = "SELECT SUBSTRING(m.username, 2, 3) FROM Member m";

....

//SIZE
String query = "SELECT SIZE(t.members) FROM Team t";

기본으로 제공하는 함수가 있는 반면 DB에 종속적인 방언적 함수가 각 DB마다 또 있을 수 있는데 그런 함수가 JPQL이 지원을 즉각적으로 안하는 경우 사용자가 직접 함수를 등록해서 사용할 수 있다.

 

사용자 정의 함수

먼저 H2 DB를 사용한다고 가정했을 때, 아래와 같이 Dialect를 커스텀할 클래스하나를 만들고 내가 원하는 함수를 생성자 안에서 등록한다. 

package org.example.entity.jpql;

import org.hibernate.dialect.H2Dialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;

public class MyH2Dialect extends H2Dialect {
    
    public MyH2Dialect() {
        registerFunction("group_concat",
                new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
    }
}

그리고 JPA 설정 파일에서 Dialect를 이 파일로 변경만 해주면 끝. 아래는 XML로 설정했기 때문에 XML 파일 형식인거고 요즘은 거의 YAML, properties 파일에서 설정하겠지.

...
<property name="hibernate.dialect" value="org.example.entity.jpql.MyH2Dialect"/>
...

 

 

경로 표현식

경로 표현식은 크게 세가지로 분류할 수 있다.

  • 상태 필드: 경로 탐색의 끝, 이후 탐색 불가능
// m.username이 상태 필드 경로 표현식
select m.username from Member m;
  • 단일 값 연관 경로: 묵시적 내부 조인 발생, 이후 탐색 가능 
// m.team이 단일 값 연관 경로 표현식
select m.team from Member m;
  • 컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 이후 탐색 불가능
// t.members가 컬렉션 값 연관 경로 표현식
select t.members from Team t;

 

여기서 솔직히 어려운 내용은 하나도 없다. 객체로 생각해보면 당연히 상태 필드는 그 녀석이 더 담고 있는 데이터가 없기 때문에 이후 탐색이 불가능한거고 특정 엔티티가 타입인 경우 그 녀석이 가지고 있는 다른 데이터가 있기 때문에 그 이후에 탐색이 가능한것이다. 컬렉션은 컬렉션의 하나하나의 데이터를 뽑아오는 게 아니라 컬렉션 자체를 반환하기 때문에 그 이후에 탐색이 불가능한 것이다.

 

이게 중요해서가 아니라 묵시적 내부 조인이 발생한다는 것이 중요하다.

// m.team이 단일 값 연관 경로 표현식
select m.team from Member m;

위 코드에서 멤버의 팀을 가져오기 위해서는 DB입장에서는 조인을 할 수 밖에 없다. 그래서 내부조인이 일어난다. 하지만 우리가 직접 명시하지 않았기 때문에 묵시적으로 조인이 발생한다. 코드 수행 결과를 한번 보면 다음과 같다.

String query = "SELECT m.team FROM Member m";

List<Team> resultList = em.createQuery(query, Team.class)
        .setFirstResult(0)
        .setMaxResults(10)
        .getResultList();

for (Team team1 : resultList) {
    System.out.println("team1 = " + team1);
}

결과 수행 출력:

select
        team1_.TEAM_ID as team_id1_1_,
        team1_.name as name2_1_ 
    from
        Member member0_ 
    inner join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID limit ?
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e

보다시피 내부 조인이 일어난다. 당연하다. 근데 작성한 코드에서는 명시적으로 조인을 사용하지 않았기에 조인을 사용할 것이라고 예측하기는 쉽지는 않다. JPA를 잘 이해하고 잘 하는 사람이야 바로 보고 알수도 있겠다만. 

 

또 컬렉션에서도 경로 탐색이 더이상 불가능한데 다음 코드를 보자.

// t.members가 컬렉션 값 연관 경로 표현식
select t.members from Team t;

이렇게 생긴 쿼리를 실제로 수행하면 당연히 묵시적 내부 조인이 발생한다. 팀에 해당하는 멤버를 가져와야 하기 때문에.

그리고 여기서는 더 이상 탐색이 불가하다. 근데 탐색하고 싶을 수 있다. 멤버 하나하나의 이름이나 뭐 다른 데이터를 알고싶을 수 있잖아.

그럴 때는 FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.

String query = "SELECT m.username FROM Team t join t.members m";

이렇게 컬렉션은 위 방법으로 이후 탐색을 진행할 수 있다. 그러나, 가장 중요한 내용은 다음과 같다.

묵시적 조인이 발생하는 경우를 모두 차단해라.

 

묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵고 조인은 SQL 튜닝에 중요 포인트다. 그렇기 때문에 묵시적 조인은 가급적(아예) 사용하지 말자. 

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30
[JPA] Part 14. 컬렉션 값 타입  (0) 2023.10.25
[JPA] Part 13. 임베디드 타입  (0) 2023.10.23
[JPA] Part 12. CASCADE  (2) 2023.10.23