[JPA] 7.JPQL : 객체지향 쿼리 언어

재우·2025년 6월 12일

JPA

목록 보기
7/11

JPQL은 테이블을 대상으로 쿼리하는것이 아니라 엔티티 객체를 대상으로 쿼리한다. JPQL은 결국 SQL로 변환되서 실행된다.

select m from Member as m where m.age > 18
  • 엔티티 이름과 속성은 대소문자를 구분한다.
  • JPQL 키워드는 대소문자 구분하지않는다.(SELECT ,FROM)
  • 엔티티 이름(엔티티클래스이름)을 사용한다. 테이블 이름이 아니다.
  • 별칭은 필수(m)이고, as는 생략 가능하다.

TypeQuery

TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);
  • 반환타입이 명확할때 사용한다.
  • 쿼리 결과의 반환 타입을 적는다. 엔티티 타입(엔티티의 타입)을 .class 형태로 적는다.

Query

Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
  • 반환타입이 명확하지 않을때 사용한다.(username은 String이고 age는 int이다.)

결과조회

  • query.getResultList()

    • 결과가 하나 이상일때 리스트로 반환한다.
      List resultList = query.getResultList();
    • 결과가 없으면 빈 리스트를 반환한다.
  • query.getSingleResult()

    • 결과가 정확히 하나일때 객체로 반환한다.
      Member result = query.getSingleResult();
    • 결과가 없거나 둘 이상이면 예외가 발생한다.
    Member member = new Member();
    member.setUsername("member1");
    member.setAge(10);
    em.persist(member);
    
    TypedQuery<Member> query1 = em.createQuery("select m from Member m", Member.class);
    
    List<Member> resultList1 = query1.getResultList();
    Member member1 = resultList1.get(0);
    String username1 = member1.getUsername();
    int age1 = member1.getAge();
    
    Member member2 = query1.getSingleResult();
    String username2 = member2.getUsername();
    int age2 = member2.getAge();
    
    System.out.println("================================");
    
    Query query2= em.createQuery("select m from Member m");
    
    List resultList2 = query2.getResultList();
    Object o2 = resultList2.get(0);
    Member member3 = (Member) o2; // Member 타입으로 변환해서 사용 가능
    String username3 = member3.getUsername();
    int age3 = member3.getAge();
    
    Object result = query2.getSingleResult();
    Member member4 = (Member) result; // Member 타입으로 변환해서 사용 가능
    String username4 = member4.getUsername();
    int age4 = member4.getAge();
    
    List<Member> resultListEx = query2.getResultList(); // TypedQuery 처럼도 할 수 있다.
    Member memberEx = resultListEx.get(0);
    String usernameEx = memberEx.getUsername();
    int ageEx = memberEx.getAge();
  • 반환 타입이 명확하지 않아서 Query를 사용한다면, Object 타입으로 값을 반환하므로 적절히 타입 변환해서 사용해야한다.

  • 반환 타입이 명확하지 않아서 Query를 사용했지만 TypedQuery처럼 getResultList()를 사용할 때 List의 제네릭으로 타입을 지정할 수 도 있다.



파라미터 바인딩

TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class);
query.setParameter("username", "memberName");  


프로젝션

  • SELECT 절에 조회할 대상을 지정하는것.
  • 엔티티, 임베디드 타입(값 타입), 스칼라 타입(숫자, 문자 등 기본 데이터 타입)
List<Member> result1 = em.createQuery("select m from Member m", Member.class).getResultList(); // 엔티티 프로젝션
List<Team> result2 = em.createQuery("select m.team from Member m", Team.class).getResultList(); // 엔티티 프로젝션
List<Address> result3 = em.createQuery("select o.address from Order o", Address.class).getResultList(); // 임베디드 타입 프로젝션
List<String> result4 = em.createQuery("select m.username from Member m", String.class).getResultList(); // 스칼라 타입 프로젝션


여러값 조회

이 중에서 String, int와 같이 여러값을 조회 하는 스칼라 타입에 대해 좀 더 알아보자.

em.createQuery("select m.username, m.age from Member m").getResultList();

이때는 createQuery할떄 타입을 지정하지않는다. 쉽게 생각하면, String과 age중에 어떤 타입으로 지정해야할지 모르기 때문이다.

