Back-end/TroubleShooting

[JPA] N+1 문제 발생 지점 찾아 해결하기

서채리 2024. 8. 30. 02:28

1️⃣ N+1 문제와 해결 방법

이전 글에서 N+1 문제와 해결 방법에 대해 정리해보았다.

 

[JPA] N+1 문제 해결 방법

N+1 문제란?N+1 문제는 ORM(객체-관계 매핑) 기술에서 특정 객체를 조회할 때, 그 객체와 연관된 다른 객체들도 각각 조회되면서 N번의 추가 쿼리가 발생하는 문제를 말한다. N+1 문제는 단일 쿼리로

chaewsscode.tistory.com

해결 방법 간단 요약

Fetch Join 사용

JPQL의 join fetch를 사용하여 부모 엔티티를 조회할 때 연관된 엔티티를 함께 가져온다.
@Query 어노테이션을 사용하여 메서드를 작성해야 한다.

 

배치 사이즈 설정

Hibernate의 배치 사이즈 설정을 통해 연관된 엔티티를 한 번의 쿼리로 가져오도록 최적화한다.
application.yml에서 default_batch_fetch_size를 전역 설정하거나, @BatchSize로 특정 엔티티에 대해 설정할 수 있다.

 

Entity Graph 사용

@EntityGraph를 통해 기본 Lazy 로딩 설정을 무시하고 연관된 엔티티를 Eager 로딩하도록 설정한다.

 

2️⃣ 문제 발생 지점 찾기

이제 실제 프로젝트에서 N+1 문제가 발생하는 지점을 찾아 해결해보려고 한다.

N+1 문제는 데이터베이스에서 발생하는 성능 이슈이기 때문에, 이를 해결하려면 쿼리가 실제로 어떻게 발생하고 있는지 확인해야 한다.

 

application.yml에서 아래 설정을 추가하면 Hibernate가 실행하는 SQL 쿼리와 바인딩되는 파라미터 값을 로그로 확인할 수 있다.

spring:
  jpa:
    show-sql: true	# Hibernate가 실행하는 SQL 쿼리를 콘솔에 출력
    
    properties:
      hibernate:
        format_sql: true	# 출력되는 SQL 쿼리 포맷팅

logging:
  level:
    org:
      hibernate:
        SQL: debug
        type.descriptor.sql: trace
    org.hibernate.orm.jdbc.bind: trace	# SQL 쿼리에 바인딩되는 파라미터 값들을 함께 출력

 

ProductService 클래스에서 Slice<Product>를 반환하는 findAllByOrderByCreatedAtDesc 메서드는 모든 상품을 생성일 기준 내림차순으로 정렬하여 반환하는 역할을 한다. 하지만 각 상품과 연관된 브랜드 정보를 조회할 때, 상품 하나하나에 대해 별도의 SELECT 쿼리가 발생할 수 있다.

@Entity
public class Product extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "brand_id")
    private Brand brand;
    
    ....
}
public class ProductService {
    
    @Transactional(readOnly = true)
    public Slice<ReadProductResponseDto> readProductList(Pageable pageable) {
        return productRepository.findAllByOrderByCreatedAtDesc(pageable).map(ReadProductResponseDto::of);
    }
}

 

해당 메서드의 SQL 로그를 출력해보면, 아래와 같은 결과를 얻을 수 있다.

2024-08-30T02:29:22.290+09:00 DEBUG 96740 --- [nio-8001-exec-2] org.hibernate.SQL                        : 
Hibernate: 
    select
        p1_0.id,
        p1_0.brand_id
    from
        product p1_0 
    order by
        p1_0.created_at desc offset ? rows fetch first ? rows only
