JPA N+1 문제

박우현 (Joshua)·2023년 8월 1일
0
post-thumbnail

서론

N+1 문제는 스프링 부트를 다루기 시작할 때 부터 들었으며, 실제로도 미니 프로젝트를 하면서 경험해본적이 있던 문제이다. 기본 게시판 CRUD 프로젝트를 만들고 있었는데, 게시판을 조회 할때, 게시판 객체와 연관 관계가 맺어져있던 댓글 객체들을 조회 할 때 N+1 문제가 발생했었다. 오늘은 N+1 문제에 관해 조금 더 자세히 알아보자

N+1 문제란?

데이터베이스 쿼리에서 발생하는 성능 문제로, 연관관계가 설정된 엔티티를 조회할 때 조회된 엔티티(N)만큼 연관관계가 있는 엔티티를 추가로 조회하며 쿼리를 호출해전체적인 쿼리 수가 N+1번 발생하여 성능 저하가 발생하는 상황을 뜻한다. 그럼 엔티티를 조회 할때 FetchMode를 바꿔주면 문제가 없어질까? 한번 확인해보자. FetchMode로는 즉시로딩(Eager)과 지연로딩(Lazy)가 있다.

FetchMode

  1. Fetch 모드를 EAGER(즉시 로딩)으로 한 경우
    일단 엔티티 설정은 Food엔티티와 Orders엔티티가 있고 서로 연관 관계가 맺어져 있다. Food 엔티티는 Orders 엔티티를 가지고 있으며 즉시로딩으로 설정이 되어있다.
@Getter
@Entity
@Setter
@NoArgsConstructor
public class Food {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String foodName;
    @Column(nullable = false)
    private int price;
    @OneToMany(mappedBy = "food", fetch = FetchType.EAGER)
    private List<Orders> orders = new ArrayList<>();
    public Food(String foodName, int price) {
        this.foodName = foodName;
        this.price = price;
    }
}
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Orders {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private int orderNo;
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "food_id")
    private Food food;
    public Orders(int orderNo) {
        this.orderNo = orderNo;
    }
}

이제 Food 엔티티를 조회 해보자. Orders 엔티티를 10개 생성하고 Food 엔티티에 넣어주었다. 확실하게 하기 위해 엔티티매니저를 clear 해주었다.

@Component
@RequiredArgsConstructor
public class Restaurant implements ApplicationRunner {
    private final FoodRepository foodRepository;
    private final OrdersRepository ordersRepository;
    @PersistenceContext
    private final EntityManager em;
    @Override
    @Transactional
    public void run(ApplicationArguments args) throws Exception {
        List<Orders> ordersList = new ArrayList<>();
        for(int i =0; i<10; i++){
            ordersList.add(new Orders(i));
        }
        ordersRepository.saveAll(ordersList);
        List<Food> foods = new ArrayList<>();
        Food food1 = new Food("후라이드", 10000);
        food1.setOrders(ordersList);
        foods.add(food1);
        Food food2 = new Food("양념치킨", 12000);
        food1.setOrders(ordersList);
        foods.add(food2);
        Food food3 = new Food("반반치킨", 13000);
        food1.setOrders(ordersList);
        foods.add(food3);
        foodRepository.saveAll(foods);
        em.flush();
        em.clear();
        System.out.println("============================");
        List<Food> everyFoods = foodRepository.findAll();
    }
}

결과:

보이는 것과 같이 Food 엔티티를 조회한 row 만큼, Orders 엔티티도 같이 조회가 되었다. 그럼 지연로딩으로 바꿔서 해보자.
결과:

지연로딩으로 바꾸었을 때에는 Food 엔티티를 조회 할때 Orders 엔티티는 조회가 안되었다.! 하지만 실제로는 연관관계 엔티티의 멤버 변수를 사용하거나 가공하는 일은 흔하기 때문에 코드에서 연관관계 엔티티를 사용하는 로직을 추가해보자. 확인하기 위해 Food 엔티티의 Orders 리스트의 크기를 구해보았다.

System.out.println("=======================================");
List<Food> everyFoods = foodRepository.findAll();
everyFoods.stream().map(food -> food.getOrders().size())
		.collect(Collectors.toList());

결과 :

동일하게 N+1 문제가 발생하였다.

발생 이유

jpaRepository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 된다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어여서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 생성한다. 그래서 JPQL은 findAll()이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 select * from Food 쿼리만 실행하게 되는것이다. JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 조회하기 때문이다. 그렇기 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 별도로 호출하게 된다.

해결방법

  1. FetchJoin
    JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법이다. (SQL join 문)

    별도의 메소드를 만들어줘야 하며 @Query 어노테이션을 사용해서 "join fetch 엔티티.연관관계_엔티티" 구문을 만들어 주면 된다.


    SQL 로그를 보면 join 구문이 포함되는 것을 알수 있다. 별도의 지정을 안하면 JPQL에서 join fetch 구문은 SQL문의 inner join 구문으로 변경되어 실행된다.
  2. Entity Graph
    @EntityGraph 라는 어노테이션을 사용해서 fetch 조인을 하는 것이다. @EntityGraph 의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 된다. EntityGraph는 몹시 복잡하기 때문에 여기까지 하겠다.

그럼 실무에서는?

아직 잘 모르겠지만 다른 블로그를 보니 이렇게 말하더라.
우선 연관관계에 대한 설정이 필요하다면 FetchType을 성능 최적화를 하기 어려운 즉시 로딩(EAGER)을 사용하는 게 아니라 지연 로딩 (LAZY) 모드로 사용을 하고 성능 최적화가 필요한 부분에서는 Fetch 조인을 사용한다.
또한 기본적으로 Batch Size의 값을 1000 이하로 설정한다. (대부분의 DB에서 IN절의 최대 개수 값 : 1000)
그 외에 팀마다 다른데 꼭 연관관계 설정이 필요 없다면 N+1 문제로 인하여 DB가 죽어버리는 불상사를 막기 위해 연관관계를 끊어버리고 사용하는 것도 방법이다.
나중에 실무 나가면 알아보아야겠다.

profile
매일 매일 성장하자

0개의 댓글