관리 메뉴

나구리의 개발공부기록

객체지향 쿼리 언어 - 기본 문법, 소개, 기본 문법과 쿼리 API, 프로젝션(SELECT), 페이징 본문

인프런 - 스프링부트와 JPA실무 로드맵/자바 ORM 표준 JPA 프로그래밍 - 기본편

객체지향 쿼리 언어 - 기본 문법, 소개, 기본 문법과 쿼리 API, 프로젝션(SELECT), 페이징

소소한나구리 2024. 10. 15. 17:28

출처 : 인프런 - 자바 ORM 표준 JPA 프로그래밍 - 기본편(유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 객체지향 쿼리 언어 소개 

1) JPQL

(1) JPA의 한계

  • JPA를 사용하면 엔터티 객체를 중심으로 개발하고 테이블은 매핑만 하는데 검색을 할 때도 테이블이 아닌 엔터티 객체를 대상으로 검색을 해야 함
  • 그러나 모든 DB데이터를 객체로 변환해서 검색하는 것은 불가능하기 때문에 애플리케이션이 필요한 데이터만 DB에서 불러오려면 검색 조건이 포함된 SQL필요함

(2) 문제 해결

  • 이런 문제를 해결하기 위해 JPA는 SQL을 추상화한 JPQL이라는 객체지향 쿼리 언어를 제공함
  • SQL과 문법이 유사하며, SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN 등,, ANSI SQL이 지원하는 문법은 전부 지원함
  • SQL은 데이터베이스 테이블을 대상으로 쿼리하는 것이고 JPQL은 엔터티 객체를 대상으로 쿼리하는 것이 가장 큰 차이이며 JPQL로 쿼리를 짜면 SQL로 번역이 되어 실행됨

(3) 사용 방법

  • 테이블이아닌 엔터티를 대상으로 쿼리를 작성하며 작성문법이 SQL과 상당히 유사하여 SQL을 다룰줄 안다면 쉽게 적용할 수 있음
  • getResultList()메소드로 호출하면 쿼리 결과를 List형태로 받을 수 있으며 해당 값을 반복문으로 꺼내서 활용할 수 있음
List<Member> result = em.createQuery("select m from Member m where m.username like '%kim%'",
                                    Member.class).getResultList();

for (Member member : result) {
    System.out.println("member = " + member);

 

(4) JPQL 정리

  • 테이블이 아닌 객체를 대상으로 검색하는 객체지향 쿼리이며 SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않을 수 있음
  • 즉, 객체지향 SQL이라고 이해하면 되며 JPA에서 발생하는 대부분의 문제를 JPQL로 해결할 수 있음

2) Criteria

(1) JPQL의 한계

  • JPQL은 대부분의 문제를 해결할 수 있지만 쿼리가 단순히 String 타입이기 때문에 동적 쿼리를 만들기 어려움
  • 작성이 불가능한것은 아니지만 여러줄의 쿼리를 입력하게되면 +와 띄어쓰기를 계속 신경써야하기에 버그가 발생할 확률이 매우 높아짐
  • ORM은 아니지만 마이바티스(과거의 아이바티스)는 이러한 동적쿼리를 편하게 짤 수 있다는 장점이 존재하는데 JPA에서도 이를 해결하기위해 문법을 개발함

(2) 동적쿼리를 위한 JPA 표준 문법

  • 쿼리를 문자가 아닌 자바 코드로 작성하기 때문에 IDE도움을 받을 수 있고 에러를 미리 방지할 수 있는 장점이 있음
  • JPQL의 빌더 역할을 하며 JPQL에 비해서 동적쿼리를 작성하기가 편리함
  • 실행을 해보면 criteria로 쿼리가 실행되었다는 로그를 확인할 수 있음

(3) 사용 방법

// Criteria 사용 준비
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> members = em.createQuery(cq).getResultList();

 

 

(4) Criteria 한계

  • 그러나 작성된 코드를 보면 개발자에게 익숙한 SQL처럼 구성이 되어있지않아 처음부터 다시 배워야하며 작성된 코드가 어떤 쿼리를 실행하는지 알아보기가 쉽지 않음
  • JPA표준이지만 실무에서는 유지보수가 너무 어려워서 사용하지 않는 것을 권장함 -> QueryDSL사용을 권장함

3) QueryDSL

