[JPA] Fetch Join의 양면성
들어가기 전
저는 Fetch Join이 장점만 존재한다고 생각을 하였었습니다. 그런데 모든 기술에는 장점만 존재하지 않다는 것을 다시 한번 깨닫게 되었습니다.
그래서 이번 포스팅에서는 N+1 문제를 해결하기 위해 사용하는 Fetch Join의 양면성에 대해서 다뤄보겠습니다.
N+1이 무엇인지 정확하게 모르시는 분은 아래 포스팅을 읽고 이번 포스팅을 읽으시는 것을 추천드리겠습니다.
https://hoestory.tistory.com/45
예제에서 사용할 코드 및 테이블 구조
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Parent{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
@OneToMany(mappedBy = "parent")
List<Child> children = new ArrayList<>();
public Parent(String name, int age) {
this.name = name;
this.age = age;
}
}
===========================================================================
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Child{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
private Parent parent;
public Child(String name, int age, Parent parent) {
this.name = name;
this.age = age;
this.parent = parent;
}
}
우선 필자가 알고 있던 Fetch Join에 대해서 설명하겠습니다.
공통 적용될 테스트 코드
@DataJpaTest
class ChildRepositoryTest {
@Autowired
private ParentRepository parentRepository;
@Autowired
private ChildRepository childRepository;
@Autowired
private EntityManager entityManager;
@BeforeEach
void init() {
Parent parent = new Parent("부모", 40);
Parent savedParent = parentRepository.saveAndFlush(parent);
Child child1 = new Child("자식1", 10, savedParent);
Child child2 = new Child("자식2", 20, savedParent);
Child child3 = new Child("자식3", 20, savedParent);
childRepository.saveAllAndFlush(List.of(child1, child2, child3));
entityManager.clear();
}
}
- entityManger.clear()을 하여 영속성 컨텍스트를 비워 준 이유는 아래에서 설명할 예제들을 위해 비워주었습니다.
Fetch Join의 이점
Fetch Join을 사용하면 연관된 엔티티도 함께 조회를 해오기 때문에 N+1 문제를 해결할 수 있어 성능에 도움이 됩니다.
Fetch Join을 사용 안 했을 때
@DisplayName("Fetch Join 사용 안했을 때")
@Test
void notUsedFetchJoin() {
Child findChild = childRepository.findById(1L).get();
System.out.println("findChild.getParent().getName() = " + findChild.getParent().getName());
}
Fetch Join을 사용했을 때
public interface ChildRepository extends JpaRepository<Child, Long> {
@Query("SELECT c FROM Child c JOIN FETCH c.parent p WHERE c.id = :id")
Optional<Child> findByIdFetchJoin(@Param("id") Long id);
}
@DisplayName("Fetch Join 사용 했을때 ")
@Test
void usedFetchJoin() {
Child findChild = childRepository.findByIdFetchJoin(1L).get();
System.out.println("findChild.getParent().getName() = " + findChild.getParent().getName());
}
Fetch Join을 사용하여 N+1을 해결한 것을 볼 수 있습니다. 이렇게 함으로써 불필요한 쿼리가 N번 더 발생하는 것을 방지할 수 있습니다.
Fetch Join의 문제점
위에서 Fetch Join을 사용했을 때에 대한 이점을 설명하였는데 Fetch Join을 사용할 때 아래와 같이 문제가 발생할 수 있습니다.
- Fetch Join 대상의 별칭 줄 수 없습니다.
- Fetch Join 대상의 별칭을 select절에서 사용할 수 없습니다.
- Fetch Join ON절 사용 용할 수 없습니다.
- Outer Fetch Join + Where 사용 시 데이터 유실될 수 있습니다.
Fetch Join 대상의 별칭 줄 수 없습니다.
public interface ChildRepository extends JpaRepository<Child, Long> {
@Query("SELECT p FROM Child c JOIN FETCH c.parent p ")
List<Parent> findFetchJoinAliasSelect();
}
- 별칭을 사용하면 오류가 발생하지 않지만 하이버네이트에서 별칭을 지원을 해주지만 JPA 표준에서는 별칭을 지원해주지 않아 주의해서 사용해야 합니다.
Fetch Join 대상의 별칭을 select절에서 사용할 수 없습니다.
public interface ChildRepository extends JpaRepository<Child, Long> {
@Query("SELECT p FROM Child c JOIN FETCH c.parent p ")
List<Parent> findFetchJoinAliasSelect();
}
===================================
@DisplayName("Fetch Join 별칭을 Select 절에 사용 했을때 ")
@Test
void findFetchJoinAliasSelect() {
List<Parent> findParent = childRepository.findFetchJoinAliasSelect();
findParent.forEach(it -> System.out.println("name = " + it.getName()));
}
Caused by: org.hibernate.query.SemanticException:
Query specified join fetching, but the owner of the fetched association was not present in the select list
[SqmSingularJoin(tomorrowme.todo.domain.Child(c).parent(p) : parent)]
- Fetch Join 대상의 별칭을 Select 절에 사용하면 위와 같은 에러가 발생합니다.
Fetch Join에서 ON 절을 사용할 수 없습니다.
public interface ChildRepository extends JpaRepository<Child, Long> {
@Query("SELECT c FROM Child c JOIN FETCH c.parent p ON p.id = :parentId")
List<Child> findByIdFetchJoinUseOn(@Param("parentId") Long id);
}
@DisplayName("Fetch Join ON절 사용 했을때 ")
@Test
void usedFetchJoinOn() {
List<Child> findChild = childRepository.findByIdFetchJoinUseOn(1L);
findChild.forEach(it -> System.out.println("name = " + it.getName()));
}
Caused by: org.hibernate.query.SemanticException: Fetch join has a 'with' clause (use a filter instead)
- Fetch Join 대상의 ON절을 사용하면 위와 같은 오류가 발생합니다.
- JPA에서는 객체의 상태와 DB의 일관성 문제가 발생할 수 있습니다.
그럼 ON절을 지원을 안 해주면 WHERE을 사용하면 조건을 세워서 데이터 추출하면 되지 않는가?라는 질문이 생길 수 있을 거 같습니다.
Fetch Join에서 ON절 대신 WHERE절 사용
public interface ChildRepository extends JpaRepository<Child, Long> {
@Query("SELECT c FROM Child c JOIN FETCH c.parent p WHERE p.id = :parentId")
List<Child> findByIdFetchJoinUseWHERE(@Param("parentId") Long id);
}
@DisplayName("Fetch Join WHERE절 사용 했을때 ")
@Test
void usedFetchJoinOn() {
List<Child> findChild = childRepository.findByIdFetchJoinUseWHERE(1L);
findChild.forEach(it -> System.out.println("name = " + it.getName()));
}
결과는 문제없이 잘 나왔습니다. 그런데 ON절 대신 WHERE을 사용하여 문제를 해결했는데 또 다른 문제를 발생시킬 수 있습니다.
Outer Fetch Join과 WHERE절을 함께 사용하면 데이터가 유실될 수 있습니다.
Outer Fetch Join + Where 사용 시 데이터 유실 가능성
엔티티가 양방향 관계일 때 연관관계 주인이 아닌 쪽에서 Outer Fetch Join과 Where을 사용하면 데이터 유실이 됩니다.
public interface ParentRepository extends JpaRepository<Parent, Long> {
@Query("SELECT p FROM Parent p LEFT JOIN FETCH p.children c WHERE c.name = :name")
Optional<Parent> findByChildName(@Param("name") String name);
}
@DisplayName("Left Fetch Join 별칭을 WHERE 절에 사용 했을때 ")
@Test
void findByChildName() {
parentRepository.findByChildName("자식1");
List<Parent> findAllParent = parentRepository.findAll();
for (Parent parent : findAllParent) {
for(Child child: parent.getChildren()) {
System.out.println("child.getName() = " + child.getName());
}
}
}
@BeforeEach를 보면 "자식 1", "자식 2", "자식3" 데이터를 삽입하였는데 위의 쿼리를 실행했을 때 결과값은 "자식1" 데이터만 나오고 나머지 데이터는 유실된 상태로 조회가 되지 않고 있습니다.
왜 데이터가 유실된 상태로 조회가 되는 것일까?
데이터는 위와 같이 존재한다고 하였을 때 parentRepository.findByChildName("자식1")을 통해 "자식1"을 조회 했을 때 영속성 컨텍스트 내부에는 "자식1" 데이터만 보관을 하고 있습니다.
Fetch Join은 연관된 엔티티의 모든 데이터값을 조회를 하여 영속성 컨텍스트에서 보관을 합니다.
그런데 위와 같이 Left Fetch Join과 Where을 사용하여 "자식1" 데이터만 영속성 컨텍스트에서 관리를 하면 JPA에서는 데이터베이스에 "자식1" 데이터만 존재하는 것으로 판단을 하여 findAll()을 하여 모든 데이터를 조회하더라도 "자식1" 데이터만 조회가 되고 "자식2", "자식3"에 대한 데이터는 조회가 되지 않는 문제가 발생합니다.
'JPA' 카테고리의 다른 글
[JPA] 2차 캐시란? (0) | 2024.01.16 |
---|---|
[JPA] JdbcTemplate과 JPA 데이터 Insert 속도 비교 (0) | 2023.06.09 |
[JPA] Cascade 옵션 종류 및 예제 (0) | 2023.04.12 |
[JPA] Kotlin을 이용하여 Soft Delete 구현 (0) | 2023.03.04 |
[JPA] N+1 원인 및 해결방법 (0) | 2023.01.18 |
댓글
이 글 공유하기
다른 글
-
[JPA] 2차 캐시란?
[JPA] 2차 캐시란?
2024.01.16 -
[JPA] JdbcTemplate과 JPA 데이터 Insert 속도 비교
[JPA] JdbcTemplate과 JPA 데이터 Insert 속도 비교
2023.06.09 -
[JPA] Cascade 옵션 종류 및 예제
[JPA] Cascade 옵션 종류 및 예제
2023.04.12 -
[JPA] Kotlin을 이용하여 Soft Delete 구현
[JPA] Kotlin을 이용하여 Soft Delete 구현
2023.03.04