Backend/JPA

JPQL의 소개와 기본 문법

olsohee 2024. 1. 10. 23:35

JPQL

JPQL은 Java Persistence Query Language의 약자로, JPA가 지원하는 객체지향 쿼리 언어이다. ORM을 사용하면 데이터베이스 테이블이 아닌 엔티티 객체를 대상으로 개발하므로 검색도 테이블이 아닌 엔티티 객체를 대상으로 하는 방법이 필요하다. JPQL은 이런 문제를 해결하기 위해 만들어졌다.

 

JPQL의 특징은 다음과 같다.

  • SQL이 데이터베이스 테이블을 대상으로 하는 데이터 중심의 쿼리라면, JPQL은 객체를 대상으로 하는 객체지향 쿼리이다.
  • SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다.
  • JPQL은 결국 SQL로 변환된다. JPQL을 사용하면 JPA는 이 JPQL을 분석한 다음 적절한 SQL을 만들어 데이터베이스를 조회한다. 그리고 조회한 결과로 엔티티 객체를 생성해서 반환한다.

select문

select문은 다음과 같이 사용한다.

select m from Member as m where m.username = 'member1'
  • 엔티티(Member)와 속성(username)은 대소문자를 구분한다. 반면 select, from, as와 같은 JPQL 키워드는 대소문자를 구분하지 않는다.
  • JPQL에서 사용한 Member는 클래스 명이 아니라 엔티티 명이다. 
  • Member as m 부분을 보면 Member에 m이라는 별칭을 주었다. JPQL은 별칭을 필수로 사용해야 한다. 참고로 as는 생략할 수 있다.

TypeQuery, Query

작성한 JPQL을 실행하려면 쿼리 객체를 만들어야 한다. 쿼리 객체는 TypeQuery와 Query가 있다.

  • TypeQuery: 반환 타입이 명확할 때 사용한다.
  • Query: 반환 타입이 명확하지 않을 때 사용한다.
// TypedQuery
TypedQuery<Member> query = em.createQuery("select m from Member m", Member.class);

List<Member> resultList = query.getResultList();
for(Member member : resultList) {
	System.out.println("member = " + member);
}
// Query
Query query = em.createQuery("select m.username, m.age from Member m");
List resultList = query.getResultList();

for(Object o : resultList) {
    Object[] result = (Object[]) o; // 결과가 둘 이상이면 Object[] 반환
    System.out.println("username = " + result[0]);
    System.out.println("age = " + result[1]);
}
  • Query 객체는 조회 대상이 둘 이상이면 Object[]를 반환하고, 조회 대상이 하나이면 Object를 반환한다. 예를 들어 "select m.username from Member m"이면 결과를 Object로 반환하고 "select m.username, m.age from Member m"이면 Object[]를 반환한다.

getResultList(), getSingleResult()

  • query.getResultList()
    • 결과가 하나 이상일 때 리스트 형태로 반환한다.
    • 만약 결과가 없으면 빈 리스트를 반환한다.
  • query.getSingleResult()
    • 결과가 하나일 때 단일 객체를 반환한다.
    • 만약 결과가 없으면 NoResultException 예외가 발생한다(spring data jpa에서는 null이나 optional을 반환한다).
    • 반면 결과가 둘 이상이면 NonUniqueResultException 예외가 발생한다.
    • 즉 getSingleResult()는 결과가 정확히 하나일 때가 아니면 예외가 발생한다.

파라미터 바인딩

JPQL은 위치 기준 파라미터 바인딩과 이름 기준 파라미터 바인딩 방식을 지원한다. 파라미터가 중간에 추가될 것을 고려해 위치 기준 파라미터 방식보다는 이름 기준 파라미터 바인딩 방식을 사용하는 것이 더 안전하고 명확하다.

  • 이름 기준 파라미터 바인딩
Member findMember = em.createQuery("select m from Member m where m.username=:username", Member.class)
					.setParameter("username", "member1")
					.getSingleResult();
  • 위치 기준 파라미터 바인딩
Member findMember = em.createQuery("select m from Member m where m.username=?1", Member.class)
					.setParameter(1, "member1")
					.getSingleResult();

파라미터 바인딩을 사용해야 하는 이유