(1) JPA 동적쿼리 대안

  • 오픈소스 라이브러리이며 Criteria처럼 자바코드로 JPQL을 작성할 수 있어 IDE도움을 받아 쿼리작성 및 에러 방지를 할 수 있으며 동적쿼리를 매우 편리하게 작성할 수 있으며 자바코드이기 때문에 재사용도 가능함
  • 마찬가지로 JPQL의 빌더 역할을 하며 Criteria는 가독성이 좋지 않은 반면에 작성된 코드를 보면 SQL과 흡사하여 단순하고 쉬우며 가독성이 좋음
  • 실무에서는 JPA에서 동적쿼리를 작성할 때 QueryDSL를 사용해야함
  • QueryDSL은 공식 사이트에서 가이드가 상세히 나와있기 때문에 JPQL만 제대로 알고 있다면 QueryDSL은 매우 쉽게 다룰 수 있음

(2) 사용예시

  • QueryDSL을 사용하기위한 설정을 적용한 뒤 EntityManager를 JPAQueryFactory의 생성자에 입력하여 객체를 생성하면 QueryDSL을 사용할 수 있음
  • 주석의 JPQL과 QueryDSL로 작성한 쿼리의 구조가 흡사하여 가독성이 좋음
//JPQL
//select m from Member m where m.age > 18
JPAQueryFactory query = new JPAQueryFactory(em);
QMember m = QMember.member;

List<Member> list = query.selectFrom(m)
                         .where(m.age.gt(18))
                         .orderBy(m.name.desc())
                         .fetch();

 

** 참고

  • QueryDSL을 사용하기 위해서는 세팅이 좀 필요하며, 직접 일일히 세팅하면 조금 번거롭지만 스프링부트를 활용하면 의존성 추가로 쉽게 세팅을 할 수 있어 한번 세팅을 해두면 해당 프로젝트에서 쉽게 동적쿼리를 작성할 수 있음
  • 객체지향 쿼리 언어 - 기본문법, 중급문법파트에서는 JPA표준 기술에 대해서 설명하기 때문에 이후에 별도의 강의로 다룸

4) 네이티브 SQL

(1) SQL을 직접 사용

  • JPA가 SQL을 직접 사용할 수 있도록 제공하는 기능
  • JPQL로 해결할 수 없는 특정 데이터베이스에 의존적인 기능을 사용하고자 할 때 사용함
  • ex) 오라클 CONNECT BY, 특정 DB만 사용하는 SQL 힌트 등
  • 네이티브 SQL을 사용하지 않아도 하이버네이트 방언 설정 등으로 특정 데이터베이스의 일부 기능을 사용할 수 있기 때문에 사용 빈도가 많지는 않음

(2) 사용방법

  • JPQL을 작성하는 방법과 동일한 방법으로 SQL을 직접 작성할 수 있으며 실행하면 dynamic native SQL query로 실행했다는 로그를 확인할 수 있음
// 네이티브 SQL
List<Member> resultList = em.createNativeQuery("select MEMBER_ID, city, street, zipcode, USERNAME from" +
                                            " MEMBER where NAME = 'kim'",
                                            Member.class).getResultList();
for (Member member : resultList) {
    System.out.println("member = " + member);
}

5) JDBC 직접 사용, Spring JdbcTemplate 등

  • JPA를 사용하면서 JDBC 커넥션을 직접 사용하거나, 스프링 JdbcTemplate, 마이바티스 등을 함께 사용할 수 있기 때문에 문제가 발생했을때 해당 기술들을 사용해서 문제를 해결해도 됨
  • 주의해야 할 것은 해당 기술들은 JPA와 관련이 없기때문에 DB에서 값을 제대로 조회하기 위해서는 영속성 컨텍스트를 적절한 시점에 강제로 플러시를 해주어야함
  • 즉 JPA를 우회해서 SQL을 실행하기 직전에 영속성 컨텍스트를 수동으로 플러시한 뒤에 다른 기술의 SQL로 접근해야 함

** 참고

  • JPQL을 잘 알고 있으면 QueryDSL도 배우기 쉽기 때문에 무조건 숙지 해야하며 실무에서 JPA를 사용하며 겪는 대부분의 문제는 JPQL + QueryDSL로 95%정도 해결이 가능함
  • 나머지 5%의 정말 복잡한 쿼리는 스프링 JdbcTemplate이나 마이바티스 등으로 네이티브 쿼리로 해결하면 됨