이때 값을 조회하는 방법에는 다음 3가지 방법이 있다.

  • Query 타입으로 조회

  • Object[] 타입으로 조회

    • resultList.get(0)을 하면 username값과 age값을 가지고 있는 Obejct[] 배열객체가 Object[]타입의 부모타입인 Object타입으로 리턴된다. 그래서 Object[] 타입으로 캐스팅해준다.
    Query query3 = em.createQuery("select m.username, m.age from Member m"); // Query 타입으로 조회
    List resultList3 = query3.getResultList();
    Object o3 = resultList3.get(0);
    Object[] resultArray3 = (Object[]) o3;
    String username5 = (String) resultArray3[0];
    Integer age5 = (Integer) resultArray3[1];
    
    Object o4 = query3.getSingleResult();
    Object[] resultArray4 = (Object[]) o4;
    String username6 = (String) resultArray4[0];
    Integer age6 = (Integer) resultArray4[1];
    
    List<Object[]> resultList = query3.getResultList(); // TypedQuery 처럼도 할 수 있다. Object[] 타입으로 조회
    Object[] resultArray5 = resultList.get(0);
    String username7 = (String) resultArray5[0];
    Integer age7 = (Integer) resultArray5[1];
  • new 명령어로 조회

    public class MemberDto {
      private String username;
      private int age;
    
      public MemberDto(String username, int age) {
          this.username = username;
          this.age = age;
      }
    }
    
    List<MemberDto> result = em.createQuery("select new jpql.MemberDto(m.username, m.age) from Member m", MemberDto.class).getResultList();
    MemberDto memberDto = result.get(0);
    System.out.println(memberDto.getUsername());
    System.out.println(memberDto.getAge());
    • 단순값들을 DTO객체로 조회하기 위해 사용한다.
    • DTO 타입 프로젝션이라고도 불린다.
    • MemberDto는 엔티티타입, 임베디드타입, 값타입, 스칼라타입이 아니므로 jpql에서 new 키워드와 함께 패키지명을 포함한 전체 클래스명을 입력해야하고, MemberDto에는 순서와 타입이 일치하는 생성자가 있어야한다.

페이징

setFirstResult(int startPosition) // 조회 시작 위치
setMaxResults(int maxResult) // 조회할 데이터 수
==> 몇번째부터 몇개를 가져올지 지정
==> 조회된 데이터 기준으로 몇번째부터 몇개를 가져올지 지정
==> setFirstResult(0) setMaxResult(2)는 조회된 데이터 기준으로 맨 첫번째부터 두개를 가져온다는것이다.

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

이렇게 실행하게되면, persistence.xml에서 지정한 hibernate.dialect 즉 db방언에 맞게 페이징쿼리 JPQL이 SQL로 변환되어 실행된다.

ex)
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 ?, ?

조인

  1. 연관관계가 있을때
Team team = new Team();
team.setName("A");
em.persist(team);

Member member = new Member();
member.setUsername("member1");
member.setAge(10);
member.setType(MemberType.USER);
member.setTeam(team);
em.persist(member);

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

String jpql = "select m from Member m left join m.team t on t.name = 'A'";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();

아래와 같은 SQL로 변환된다.

select
	member0_.id as id1_0_,
	member0_.age as age2_0_,
    member0_.TEAM_ID as team_id5_0_,
    member0_.type as type3_0_,
    member0_.username as username4_0_ 
from
	Member member0_ 
left outer join
	Team team1_ 
on 
  	member0_.TEAM_ID=team1_.id 
    and (
    	team1_.name='A'
    )
  1. 연관관계가 없을때
Team team = new Team();
team.setName("A");
em.persist(team);

Member member = new Member();
member.setUsername("member1");
member.setAge(10);
member.setType(MemberType.USER);
member.setTeam(team);
em.persist(member);

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

String jpql = "select m from Member m left join Team t on m.username = t.name;
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();

아래와 같은 SQL로 변환된다.

select
	member0_.id as id1_0_,
    member0_.age as age2_0_,
    member0_.TEAM_ID as team_id5_0_,
    member0_.type as type3_0_,
    member0_.username as username4_0_ 
from
	Member member0_ 
left outer join
	Team team1_ 
on (
	member0_.username=team1_.name
)

서브쿼리

SQL과 동일하게 JPQL에서도 서브쿼리를 사용할 수 있다.

ex1) select m from Member m where m.age > (select avg(m2.age) from Member m2)

ex2)
select m from Member m where (select count(o) from Order o where m = o.member) > 0

exists
select m from Member m where exists (select t from m.team t where t.name = '팀A')
=> m과 연관된 t의 name이 팀A인 데이터가 있으면 해당 데이터(m)(=그걸 만족하는 m)(=t의 name이 팀A인 데이터)을 조회

ALL
select o from Order o where o.orderAmount > ALL(select p.stockAmount from Product p)
=> 서브쿼리 결과의 모든값보다 orderAmount의 값이 큰 데이터가 있으면 해당 데이터(=그걸 만족하는 o)(=서브쿼리 결과 각각의 모든값보다 orderAmount의 값이 큰 데이터)를 조회