파라미터 바인딩 방식을 사용하지 않고 다음과 같이 직접 문자를 더해 JPQL을 만들면 SQL 인젝션 공격을 당할 수 있다. 따라서 파라미터 바인딩은 SQL 인젝션 공격을 방어한다. 

"select m from Member m where m.username = '" + usernameParam + "'"

그 뿐만 아니라 파라미터 바인딩을 사용하면 파라미터 값이 달라도 같은 쿼리로 인식해서 JPA는 JPQL을 SQL로 파싱한 결과를 재사용할 수 있다. 그리고 데이터베이스도 내부에서 실행한 SQL을 파싱해서 사용하는데 같은 쿼리를 파싱한 결과를 재사용할 수 있다. 결과적으로 애플리케이션과 데이터베이스 모두 해당 쿼리의 파싱 결과를 재사용할 수 있어서 전체 성능이 향상된다.

 

따라서 이러한 이유로 파라미터 바인딩 방식은 선택이 아닌 필수이다.

  • SQL 인젝션 공격을 방어
  • 성능 향상

프로젝션

select 절에 조회할 대상을 지정하는 것을 프로젝션(projection)이라 한다. 프로젝션 대상으로는 엔티티, 임베디드 타입, 스칼라 타입(숫자, 문자 등 기본 데이터 타입)이 있다.

엔티티 프로젝션

다음 예제는 회원과 팀이라는 엔티티를 대상으로 조회한다. 이렇게 조회한 엔티티는 영속성 컨텍스트에서 관리된다.

select m from Member m // 회원 조회
select m.team from Member m // 팀 조회

 

임베디드 타입 프로젝션

임베디드 타입은 조회의 시작점이 될 수 없다. 따라서 다음과 같이 임베디드 타입인 Address를 조회의 시작점으로 사용할 수 없다.

String query = "select a from Address a"; // X

임베디드 타입을 조회하려면 다음과 같이 엔티티를 통해서 임베디드 타입을 조회해야 한다.

String query = "select o.address from Order o";
List<Address> addresses = em.createQuery(query, Address.class)
                            .getResultList();

임베디드 타입은 엔티티 타입이 아닌 값 타입이다. 따라서 이렇게 조회한 임베디드 타입은 영속성 컨텍스트에서 관리되지 않는다.

스칼라 타입 프로젝션

숫자, 문자, 날짜와 같은 기본 데이터 타입들을 스칼라 타입이라 한다.

List<String> usernames = em.createQuery("select m.username from Member m", String.class)
                           .getResultList();

중복 데이터를 제거하려면 distinct를 사용하면 된다.

select distinct m.username from Member m

여러 값 조회

하나의 엔티티를 대상으로 조회하는 것이 아닌, 프로젝션에 여러 값을 선택하면 TypeQuery를 사용할 수 없고 Query를 사용해야 한다.

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

new 명령어를 사용하면 조회한 값들을 이용해서 dto로 손쉽게 변환할 수 있다. 이때 UserDto와 같은 클래스명은 패키지 명을 포함하여 작성해주어야 하고, 순서와 타입이 일치하는 생성자가 있어야 한다.

TypeQuery<UserDto> query = em.createQuery("select new jpabook.jpql.UserDto(m.username, m.age) from Member m", UserDto.class);

List<UserDto> resultList = query.getResultList();
public class UserDto {
    
    private String username;
    private int age;

    public UserDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

페이징

페이징 처리용 SQL을 작성하는 일은 지루하고 반복적이며, 무엇보다 데이터베이스마다 페이징을 처리하는 SQL 문법이 다르다. JPA는 페이징을 다음 두 API로 추상화했다. 따라서 데이터베이스 방언에 따라 JPQL이 적절한 SQL문으로 변환된다.

  • setFirstResult(int startPosition): 조회 시작 위치(0부터 시작)
  • setMaxResults(int maxResult): 조회할 데이터 수
List<Member> result = em.createQuery("select m from Member order by m.age desc", Member.class);
                        .setFirstResult(0)
                        .setMaxResults(10)
                        .getResultList();

조인

내부 조인

내부 조인은 inner join을 사용한다. 참고로 inner는 생략할 수 있다. 다음 예제는 회원과 팀을 내부 조인해서 teamA에 소속된 회원을 조회한다.

String teamName = "teamA";
List<Member> members = em.createQuery("select m from Member m inner join m.team t where t.name = :teamName", Member.class)
                         .setParameter("teamName", teamName)
                         .getResultList();

SQL의 조인과 다르게 JPQL의 조인은 연관 필드를 사용한다. 위 예제에서는 m.team이 연관 필드이다. 그리고 이 연관 필드에 t라는 별칭을 주었다.

외부 조인

select m from Member m left join m.team t

컬렉션 조인

일대다 관계나 다대다 관계처럼 컬렉션을 사용하는 곳에 조인하는 것을 컬렉션 조인이라 한다.