2. 기본 문법과 쿼리 API

1) JPQL(Java Persistence Query Language) - 기본 문법과 기능 

  • 객체지향 쿼리 언어이며 테이블 대상이 아닌 엔터티를 대상으로 쿼리함
  • 특정 SQL에 의존하지 않으며 JPQL은 SQL로 변환되어 작동함

2) 예제 모델

  • JPQL예제를 위한 별도의 프로젝트를 생성(JPA, H2 DB설정은 동일하나 url은 jpqltest로 새로 생성하여 접속)

 

(1) Member와 Team의 양방향 다대일 연관관계 매핑

package hellojpa.jpql;
@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String username;
    private int age;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    // getter, setter
}
package hellojpa.jpql;

@Entity
public class Team {

    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    // getter, setter
}

 

(2) Order와 Product 단방향 다대일 연관관계 매핑 및 Order필드에 Address 값타입 추가

  • Order의 테이블명은 예약어때문에 생성이 안되는 경우가 많아 ORDERS로 많이 사용함
package hellojpa.jpql;

@Entity
@Table(name = "ORDERS")
public class Order {

    @Id @GeneratedValue
    private Long id;
    private int orderAmount;

    @Embedded
    private Address address;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

	// getter, setter
}
package hellojpa.jpql;

@Entity
public class Product {

    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;
    private int stockAmount;

    // getter, setter
}
package hellojpa.jpql;

@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipCode;
    
    // getter, setter
}

3) JPQL 문법

(1) 기본 구조

  • select문: select_절 from절 [where_절] [group by_절] [having_절] [orderby_절]
  • update문: update_절 [where_절]
  • delete문: delete_절 [where_절]
  • select m from Member as m where m.age > 18 처럼 SQL과 동일한 형태의 구조를 가지고 있음
  • 엔터티의 속성은 대소문자 구분하며 JPQL 키워드는 대소문자 구분하지 않음(select, from 등등)
  • 테이블의 이름이 아닌 엔터티의 이름을 사용하며 별칭은 필수임(as 생략 가능)

(2) 집합과 정렬

  • ANSI SQL이 지원하는 COUNT, SUM, AVG, MAX, MIN, GROUP BY, HAVING, ORDER BY모두 지원하며 사용법도 SQL과 동일하기 때문에 어려움 없이 사용할 수 있음
  • ex) select count(m), sum(m.age), avg(m.age), max(m.age), min(m.age) from Member m

(3) TypedQuery, Query

  • TypedQuery: 반환 타입이 명확할 때 사용
  • Query: 반환 타입이 명확하지 않을 때 사용
// 두번째 인수로 타입 정보를 입력하면 반환타입이 TypedQuery
TypedQuery<Member> typedQuery = em.createQuery("select m from Member m", Member.class);

// JPQL의 검색 대상이 username(String), age(int) 두개의 타입이므로 타입을 지정할 수 없을 때는 Query로 반환됨
Query query = em.createQuery("select m.username, m.age from Member m");

 

(4) 결과 조회 API

  • query.getResultList() : 결과가 하나 이상일 때 사용하며 리스트를 반환타입으로 사용하고 결과가 없으면 빈 리스트를 반환함
  • query.getSingleResult() : 결과가 정확히 하나일 때 사용하며 단일 객체를 반환함, 공식 스펙으로는 결과가 없으면 NoResultException, 둘 이상이면 NonUniqueResultException 예외가 터짐
  • getSingleResult는 결과가 없어도 예외를 발생시키기 때문에 사용하기가 부담스러워 실무에서는 스프링 데이터 JPA가 제공하는 반환메서드를 사용하는데, 해당 메서드도 내부적으로 보면 try-catch를 사용하여 getSingleResult가 발생하는 예외를 잡아서 null이나 Optional로 반환하는 로직으로 구현되어 있음
// getResultList를 사용하며 쿼리 결과를 반환하면 List로 반환됨
List<Member> findMembers = em.createQuery("select m from Member m", Member.class).getResultList();