ANY
select m from Member m where m.team = ANY (select t from Team t)
=> 서브쿼리 결과에 m과 연관된 team이 하나라도 있으면 해당 데이터(m)(=그걸 만족하는 m)을 조회 => IN과 의미가 같다.


참고 - enum

public enum MemberType { // enum(클래스가 아님) 
	ADMIN, USER // enum상수
}

MemberTYpe d = MemberType.ADMIN; // 이렇게 사용
  1. enum도 클래스처럼 객체를 생성할 수 있다.
    enum객체는 미리 만들어진 각각의 enum상수를 말한다. 즉 enum상수는 enum객체이다. 즉 enum상수 = enum객체이다.
    enum을 정의할때 enum상수를 정의하는데 이게 enum객체를 생성하는것이다.
    그래서 MemberType.ADMIN를 하면 이미 만들어진 enum객체를 생성하는게 아니라 참조하는것이다.
  2. class는 new()로 class의 객체를 생성하지만, enum은 MemberType.USER와 같은 형태로 enum객체를 참조한다.
  3. 참고로 각각의 enum객체는 싱글톤 객체이고, enum이 정의될 때 단 한 번만 생성되며, 이후 프로그램 전역에서 공유되어 사용된다. 그래서 단 하나의 객체만 생성되고,
    이를 다른곳에서 사용하는것이다. if(d==MemberType.ADMIN)를 하면 객체의 주소값 비교이기 때문에 true가 나온다. 그리고 d를 출력하면 enum상수 ADMIN이 출력된다.
@Entity
public class Member {

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

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

    private MemberType type; // enum 타입
}


Member member = new Member();
member.setUsername("member1");
member.setAge(10);
member.setType(MemberType.USER);
em.persist(member);

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

String query = "select m.username, 'HELLO', true from Member m where m.type = jpql.MemberType.USER";
List<Object[]> result = em.createQuery(query).getResultList();

참고로 위 코드를 실행하면,

select
	member0_.username as col_0_0_,
	'HELLO' as col_1_0_,
	true as col_2_0_ 
from
	Member member0_ 
where
	member0_.type=1

이와 같은 sql로 변환되어 실행되는데, member0_.type=1에서 1로 되어있다.
이는 자바에서 enum타입의 값을 저장할때, USER가 아니라 순서를 저장하기 때문이다. 그래서 db에 보면 1이라고 저장되어있다.
이게 아니라 USER 자체를 저장하기 위해서는,

@Enumerated(EnumType.STRING)
private MemberType type;

이렇게 해주어야한다.
그럼 sql도

select
	member0_.username as col_0_0_,
	'HELLO' as col_1_0_,
	true as col_2_0_ 
from
	Member member0_ 
where
	member0_.type='USER'

이와 같이 실행되고, db에도 USER라고 저장이 된다.

참고

JPQL을 작성할 때, select t.members.username from Team t 형태로 하면 안된다.
대신에 select m.username from Team join t.members m 형태로 해야한다.
select m.username from Team join t.members m를 실행하면 SELECT m.username FROM team t JOIN member m ON t.id = m.team_id 쿼리로 변환되서 실행된다.

여기서,

  1. t.members가 member로 변환되는 이유는 컬렉션 필드 members의 제네릭 타입이 Member이기 때문이다. t.members는 Team 객체가 갖는 List 컬렉션이다. 이 컬렉션은 결국 Member 엔티티에 해당하는 테이블, 즉 member 테이블의 여러 행들을 의미한다. 따라서 JPA는 join t.members를 Member 엔티티 대상으로 join하는것으로 해석하고, SQL에서는 member 테이블로 변환된다.

  2. ON t.id = m.team_id가 추가되는 이유는 Team의 members와 Member의 team이 연관관계인 상태이기 때문이다.

  3. from 뒤에는 엔티티객체만 적을수 있는데, join 뒤에는 엔티티객체뿐만 아니라 엔티티객체의 연관필드도 적을수 있다. 하지만 이때는 반드시 경로표현식 형태인 .을 통해 나타내야한다.


조건식

  • COALESCE : 하나씩 조회해서 null이 아니면 반환
    SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m;
    => m.username이 NULL이 아니면 → m.username 반환
    m.username이 NULL이면 → '이름 없는 회원' 반환

    SELECT COALESCE(m.username, m.nickname, '이름 없는 회원') FROM Member m;
    => m.username이 NULL이 아니면 → m.username 반환
    m.username이 NULL이고, m.nickname이 NULL이 아니면 → m.nickname 반환
    m.username, m.nickname 모두 NULL이면 → '이름 없는 회원' 반환

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


