N+1 문제
서론
Spring Data JPA를 사용해본 사람이라면 누구나 N+1 문제에 대해 들어본 적이 있을 것이다. 악명이 높다고도 알려진 N+1 문제는 조회 성능에 대한 심각한 문제를 초래할 수도 있기에 해당 문제의 개념부터 해결법을 알아보고자 한다.
N+1 문제는 사실 1+N 문제라고 보는 것이 더욱 타당할 지 모른다. 왜냐하면 N+1 문제란, 하나(1)의 엔티티에 대한 조회 쿼리 발생 시 해당 엔티티와 연관된 엔티티 조회를 위한 추가 N개의 쿼리가 발생하는 현상이기 때문이다. 따라서, 해당 포스팅에서는 1과 N의 순서가 다르게 나올 수도 있으며 이 부분은 단순히 표현 차이임을 인지하여야한다.
N+1 문제 발생 이유
그렇다면 N+1 문제는 왜 발생할까?
N+1 문제는 RDB와 OOP 환경 간 패러다임 차이에 의해 발생한다. RDB는 다른 엔티티의 PK를 외래키로 가지고 있으며, 테이블 조회 시 필요한 경우에 따라 테이블을 조인하여 결과 테이블을 유동적으로 생성한다. 그러나, JPA의 경우에는 엔티티와 연관된 다른 엔티티는 멤버 변수로 참조된다. RDB에서 필요한 경우에 Join 쿼리를 적절히 사용해 유동적으로 결과를 가져오는 반면에, 일반적으로 JPA에서는 하나의 엔티티에 대한 조회 쿼리를 생성할 때 연관된 다른 엔티티는 추가적인 쿼리를 발생시켜 가지고 오기 때문에 이러한 문제가 생기는 것이다.
또한, 이는 이전에 작성하였던 JPA fetch 전략에서 이야기하였던 지연 로딩과도 관련이 있다. 지연 로딩은 필요한 시점에서 연관된 엔티티를 늦게 조회하는 방법을 의미한다. 따라서, 하나의 엔티티가 조회된 이후 특정 시점에 연관된 엔티티를 조회하기 위해 해당 엔티티만 조회하여 영속성 컨텍스트로 가져와야 한다. 따라서, 연관된 다른 엔티티에 대한 추가적인 쿼리가 필요한 것이다.
참고로, JPA fetch 전략 포스팅에서도 N+1 문제가 발생하는 것을 확인할 수 있다.
N+1 문제가 왜 ‘문제’인가?
N+1 문제는 엔티티 조회 시 추가적인 쿼리를 보내게 되는 현상이다. 그런데, 이것이 왜 ‘문제’일까?
쿼리를 보내는 것은 곧 DB에 접근하는 것이다. DB에 저장된 데이터들은 기본적으로 ‘디스크’에 저장되어 있다.
위 그림에서 알 수 있듯이 디스크로의 접근 시간은 메모리(RAM)의 접근 시간에 비해 수백만 배 차이가 난다.
따라서, 추가적인 쿼리가 계속해서 발생하는 것은 지속적으로 디스크에 접근하는 것을 의미하며 이는 사용자 응답 속도 저하를 초래한다. 또한, id(PK)에 대해서는 기본적으로 인덱싱이 되어있지만, 일반 컬럼들은 인덱싱이 되어있지 않기 때문에 이러한 문제에 직면으로 노출되게 된다.
N+1 문제
앞서 언급하였듯이, N+1 문제는 하나(1)의 엔티티를 조회할 때, 이후 엔티티와 연관된 모든 엔티티를 조회하기 위한 추가적인 N개의 쿼리가 발생해 총 1+N개 쿼리가 발생하는 현상이다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@Getter
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
private List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@Getter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
public Member(String username) {
this.username = username;
}
}
현재 Team:Member = 1:N 양방향 관계, 지연 로딩으로 구성되어 있다.
Team team = teamRepository.findById(1L)
.orElseThrow(() -> new RuntimeException("Team not found"));
System.out.println("==== team의 멤버 조회 ====");
team.getMembers().forEach(member -> {
System.out.println(member.getUsername());
});
team
의 members
필드를 조회할 시에 Member 테이블에서 team_id
컬럼 일치하는 엔티티를 찾기 위한 추가적인 쿼리가 발생하는 것을 알 수 있다.
현재는 단순히 1개의 team을 조회하는 상황이지만, 아래와 같은 상황에 대해 생각해보자.
List<Team> teamList = teamRepository.findAll();
for (Team team : teamList) {
System.out.println("==== " + team.getName() + "의 멤버 목록 ====" );
team.getMembers().forEach(member -> System.out.println(member.getUsername()));
}
모든 team 엔티티에 대한 한 번(1)의 조회 쿼리가 나간 뒤, 각 엔티티의 members
필드를 조회할 시 엔티티의 수(N) 만큼 쿼리가 추가적으로 나간 것을 알 수 있다.
실제 상용 서비스에서는 엔티티간 다양한 연관 관계로 구성되어 있으며 엔티티의 수도 훨씬 많을 것이다. 이러한 상황에서 N+1 문제가 발생한다면 심각한 성능 문제로 이어질 것이다.
OneToOne에서는 N+1 문제는 발생하지 않을까?
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Mentor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mentee_id")
private Mentee mentee;
public Mentor(String name) {
this.name = name;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class Mentee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String studentNumber;
@OneToOne(mappedBy = "mentee")
private Mentor mentor;
public Mentee(String studentNumber) {
this.studentNumber = studentNumber;
}
}
Mentor:Mentee = 1:1 양방향 관계이며, 관계의 주인은 Mentor이다. 또한, 지연 로딩이 적용되어 있다.
Mentor mentor = mentorRepository.findById(1L)
.orElseThrow(EntityNotFoundException::new);
System.out.println("==== mentor와 연관된 멘티 ====");
System.out.println(mentor.getMentee().getStudentNumber());
위 결과에서 알 수 있듯이 mentee 필드가 사용(호출)되는 시점에서 추가적인 쿼리가 발생하는 것을 알 수 있다. 즉, N+1 문제가 발생한 것이다.
해당 포스팅을 작성하기 위해 여러 블로그를 참고하던 중 “지연 로딩이 적용되어 있기 때문에 N+1 문제가 발생한다”라는 내용들을 꽤 볼 수 있었다. 과연 지연 로딩이 N+1의 원인일까? 결론부터 이야기하자면 아니다.
이를 알아보기 위해 즉시로딩일 때 쿼리를 확인해본다.
즉시 로딩에서는 N+1 문제가 발생하지 않는다?
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Mentor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(fetch = FetchType.EAGER) // 즉시 로딩
@JoinColumn(name = "mentee_id")
private Mentee mentee;
public Mentor(String name) {
this.name = name;
}
}
Mentor mentor = mentorRepository.findById(1L)
.orElseThrow(EntityNotFoundException::new);
System.out.println("==== mentor와 연관된 멘티 ====");
System.out.println(mentor.getMentee().getStudentNumber());
즉시 로딩을 적용하였을 때 자동으로 left outer join을 적용하여 하나의 쿼리만 발생한 것을 알 수 있다. 그러면 정말 즉시 로딩은 N+1 문제가 발생하지 않을까?
id(PK) 값이 아닌 일반 컬럼으로 조회를 해보자.
Mentor mentor = mentorRepository.findByName("kim")
.orElseThrow(EntityNotFoundException::new);
System.out.println("==== mentor와 연관된 멘티 ====");
System.out.println(mentor.getMentee().getStudentNumber());
위 결과에서 즉시 로딩이더라도 일반 컬럼을 통해 엔티티를 조회하였을 때 추가 쿼리가 발생하는 것을 알 수 있다. 즉, 즉시 로딩이 적용되어 있더라도 N+1 문제는 발생하는 것이며, 지연 로딩 자체가 N+1 문제의 원인이 아니다.
또한 이전 포스팅(JPA fetch 전략)에서 언급하였던 OneToOne 관계의 주인이 아닌 엔티티에서 연관 관계 필드 조회 시 지연 로딩 미적용과 같은 상황을 N+1 문제로 설명하는 포스팅도 있지만, 잘못된 정보이다.
Solution of N+1
N+1 문제는 엔티티 조회 시 추가적인 쿼리가 발생하는 것으로 인해 생기는 문제이다. 따라서, 추가적으로 발생하는 쿼리를 줄임으로써 해결할 수 있다. N+1 문제 해결 방법으로는 아래와 같은 방법들이 있다.
- fetch join
- Batch Size
- EntityGraph
위 해결 방법들의 차이와 동작 방식은 각 절에서 자세히 설명한다.
1. fetch join
앞서, OneToOne 관계에서 id(PK) 값을 통한 엔티티 조회 시 join(left outer join)을 사용하여 단 하나의 쿼리만 나가는 것을 확인할 수 있었다. 이와 비슷한 원리로 fetch join을 통하여 한 번의 쿼리에 필요한 모든 내용을 가지고 오는 방법이다.
fetch join 방법은 Repository 인터페이스의 메서드에 @Query
어노테이션을 사용하여 직접 join 쿼리를 명시해주면 된다.
public interface MentorRepository extends JpaRepository<Mentor, Long> {
@Query("select m from Mentor m join fetch m.mentee")
Optional<Mentor> findByName(String name);
}
위와 같이 단 하나의 쿼리만 발생한 것을 알 수 있으며, 쿼리 내 join fetch
키워드를 사용하였다. fetch join은 JPQL에서 제공하는 성능 최적화를 위한 기능으로 연관된 엔티티나 컬렉션을 한 번에 조회할 수 있는 기능이다.
실제 발생 쿼리에서는 join
키워드를 사용하였다. 이는 inner join을 나타낸다.
inner join은 일반적으로 가장 많이 사용되는 방법으로 그냥 join이라 부르기도 한다. inner join은 교집합과 같다. JPA에서 join fetch
키워드 사용 시 두 엔티티간 FK-PK를 이용하여 조인을 진행한다.
그러나, fetch join에도 몇 가지 단점이 존재한다.
- 중복된 데이터 조회 (hibernate 5 이하)
- 연관된 컬렉션이 2개 이상인 경우 조인 불가능
- 페이징(paging) 처리 불가
단점 1) 중복된 데이터 조회
중복된 데이터 조회 문제는 inner join의 특성에서 발생한다. 그러나, 이는 hibernate 5 이하 버전에서 발생하는 문제이다. 공식문서에 따르면 hibernate 6 이상에서는 자동으로 쿼리에 distinct를 적용하여 해당 중복 데이터 조회 문제가 발생하지 않는다.
단점 2) 연관된 컬렉션이 2개 이상인 경우 조인 불가능
연관된 컬렉션이 2개 이상인 경우는 ~toMany
관계인 필드가 2개 이상인 상황이다. 이 상황을 코드로 구현하여 왜 조인이 불가능한 지 알아보자.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@Getter
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
private List<Member> members = new ArrayList<>();
@OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
private List<Teacher> teachers = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
현재 Team:Member = 1:N, Team:Teacher = 1:N으로 연관된 컬렉션이 2개이다. 이 상황에서 fetch join을 적용해보자.
@Query("select t from Team t join fetch t.members join fetch t.teachers where t.name = :name")
List<Team> findByNameFetchJoin(@Param("name") String name);
public void multipleFetchJoinTest() {
List<Team> resultList = teamRepository.findByNameFetchJoin2("team1");
}
2025-04-01T23:34:33.418+09:00 ERROR 35420 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.jpa.entity.Team.members, com.example.jpa.entity.Team.teachers]] with root cause
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.jpa.entity.Team.members, com.example.jpa.entity.Team.teachers]
at org.hibernate.query.sqm.sql.BaseSqmToSqlAstConverter.createFetch(BaseSqmToSqlAstConverter.java:8538) ~[hibernate-core-6.6.11.Final.jar:6.6.11.Final]
at org.hibernate.query.sqm.sql.BaseSqmToSqlAstConverter.visitFetches(BaseSqmToSqlAstConverter.java:8584) ~[hibernate-core-6.6.11.Final.jar:6.6.11.Final]
... 생략
team1 엔티티 조회 시 MultipleBagFetchException이 발생하였다.
MultipleBagFetchException
이전 JPA fetch 전략 포스팅에서 PersistenceBag에 관한 내용을 다루었다.
Bag은 Set처럼 순서는 없지만, List처럼 중복을 허용하는 자료구조이다. Java에서는 Bag과 같은 자료구조는 없기에 List
필드에 대해서는 PersistenceBag
자료구조를 사용한다. 참고로 Set
필드의 경우에는 PersistenceSet
을 사용한다.
그러나, Java에서는 이를 List로 치환하여 사용하기 때문에 순서가 없다는 점과 중복을 허용한다는 점은 데이터 매핑 시에 문제를 초래하게 된다. 데이터베이스에 직접 쿼리를 날려 결과 테이블을 확인해보자.
위 테이블이 Team, Member, Teacher를 inner join한 결과 테이블이다.
여기서 맨 끝의 3개의 컬럼(team)을 제외하고 생각해보면 아래와 같을 것이다.
위 결과와 비교해보면 MultipleBagException이 발생하는 이유가 보인다. 현재 상황에서는 Team에 대해여 중복된 결과에 대한 distinct가 충분히 가능해보인다.
그러나, Team - Member - Teacher 3가지 테이블을 모두 join한 결과 테이블에서는 distinct가 불가능하다.
team_id | member_id | teacher_id |
1 | 1 | 1 |
1 | 1 | 2 |
1 | 2 | 1 |
1 | 2 | 2 |
위 테이블은 join 결과에서 아이디 값만 추출한 테이블이다. 이 결과에서 distinct 연산은 불가능하다.
fetch join 시에 카테션 곱이 일어남으로써 매핑 과정에서 distinct 적용이 불가능하기 때문에 2개 이상(Mutiple)의 컬렉션(Bag)과 연관 관계를 맺고 있는 엔티티에 대해서는 조인 연산이 불가능한 것이다.
List
로 연관된 컬렉션을 Set
으로 바꾸면 조회가 가능하다. Set
은 중복을 허용하지 않는다. 따라서, 여러 개의 team 1이 여러 개 응답되더라도 결국 하나의 team 1만 매핑되기 때문에 가능한 것이다. 그러나, 결국 메모리에 로드되는 데이터 자체는 카테션 곱을 수행한 상태의 테이블이 로드되기 때문에 실행에서는 문제가 없지만 성능 상 문제는 여전히 존재한다.
단점 3) 페이징 처리가 불가능
@Query("select t from Team t join fetch t.members where t.name = :name")
Page<Team> findByNameFetchJoin2(@Param("name") String name, Pageable pageable);
Hibernate:
select t1_0.id,m1_0.team_id,m1_0.id,m1_0.username,t1_0.name
from team t1_0
join member m1_0
on t1_0.id=m1_0.team_id
where t1_0.name=?
위와 같이 페이징 처리를 하였지만 실행된 쿼리에서는 limit
나 offset
키워드를 확인할 수 없다. 즉, 페이징 처리가 되지 않고 있는 것이다.
추가적으로 아래와 같은 WARN 로그를 확인할 수 있다.
2025-04-02T01:00:58.239+09:00 WARN 54390 --- [nio-8080-exec-1] org.hibernate.orm.query: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
“applying in memory”라는 문구에서 알 수 있듯이 hibernate에서는 엔티티를 select 후 그 결과를 인메모리에서 페이징 처리한다. 이것은 사실상 페이징 처리의 사용 이유와 어긋나는 동작이다.
수많은 데이터를 결국에 메모리에 올려 인메모리에서 페이징 처리를 진행하게 되면 OOM(Out Of Memory)이 발생할 가능성이 높아진다.
그러나, fetch join에서도 이러한 페이징 문제를 해결하는 여러 방법과 사례들이 있는데 이는 차후 포스팅에서 다뤄볼 예정이다.
2. BatchSize
정확하게는 Batch Fetch Size이다. 즉, 쿼리를 한 번 보냈을 가져오는 데이터의 양을 늘려 N+1 문제를 해결하겠다는 것이다. 쿼리를 보낼 때 where ... in ()
구문을 사용하여 한 번에 가져오는 데이터의 양을 늘리는 것이다.
BatchSize를 설정하는 방법은 2가지 정도가 있다.
- application.yml 파일에 default-batch-fetch-size 설정
- 해당 엔티티나 컬렉션 필드에
@BatchSize
어노테이션 설정
application.yml 파일 내에 default-batch-fetch-size를 설정한 경우 전역적으로 배치 사이즈가 설정된다.
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 10
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@Getter
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@BatchSize(size = 10) // 특정 필드에 Batch Size 설정
@OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
private List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
필자는 application.yml에 default-batch-fetch-size를 설정하여 실습을 실행하였다.
public void findByName() {
List<Team> teamList = teamRepository.findAll();
for (Team team : teamList) {
System.out.println("==== " + team.getName() + "의 멤버 ====");
for (Member member : team.getMembers()) {
System.out.println("--> " + member.getUsername());
}
}
}
select
t1_0.id,
t1_0.name
from
team t1_0
==== team1의 멤버 ====
select
m1_0.team_id,
m1_0.id,
m1_0.username
from
member m1_0
where
m1_0.team_id in (1, 2, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)
--> jung
--> heo
--> lee
--> park
--> kim
--> choi
==== team2의 멤버 ====
--> m1
--> m2
--> m3
--> m4
_Notice
BatchSize 출력 쿼리에서 파라미터값 확인을 위하여 p6spy 의존성을 추가하여 쿼리 형태가 앞서 본 쿼리들과 다르다.
로그에서 알 수 있듯이 지연 로딩으로 인해 Team 별 Member 목록을 조회하는 시점에 where ... in ()
쿼리를 사용하여 한 번에 team1의 members와 team2의 members 데이터를 가지고 온다.
여기서 in (1, 2, NULL, ..., NULL)
과 같이 in
절 내에 10개의 파라미터가 있지만, 필요한 값 외에는 NULL로 채워진 것을 알 수 있다. 필자는 “굳이 NULL 값을 넣어서 쿼리를 날리는 게 효율적인가?”라는 생각이 들었다.
Batch Size 적용에 관한 다른 블로그 자료들을 찾아보면 in
절에 요구되는 데이터만큼 정확하게 파라미터가 들어가있는 경우가 많았다. 이는 hibernate의 캐싱 케이스 최적화로 인해 일어난다. hibernate는 배치 사이즈를 관리할 때 최적화를 위해 캐싱 케이스를 줄이는데 이를 위해서 선언된 배치 사이즈의 절반씩 줄여나간다. 또한, 1~10 까지는 기본적으로 캐싱 케이스에 포함되어 있다.
예를 들어 batch size = 100 이라면 100, 50, 25, 12, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1이 캐싱 케이스가 된다. 즉, 83개의 데이터에 대한 쿼리가 필요하다면 이를 3번의 쿼리(50 + 25 + 8)로 나누어 데이터를 가져올 수 있게 된다.
여러 블로그 자료에서 in
절에 데이터 수에 맞게 파라미터가 바인딩된 것은 대부분 캐싱 케이스 안에 있는 값(대부분 10 이하)을 사용하였기 때문에 그런 것이다.
그러나, hibernate 6.2 이상에서는 캐싱 케이스 설정하지 않기에 명시된 batch size 만큼의 파라미터가 in 절에 포함되며, 필요한 데이터 외의 파라미터들은 NULL 값으로 채워지게 된다.
select * from team where id in (?);
select * from team where id in (?, ?);
select * from team where id in (?, ?, ?);
select * from team where id in (?, ?, ?, ...);
SQL에서 아래 명령어는 다 다른 명령어이다. 따라서, hibernate 6.2 이상에서는 이 모든 SQL 구문을 캐싱하지 않고 배치 사이즈만큼의 in 절을 포함한 쿼리 하나만 저장하고 이를 데이터와 NULL 값으로 바인딩하여 사용한다.
3. @EntityGraph
@EntityGraph
는 fetch join과 비슷하게 Join 연산을 통하여 데이터를 로딩함으로써 N+1 문제를 해결하는 방법이다.
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Documented
public @interface EntityGraph {
String value() default "";
EntityGraphType type() default EntityGraph.EntityGraphType.FETCH;
String[] attributePaths() default {};
public static enum EntityGraphType {
LOAD("jakarta.persistence.loadgraph"),
FETCH("jakarta.persistence.fetchgraph");
private final String key;
private EntityGraphType(String value) {
this.key = value;
}
public String getKey() {
return this.key;
}
}
}
@EntityGraph
어노테이션에서 중요하게 볼 부분은 attributePaths
와 type
이다.
- attributePaths: join 연산을 진행할 필드명 (배열로 지정)
- type: 엔티티 fetch 방법
public interface TeamRepository extends JpaRepository<Team, Long> {
@EntityGraph(attributePaths = {"members"}, type = EntityGraph.EntityGraphType.FETCH)
List<Team> findAll();
}
EntityGraph의 attributePaths
는 join 연산을 진행할 필드(엔티티)를 정의한다. members 필드에 대한 EntityGraph 설정 후 실행 쿼리는 위와 같다.
앞서 본 fetch join과 달리 left join
키워드가 사용되었다. left join은 Left Outer Join이다. Left Outer Join은 합집합과 같다. 조인 시 왼쪽 테이블에 위치한 레코드는 모두 결과 테이블에 포함되며, 조인을 하지 못해 채울 수 없는 컬럼은 NULL 값으로 채운다. 따라서, Team에 존재하는 모든 레코드들을 가져오게 된다.
현재 데이터베이스에는 team1, team2, team3가 있으며 team3는 연관된 멤버가 없다. 다시 한 번 모든 Team을 조회해보자.
출력 로그에서 알 수 있듯이 Member와 연관 관계가 없는 team3도 조회된 것을 알 수 있다.
EntityGraph의 type
은 해당 필드에 대한 fetchType을 설정한다.
정확하게는 해당 필드의 fetch 방식 + 나머지 필드의 fetch 방식을 설정한다.
EntityGraph 어노테이션에서 알 수 있듯이 type
은 LOAD와 FETCH 중 하나로 설정할 수 있다.
FETCH
: 해당 연관 엔티티는 즉시 로딩, 나머지는 지연 로딩LOAD
: 해댱 연관 엔티티는 즉시 로딩, 나머지는 각 엔티티에 명시한 fetchType이나 기본 fetchType으로 로딩
EntityGraph는 fetch join과 달리 쿼리를 직접 적어줄 필요가 없고, Left Outer Join을 통해 연관 관계가 없는 레코드여도 조회가 가능하기 때문에 상황에 맞게 선택하여 사용하여야 한다.
Conclusion
JPA의 N+1 문제에 대해 실습을 진행하며 원인과 해결책에 대해 알아보니 모호했던 개념들을 잘 이해할 수 있게 된 것 같다. 기타 많은 블로그들에서 N+1 문제를 다루는 글들을 확인할 수 있었는데 잘못된 정보들이 많았고, hibernate 버전에 따라 다르게 동작하는 기능들이 있어 실습을 진행하지 않았다면 모르고 넘어갔을 부분이었던 것 같다.
지금 실습은 단순히 소규모의 환경에서 진행한 테스트이기에 N+1 문제가 일어나더라도 응답시간의 차이가 그리 크지 않지만, 실제 운영 서비스에 대해 N+1 문제가 일어날 경우 그 심각성은 굉장히 클 것이다.