// getSingleResult를 사용하며 쿼리 결과를 반환하면 단일 객체과 반환됨
Member findMember = em.createQuery("select m from Member m", Member.class).getSingleResult();

 

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

  • 이름과 위치기준으로 파라미터를 바인딩 할 수 있으나 무조건 실무에서는 :username 처럼 무조건 이름을 기준으로 파라미터를 바인딩 하는 것을 사용해야함
  • 위치 기반의 파라미터 바인딩을 하는 방법은 JPQL을 떠나서 모든 곳에서 사용하는 것을 권장하지 않는데, 쿼리나 조회 대상 등이 변경이 되어 위치가 바뀌어버리면 대안으로 해결할 수 있었던 문제를 겉잡을 수 없는 심각한 버그로 이어질 확률이 높기 때문임
// 이름을 기준으로 파라미터 바인딩을 적용
Object findMember1 = em.createQuery("select m from Member m where m.username=:username")
                        .setParameter("username", "member1")
                        .getSingleResult();

// 위치로도 파라미터 바인딩을 적용할 수 있으나 실무에서 사용하지 말 것
// 이런식으로 위치로 바인딩을 적용하는 것은 언제든지 쿼리가 변경될 수 있기 때문에 심각한 문제를 일으킬 수 있음
Object findMember2 = em.createQuery("select m from Member m where m.username=?1")
                        .setParameter(1, "member1")
                        .getSingleResult();

3. 프로젝션(SELECT)

1) 설명

  • SELECT 절에 조회할 대상을 지정하는 것
  • 프로젝션 대상으로 엔터티, 임베디드 타입, 스칼라 타입(숫자, 문자 등 기본 데이터 타입)이 대상이 됨
  • SELECT 절에 DISTINCT로 중복을 제거할 수 있음

2) 프로젝션 종류

(1) SELECT m FROM Member m

  • 대상의 엔터티를 지정하여 조회
  • 가장 기본적인 조회쿼리이며 지정된 대상으로 가장 일반적인 select 쿼리가 전송됨
  • 엔터티 프로젝션으로 대상을 지정하면 반환값이 모두 영속성 컨텍스트에서 관리되어 persist 없이 해당 값이 수정만 하여도 값이 변경이됨
List<Member> result = em.createQuery("select m from Member m", Member.class)
                         .getResultList();

Member findMember = result.get(0);
findMember.setUsername("member2"); // 조회된 findMember를 변경하는 것만으로도 DB에 update쿼리가 전송됨

 

(2) SELECT m.team FROM Member m

  • Member 엔터티와 연관관계를 맺은 m.team 필드를 조회하는 것도 엔터티 프로젝션임
  • 이렇게 JPQL을 작성하여 조회하면 SQL 입장에서는 Member와 Team을 조인하여 연관된 Team정보를 찾아야 하기 때문에 조인 쿼리가 전송됨
  • 이후에 경로표현식이라는 곳에서 자세히 배우지만 이렇게 명시적으로 조인을 작성하지 않았는데 조인 쿼리가 나가는것을 묵시적 조인이라고 하는데 가능하면 JPQL과 SQL과 비슷하게 작성하는 것이 좋음
  • 조인이라는 것은 튜닝 등의 방법으로 성능에 영향을 줄 수 있는 것이 많기 때문에 한눈에 보이는 것이 좋으며, 유지보수 및 운영을 용이하게 하기위해 JPQL쿼리만 보고 번역될 SQL을 예측할 수 있도록 작성하는 것이 좋음
// 연관관계를 맺은 테이블을 조회하면 조인 쿼리가 전송됨
List<Member> result = em.createQuery("select m.team from Member m", Member.class)
                        .getResultList();

// 가급적 번역되는 SQL과 비슷하게 join을 적용하여 JPQL을 작성하는 것이 좋음
List<Team> result = em.createQuery("select t from Member m join m.team t", Team.class)
                        .getResultList();

 

(3) SELECT m.address FROM Member m

  • m.address 처럼 엔터티의 임베디드 타입을 지정된 것을 임베디드 타입 프로젝션이라고 함
  • 임베디드 타입은 엔터티에 소속되어있기 때문에 그 자체만으로 별칭을 가지고 조회할 수 없고 엔터티로부터 조회를 해야하는 한계를 가지고 있음
em.createQuery("select o.address from Order o", Address.class).getResultList();

 

(4) SELECT m.username, m.age FROM Member m

  • 원하는 값을 m.usename, m.age 처럼 지정된 것을 스칼라 타입 프로젝션이라고 함
  • 조회 대상의 타입이 여러가지가 있을 수 있으므로 타입을 조회해야 할 수 있음
em.createQuery("select m.username, m.age from Member m").getResultList();

