본문 바로가기
BackEnd/JPA

[JPA] 객체지향 쿼리 언어2 - 중급 문법(1)

by 개발 Blog 2024. 7. 21.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

경로 표현식

.(점)을 찍어 객체 그래프를 탐색하는 것

select m.username -> 상태 필드
  from Member m
  join m.team t -> 단일 값 연관 필드
  join m.orders o -> 컬렉션 값 연관 필드
 where t.name = '팀A'

 

경로 표현식 용어 정리

- 상태 필드(state field): 단순히 값을 저장하기 위한 필드 (ex: m.username)

- 연관 필드(association field): 연관관계를 위한 필드

  • 단일 값 연관 필드: @ManyToOne, @OneToOne, 대상이 엔티티(ex: m.team)
  • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany, 대상이 컬렉션(ex: m.orders)

경로 표현식 특징

상태 필드(state field) : 경로 탐색의 끝, 탐색이 불가능하다.

String query = "select m.username From Member m";

- m.username에서 . 을 찍어서 추가 탐색이 불가능하다.

 

단일 값 연관 경로 : 묵시적 내부 조인(inner join)이 발생하며 탐색이 가능하다.

- 객체 입장에서는 m.team 처럼 작성해도 되지만 테이블 입장에서는 조인을 해야 값을 가져올 수 있다. (묵시적 조인)

 

컬렉션 값 연관 경로 : 묵시적 내부 조인이 발생하며 탐색이 불가능하다.

select t.members From Team t

- t. 으로 탐색하는 것과 다르게 t.members는 컬렉션이기 때문에 쓸 수 있는 게 size 뿐이다.

 

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

 

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

- 항상 내부 조인

- 컬렉션은 경로 탐색의 , 명시적 조인을 통해 별칭을 얻어야 한다.

- 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시 조인으로 인해 SQL FROM (JOIN) 절에 영향을 준다.

 

실무 조언

- 가급적 묵시적 조인 대신에 명시적 조인 사용

- 조인은 SQL 튜닝에 중요 포인트

- 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어려움

 

결론 : 묵시적 조인 쓰지말고 명시적 조인을 쓰자.

 

페치 조인 - 1 기본 (실무에서 엄청 중요한 파트)

- SQL 조인 종류는 제외한다.
- JPQL에서 성능 최적화를 위해 제공하는 기능이다.
- 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다.
- join fetch 명령어를 사용한다.
- 페치 조인 구문은 LEFT [OUTER] | INNER JOIN FETCH 조인경로이다.

 

엔티티 페치 조인

- 회원을 조회하면서 연관된 팀도 함께 조회(SQL 한 번에)한다.
- SQL을 보면 회원 뿐만 아니라 팀(T.*)도 함께 SELECT 한다.

// [JPQL]
select m from Member m join fetch m.team

// [SQL]
SELECT M.*, T.* FROM MEMBER M INNER JOIN TEAM T ON M.TEAM_ID=T.ID

- JPQL에서 select에 m만 적었지만 실행되는 SQL은 M과 T의 모든 정보를 가져오고 있다.

- 마치 즉시로딩처럼 동작하지만 원하는 데이터를 명시적으로 동적인 타이밍에 가져올 수 있다.

 

페치 조인 예제

 

- MEMBER에서 TEAM 정보를 가져올 때 모든 정보를 다 가져온다고 가정한다.

 

Member 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    @Enumerated(EnumType.STRING)
    private MemberType type;
    
    ...
    
}

 

fetch 조인을 사용하지 않은 코드

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("회원1");
member1.setTeam(teamA);
em.persist(member1);

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

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

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

String query = "select m From Member m";

List<Member> result = em.createQuery(query, Member.class)
        .getResultList();

for (Member member : result) {
    System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
    //회원1, 팀A(SQL)
    //회원2, 팀A(1차캐시)
    //회원3, 팀B(SQL)
}

tx.commit();

- Member 엔티티를 보면 team에 대한 패치 타입이 LAZY이다. 그럼 Team은 프록시로 처리되기 때문에 지연로딩이 발생한다. 따라서 Team에 대한 정보는 member.getTeam().getName()을 호출할 때마다 DB에서 가져온다.