페치조인

연관된 엔티티나 컬렉션을 SQL 한번에 함께 조회하는 기능이다.

  • join fetch나 left join fetch 명령어를 사용한다.
  • fetch 조인은 결국 즉시로딩 처럼 동작한다.
  • 그런데, fetch = EAGER 즉시로딩으로 설정하지 않는 이유는 아예 로딩 설정을 저렇게 해버리면 fetch 조인이 필요하지 않을 경우에도 모든 쿼리가 한 번에 날아가서 리소스 낭비이다.
    기본적으로 eager loading을 지양하는 이유는 불필요한 부분도 조회하기 때문이다. 예를 들어 A만 필요해서 조회했는데 eager loading으로 설정해둔 B, C 등도 같이 조회하게 되면 이것은 리소스 낭비일 수 있다.
    lazy loading + fetch join을 권장하는 이유는 본질적으로 필요할 때만 같이 불러오기 위해서이다. A만 필요할 때는 A만 부르고 A와 B를 같이 부르고 싶을 때 fetch join을 사용하는 것이다. 개발자가 선택할 수 없이 무조건 같이 부르는 것과 필요할 때만 같이 부르는 것은 분명 다르다고 생각한다.
public class Member {
	...

	@ManyToOne(fetch = FetchType.EAGER)
	//@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn
	private Team team;
    
    ... getter and setter
}

em.find

Team team = new Team();
team.setName("teamA");
em.persist(team);

Member member = new Member();
member.setName("hello1");
member.setTeam(team);
em.persist(member);

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

Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember = " + findMember.getTeam().getName());

1. 즉시로딩
하나의 쿼리로 연관된 데이터도 조회한다. Member와 Team의 데이터를 한번에 조회한다. 연관관계의 데이터도 함께 조회한다.
2. 지연로딩
Member의 데이터만 조회한다. 연관관계의 데이터는 조회하지않는다. 이후에 findMember.getTeam().getName();을 호출하면 추가적인 쿼리가 발생하고, 연관된 데이터가 조회된다.(N+1)

jpql

Team team = new Team();
team.setName("teamA");
em.persist(team);

Member member = new Member();
member.setName("hello1");
member.setTeam(team);
em.persist(member);

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

String jpql = "select m from Member m";
//String jpql = "select m from member m join fetch m.team";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();
	for (Member member1 : resultList) {
		System.out.println("member1 = " + member1);
               //member1.getTeam().getName();
       }

1. 즉시로딩

  • Member를 조회하기 위해 JPQL 실행
  • JPQL은 EAGER과 무관하게 SQL 그대로 번역되서 실행됨. -> select m from member m
  • JPQL 결과는 member만 조회하고, team은 조회하지않음. 하지만 em.createQuery(jpql, Member.class).getResultList();를 통해 member를 조회할때, member와 team이 즉시로딩으로 설정되어있기 때문에 연관된 데이터를 조회하기 위한 추가적인 쿼리가 발생함. (N+1)

2. 지연로딩

  • Member를 조회하기 위해 JPQL 실행
  • JPQL은 LAZY와 무관하게 SQL 그대로 번역되서 실행됨. -> select m from member m
  • JPQL 결과가 member만 조회하고, team은 조회하지않음
  • member와 team이 지연로딩으로 설정되어있기 때문에 연관된 데이터는 조회하지않음.
  • 실제 team을 사용하는 시점(member1.getTeam().getName())에 연관된 데이터를 조회하기 위한 추가적인 쿼리가 발생함.(N+1)

3. fetch join(연관된 데이터를 SQL한번에 함께 조회하는 기능)

  • Member와 Team을 한번에 조회하기 위해 JPQL 실행
  • JPQL에서 fetch join을 사용했으므로 LAZY든 EAGER든 상관없이 SQL은 Member와 Team을 하나의 쿼리로 조회 -> select m., t. from member inner join team t ON m.team_id = t.id
  • JPQL 결과가 member와 team을 한꺼번에 조회함.
  • member와 team이 한번에 조회됬으므로 N+1문제가 발생하지않음.

즉, em.find()를 사용하면 지연로딩일때 N+1문제가 발생하고, jpql을 사용하면 즉시로딩 및 지연로딩일때 N+1문제가 발생한다.


