[JPA] N+1 문제

이건영·2023년 6월 10일
0

JPA N+1 문제란

N+1문제란 1번의 쿼리를 날렸을 때 의도치 않은 N개의 쿼리가 실행되는것을 의미한다.

언제 N+1문제가 발생 될까?

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. 즉시 로딩

1:n관계에서 FetchType.EAGER로 설정 한 경우 JPA는 EAGER가 걸려있는 것을 보고 select에서 검색 된 user에 대해 cars가 있는지를 검색하게 된다.

List형태로 findAll을 할 경우 list의 갯수(n개) 만큼 DB에서 조회하게 된다.
즉시 로딩은 JPQL로 전달되는 과정에서 JPQL 연산 후 EAGER 감지로 인해 쿼리가 추가(n개)로 발생하는 경우가 있어 사용 시 조심해야 한다.

2. 지연 로딩

n:1관계에서 FetchType.LAZY로 설정 한 경우 연결 된 Entity에 대해서 프록시로 걸어두고, 해당 객체를 사용할 때 쿼리문을 날리게 된다. 처음 find함수를 날릴 때는 N+1 문제가 발생하지 않지만 이후에 Car의 User를 사용할 때 이미 캐싱된 Car의 User프록시에 대한 쿼리가 발생하게 된다.

처음 findAll() 함수 호출 시 N+1 이슈는 발생하지 않지만 나중에 해당 객체를 get()하는 순간 쿼리가 발생하여 N+1 이슈는 나타나게 된다.

해결책

  • 여러 해결책이 있지만 대표적인 방법과 마지막으로 내가 해결 한 방법에 대해 작성 해 보겠다.

1. Fetch Join

N+1이슈가 발생하는 이유는 한쪽 테이블을 먼저 조회하고 이후에 연결 된 다른 테이블을 다시 조회하기 때문이다.
미리 두 테이블을 Join하여 한번에 모든 데이터를 조회하여 가져온다면 애초에 N+1이슈는 발생하지 않을 것이다.
그렇게 나온 방법이 Fetch Join방법이다.

@Query("SELECT DISTINCT u FROM USER u JOIN FETCH u.CARS")
List<User> findAllJoinFetch();

Fetch Join 단점

  • 쿼리 한번에 모든 데이터를 가져오기 때문에 JPA가 제공하는 Paging API 사용 불가능(Pageable 사용 불가)
  • 1:N 관계가 두 개 이상인 경우 사용 불가
  • 패치 조인 대상에게 별칭(as) 부여 불가능
  • 번거롭게 쿼리문을 작성해야 함

2. @EntityGraph 어노테이션 사용

@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이 성능 최적화에 더 유리하다.)

3. 나의 경우(이런 실수를 답습하지 않기위한 기록....)

나의 경우에는 화면상에서 보이는 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을 조회하는거 보다는 시간을 많이 줄일 수 있었다.

profile
일단 해보자

0개의 댓글