Spring Boot JPA 프로젝트 진행 과정 중, 생각 없이 연관관계를 쓰다가 테스트 과정에서 필요 이상의 쿼리문이 찍히는 것을 보고 원인을 추적하다 알게 된 개념. 사실 어느정도 알고 있는 개념이었는데 실제로 맞딱뜨리니 이제서야 똑바로 정리하고 해결방법을 적용할 계기가 생겼다. 역시 개발 관련은 일단 만들고 봐야
1. N + 1 문제란?
연관 관계가 존재하는 엔티티를 조회(1)했을 때 조회된 데이터의 갯수(N)만큼 조회 쿼리가 추가로 나가는 현상.
추가 쿼리는 의도하지 않은 사항이고, 조회 결과로 나온 데이터의 갯수만큼 추가 쿼리가 발생하기 때문에 프로그램의 성능에 큰 문제를 일으킬 수 있다. 게시판 프로젝트에서 디비에 존재하는 게시글이 10만개라면 10만 개의 추가 쿼리가 나갈 수도 있는 것이다.
- N+1이란 : 연관 관계가 존재하는 엔티티를 조회할 때 엔티티의 갯수만큼 추가 쿼리가 발생하는 문제
- 이유 : JPA의 find method 실행 시 하위 엔티티들을 한 번에 로딩하지 않고 사용이 필요할 때 추가로 검색하기 때문. DB에서 조회된 데이터를 인스턴스로 생성하는 과정에서, 필요한 연관 엔티티가 해당 트랜잭션의 영속성 컨텍스트에 존재하지 않으면 hibernate는 연관된 엔티티를 추가 조회한다.
- 연관 관계의 FetchType에 따라 발생하는 상황
- FetchType.EAGER : 엔티티 조회가 이뤄질 때 자동으로 연관된 엔티티도 추가 조회
- FetchType.LAZY : 최초의 조회때는 추가 쿼리가 일어나지 않으나, 연관된 엔티티를 조회하는 상황이 올 때 추가 조회
FetchType은 특정 엔티티가 JPA를 통해 조회될 때 연관 관계에 있는 엔티티를 언제 조회할 것인지 전략을 설정해준다. @xxxToxxx 어노테이션에 설정값으로 붙어 사용된다.
FetchType.EAGER | 엔티티가 로딩될 때 연관 엔티티 역시 자동으로 로딩됨. ~ToOne 연관 관계의 default fetch 전략. |
FetchType.LAZY | 엔티티가 로딩되어도 연관 엔티티는 로딩되지 않음. 연관된 엔티티가 필요할 때에 로딩. ~ToMany 연관관계의 default fetch 전략. |
2. N + 1 문제 발생 예제
다음과 같이 OneToMany, ManyToOne의 연관관계가 있는 2개의 엔티티가 있다고 해보자. User 한 명이 여러개의 Log(일기)를 가지고 있는 간단한 상황이다.
JPA 환경에서 각각의 Entity code를 작성했다.
@Entity
@AllArgsConstructor
@Builder
@Getter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author") // LAZY FETCH TYPE
private List<Log> logs;
}
@Entity
@AllArgsConstructor
@Builder
@Getter
public class Log {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne // EAGER FETCH TYPE
@JoinColumn(name = "author")
private User author;
}
~ToOne 연관 관계는 eager loading을, ~ToMany 연관 관계는 lazy loading이 기본값인 것을 다시 한 번 숙지하자.
이제 각 엔티티의 Repository를 생성한 후, 10명의 유저가 일기 2개를 작성하는 상황을 테스트해보았다.
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class EntityTest {
@Autowired
private UserRepository userRepository;
@Autowired
private LogRepository logRepository;
@BeforeAll
@Transactional
public void setDB() {
List<User> users = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
User user = User.builder().name("유저"+i).logs(new ArrayList<>()).build();
users.add(user);
}
userRepository.saveAll(users);
for (User user : users) {
user.getLogs().add(
logRepository.save(
Log.builder().author(user).content(user.getName()+" 일기1").build()));
user.getLogs().add(
logRepository.save(
Log.builder().author(user).content(user.getName()+" 일기2").build()));
}
}
@Test
public void eager_N1_problem() {
System.out.println("-----------------userRepository.findALl()-----------------------");
userRepository.findAll();
System.out.println("-----------------logRepository.findAll()------------------------");
logRepository.findAll();
}
}
위 테스트코드에서 hibernate가 실제로 DB 쿼리를 날리는 것은 foundUsers, foundLogs를 불러내는 과정에서 각각 select * from user, select * from log 2번이어야 할 것이다. 그러나 실제로 콘솔에 출력된 결과를 보면,
User에 대해 Eager loading이 일어나는 Log Entity에서 log 갯수인 10개만큼 유저의 추가 쿼리가 발생되었음을 확인할 수 있다.
Q) 그런데 Log 갯수는 총 20개인데 왜 추가 쿼리는 10번만 나가나요? N+1이면 20번이 추가로 나가야하지 않나?
A) 유저 1명당 가지고 있는 일기의 갯수는 2개이다. logRepository.findAll()에서 유저1의 일기1이 조회될 때, 유저1은 영속성 컨텍스트에 존재하지 않으므로 유저1이 조회되는 쿼리가 나가게 된다. 그러나 유저1의 일기2가 조회될 때는 유저1이 이미 영속성 컨텍스트에 존재하므로 유저를 추가 조회하는 쿼리가 나가지 않는다. 전술한 N+1의 발생 이유 중 '필요한 연관 엔티티가 해당 트랜잭션의 영속성 컨텍스트에 존재하지 않으면' 참고.
3. 해결 방법
일단 가장 단순하게, 혹시 모를 문제를 위해 ~ToOne 연관관계에서도 FetchType을 LAZY로 설정하는 것이다. 혹은 100% 필요한 경우가 아니라면 연관 관계를 아예 끊어버릴 수도 있고, 연관 테이블을 따로 빼서 사용할 수도 있다.
물론 기본적으로 위의 방법을 깔고 가는 것도 맞는 말이지만, N+1 문제를 해결하는 가장 널리 알려진 방법은 fetch join이다.
3-1. Fetch Join
N+1 문제가 DB 결과 조회 -> 인스턴스 생성 과정에서 발생하므로, 애초에 쿼리의 결과가 온전하면 된다. 최초에 조회시 가져오고 싶은 연관 필드를 join해서 영속성 컨텍스트로 가져오면 각각의 결과 엔티티 구성을 위해 추가 쿼리를 날릴 필요가 없을 것이다.
fetch join이란, hibernate에서 지원하는 특별한 join 방법으로 쿼리를 날리는 대상 엔티티와 더불어 fetch join한 연관 엔티티까지 한꺼번에 영속성 컨텍스트에 로드할 수 있는 기능이다. JPQL을 직접 작성하여 구현할 수 있다. left fetch join을 하면 left outer join 쿼리가, 그냥 fetch join을 하면 inner join 쿼리가 나가게 된다.
일단 N+1문제를 일으키기 위해 유저 엔티티에서 logs를 Eager loading으로 바꿔준다.
@OneToMany(mappedBy = "author",fetch = FetchType.EAGER)
private List<Log> logs;
그리고 UserRepository에서 Fetch join을 이용하는 JPQL 쿼리를 직접 작성한다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select u from User u join fetch u.logs")
public List<User> findAllJoinFetch();
}
이제 hibernate가 u.logs, 즉 조회하는 user entity의 logs 필드도 한꺼번에 로드했을 것이다.
예시와 같은 setDB() method가 존재하는 상황에서 다음의 테스트문을 실행해보자.
@Test
public void findAllUsers_plain() {
System.out.println("-----------------userRepository.findAll()---------------------------------");
Assertions.assertEquals(10,userRepository.findAll().size());
}
@Test
public void findAllUsers_joinFetch() {
System.out.println("-----------------userRepository.findAllJoinFetch()------------------------");
Assertions.assertEquals(10,userRepository.findAllJoinFetch().size());
}
findAllJoinFetch()는 Hibernate에서 나가는 쿼리문에 join이 붙어 N+1문제가 나타나지 않았다.
꽤나 다만 FetchJoin에도 몇 가지 한계점이 있다.
- JPQL을 직접 작성해야 한다. 그런데 join fetch만 붙이면 되고, queryDSL을 이용하면 또 그렇게 복잡한 일도 아니라 큰 단점은 아닌듯.
- 1:N 관계가 2개 이상일 땐 `MultipleBagFetchException`이 발생한다. 여러 개의 1:N 연관 엔티티(bag)들을 fetch할 수 없다는 오류인데, 다중 fetch join으로 메모리 오버 현상이 일어날 확률이 높아 hibernate에서 던지는 예외이다.
- 1:N의 1 쪽에서 JPA의 Pageable 인터페이스를 사용할 때, LIMIT을 단 쿼리가 나가지 않고 결과 목록을 전부 메모리에 올려놓은 뒤 페이징을 수행하기 때문에 여기서 역시 메모리 오버가 발생할 위험성이 있다.
3-2. @EntityGraph
또다른 방법으로는 @EntityGraph 어노테이션을 붙이는 것이다. 복잡하고 설정을 잘못 건들면 대참사가 나서 실무에선 잘 사용 안하는 방식이라는데, 기존의 findAll과 같은 쿼리에 @EntityGraph(attributePath = XXX) 형식으로 사전에 fetch하길 원하는 field 명을 작성하면 된다.
UserRepository에 아래 method를 추가하고
@EntityGraph(attributePaths = "logs")
@Query("select u from User u")
public List<User> findAllEntityGraph();
테스트 method에도 추가한다.
@Test
public void findAllUsers_entityGraph() {
System.out.println("-----------------userRepository.findAllEntityGraph()------------------------");
Assertions.assertEquals(10,userRepository.findAllEntityGraph().size());
}
테스트를 진행하면 역시 N+1 문제가 해결되었음을 알 수 있다.
# 기존의 Cartesian Product 문제 -> 자동 해결!
hibernate6 미만의 버전에서는 join fetch나 entity graph를 사용할 때 카테시안 곱 문제가 있었다. 1:N 연관관계에서 1측의 엔티티를 조회할 때 N측의 엔티티 개수만큼 조회 결과가 나오는 문제였다. 그래서 이를 방지하기 위해 JPQL 쿼리문에 distinct를 추가하곤 했는데, hibernate 6 공식 문서에서 이러한 문제는 hibernate가 자동적으로 수정해준다고 안내되어있다.
.size() 확인하면서 왜 cartesian product 문제 발생 안하지 다리를 달달 떨고있었는데.. 수정되었다니
3-3. Batch Size 설정
N+1 문제는, N개가 되는 부모의 정보를 가지고 자식 테이블을 N번 추가 조회하기 때문에 발생한다. 다음과 같은 상황인 것이다.
- findAll()로 id가 각각 1, 2, 3, ..., 10인 부모 엔티티들이 조회되었다.
- 자식 테이블에서 부모 id가 1인 엔티티를 조회한다.
- 자식 테이블에서 부모 id가 2인 엔티티를 조회한다.
- ...
- 자식 테이블에서 부모 id가 10인 엔티티를 조회한다.
저 흰 색 동그라미로 이뤄지는 연관 테이블 조회를 줄일 순 없을까? 예를 들면 다음과 같이 말이다.
- findAll()로 id가 각각 1, 2, 3, ...,10인 부모 엔티티들이 조회되었다.
- 자식 테이블에서 부모 id가 { 1, 2, 3, ..., 10 } 안에 들어있는 엔티티를 검색한다.
그러면 원래 10 + 1 = 11번 나가야 할 쿼리를 1 + 1 = 2번 으로 줄일 수 있지 않을까.
... 라는 아이디어에서 나온 것이 Batch Size이다. 연관 테이블을 부모값으로 하나하나 조회하는 것이 아닌, 부모를 Batch로 두고 연관 테이블을 한번에 조회하는 것이다. User Entity의 연관 필드에 @BatchSize 옵션을 붙여주자.
@BatchSize(size = 1000)
@OneToMany(mappedBy = "author",fetch = FetchType.EAGER)
private List<Log> logs;
아니면, application.properties에 hibernate 자체의 기본 batch size 옵션을 넣어줄 수도 있다.
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
이 상황에서 userRepository.findAll()을 돌리면,
연관 관계에 있는 log를 조회하는 쿼리가 기존과 다르게 1번만 나갔다. 조회된 모든 user에 대한 id를 하나씩 이용하는 대신, in절 안에 batch로 넣어놓고 한꺼번에 찾는 방법으로 쿼리 수를 줄였다. batch size가 1000이므로 in절 안에는 최대 1000개의 user id가 들어갈 수 있다.
BatchSize는 정확히 말하면 N+1 문제가 발생하지 않게 하는게 아니고(어쨌든 연관관계 테이블에 추가 쿼리가 나갔으므로) 문제의 규모를 획기적으로 줄여주는 방법이다. 쿼리 수를 부모 엔티티 갯수 기준, 최대 1/{BatchSize} 만큼 줄일 수 있는 것이다! 예시에선 Batch size가 1000이므로, 부모 엔티티의 갯수가 1000개 이하라면 연관된 관계를 가져오기 위해 연관 테이블을 1번만 조회하면 된다.
또한 BatchSize를 이용하면 hibernate에서 막아놓은 multiple join fetch도 이용할 수 있다. 부모와 연관된 또다른 ToMany 관계의 자식 엔티티가 존재한다고 해도, 자식 테이블을 또다시 in절로 조회하기만 하면 그만이다.
REFERENCE)
https://programmer93.tistory.com/83
https://jojoldu.tistory.com/457