엔티티 페치조인

  • 다대일 관계나 일대일 관계에 대해서 페치조인을 하는것
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().name());
  }
  // 페치조인으로 회원과 팀을 함께 조회해서 지연로딩X
  1. 연관된 엔티티도 함께 조회할 수 있다.
    [JPQL]
    select m from Member m join fetch m.team
    [SQL]
    select M.*, T.* MEMBER M INNER JOIN TEAM T ON M.TEAM_ID = T.ID

  2. 참고로, 영속성 컨텍스트의 1차캐시에는 3개의 member객체와 2개의 team객체가 들어가 있으며,
    JPQL 결과 리스트에는 3개의 Member객체가 들어가 있다.
    jpql을 실행해서 sql쿼리가 실행되고 난 결과 row 수 만큼 데이터가 리턴된다.


컬렉션 페치조인

  • 일대다 관계에서 페치조인을 하는것
  String jpql = "select t from Team t join fetch t.members where t.name = '팀A'";
  List<Team> teams = em.createQuery(jpql, Team.class).getResultList();.
  
  for(Team team : teams ) {
  		System.out.println("teamName = " + team.getName());
  		for(Member member : team.getMembers()) {
  			System.out.println("username = " + member.getUsername());
  			// 페치조인으로 회원과 팀을 함께 조회해서 지연로딩X![](https://velog.velcdn.com/images/limjaewoo/post/7dfbc655-6024-474e-a984-81104db3a488/image.png)

  		}
  }
  1. 1대다로 연관된 컬렉션을 함께 조회할 수 있다.
    [JPQL]
    select t from Team t join fetch t.members where t.name = '팀A'
    [SQL]
    SELECT T.*, M.* FROM TEAM T INNER JOIN MEMBER M ON T.ID = M.TEAM_ID WHERE T.NAME = '팀A'

  2. 컬렉션 페치 조인을 사용할 때에는 중복된 데이터가 결과 리스트에 포함될 수 있다.

    • jpql을 실행해서 sql쿼리가 실행되면, 연관된 컬렉션에 있는 엔티티의 수만큼(=일대다 연관관계에서 다의 개수 만큼) 결과 row가 늘어나므로 JPQL 결과 리스트에 같은 엔티티 객체가 중복되어 포함될 수 있다.
    • jpql을 실행해서 sql쿼리가 실행되면 결과가 2개가 나오는데, jpql에서 select t를 했으므로 2개의 Team데이터가 리스트에 들어간다. 즉 jpql을 실행해서 sql쿼리가 실행되고 난 결과 row 수 만큼 데이터가 리턴된다. 이때, Team과 연관된 Member가 아닌 Team자체만으로 봤을때에는 동일한 Team데이터이다. 즉, 중복된 Team데이터가 리스트에 들어가 있다.
    • 그래서 팀1개에 조인된 멤버가 2명이면 동일한 팀 객체가 2번 중복되어 리스트에 들어간다.

👆 참고로, 관계형 데이터베이스에서, orders 테이블의 결과는 하나이고 order_item 테이블의 결과는 2개일때, order_id로 조인을 하게되면, order_item 테이블의 결과의 개수만큼 결과가 나온다.
select * from A JOIN B ON A.id = B.id; 에서 JOIN 조건에 따라 A의 한 행과 B의 N행이 매칭되면 결과는 N행이 된다. JOIN 조건에 따라 A의 N행과 B의 한 행이 매칭되면 결과는 N행이 된다. => 데이터가 뻥튀기 된다.
참고로 이 내용은, JOIN이든 LEFT JOIN이든 상관없이 적용되고, A와 B가 일대다 관계일때 A가 일이든 B가 다이든, A가 다이든 B가 일이든 상관없이 적용된다.

  1. 이때 distinct를 사용하면,
    3-1. sql에 distinct를 추가해서 db에 쿼리를 날린다.
    3-2. 그 결과가 애플리케이션에 넘어올때, 동일 식별자의 중복 데이터가 있으면 중복을 제거한다.

  2. 참고로, distinct를 사용하든 사용하지않든 영속성 컨텍스트의 1차캐시에는 하나의 team객체와 두개의 member객체가 들어가 있으며,
    distinct를 사용하면, JPQL 결과 리스트에 들어 있는 하나의 Team 객체는 1차 캐시의 Team 객체를 참조하고 있는것이고,
    distinct를 사용하지 않으면 JPQL 결과 리스트에 들어 있는 Team 객체들은 모두 동일한 1차 캐시의 Team 객체를 참조하고 있는것이다.