  • 회원 ➡️ 팀으로 조인하는 것은 다대일 조인이면서 단일 값 연관 필드(m.team)를 사용한다.
  • ➡️ 회원으로 조인하는 것은 일대다 조인이면서 컬렉션 값 연관 필드(t.members)를 사용한다.

다음 예제는 팀과 팀이 보유한 회원들을 컬렉션 값 연관 필드로 외부 조인한다.

select t, m from Team t left join t.members m

세타 조인(where 절)

where 절을 사용해서 세타 조인을 할 수 있다. 세타 조인을 사용하면 전혀 관계없는 엔티티 간에 조인할 수 있다. 다음 예제는 전혀 관련없는 Member.username과 Team.name을 조인한다.

select count(m) from Member m, Team t where m.username = t.name

참고로 세타 조인은 내부 조인만 지원한다. 반면 다음에 나오는 join on 절을 사용하면 관계없는 엔티티 간에 외부 조인도 할 수 있다.

join on 절

on 절을 사용하면 조인 대상을 필터링해서 조인할 수 있다. 다음 예제는 회원과 팀을 외부 조인하면서 팀 이름이 teamA인 팀만 조인한다.

select m, t from Member m left join m.team t on t.name = 'teamA'

그리고 세타 조인과 다르게 관계없는 엔티티 간에 외부 조인도 가능하다. 다음 예제는 연관관계가 없는 Member.username과 Team.name을 외부 조인한다.

select m, t from Member m left join Team t on m.username = t.name

경로 표현식

경로 표현식은 .(점)을 찍어서 객체 그래프를 탐색하는 것이다.

상태 필드

  • 상태 필드는 단순히 값을 저장하기 위한 필드이다.
  • 경로 탐색의 끝으로 더는 탐색할 수 없다.
// JPQL
select m.username, m.age from Member m

// SQL
select m.name, m.age from Member m

단일 값 연관 필드

  • 단일 값 연관 필드는 조회 대상이 엔티티(@ManyToOne, @OneToOne)이다.
  • 묵시적으로 내부 조인이 일어난다.
  • 단일 값 연관 경로는 계속 탐색할 수 있다.
// JPQL
select o.member from Order o

// SQL
select m.*
from Orders o
inner join Member m on o.member_id=m.id
// JPQL
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'

컬렉션 값 연관 필드

  • 컬렉션 값 연관 필드는 조회 대상이 컬렉션(@OneToMany, @ManyToMany)이다.
  • 묵시적으로 내부 조인이 일어난다.
  • 더는 탐색할 수 없다. 단 from 절에서 조인을 통해 별칭을 얻으면 별칭으로 추가 탐색이 가능하다.
select t.members from Team t // O

select t.members.username from Team t // X

select m.username from Team t join t.members m // O

 

경로 탐색시 묵시적 조인의 발생

경로 탐색을 사용하면 묵시적 조인이 발생해서 SQL에서 내부 조인이 발생할 수 있다. 참고로 묵시적 조인은 모두 내부 조인이다.

  • 명시적 조인: join을 직접 작성하는 것
  • 묵시적 조인: 경로 표현식에 의해 묵시적으로 내부 조인이 발생하는 것

조인이 성능 튜닝의 핵심이다. 그런데 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다. 따라서 성능 튜닝을 용이하게 하기 위해 묵시적 조인보다 명시적 조인을 사용하자.


Reference

  • 자바 ORM 표준 JPA 프로그래밍, 김영한

'Backend > JPA' 카테고리의 다른 글

엔티티 조회 성능 최적화  (1) 2024.02.02
값 타입 컬렉션  (1) 2024.01.13
영속성 전이(cascade)와 고아 객체  (0) 2024.01.09
프록시와 지연 로딩  (1) 2024.01.09
양방향 연관관계 매핑  (0) 2024.01.09