- select 쿼리가 총 3번 나갔다. 그 이유를 분석해 보자.

  • "select m From Member m"를 실행하여 Member 엔티티의 모든 인스턴스를 조회한다. 
  • 첫 번째 SQL : em.createQuery(query, Member.class).getResultList()가 호출될 때 DB에서 Member 엔티티들을 가져오고 영속성 컨텍스트에 등록된다.
  • 두 번째 SQL: member.getTeam().getName()를 호출할 때 여기서 연관된 Team 엔티티도 프록시 객체를 통해 지연 로딩되어 실제 DB에서 조회된다. 회원1과 회원2는 같은 팀A에 속해 있으므로 회원1의 팀이 조회된 이후에는 회원2의 팀은 1차 캐시에서 조회된다. 따라서 회원2의 팀 조회 시 SQL 쿼리가 발생하지 않는다.
  • 세 번째 SQL : 회원3의 팀B는 새로운 팀이므로 DB에서 조회된다.

요약 

  • 첫 번째 루프에서 Member 엔티티의 모든 인스턴스(회원1, 회원2, 회원3)는 영속성 컨텍스트에 등록된다.
  • 각 Member 엔티티에 접근할 때 연관된 Team 엔티티도 지연 로딩된다.
  • 회원1의 팀은 데이터베이스에서 조회되지만, 이후 회원2의 팀은 1차 캐시에서 조회된다.
  • 회원3의 팀은 다시 데이터베이스에서 조회된다.

만약 회원을 100명 조회한다고 하면 N + 1의 쿼리가 발생한다. (101번 조회) 

 

회원이 1000명이면? 1000000명이면? 상상만 해도 비효율적인 것을 알 수 있다.

 

fetch 조인을 사용한 코드

String query = "select m From Member m join fetch m.team";

- select 쿼리 한 번에 모든 데이터를 가져온다. 

- 따라서 fetch 조인으로 위의 문제를 해결할 수 있다. (실무에서 엄청 많이 쓰임)

- 지연로딩보다 fetch조인이 먼저 실행된다.

 

컬렉션 페치 조인

 1:N 관계에서의 조인을 말한다.

 //[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'

- 1:N관계일 때 1의 입장에서 JOIN을 하면 데이터가 중복으로 조회될 수 있다.

 

페치 조인과 DISTINCT

- SQL DISTINCT 중복된 결과를 제거하는 명령이다.

- JPQL DISTINCT 2가지 기능을 제공한다.

  • SQL에 DISTINCT를 추가한다.
  • 애플리케이션에서 엔티티 중복 제거한다.

예시

 select distinct t from Team t join fetch t.members where t.name = ‘팀A’

 

- SQL에 distinct를 추가했지만 데이터가 100% 일치하지 않으므로 SQL 결과에서 중복제거에 실패한다.

 

SQL만으로는 중복 제거가 되지 않기 때문에 JPA에서는 애플리케이션에서 같은 식별자를 가진 엔티티를 제거한다.

String query = "select distinct t from Team t join fetch t.members";

List<Team> result = em.createQuery(query, Team.class).getResultList();

System.out.println("result = " + result.size());

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

- 중복이 제거되었다.

 

하이버네이트6 변경 사항 

- 하이버네이트 6부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용된다.

 

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

일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않는다.

String query = "select t from Team t join t.members m";

- JPQL은 결과 반환 시 연관관계를 고려하지 않는다.
- SELECT 절에 지정한 엔티티만 조회한다.
- 여기서는 팀 엔티티만 조회하고, 회원 엔티티는 조회하지 않는다.
- 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회한다. (즉시 로딩)
- 페치 조인은 객체 그래프를 한 번의 SQL로 조회하는 개념이다.

 

페치 조인 2 - 한계

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

String query = "select t from Team t join fetch t.members as m";

- 하이버네이트는 가능하지만 가급적 사용하지 않는 것을 권장한다.

 

예를 들어 아래와 같은 예가 있을 때

String query = "select t from Team t join fetch t.members as m where m.age > 10";

 