페치조인 특징

  • 페치조인 대상에는 별칭을 줄수없다

    • select t from Team t join fetch t.members where m.username = "test" 형태로 쓸 수 없다. 왜냐하면 JPA의 페치조인의 개념이 t.members에 대한 데이터를 전부 가져오겠다는것인데, 별칭을 주고 일부 데이터를 가져오거나 할 수 없다. 페치 조인의 목적은 연관된 엔티티를 한 번에 모두 로딩하기 위한 것이다. Team을 기준으로 members를 모두 함께 가져와야 페치조인의 의미가 있다.
  • 둘 이상의 컬렉션을 페치조인 할 수없다.

    • select t from Team t join fetch t.members join fetch t.orders 처럼 컬렉션을 페치 조인하고 거기에 또 컬렉션을 페치 조인 하는 상황을 의미한다.
    • 컬렉션을 페치조인 할때에도 중복된 데이터가 있을 수 있어서 데이터가 뻥튀기가 되는데 거기에 컬렉션을 한 번 더 페치 조인 시키면 데이터가 얼마나 뻥튀기 될 지 모르기에 하면 안 된다.
  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.

    • jpql을 사용할때 페이징api를 사용하면 jpql이 sql로 변환되서 실행될때 해당 sql에 페이징쿼리가 추가된다. 컬렉션을 페치 조인한 경우에는 데이터가 뻥튀기되기 때문에, 페이징을 사용하더라도 뻥튀기된 결과에 대해 페이징이 적용된다. 이로 인해 의도한 순서나 범위에 맞지 않는 결과가 반환될 수 있다.

    • distinct를 사용해봐도 안된다. jpql을 사용할때 페이징api를 사용하면 jpql이 sql로 변환되서 실행될때 해당 sql에 페이징쿼리가 추가된다. 이때 distinct는 sql에 DISTINCT를 추가해서 쿼리를 날리고나서, 그 결과가 애플리케이션에 넘어올때 중복된 데이터가 있으면 중복을 추가적으로 제거하는것인데, 예를들어 setMaxResult(2)를 해서 2개의 데이터를 가져오려는 상황속에서 sql에 DISTINCT를 추가해서 쿼리를 날렸는데 distinct가 되지않고, 이때는 결과가 2개이다. setMaxResult(2)이기 때문에 이 결과가 애플리케이션에 넘어오는데 이때 중복된 데이터가 있어서 제거해버리면 결국 1개의 데이터만 넘어오게된다. sql에서는 페이징쿼리에 의해 2개의 데이터가 넘어갔는데 애플리케이션에 넘어올때 중복된 데이터가 있어서 제거해버려서 결과적으로 1개의 데이터만 넘어오기때문에 문제가 발생한다. 2개의 데이터를 가져오려는 페이징인데 1개의 데이터만 넘어오게되서 문제가 발생한다. 즉 SQL쿼리상으로는 정상적으로 데이터를 2개 보냈는데 애플리케이션에서 중복된 데이터이기때문에 중복 데이터를 제거해서 1개만 나오기 때문에 문제가 있다.

      예를들어, Team이 총 10개 있고 각 Team마다 Member가 3명씩 있다고 했을때 sql로 변환되서 실행되면

      SELECT DISTINCT ...
      FROM team t
      JOIN member m ON t.id = m.team_id
      LIMIT 2

      이런식으로 실행된다.
      하지만 의도한바는 Team 2개를 가져오고 싶어서 페이징했는데, 중복된 row로 인해 결국 Team 1개만 반환되는 결과가 나온다. SQL 쿼리 상으론 분명 2개를 보냈는데 애플리케이션에서는 중복 제거로 인해 1개만 남게 되는 것이다. 2개의 데이터를 가져오기 위해 페이징을 했고, SQL 쿼리 상으로는 분명 2개의 결과가 반환되었지만, 애플리케이션에서 중복 제거 과정으로 인해 1개만 남게 되는 문제가 발생한다. 즉, 오류 없이 정상 실행되지만, 원하는 결과가 아니기 때문에 문제가 된다.


컬렉션을 페치 조인하면 페이징 API를 사용할수 없기 때문에 아래와 같은 2가지 방법을 사용하면 된다.

  1. 일대다 페치 조인이 아니라 다대일 페치조인으로 한다.
    select t from Team t join fetch t.members가 아니라 select m from Member m join fetch m.team으로 한다.
    이렇게 하면 데이터 뻥튀기가 발생하지 않기 때문에 페이징 사용이 가능하다.
    Member 기준 페이징 → row 수 뻥튀기 없음 → 페이징 가능
  2. 지연로딩 + @BatchSize
	       Team teamA = new Team();
            teamA.setName("팀A");
            em.persist(teamA);

            Team teamB = new Team();
            teamB.setName("팀B");
            em.persist(teamB);

            Member member1 = new Member();
            member1.setUsername("member1");
            member1.setTeam(teamA);
            em.persist(member1);

            Member member2 = new Member();
            member2.setUsername("member2");
            member2.setTeam(teamA);
            em.persist(member2);

            Member member3 = new Member();
            member3.setUsername("member3");
            member3.setTeam(teamB);
            em.persist(member3);

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

            String jpql = "select t from Team t";
            List<Team> result = em.createQuery(jpql, Team.class)
                    .setFirstResult(0)
                    .setMaxResults(2)
                    .getResultList();
            System.out.println("result = " + result.size());

            for (Team team : result) {
                System.out.println("team = " + team.getName() + "|members" + team.getMembers());
                for (Member member : team.getMembers()) {
                    System.out.println("-> member = " + member);
                }
            }