3) 여러 값 조회

  • 스칼라 타입 프로젝션으로 여러 타입의 대상을 조회할 때 방법이 3가지가 있음

(1) Query 타입으로 조회

  • Query는 타입이 없어서 쿼리의 조회된 값들을 List로 반환 후 get(0)으로 꺼내면 Object 타입으로 반환됨
  • Object[]로 형변환하여 쿼리문의 조회대상을 작성한 순서대로 배열의 인덱스를 0부터 지정하면 값을 꺼낼 수 있음
// Member에 age와 username을 입력 후 조회
List resultList = em.createQuery("select m.username, m.age from Member m").getResultList();

Object o = resultList.get(0);
Object[] result = (Object[]) o;

System.out.println("username = " + result[0]);
System.out.println("age = " + result[1]);

// 출력 결과
// username = member1
// age = 30

 

(2) Object[] 타입으로 조회

  • 쿼리결과의 반환값 타입에 제네릭스로 Object[]을 지정하면 형변환 코드를 생략하여 조회된 값을 꺼낼 수 있음
List<Object[]> resultList = em.createQuery("select m.username, m.age from Member m")
                              .getResultList();

Object[] result = resultList.get(0);
System.out.println("username = " + result[0]);
System.out.println("age = " + result[1]);

// 출력 결과
// username = member1
// age = 30

 

(3) new 명령어로 조회

  • DTO 클래스를 만들어 조회된 값을 DTO 타입으로 지정하여 new의 명령어로 생성한DTO클래스에 생성자를 사용하여 조회
  • 생성자를 사용하기 때문에 쿼리의 순서와 타입이 일치하는 생성자가 필요함
  • 가장 권장되는 방법이나 JPQL만 사용하면 쿼리가 문자이기 때문에 MemberDTO클래스의 전체경로를 패키지를 포함하여 입력해주어야 함 -> 이후에 QueryDSL을 사용하면 자바 코드로 작성하기 때문에 경로를 Import가 가능해짐 
package hellojpa.jpql;

// MemberDTO 클래스 생성
public class MemberDTO {

    private String username;
    private int age;

    // 생성자 및 getter, setter
}

// 프로그램을 실행하는 클래스

List<MemberDTO> resultList = em.createQuery("select new hellojpa.jpql.MemberDTO(m.username, m.age) from Member m",
                                                MemberDTO.class)
                                                .getResultList();
MemberDTO memberDTO = resultList.get(0);
System.out.println("memberDTO.getUsername() = " + memberDTO.getUsername());
System.out.println("memberDTO.getAge() = " + memberDTO.getAge());

// 출력 결과
// memberDTO.getUsername() = member1
// memberDTO.getAge() = 30

4. 페이징 API

  • JPA는 페이징을 두개의 API로 추상화 해놓았음
  • setFirstResult(int startPosition) : 조회 시작 위치(0부터 시작)
  • setMaxResults(int maxResult) : 조회할 데이터 수

(1) 적용 예시

  • Member 엔터티의 값을 반복문으로 100개를 생성해놓고 JPA의 페이지네이션을 적용하면 데이터베이스 방언 설정에 따라 SQL쿼리가 DB에 반영되면서 조회결과가 출력됨
  • H2 데이터베이스는 offset row가 적용됨
for (int i = 0; i < 100; i++) {
    Member member = new Member();
    member.setUsername("member" + i);
    member.setAge(i);
    em.persist(member);
}

em.flush();
em.clear();

List<Member> result = em.createQuery("select m from Member m order by m.age desc", Member.class)
                        .setFirstResult(9)  // 조회 시작 위치를 지정(0부터 시작)
                        .setMaxResults(10)  // 시작 위치부터 조회할 데이터의 수를 지정
                        .getResultList();

System.out.println("result.size() = " + result.size());
for (Member m : result) {
    System.out.println("m = " + m); // Member에 toString 오버라이딩 적용
}

// 출력 결과
result.size() = 10
m = Member{id=91, username='member90', age=90}
m = Member{id=90, username='member89', age=89}
m = Member{id=89, username='member88', age=88}
m = Member{id=88, username='member87', age=87}
m = Member{id=87, username='member86', age=86}
m = Member{id=86, username='member85', age=85}
m = Member{id=85, username='member84', age=84}
m = Member{id=84, username='member83', age=83}
m = Member{id=83, username='member82', age=82}
m = Member{id=82, username='member81', age=81}