- 페치 조인은 조회하는 대상과 연관된 모든 데이터를 함께 가져오는 것을 의미한다.

- 그러나 WHERE 절로 데이터를 필터링하면 필요한 데이터가 누락될 가능성이 있다.

- 예를 들어, 팀에 속한 멤버 5명 중, 10살 초과인 3명만 조회할 경우, JPA는 객체 그래프를 통해 모든 데이터를 일관되게 조회하는 것을 의도하기 때문에 `Team.members`를 호출했을 때 원래는 5명이 다 나와야 하는 것이 맞다.

- 또한, 동일한 `members` 조회에서 한 경우는 5명, 다른 경우는 3명만 가져오게 되면, 영속성 컨텍스트에서는 이러한 데이터 정합성을 보장하지 않는다.

- 따라서 페치 조인을 사용할 때는 AS를 사용하지 않는 것이 권장된다. 이는 객체 그래프의 일관성을 유지하고 데이터 누락을 방지하기 위함이다.

 

2. 둘 이상의 컬렉션은 페치 조인을 할 수 없다.

- 이전 시간에서 1:N인 경우에 데이터가 뻥튀기가 된다고 배웠다. 지금은 1:N:N인 경우이기 때문에 데이터가 예상하지 못하게 늘어날 수 있다.

 

3. 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.

- 1:1, N:1 같은 단일 값 연관 필드들은 페치 조인을 해도 페이징이 가능하다. (데이터 뻥튀기가 안되기 때문)

- 하이버네이트는 경고 로그를 남기고 메모리에서 페이징 한다.(매우 위험)

예시)

- 팀A는 데이터를 2건 가지고 있다.

- 하지만 페이지 사이즈를 1로 설정하면 빨간 박스 안에 있는 데이터만 나온다.

- 그럼 JPA는 팀A는 하나이고 회원1만 가졌다고 인식한다.

- 이런 데이터 불일치가 문제가 된다.

 

코드 예시)

String query = "select t from Team t join fetch t.members as m";

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

- 경고 메시지의 의미는 JPA가 페이징을 SQL쿼리 레벨에서 처리할 수 없기 때문에, 메모리 내에서 페이징을 적용한다는 뜻이다.

- 즉 DB의 모든 데이터를 메모리에서 처리한다는 뜻이다.

- 예를 들어 데이터가 100만 건 있다고 하면 100만 건을 전부 메모리에 올린 다음 페이징을 적용할 것이다. 장애 나기 딱 좋다.

 

해결방법

1) 1:N -> N:1 관계로 변경

String query = "select m from Member m join fetch m.team t";

1:N 관계를 N:1 관계로 변경한다. from절을 Member에서 시작하면 된다.

 

2) BatchSize() 사용

@Entity
public class Team {
	...
    
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    
}

- Team 엔티티의 각 멤버를 접근할 때 지연 로딩이 발생하며, @BatchSize 설정에 따라 여러 멤버들은 한 번에 가져오도록 최적화한다.

- BatchSize를 사용하면 쿼리를 여러 번 사용할 필요 없이 한 번에 데이터를 가져올 수 있다.

 

아래와 같이 글로벌 설정으로 BatchSize를 적용할 수도 있다.

persistence.xml

<property name="hibernate.default_batch_fetch_size" value="100"/>

 

정리

- 연관된 엔티티들을 SQL 한 번으로 조회하여 성능을 최적화할 수 있다.
- 페치 조인은 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선한다.
- 글로벌 로딩 전략은 주로 `@OneToMany(fetch = FetchType.LAZY)`와 같은 지연 로딩을 사용한다.
- 실무에서는 글로벌 로딩 전략을 모두 지연 로딩으로 설정한다.
- 최적화가 필요한 곳에 페치 조인을 적용한다.
- 모든 경우에 페치 조인을 사용할 수는 없다.
- 페치 조인은 객체 그래프를 유지할 때 효과적이다.
- 여러 테이블을 조인해서 엔티티 모양이 아닌 다른 결과를 만들어야 할 때는 페치 조인보다 일반 조인을 사용하고 필요한 데이터만 조회하여 DTO로 반환하는 것이 효과적이다.