위 코드에서, 단순 지연로딩일때는

Hibernate: 
    /* select
        t 
    from
        Team t */ select
            team0_.id as id1_3_,
            team0_.name as name2_3_ 
        from
            Team team0_ limit ?
result = 2
Hibernate: 
    select
        members0_.TEAM_ID as team_id5_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.age as age2_0_1_,
        members0_.TEAM_ID as team_id5_0_1_,
        members0_.type as type3_0_1_,
        members0_.username as username4_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
team =A|members[Member{id=3, username='member1', age=0, type=null}, Member{id=4, username='member2', age=0, type=null}]
-> member = Member{id=3, username='member1', age=0, type=null}
-> member = Member{id=4, username='member2', age=0, type=null}
Hibernate: 
    select
        members0_.TEAM_ID as team_id5_0_0_,
        members0_.id as id1_0_0_,
        members0_.id as id1_0_1_,
        members0_.age as age2_0_1_,
        members0_.TEAM_ID as team_id5_0_1_,
        members0_.type as type3_0_1_,
        members0_.username as username4_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
team =B|members[Member{id=5, username='member3', age=0, type=null}]
-> member = Member{id=5, username='member3', age=0, type=null}

이런식으로 2번의 추가 쿼리가 발생한다.

@Entity
public class Team {

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

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

}

하지만 지연로딩일때 위와 같이 @BatchSize를 설정하게되면,

Hibernate: 
    /* load one-to-many jpql.Team.members */ select
        members0_.TEAM_ID as team_id5_0_1_,
        members0_.id as id1_0_1_,
        members0_.id as id1_0_0_,
        members0_.age as age2_0_0_,
        members0_.TEAM_ID as team_id5_0_0_,
        members0_.type as type3_0_0_,
        members0_.username as username4_0_0_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID in (
            ?, ?
        )
team =A|members[Member{id=3, username='member1', age=0, type=null}, Member{id=4, username='member2', age=0, type=null}]
-> member = Member{id=3, username='member1', age=0, type=null}
-> member = Member{id=4, username='member2', age=0, type=null}
team =B|members[Member{id=5, username='member3', age=0, type=null}]
-> member = Member{id=5, username='member3', age=0, type=null}

이런식으로 1번의 추가 쿼리가 발생한다. @BatchSize 설정에 의해 최적화된 지연 로딩 방식이다.
하나의 쿼리로 팀A와 연관된 멤버뿐만 아니라 팀B와 연관된 멤버도 함께 가져온다.
team.getMembers()를 호출하기 전에 이미 영속성 컨텍스트에는 여러 팀(팀A, 팀B)이 존재한다.
그래서 처음 team.getMembers()를 호출하게되면 팀A와 연관된 멤버만 조회하는 쿼리가 발생할것 같지만, 내부적으로 영속성 컨텍스트에 있는 모든 각각의 팀들과 연관된 멤버를 한번에 가져오는 쿼리(=PK 기반의 IN 조건을 사용하는 쿼리)가 발생한다.
즉 첫번째 팀의 getMembers()를 호출하는 시점에 모든 각각의 팀들과 연관된 데이터를 한번에 가져온다.
참고로 size는 최대 10개의 팀에 대해, 연관된 모든 멤버를 가져오라는 의미이다. 영속성 컨텍스트에 팀이 20개가 있으면 2번의 쿼리로 나누어 실행된다.


엔티티 직접 사용

JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값을 사용한다.

  1. [JPQL]
    select count(m) from Member m // 엔티티를 직접 사용
    select count(m.id) from Member m // 엔티티의 아이디를 사용

    [SQL]
    select count(m.id) as cnt from Member m // 둘다 이렇게 SQL이 실행된다.

  2. 엔티티를 파라미터로 전달하는 경우
    2-1
    String jpql = "select m from Member m where m = :member";
    List resultList = em.createQuery(jpql).setParameter("member", member).getResultList();

    String jpql = "select m from Member m where m.id = :memberId";
    List resultList = em.createQuery(jpql).setParameter("memberId", memberId).getResultList();

    [SQL]
    select m.* from Member m where m.id=? // 둘다 이렇게 SQL이 실행된다.

    2-2
    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();

    String jpql = "select m from Member m where m.team.id = :teamId";
    List resultList = em.createQuery(jpql).setParameter("teamId", teamId).getResultList();

    [SQL]
    select m.* from Member m where m.team_id=? // 둘다 이렇게 SQL이 실행된다.

    참고로, select m from Member m 에서 select m 부분의 m은 엔티티 자체를 의미한다. 그래서 SQL로 변환될때에는 해당 엔티티를 구성하는 모든 컬럼을 SELECT에 나열한다. 왜냐하면 JPA가 이 결과를 바탕으로 Member 객체에 매핑해야 하기 때문이다.
    [JPQL]
    select m from Member m
    [SQL]
    select id, username, team_id from member



Named 쿼리

@Entity
@NamedQuery(name = "Member.findByUsername", query="select m from Member m where m.username = :username")
public class Member {
  	...
}

List<Member> result = em.createNamedQuery("Member.findByUsername", Member.class)
  						.setParameter("username", "회원1")
  						.getResultList();
  • 미리 정의해서 이름을 부여해두고 사용하는 JPQL이다.

  • 애플리케이션 로딩 시점(=애플리케이션 시작 시점=서버가 띄워질때)에 한번 초기화하고나서, 다른곳에서 사용할때는 초기화했던 named쿼리를 재사용하는것이다.

  • 애플리케이션 로딩 시점에 쿼리를 검증하기때문에 문법에러 같은 것을 검증한다.



벌크연산

쉽게 말해 SQL의 UPDATE나 DELETE 라고 생각하면 된다.

예를들어, 재고가 10개 미만인 모든 상품의 가격을 10% 상승하려고 할때,

  1. JPA의 변경 감지(dirty checking) 기능으로 실행하려면 너무 많은 SQL을 실행해야한다.
    1-1. 재고가 10개 미만인 상품을 리스트로 조회한다.
    1-2. 루프를 돌리면서 상품의 가격을 10% 증가한다.
    1-3. 그러면 트랜잭션 커밋 시점에 변경감지가 동작한다.
    ==> 변경된 데이터가 100건이라면 100번의 UPDATE SQL이 실행된다.

  2. 벌크 연산을 사용하면 쿼리 한 번으로 가능하다.
    String jpql = "update Member m set m.age = 20";
    int resultCount = em.createQuery(jpql).executeUpdate();

    • UPDATE나 DELETE를 할때 사용 가능하다.
    • jpql을 실행하면 SQL로 변환되어 UPDATE나 DELETE문이 실행된다.
    • executeUpdate()의 결과는 영향받은 엔티티 수를 반환한다

<벌크연산 주의사항>

Member member1 = new Member();
member1.setUsername("member1");
member1.setAge(0);
em.persist(member1); // 영속성 컨텍스트에 저장됨
em.createQuery("update Member m set m.age = 20").executeUpdate(); // DB만 갱신
System.out.println(member1.getAge()); // 🔴 여전히 0
  • 벌크연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 실행한다.
  • 영속성 컨텍스트와 상관없이 데이터베이스에 직접 쿼리를 날린다.
  • 즉, 영속성 컨텍스트의 객체가 있더라도 해당 객체에는 반영하지않고 데이터베이스에 바로 쿼리를 날린다.
  • JPA는 JPQL쿼리가 실행되면 flush()가 자동으로 호출되고 나서 JPQL이 실행된다.
    그래서 insert쿼리가 실행되고 upate쿼리가 실행된다.

그렇기 때문에 아래 두가지 방법 중 하나로 해야한다.
  1. 벌크 연산을 먼저 실행
    em.find() 등 영속성 컨텍스트에 작업을 먼저 하지말고 벌크연산을 먼저 실행한다.
    이렇게 하면 영속성 컨텍스트에 아무것도 없으니까 문제가 없다.

  2. 벌크 연산 수행 후 영속성 컨텍스트 초기화
    영속성 컨텍스트에 값이 이미 있는 상태에서 벌크 연산을 수행하면, 수행 후 영속성 컨텍스트를 초기화 해주어야한다.

    Member member1 = new Member();
    member1.setUsername("member1");
    member1.setAge(0);
    em.persist(member1); // 영속성 컨텍스트에 저장됨
    em.createQuery("update Member m set m.age = 20").executeUpdate(); // DB만 갱신
    
    em.clear();
    
    Member findMember = em.find(Member.class, member1.getId());
    System.out.println("findMember = " + findMember.getAge()); // 🔴 20

0개의 댓글