💡 프로젝트를 앞두고 JPA를 공부했을 때 작성했던 글
프로젝트를 앞두고 JPA에 대해 예제를 통해 한번 다시 톺아보려고 한다. 간단히 설계한 ERD는 아래와 같다.
예시
요구사항
- 학생은 학과 1개에 소속된다.
- 학생은 여러 게시글을 소유한다.
더미데이터
더미데이터는 아래와 같이 넣었다.
학과는 2개이고, 학생은 3명으로 그 중 2명이 DEPT1에, 1명이 DEPT2에 소속된다. STU1은 post를 3개, STU2는 post를 2개 소유한다.
- department
- post
- student
Entity 코드
DataGrip을 사용해 더미데이터를 넣을 것이기 때문에 @Getter 외에 불필요한 Lombok 어노테이션은 선언하지 않았다. 모두 양방향 매핑했고, 지연로딩(FetchType.LAZY) 설정을 해주었다.
Department
@Entity
@Getter
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "department")
List<Student> students = new ArrayList<>();
}
Student
@Entity
@Getter
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "dept_id")
Department department;
@OneToMany(mappedBy = "student")
List<Post> posts = new ArrayList<>();
}
Post
@Entity
@Getter
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "stu_id")
Student student;
}
번외) 컬렉션의 지연로딩
엔티티를 매핑할 때 @OneToMany, 즉 컬렉션 값 타입의 경우 왜 fetch = FetchType.LAZY를 붙이지 않을까 했는데, 이 경우 프록시가 아닌 Hibernate가 지원하는 기능인 PersistentBag가 지연로딩을 내부에서 처리해준다고 한다(링크).
컬렉션은 Hibernate에서 org.hibernate.collection.internal.PersistentBag로 Wrapping된다. 확인하기 위해서 아래와 같은 코드를 실행시켜보았다. 그러면 컬렉션이 PersistentBag 타입인 것을 확인할 수 있다. PersistentBag은 컬렉션을 실제로 사용하기 전까지 로드하지 않는 지연로딩과, 값 변경 등을 추적하는 기능 등을 지원한다.
Student s1 = studentRepository.findById(1L).orElseThrow();
System.out.println("class type : " +s1.getPosts().getClass());
------ 실행결과 ------
class type : class org.hibernate.collection.spi.PersistentBag
지연 로딩의 N + 1 문제 (@ManyToOne)
N + 1 문제는 정확히 표현하자면 1 + N 문제라고 불러야할 것 같다. 한 개의 엔티티를 조회할 때 연관된 엔티티 N개의 갯수만큼 쿼리가 더 나가는 것을 의미한다. 예제를 통해 자세히 알아보자.
아래와 같이 모든 student를 조회한 뒤 루프문을 통해 student의 Department에 접근하는 코드를 작성했다.
System.out.println("-----1. Student 찾기 ----");
List<Student> students = studentRepository.findAll();
System.out.println("-----department name접근----");
for (Student stu : students) {
System.out.println("stuId : "+stu.getId()+" : department name == " + stu.getDepartment().getName());
}
코드 실행 결과는 아래와 같다. Department에 접근하는 시점에서 추가적인 쿼리가 2번 나간 것을 볼 수 있다.
-----1. Student 찾기 ----
Hibernate:
select
s1_0.id,
s1_0.dept_id,
s1_0.name
from
student s1_0
-----department name접근----
Hibernate:
select
d1_0.id,
d1_0.name
from
department d1_0
where
d1_0.id=?
stuId : 1 : department name == DEPT1
stuId : 2 : department name == DEPT1
Hibernate:
select
d1_0.id,
d1_0.name
from
department d1_0
where
d1_0.id=?
stuId : 3 : department name == DEPT2
쿼리가 추가적으로 발생한 이유
다시 Student의 엔티티와 데이터를 보자.. 위로 올라가기 귀찮으니까 똑같은거 한번 더 넣음
@Entity
@Getter
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "dept_id")
Department department;
@OneToMany(mappedBy = "student")
List<Post> posts = new ArrayList<>();
}
아래 코드에서는 모든 Student들을 찾아 영속성 컨텍스트에 올린다. 이 것이 우리가 의도한 쿼리 1번이다.
List<Student> students = studentRepository.findAll();
그런데 Department에 FetchType.LAZY 를 설정했으므로, 영속성 컨텍스트는 이 시점에서 Department를 프록시 객체로 만들어놓는다.
문제는 아래처럼 department에 접근하는 시점에 프록시가 아닌 실제 데이터가 필요해진다는 것이다.
System.out.println("-----department name접근----");
for (Student stu : students) {
System.out.println("stuId : "+stu.getId()+" : department name == " + stu.getDepartment().getName());
}
따라서 위의 루프문은 이렇게 동작한다.
- STU1의 Department에 접근 → Department를 찾는 쿼리를 날려 DEPT1을 영속성 컨텍스트에 올림.
- STU2의 Department에 접근 → DEPT1은 영속성 컨텍스트에 존재하므로 DB가 아닌 1차 캐시에서 가져옴
- STU3의 Department에 접근 → Department를 찾는 쿼리를 날려 DEPT2을 영속성 컨텍스트에 올림.
만약 STU1과 STU2의 Department가 달랐다면 총 3번의 쿼리가 추가적으로 발생했을 것이다. 지금은 데이터의 개수가 적어 괜찮지만, 만약 1000명의 학생과 100개의 서로 다른 학과가 있다면 쿼리가 아주 많이 나갈 것이다.
그래서 한 트랜젝션 내에서 연관된 엔티티를 조회하고자 하면 한번의 쿼리로 영속성 컨텍스트에 올릴 필요가 있다. 이 때 사용하는 것이 fetch join 이다.
Fetch Join
Join과의 차이점을 통해 톺아보기
fetch join은 SQL 문법은 아니고 JPQL 또는 HQL에서 사용되는 기법이다. 성능 최적화를 위해 제공되는 기능인데, 일반 join과 비교하기 위해 아래와 같은 쿼리를 만들어 실행해보았다.
------ 리포지토리 코드 --------
@Query("select s from Student s join s.department")
List<Student> findWithDepartment1();
@Query("select s from Student s join fetch s.department")
List<Student> findWithDepartment2();
------ 테스트 코드 --------
System.out.println("----- join ----");
List<Student> students1 = studentRepository.findWithDepartment1();
for (Student stu : students1) {
System.out.println("stuId : "+stu.getId()+" : department name == " + stu.getDepartment().getName());
}
System.out.println("----- fetch join ----");
List<Student> students2 = studentRepository.findWithDepartment2();
for (Student stu : students2) {
System.out.println("stuId : "+stu.getId()+" : department name == " + stu.getDepartment().getName());
}
아래는 쿼리 실행결과이다.
Join 실행 결과
----- join ----
Hibernate:
select
s1_0.id,
s1_0.dept_id,
s1_0.name
from
student s1_0
join
department d1_0
on d1_0.id=s1_0.dept_id
-----department name접근----
Hibernate:
select
d1_0.id,
d1_0.name
from
department d1_0
where
d1_0.id=?
stuId : 1 : department name == DEPT1
stuId : 2 : department name == DEPT1
Hibernate:
select
d1_0.id,
d1_0.name
from
department d1_0
where
d1_0.id=?
stuId : 3 : department name == DEPT2
Fetch Join 실행 결과
----- fetch join ----
Hibernate:
select
s1_0.id,
d1_0.id,
d1_0.name,
s1_0.name
from
student s1_0
join
department d1_0
on d1_0.id=s1_0.dept_id
-----department name접근----
stuId : 1 : department name == DEPT1
stuId : 2 : department name == DEPT1
stuId : 3 : department name == DEPT2
일반 join의 경우 Department에 접근할 때 위에서 실행했던 것과 같은 N+1 문제가 발생한 반면, fetch join으로 작성한 코드의 경우 단 한 번의 쿼리만 나갔음을 알 수 있다.s elect절의 컬럼들에서 확인할 수 있듯 일반 join은 student의 필드만, fetch join의 경우 department의 필드를 포함해서 조회했다.
이렇게 fetch join은 join한 엔티티의 필드들도 조회함으로써 단 한번의 쿼리로 영속성 컨텍스트에 올린다.
@OneToMany에서 fetch join시 문제점 (Hibernate6 이전)
위와 같은 @ManyToOne이 아닌 @OneToMany, 즉 컬렉션에서 fetch join시 데이터가 중복으로 나오는 문제가 있었다. 이번엔 Student에서 Post를 fetch join 해보겠다.
@Query("select s from Student s join fetch s.posts")
List<Student> findFromPost();
... test 코드
@Test
public void test() {
List<Student> students = studentRepository.findFromPost();
System.out.println(students.size());
}
해당 코드를 실행하면 결과가 5가 나온다.
이는 student를 기준으로 post가 join되기 때문이다. SQL을 배운 사람이라면 알 것이다.
select
s1_0.id,
s1_0.dept_id,
s1_0.name,
p1_0.stu_id,
p1_0.id,
p1_0.content,
p1_0.title
from
student s1_0
join
post p1_0
on s1_0.id=p1_0.stu_id
Hibernate6 이후
이건 distinct를 사용해주면 해결되는 문제였다.
사실 SQL에서는 distinct가 완전히 같은 레코드여야 적용되지만, 애플리케이션에 올라올 때 JPA에서 자체적으로 중복 데이터를 제거해주었다고 한다. 영속성 컨텍스트는 ID를 식별자로 취급하기 때문에 같은 ID를 가진 데이터라면 중복 데이터로 간주해 제거해주었다.
그런데 Hibernate 6부터 distinct를 자동 적용해준다고 한다. (링크) 스프링 3버전 이상부터 사용할 수 있다.
해당 매뉴얼에서 distinct를 찾아보면 더 자세한 설명이 나와있다. 일부 설명을 파파고 돌려보면 아래와 같다.
Hibernate ORM 6부터는 조인이 자식 집합을 가져올 때 더 이상 동일한 상위 엔티티 참조를 필터링하기 위해 JPQL과 HQL에서 구분하여 사용할 필요가 없습니다. 반환되는 엔티티의 중복은 이제 항상 Hibernate에 의해 필터링됩니다.
DB → Application 단계에서 필터링을 해주는 듯하다. 이제 쿼리에 별도로 distinct를 붙여주지 않아도 아래의 코드에서 2라는 숫자가 잘 출력된다.
@Test
public void test() {
List<Student> students = studentRepository.findFromPost();
System.out.println("size == " +students.size());
for (Student stu : students) {
System.out.println("stu ID == " +stu.getName());
}
}
----------- 실행결과 -----------
size == 2
stu ID == STU1
stu ID == STU2
컬렉션 패치조인에서의 페이징의 경우 데이터 중복 조회문제, 그리고 페이징 처리가 메모리에서 이루어지기 때문에 OOM 문제를 발생시킬 수 있다. 따라서 하이버네이트에서 경고 로그를 남긴다.
Hibernate6에서 자체적으로 중복데이터를 거르는 기능을 제공해주었으니 이제 컬렉션에서 fetch join을 사용해도 되는건가? 라고 생각해서 인프런 질문게시판에 검색해봤는데, 여전히 경고 문고가 나온다고 한다(링크).
따라서 페이징을 사용하고자 하면 기존처럼 @BatchSize를 사용해야한다.
BatchSize
BatchSize는 위의 fetch join의 한계를 보완할 수 있는 기능이다.
appliation.yml과 같은 환경파일에 적용해서 전역적으로 적용할 수도 있고, 엔티티나 필드의 위에 @BatchSize 어노테이션을 선언해서 선택적으로 사용할 수도 있는데 나의 경우엔 필드에 선언해보겠다. size는 임의로 조정해주면 된다.
참고로 @BatchSize 어노테이션은 ToOne 에는 적용하지 못한다.
@Entity
@Getter
public class Student {
...생략
@BatchSize(size = 5)
@OneToMany(mappedBy = "student")
List<Post> posts = new ArrayList<>();
}
아래는 쿼리 결과를 조회하기 위한 테스트 코드이다.
System.out.println("-----1. Student 모두 찾기 ----");
List<Student> students = studentRepository.findAll();
System.out.println("----post에 접근----");
for (Student stu : students) {
stu.getPosts().stream().forEach(p ->
System.out.println("stuId : " +stu.getName()+ " " +p.getTitle()+ " : "+p.getId())
);}
쿼리의 실행결과는 아래와 같다. 확실히 알아보기 위해 @BatchSize를 주석처리한 후의 결과와 비교해서 보겠다.
BatchSize 적용 안했을 경우
-----------
아래는 @BatchSize 적용x
-----------
-----1. Student 모두 찾기 ----
Hibernate:
select
s1_0.id,
s1_0.dept_id,
s1_0.name
from
student s1_0
----post에 접근----
Hibernate:
select
p1_0.stu_id,
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0.stu_id=?
stuId : STU1 TITLE1 : 1
stuId : STU1 TITLE2 : 2
stuId : STU1 TITLE3 : 3
Hibernate:
select
p1_0.stu_id,
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0.stu_id=?
stuId : STU2 TITLE4 : 4
stuId : STU2 TITLE5 : 5
Hibernate:
select
p1_0.stu_id,
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0.stu_id=?
BatchSize 적용 결과
-----1. Student 모두 찾기 ----
Hibernate:
select
s1_0.id,
s1_0.dept_id,
s1_0.name
from
student s1_0
----post에 접근----
Hibernate:
select
p1_0.stu_id,
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0.stu_id in (?, ?, ?, ?, ?)
stuId : STU1 TITLE1 : 1
stuId : STU1 TITLE2 : 2
stuId : STU1 TITLE3 : 3
stuId : STU2 TITLE4 : 4
stuId : STU2 TITLE5 : 5
이렇게 @BatchSize를 사용했을 경우 in 연산자를 통해 모든 post를 가져옴을 확인할 수 있다.
@OneToMany 관계에서 페이징을 사용할 때는 fetch join을 사용할 수 없으므로 어쩔 수 없이 N+1가 발생하는데, BatchSize를 사용하면 단 2번의 쿼리로 데이터를 가져올 수 있다.
페이징을 해야하는 데이터면 BatchSize 설정을 적용해서 조회하자.
'톺아보기' 카테고리의 다른 글
[GIT] Rebase 톺아보기 (feat.Need to specify how to reconcile divergent branches) (0) | 2024.08.02 |
---|