제가 프로젝트를 진행하면면서 JPA를 많이 사용을 하였는데, 그 때 많이 맞이하였던 문제이기도 하고 제일 발생할 수 있는 대표적인 성능 문제 중 하나가 N+1 문제입니다.
이 문제는 JPA에서 1개의 쿼리로 기대했던 결과를 가져오는 대신, N개의 추가 쿼리가 발생하는 현상입니다. 특히 일 대 다 관계에서 지연 로딩이 적용될 때 자주 발생하는데, 이번 포스팅 글에서는 그 N+1 문제의 개념과 해결 방법을 코드로 한 번 살펴보려고 합니다

N+1 문제와 지연로딩이란?
코드를 설명하기에 앞서 좀 더 자세히 해당 문제를 들여다 보면 좋을 것 같은데요, N+1 문제는 간단히 말해 한 번의 쿼리로 가져오려던 데이터를 여러 개의 추가 쿼리를 통해 가져오는 현상을 의미합니다.
예를 들어, 하나의 Parent 엔티티가 여러 개의 Child 엔티티를 가질 때, Parent 데이터를 한 번에 가져왔지만, 각 Child 데이터를 가져오기 위해 추가적인 쿼리가 실행되는 상황을 말합니다
그리고 지연 로딩은 데이터를 필요로 할 때 까지 쿼리를 미루는 전략인데요, 예를 들어 Parent 엔티티에서 Child 엔티티들을 Lazy Loading 으로 설정을 했다면, 처음에는 자식 데이터를 가져오지 않고 필요할 때, 즉 getChildren을 호출할 때 쿼리가 실행이 될 것입니다.
이 때, 프록시 객체가 사용이 된다는 걸 아셔야 합니다! 프록시 객체는 실제 데이터가 아닌, 데이터를 대신하는 가짜 객체로서, 실제로 데이터에 접근할 때만 JPA가 쿼리를 실행하여 데이터를 가져오는 구조입니다. 이러한 방식으로 데이터를 가져오는 방식이 지연 로딩의 핵심입니다.
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Child> children;
}
@Entity
public class Child {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Parent parent;
}
현재 부모 엔티티와 자식 엔티티를 구성을 해보았는데, 사실 부모와 자식이라기 보다는, 일과 다로 구분하시는 게 더 이해하기 구분짓기 편하실 겁니다. 하나의 엔티티를 조회하는데 연관된 엔티티들을 조회하여야 한다는 점을 강조하기 위해, 부모와 자식이라고 지어봤습니다 😊
우선 코드를 보시면, 위와 같은 일 대 다 관계에서 Parent 엔티티는 여러 개의 Child 엔티티를 가지고 있는 게 보이실텐데, 자식 엔티티는 지연 로딩으로 설정되어 있어서, 처음에는 자식 엔티티들의 데이터를 가져오지 않고, 필요할 때 데이터베이스에서 쿼리로 데이터를 가지고 오게합니다
public List<Parent> findAllParents() {
List<Parent> parents = parentRepository.findAll(); // 1번 쿼리
for (Parent parent : parents) {
System.out.println(parent.getChildren().size()); // N번 쿼리
}
return parents;
}
위 코드에서 findAll() 메서드를 통해 부모 데이터는 한 번의 쿼리로 가져오지만, getChildren() 메서드를 호출할 때마다 자식 엔티티를 조회하는 추가 쿼리가 발생합니다. 만약 부모가 100명이라면, 자식 데이터를 가져오기 위해 100번의 추가 쿼리가 발생하게 됩니다. 이것이 바로 N+1 문제입니다.
조금만 더 딥하게 알아보자면, 지연 로딩이 적용된 필드는 프록시 객체로 초기화가 되는데, 이 프록시 객체는 실제데이터를 대신하는 임시 객체이며 실제 데이터에 접근하려고 할 때 JPA는 먼저 1차 캐시에서 해당 데이터를 찾겠죠?
1차 캐시는 JPA에서 관리하는 메모리 저장소로 이미 조회된 엔티티는 여기에 저장됩니다, 어찌됐던 프록시이기에 1차 캐시 저장소에는 값이 당연히 없을 것이고, 실제로 데이터를 필요로 할 때만 쿼리가 실행이 되기에 반복문을 통해 연관 엔티티의 데이터를 순회하는 순간마다 추가적인 쿼리를 빵빵 하고 찍어내는 것입니다
만일 100건이 아니라 회사 단위로 100만 건이 된다고 하면 쿼리가 100만 건이라 빠방하게 찍히게 되는 겁니다. 그만큼 성능 저하가 무지막지하게 일어나겠죠? 그러니 백엔드 개발자라면 필히 알아야 할 개념입니다.
해결 방법
Fetch Join
N + 1 문제의 해결책 중 하나는 패치 조인을 이용하는 것입니다, 패치 조인 사용 시 부모와 자식 데이터를 한 번의 쿼리로 가져올 수 있게 하기 때문에 추가적인 N 개의 쿼리가 발생하지 않으며, 한 번의 쿼리로 모든 데이터를 가져올 수 있게 합니다
public interface ParentRepository extends JpaRepository<Parent, Long> {
@Query("SELECT p FROM Parent p JOIN FETCH p.children")
List<Parent> findAllWithChildren();
}
위의 코드처럼 @Query로 해결할 수 있고, 아니면 엔티티그래프를 이용하여서 해결할 수도 있습니다. 그렇다면 단순히 리포지토리로 findAll을 하는 것과 다르게, 지연로딩으로 인하여 생기는 프록시 객체를 사용하는 것이 아니라 진짜 객체를 가지고 오게 되고 반복문을 순회할 때 1차 캐시에 데이터가 있기 때문에 DB를 거치지 않고 데이터를 꺼내서 반환할 수 있게 되는 것이죠.
하지만 여기서 우리는 이러한 의문점이 생깁니다.
지연 로딩 말고, 즉시 로딩으로 하면 되지 않나?
먼저 답을 알려주자면 아닙니다, 즉시 로딩일 때도 N+1 문제가 발생하게 됩니다. JPQL 자체가 엔티티 기준으로 쿼리를 만들기 때문입니다
즉시 로딩의 경우에도 첫 번째 쿼리는 연관된 엔티티에 대한 정보 없이 주 엔티티에 대해서만 쿼리가 실행됩니다. 이후에 JPA가 패치 전략을 살펴봄으로 연관된 엔티티에 대해서도 쿼리가 발생하게 되고, 결국 N+1 문제가 발생할 수 있습니다.
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "parent", fetch = FetchType.EAGER)
private List<Child> children;
}
@Entity
public class Child {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Parent parent;
}
public List<Parent> findAllParents() {
List<Parent> parents = parentRepository.findAll(); // 1번 쿼리 (Parent 엔티티 조회)
for (Parent parent : parents) {
System.out.println(parent.getChildren().size()); // N번 쿼리 (각 Parent의 Child 엔티티 조회)
}
return parents;
}
findAll 메서드를 통해 부모 엔티티를 조회를 할텐데, 이 때 자식 엔티티도 즉시 로딩이 되므로 자식 엔티티를 함께 조회하기 위해 추가적인 쿼리가 발생합니다.
SELECT * FROM Child WHERE parent_id = ?;
결국, 즉시 로딩이 적용되어도 처음 쿼리는 부모 엔티티만을 대상으로 하고, 이후에 자식 엔티티가 따로 조회되면서 N+1 문제가 발생하게 됩니다.
그래서 답이 뭔데?
즉시로딩은 최대한 사용하지 말고, 지연 로딩 + fetch Join을 권장하고 있습니다. 데이터가 한 꺼번에 많이 필요할 시에만 fetch join을 함께 사용하라고 권장이 됩니다.
그렇다면 N+1은 Fetch Join이면 다 해결이 되는가? fetch join을 사용하므로 n+1 문제는 해결을 하였지만 Fetch Join을 사용할 때 발생할 수 있는 사이드 이펙트도 있습니다. 특히 일 대 다 관계에서 페이징 처리를 할 때 문제가 발생할 수 있습니다.
관계형 데이터베이스와 객체의 차이
관계형 데이터베이스는 데이터를 Row 단위로 저장하고 조회합니다. 하지만 객체 지향 프로그래밍에서 객체는 객체 단위로 데이터를 표현하죠. 이 차이점 때문에 OneToMany 관계에서는 객체의 데이터가 여러 개의 Row로 표현될 수 있습니다.
@Entity
public class Developer {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "developer", fetch = FetchType.LAZY)
private List<Task> tasks;
}
@Entity
public class Task {
@Id
@GeneratedValue
private Long id;
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "developer_id")
private Developer developer;
}
Developer는 여러 개의 Task를 가질 수 있습니다. 예를 들어, "진영"이라는 개발자가 할 일이 5개라고 가정하면, "진영"은 하나지만 할 일은 여러 개이므로 여러 개의 Row로 데이터베이스에서 표현됩니다.
이제 페이징 요청을 했다고 가정해봅시다. 5명의 개발자만 가져오고 싶다면 데이터베이스에 LIMIT를 설정해 데이터를 가져오게 될 것입니다. 하지만 OneToMany 관계에서는 한 명의 개발자가 여러 개의 Task를 가지고 있기 때문에 문제가 발생할 수 있습니다.
@Query("SELECT d FROM Developer d JOIN FETCH d.tasks")
Page<Developer> findAllWithTasks(Pageable pageable);
이 쿼리는 페이징을 요청하지만, OneToMany 관계에서 조인된 데이터 때문에 데이터가 중복되어 여러 개의 Row로 결과가 반환될 수 있습니다. 예를 들어, "진영"이라는 개발자가 5개의 할 일을 가지고 있다면, 데이터베이스에서 "진영"에 대한 정보가 5번 반복되며 결과로 반환됩니다.
즉, 5"명"의 데이터를 보고 싶었지만, 결국 1"명"의 5"개"의 작업을 추출하게 되는 것으로 "진영" 한 명의 정보만 5개의 Row로 반환되는 문제가 발생할 수 있습니다.
또한 페이징 처리를 제대로 하려면 개발자 5명을 가져와야 겠지만, 조인된 테이블의 데이터로 인해 데이터가 중복되고 누락되는 문제가 발생 할 수도 있겠죠?
그리고 사실 제일 치명적인 문제라고 함은, 메모리 부하 문제입니다, JPA는 패치 조인을 통해 조인된 데이터를 전부 메모리에 로드합니다. 만일 조인된 데이터가 예상 보다 너무 많은 시, 메모리에 큰 부하가 발생하게 되죠. 뭐 간단한 데이터라면 상관없지만 100만 건의 데이터를 패치 조인으로 가져오면 DB에서 Full Scan이 발생하고, 모든 데이터를 한 번에 메모리로 로드되기 때문에 조심하여야 합니다
페이징 시, 지연 로딩 + 패치 조인의 조합 문제
| 데이터 누락 |
| 패치 조인은 조인 결과를 메모리에 로드하기 때문에 이에 따른 메모리 부하 |
| Row를 기준으로 데이터를 추출하기에 원하는 객체의 데이터를 못 얻음 |
문제 해결: @BatchSize와 @EntityGraph
N+1 문제를 해결하고 메모리 부하를 줄이기 위한 두 가지 방법은 @BatchSize와 @EntityGraph으로 이 두 방법은 Fetch Join을 사용하지 않으면서도 효율적으로 데이터를 조회하는 방법입니다.
@Entity
public class Developer {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "developer", fetch = FetchType.LAZY)
@BatchSize(size = 100) // 한 번에 100개씩 데이터를 가져옴
private List<Task> tasks;
}
@Entity
public class Task {
@Id
@GeneratedValue
private Long id;
private String description;
@ManyToOne(fetch = FetchType.LAZY)
private Developer developer;
}
@BatchSize는 JPA가 지연 로딩을 사용하는 상황에서 한 번에 많은 데이터를 한꺼번에 조회하지 않도록 최적화하는 방법입니다. 패치 조인 대신 지연 로딩을 사용하면서도 N+1 문제를 완화하고, 데이터베이스에서 한 번에 너무 많은 데이터를 가져오지 않도록 조정할 수 있습니다.
즉, @BatchSize는 지연 로딩을 사용하므로 프록시 객체가 생성되죠 모든 연관 데이터를 한꺼번에 조회하지는 않지만, 한 번에 100개의 데이터를 가져오는 방식으로 N+1 문제를 "줄여줄" 수 있죠
결론은 N+1 문제는 쿼리가 각각의 연관 엔티티마다 반복되어 비효율적으로 많이 발생하는 것이 문제였지만 @BatchSize는 쿼리를 한 번에 여러 개의 엔티티를 가져오도록 최적화하여, 쿼리 수를 줄이면서 성능을 크게 개선 할 수 있고 패치 조인으로 인한 메모리 부하 걱정을 없앨 수가 있습니다
하지만 이것은 메모리 부하를 줄이기 위한 방법일 뿐, 페이징 처리나 데이터 누락 문제를 직접적으로 해결할 순 없죠
또 다른 방법은 @EntityGraph를 사용하는 것입니다. @EntityGraph는 특정 엔티티의 연관 데이터를 즉시 로딩으로 설정하여 Fetch Join 없이도 N+1 문제를 해결할 수 있습니다. 이는 특히 페이징 처리를 할 때 유용한 방법입니다.
public interface DeveloperRepository extends JpaRepository<Developer, Long> {
@EntityGraph(attributePaths = {"tasks"}, type = EntityGraph.EntityGraphType.FETCH)
Page<Developer> findAll(Pageable pageable);
}
Developer 엔티티를 조회할 때 연관된 Task 데이터를 즉시 로딩하여 한 번에 가져옵니다. 이렇게 하면 N+1 문제가 발생하지 않고, Fetch Join을 사용하지 않으면서도 필요한 데이터를 미리 가져올 수 있습니다.
패치조인과 똑같이 미리 로드하는 데 뭐가 더 좋냐고 할 수 있지만, 쿼리 구조와 동작 방식이 엔티티 그래프는 다릅니다. 메모리에 모든 데이터를 즉시 로드하지 않고 필요한 부분만 효율적으로 로드합니다, 페이징 처리를 할 때 패치 조인처럼 모든 데이터를 한꺼번에 로드하지 않고 페이징 범위 내에서 필요한 데이터만 먼저 가져오게 되는 것이죠
즉 엔티티 그래프는 필요한 연관 데이터를 1차 캐시에 저장하지만, 패치 조인은 모든 데이터를 한 번에 메모리에 올리는 방식이 아니라 페이징을 고려한 부분적인 즉시로딩을 수행한다는 것이 차이점이겠죠
'spring' 카테고리의 다른 글
| [DB] 낙관적 락 vs 비관적 락 (2) | 2024.11.30 |
|---|---|
| 스프링에서 쓰레드는 어떻게 사용되고 멀티쓰레드는 언제 사용할까? (1) | 2024.11.20 |
| AOP와 스프링 AOP란? (0) | 2024.09.23 |
| 스프링 세션과 Redis 응용 (1) | 2024.09.18 |
| [MileStone 프로젝트] 계층형 데이터베이스 구축 - JPA (0) | 2024.08.14 |