⌨️ JPA N+1?
N+1 문제는 비단 JPA뿐만 아니라 ORM을 사용하는 애플리케이션에서 발생하는 성능 문제 중 하나이다.
이 문제는 일반적으로 관계형 데이터베이스와 객체 간의 매핑에서 발생하며, 추가적인 쿼리가 발생하여 성능을 저하시키는 현상이다.
주로 일대다 또는 다대다 관계에서 발생한다.
예를 들어, 부모 엔티티를 조회할 때 자식 엔티티를 함께 조회하지 않고 각 부모 엔티티마다 추가적인 쿼리를 실행하여 자식 엔티티를 조회하는 경우 발생한다.
*다대일에서는 발생하지 않는지?
유명한 강의 그리고 서적에서는 위와 같은 이유로 연관관계의 매핑을 다대일로 하고 N쪽에 연관관계의 주인을 설정하는 것을 권장하고 있다.
또한 다대일 관계는 많은 경우에 객체 간의 관계를 효율적으로 표현하고 관리할 수 있으며,
객체지향적인 모델링을 통해 데이터베이스 스키마를 더 잘 표현할 수 있다는 장점이 있다.
그러나 모든 상황에서 다대일이 무조건적으로 옳은 것은 아니므로 우리는 요구사항에 따른 기능 구현에 있어 적절히 활용할 줄 알아야한다. 그렇기에 N+1문제를 해결하는 방법도 알아두면 좋을 것이다.
1. Spring Data JPA N+1 문제
1.1. 예시를 통한 JPA N+1 문제 알아보기
아래는 예시 코드이다.
//JPA
// 작가의 ID로 책 리스트를 가져오는 메서드
public List<Book> findBooksByAuthorId(Long authorId) {
Author author = entityManager.find(Author.class, authorId);
return author.getBooks(); // N+1 문제 발생
}
//Spring Data JPA
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a JOIN a.books WHERE a.id = :authorId")
Optional<Author> findByIdWithBooks(@Param("authorId") Long authorId);
}
// 작가의 ID로 책 리스트를 가져오는 메서드
public List<Book> findBooksByAuthorId(Long authorId) {
Optional<Author> authorOptional = authorRepository.findByIdWithBooks(authorId);
return authorOptional.orElseThrow(() -> new NotFoundException("Author not found"))
.getBooks(); // N+1 문제 발생
}
진행한 프로젝트가 작가와 글인데 그 글들을 갈무리하여 책으로 출판할 수도 있으니 서적 엔티티를 만들어봤다.
위 코드는 책을 가져온 후 각 책에 대해 작가를 조회한다. 이렇게 하면 각 책마다 추가적인 쿼리가 발생한다.
부분적인 코드로 인해 잘 이해가 되지 않을 수 있으므로 좀 더 자세히 시나리오를 들여다보자.
전체적으로 생각해보자면 우리의 엔티티는
Author
...
@OneToMany(mappadBy="author")
private List<Book> books = new ArrayList<>();
...
Book
...
@ManyToOne
@JoinColumn(nmae ="author_id")
private Author author;
...
이런식일 가능성이 크다.
코드를 다시 보면 책이 아닌 작가 엔티티에서 책을 조회한다. 그렇기에 당연히 책을 조회하기 위해 작가를 조회하는 쿼리가 한번 더 나가게 되는 것이다.
모든 경우는 아니지만, 많은 경우에서 이런 방법으로 쉽게 N+1의 문제가 발생하는 지 판단할 수 있다. 물론 약식이지만
예를 들어 내가 가져올 객체가 댓글이다 근데 댓글 가져오는 메서드는 ArticleRepository에 구현되어있다면 N+1을 의심해볼만 하다.
당장 우리의 Repository Layer를 다시 한번 점검해보고 연관 관계를 다시 살펴보자.
2. N+1 문제의 성능 이슈
이유 없는 이슈는 없다. N+1 문제가 왜 이렇게 많은 신입들이 해결하고 가야할 패시브처럼 됐는지 알아보자.
2.1. N+1 문제가 애플리케이션 성능에 미치는 부정적 영향
크게 육안으로도 확인 가능한 부정적 영향을 아래와 같다고 생각한다.
1. 네트워크 부하
- 원인 : N+1 문제는 쿼리를 반복적으로 실행하는 문제이다. 쿼리 또한 데이터베이스와의 네트워크 통신을 하기 때문에, 반복 실행은 불필요한 네트워크 트래픽을 방생시킨다.
- 예시 : 작가가 10명이고, 각 작가가 책 1권을 가지고 있다고 한다면, 작가 리스트를 조회할 때 작가 수만큼 쿼리가 실행되고 각 작가마다 책을 조회하려고 추가적인 쿼리가 실행 된다.
2. 데이터베이스 부하
- 원인 : 반복적인 쿼리 실행으로 인행 데이터베이스 부하가 발생한다. 즉 CP(Connection Pool)에서 처리할 일이 늘어난다고 볼 수 있다. 또한 일반적으로 CP를 튜닝 하지 않았다면 row의 수 제한 등 여러가지 문제도 있으므로 객체가 많아질 수록 DB 부하가 커진다고 볼 수 있다.
- 예시 : 네트워크와 같은 경우
3. 메모리 부하
- 원인 : 객체 연관관계 매핑 시 결국 컬렉션에 객체를 저장하는데 이를 반복적으로 가져와서 새로운 컬렉션을 계속 초기화 한다면 메모리 사용량이 증가할 수 있다.
- 예시 : 반복적인 쿼리결과를 저장 할 경우
조금 상투적으로 풀어서 설명 했지만 쉽게말하면 한 번에 할 일을 두 번에 하면 당연히 힘들다이다.
2.2. 왜 N+1 문제를 해결 해야 하는가?
위에서 말한 성능 저하를 야기하는 부정적 원인들로 인한 프로그램의 성능 저하 뿐만 아니라, 다양한 이유를 통해 N+1을 해결 해야 하는 근거를 가질 수 있다.
1. 성능 저하 : 위에서 설명한 부정적 영향들을 기반으로 한 애플리케이션의 성능 저하
2. 불필요한 리소스 소비 : 우리가 특히 트래픽과 R/W가 정해져 있는 Cloud 환경을 사용한다면 불필요한 리소스는 Ondemend 요금에 있어 충분히 줄일 수 있는 소비가 될 것이다.
3. 확장성 저하 : N+1 문제로 인해 성능이 저하된 애플리케이션을 보수하기 위한 추가적인 리소스 할당과 시스템 재설계등 추가 작업이 들어가게 된다.
4. 유지보수 어려움 : 성능이 저하되면 성능을 해결하기 위한 코드 수정이 들어 간다. 그러나 이미 많은 객체들이 연관된 완성된 애플리케이션에서 다시 문제의 원인을 찾아가며 해결하기엔.. 힘이 많이 들 수도 있다.
이것도 상투적으로 풀었지만..
그냥 우리말로 표현하자면 시간도 더 들고 돈도 더들고.. 즉 기업 입장이나 개발자 입장에서도 굳이 방치해야할 이유가 없기 때문이라고 생각한다.
3. N+1 문제 판단하기
예시와 해결 해야하는 이유에 대해 알아봤다면 당연, 어떤 것이 그러한 문제를 야기하는 지 판단하는 안목도 중요하다.
보고도 모른다면 사실 개선할 수가 없기 때문이다.
3.1. 쿼리 로그 분석
책 리스트를 가져오는 쿼리가 실행됐다
1. 책을 조회하는 메인 쿼리:
SELECT * FROM book WHERE author_id = :authorId;
2. 각 책마다 작가를 조회하는 추가 쿼리 (N+1)
SELECT * FROM author WHERE id = :authorId;
-- 위 쿼리가 10번 실행됨
hibernate 로그는 생략하자 어차피 위와 별반 다를게 없으므로 판단할 수 있을 것이다.
이는 JPA의 기본 fetch 전략이 Lazy 이기 때문에 발생하는 데
즉 책을 10권을 가져왔다고하면, 그 책에 있는 작가정보를 또 가져와야하기 때문에
책의 수만큼 N개의 쿼리가 나간다 그리고 책 전체를 가져오는 쿼리 1줄이 있기 때문에 N+1이라고 하는 것이다.
3.2. 코드 검토
처음에 말했던 대로 연관 관계를 점검하고, 내 코드가 부모에서 자식을 불러오는 일대다 관계로 구현 되어 있는 지 확인해보자
나아가 그렇다면 엔티티가 지연 로딩으로 설정되어 있는 지 확인해야할 것이다. 일대다여도 Eager로 설정하면 책을 가져오는 동시에 작가도 가져오므로 N+1이 발생하지 않는다.
그럼에도 불구하고 JPA는 DB의 성능 이슈 등 다양한 장점을 근거로 Lazy를 기본값으로 해놨으니 그 이유에 대해서는 숙지할 필요가 있다.
3.3. 테스트 케이스 작성
테스트 케이스로 쿼리 횟수를 기댓값으로 설정할 수 있다.
@SpringBootTest
@Transactional
public class BookRepositoryTest {
@Autowired
private BookRepository bookRepository;
@Test
public void testFindAllBooks_NPlusOneIssue() {
// N+1 문제가 발생하는 쿼리 메서드를 실행
List<Book> books = bookRepository.findAll();
// N+1 문제 발생 확인을 위한 테스트 코드
for (Book book : books) {
assertNotNull(book.getAuthor()); // 작가 정보가 있는지 확인
}
// 실제로 실행된 쿼리 횟수가 N+1인지 확인
// N은 books의 크기, 즉 책의 수를 의미합니다.
int expectedQueryCount = books.size() + 1; // N+1
int actualQueryCount = HibernateStatistics.getQueryExecutionCount();
assertEquals(expectedQueryCount, actualQueryCount);
}
}
일반적으로 N+1이 발생하면 확실히 N+1이지만
아니라면 N을 보장하지 않으므로 N+1이라면 통과하는 케이스를 짜서 검증하는 것이 더 편할 것이다.
4. N+1 문제 해결하기
마지막으로 이제 문제를 판단했으면 해결을 해보자. 쭉 글을 써오며 사이사이 해결책이 들어가긴 했지만 마무리로 한 번에 정리를 해보도록 하자
4.1. 일대다,다대다 보다 다대일을 사용
처음에 얘기한 대로 다대일의 연관 관계로 관계의 주인을 다쪽에 두어서 사용을 하자
그럴 경우 N쪽에서 부모 객체에 대한 정보 또한 알고 있기 때문에 N+1이 발생하지 않는다
4.2. Fetch Join을 사용
페치 조인은 관련된 엔티티나 컬렉션을 한번의 쿼리로 함께 가져오는 방법이다. 이를 통해 간단히 N+1 문제를 해결할 수 있다.
그러나 페이지네이션 등 해결이 안되는 컬렉션도 있기 때문에 각각에 맞는 전략을 활용하자
JPA에서는 JOIN FETCH 구문을 통해 페치 조인을 수행한다.
@Query("SELECT DISTINCT a FROM Author a JOIN FETCH a.books")
List<Author> findAllAuthorsWithBooks();
적용 시 고려해야할 사항
페치 조인은 한 번에 모든 연관된 엔티티를 가져오므로 데이터베이스에 부하를 줄 수 있다.
따라서 쿼리 결과가 매우 크거나 성능에 영향을 줄 수 있는 경우에 사용해야 한다
4.3. 즉시 로딩 설정 변경
위에 잠시 말했지만 JPA는 기본적으로는 지연 로딩이다.
하지만 이를 즉시 로딩으로 변경하면 연관된 엔티티를 즉시 가져오기 때문에 N+1을 방지할 수 있다. 다만 DB 성능에 부담을 줄 수 있으므로 주의해야 한다.
@Entity
public class Book {
// 다른 필드들...
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "author_id")
private Author author;
// 다른 메서드들...
}
4.4. 배치 사이즈 조절
? 배치 사이즈 : 데이터베이스에서 한 번에 가져올 엔티티의 수
일반적으로 배치 사이즈는 데이터베이스와 애플리케이션의 성능을 고려하여 조정해야 한다.
너무 작으면 오버헤드가 발생하고 너무 크면 메모리 부족이나 성능 저하를 야기하기 때문이다.
-> 애플리케이션에 대해 이해도가 낮다면 섣불리 건들였다가 오히려 안 좋을 수 있으니 쉬운 방법으로 해결하자..
이거 건드는게 능사는 아니다..
applicaiton.yaml(properties) 파일을
hibernate.jdbc.batch_size=20
이런식으로 설정하면 된다
5. 결론
N+1 문제는 성능 저하와 메모리 부족 등의 심각한 문제를 초래할 수 있으므로 최대한 해결하는 것이 좋다고 생각한다.
그러나 그 해결 방법은 각 요구 사항에 맞는 기능을 구현하는 방식이 각자 다를 수 있으므로
정해진 한 방법이 아닌 매 순간 적절한 전략을 판단하는 안목과, 그리고 문제가 발생하는 지 인지할 수 있는 능력이 필요하다고 생각한다.