2024-08-30T02:29:22.296+09:00 TRACE 96740 --- [nio-8001-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [INTEGER] - [0]
2024-08-30T02:29:22.297+09:00 TRACE 96740 --- [nio-8001-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter [2] as [INTEGER] - [21]
2024-08-30T02:29:22.323+09:00 DEBUG 96740 --- [nio-8001-exec-2] org.hibernate.SQL                        : 
Hibernate: 
    select
        b1_0.id,
        b1_0.name 
    from
        brand b1_0 
    where
        b1_0.id=?
2024-08-30T02:29:22.323+09:00 TRACE 96740 --- [nio-8001-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [4]
2024-08-30T02:29:22.324+09:00 DEBUG 96740 --- [nio-8001-exec-2] org.hibernate.SQL                        : 
Hibernate: 
    select
        b1_0.id,
        b1_0.name 
    from
        brand b1_0 
    where
        b1_0.id=?
2024-08-30T02:29:22.324+09:00 TRACE 96740 --- [nio-8001-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [3]
2024-08-30T02:29:22.325+09:00 DEBUG 96740 --- [nio-8001-exec-2] org.hibernate.SQL                        : 
Hibernate: 
    select
        b1_0.id,
        b1_0.name 
    from
        brand b1_0 
    where
        b1_0.id=?
2024-08-30T02:29:22.325+09:00 TRACE 96740 --- [nio-8001-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [5]
2024-08-30T02:29:22.329+09:00 DEBUG 96740 --- [nio-8001-exec-2] org.hibernate.SQL                        : 
Hibernate: 
    select
        b1_0.id,
        b1_0.name 
    from
        brand b1_0 
    where
        b1_0.id=?
2024-08-30T02:29:22.329+09:00 TRACE 96740 --- [nio-8001-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [2]
2024-08-30T02:29:22.330+09:00 DEBUG 96740 --- [nio-8001-exec-2] org.hibernate.SQL                        : 
Hibernate: 
    select
        b1_0.id,
        b1_0.name 
    from
        brand b1_0 
    where
        b1_0.id=?

위 로그를 보면, 각 Product 엔티티와 연관된 Brand 엔티티를 조회하기 위해 개별적인 SELECT 쿼리가 여러 번 실행되는 것을 확인할 수 있다. 이처럼 불필요하게 많은 쿼리가 발생하는 것을 N+1 문제라고 한다.

 

3️⃣ 문제 해결하기

이를 해결하기 위해 ProductRepository에 @Query 어노테이션을 사용하여 fetch join을 적용할 수 있다.

interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("SELECT p FROM Product p JOIN FETCH p.brand ORDER BY p.createdAt DESC")
    Slice<Product> findAllByOrderByCreatedAtDesc(Pageable pageable);
}

JPQL의 fetch join을 사용하여 Product 엔티티를 조회할 때 연관된 Brand 엔티티도 함께 조회하도록 설정해주었다.

 

2024-08-30T02:49:56.191+09:00 DEBUG 97058 --- [nio-8001-exec-2] org.hibernate.SQL                        : 
Hibernate: 
    select
        p1_0.id,
        b1_0.id,
        b1_0.name,
        p1_0.name 
    from
        product p1_0 
    join
        brand b1_0 
            on b1_0.id=p1_0.brand_id 
    offset ? rows fetch first ? rows only
2024-08-30T02:49:56.219+09:00 TRACE 97058 --- [nio-8001-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [INTEGER] - [0]
2024-08-30T02:49:56.219+09:00 TRACE 97058 --- [nio-8001-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter [2] as [INTEGER] - [21]

생성된 SQL 로그를 보면, 필요한 Product와 Brand 데이터를 모두 포함하고 있고 JOIN 쿼리가 한 번만 실행되었음을 확인할 수 있다.

 

이처럼 Fetch Join을 사용함으로써, 각 엔티티에 대해 개별적인 SELECT 쿼리를 실행하지 않고 한 번의 JOIN 쿼리로 부모 엔티티와 연관된 자식 엔티티를 함께 조회해 N+1 문제를 방지할 수 있다.

 

Fetch Join을 사용한 이유

  1. 부모인 Product와 자식 Brand 엔티티가 OneToOne 관계라 Fetch Join의 페이징 처리 제한 문제가 발생하지 않았다.
  2. Fetch Join은 INNER JOIN을 사용하여 Product와 연관된 Brand 엔티티가 반드시 있을 경우에만 데이터를 가져오므로 불필요한 데이터를 조회하지 않는다. EntityGraph는 LEFT OUTER JOIN을 사용해 연관된 Brand 엔티티가 없어도 데이터를 가져올 수 있다.
  3. Batch Size 조절은 OneToMany 관계에서 N+1 문제를 해결하기 유용하지만, OneToOne 관계에서는 Fetch Join을 사용할 경우에도 충분히 성능 최적화가 이루어지기 때문에 추가적인 배치 사이즈 조정이 필요하지 않았다.

 

 

다음 글: logging 옵션 대신, P6Spy로 쿼리 로깅 관리하기

SQL 쿼리와 파라미터를 한눈에 확인하기 어려운 logging 옵션 대신, P6Spy로 쿼리 로깅을 관리하는 글을 추가적으로 써보았다.

 

[Spring Boot] P6Spy 쿼리 로깅

🌱 문제 상황 [JPA] N+1 문제 발생 지점 찾아 해결하기이전 글에서 N+1 문제와 해결 방법에 대해 정리해보았다. [JPA] N+1 문제 해결 방법N+1 문제란?N+1 문제는 ORM(객체-관계 매핑) 기술에서 특정 객체

chaewsscode.tistory.com