N+1문제란 1번의 쿼리를 날렸을 때 의도치 않은 N개의 쿼리가 실행되는것을 의미한다.
1:n(OneToMany) 또는 n:1(ManyToOne)관계를 가진 entity를 조회할 때 발생
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "USER_ID")
private Long userId;
private String userName;
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER) // 즉시 로딩
private List<Car> cars;
}
@Entity
public class Car {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long carId;
private String carName;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
@JoinColumn(name = "USER_ID", nullable = false)
private User user;
}
1:n관계에서 FetchType.EAGER로 설정 한 경우 JPA는 EAGER가 걸려있는 것을 보고 select에서 검색 된 user에 대해 cars가 있는지를 검색하게 된다.
List형태로 findAll을 할 경우 list의 갯수(n개) 만큼 DB에서 조회하게 된다.
즉시 로딩은 JPQL로 전달되는 과정에서 JPQL 연산 후 EAGER 감지로 인해 쿼리가 추가(n개)로 발생하는 경우가 있어 사용 시 조심해야 한다.
n:1관계에서 FetchType.LAZY로 설정 한 경우 연결 된 Entity에 대해서 프록시로 걸어두고, 해당 객체를 사용할 때 쿼리문을 날리게 된다. 처음 find함수를 날릴 때는 N+1 문제가 발생하지 않지만 이후에 Car의 User를 사용할 때 이미 캐싱된 Car의 User프록시에 대한 쿼리가 발생하게 된다.
처음 findAll() 함수 호출 시 N+1 이슈는 발생하지 않지만 나중에 해당 객체를 get()하는 순간 쿼리가 발생하여 N+1 이슈는 나타나게 된다.
N+1이슈가 발생하는 이유는 한쪽 테이블을 먼저 조회하고 이후에 연결 된 다른 테이블을 다시 조회하기 때문이다.
미리 두 테이블을 Join하여 한번에 모든 데이터를 조회하여 가져온다면 애초에 N+1이슈는 발생하지 않을 것이다.
그렇게 나온 방법이 Fetch Join방법이다.
@Query("SELECT DISTINCT u FROM USER u JOIN FETCH u.CARS")
List<User> findAllJoinFetch();
@EntityGraph 의 attributePaths는 같이 조회할 연관 엔티티명을 적으면 된다. ,(콤마)를 통해 여러 개를 줄 수도 있다.
Fetch join과 동일하게 JPQL을 사용해 Query문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 된다.
@EntityGraph(attributePaths = {"cars"})
@Query("SELECT DISTINCT u FROM USER u")
List<User> findAllEntityGraph();
Fetch join의 경우 inner join을 하는 반면에 EntityGraph는 outer join을 기본으로 한다.
(기본적으로 outer join 보다 inner join이 성능 최적화에 더 유리하다.)
나의 경우에는 화면상에서 보이는 list를 excel로 다운로드 받을 때 N+1 이슈가 크게 발생하였다.
화면에서는 페이징을 함으로서 N+1이슈가 크게 붉어지지 않았지만 excel로 받을 때는 해당 조건의 모든 데이터를 excel로 내려주어야 하기 때문에 방대한 row에 대해 N+1 이슈가 발생한 것이다.
처음부터 N+1 이슈가 고려돼서 설계 되었다면 좋았겠지만 이미 개발이 완료된 상황이었다.....
다운로드 시간이 30분이 넘어가는걸 줄이기 위해(기존에 연관 된 다른 비즈니스 로직에 영향이 없이 수정하는 것이 목표) 우회적인 방법을 사용 하였다.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "USER_ID")
private Long userId;
private String userName;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY) // 지연 로딩
private List<Car> cars;
...
}
@Entity
public class Car {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "CAR_ID")
private Long carId;
private String carName;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
@JoinColumn(name = "USER_ID", nullable = false)
private User user;
}
public void drawContent() {
//paging처리 되어 반복문이 있으나 예시에서는 paging 제외
List<User> userList = userRepository.findAll();
List<String> userIdList = userList.stream().map(User::getUserId).toList();
Map<String, List<Car>> carMap = getUserIdKeyCarMap(userIdList);
for(User user : userList) {
List<Car> carList = null;
if(carMap.containKey(user.getUserId())) {
carList = carMap.get(user.getUserId());
}
...
}
...
}
private Map<String, List<Car>> getUserIdKeyCarMap(List<String> userIdList) {
return carRepository.findAllUser_userIdIn(userIdList).stream()
.collect(
Collectors.groupingBy(info -> info.getUser().getUserId())
);
}
처리 방법이 깔끔하지 않고 DB에서 한번에 가져오는 방법이 아닌 User 클래스의 LAZY의 갯수만큼 DB에서 조회 하지만 UserList가 수만건인 만큼 N+1을 조회하는거 보다는 시간을 많이 줄일 수 있었다.