1️⃣ N+1 문제와 해결 방법
이전 글에서 N+1 문제와 해결 방법에 대해 정리해보았다.
해결 방법 간단 요약
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을 사용한 이유
- 부모인 Product와 자식 Brand 엔티티가 OneToOne 관계라 Fetch Join의 페이징 처리 제한 문제가 발생하지 않았다.
- Fetch Join은 INNER JOIN을 사용하여 Product와 연관된 Brand 엔티티가 반드시 있을 경우에만 데이터를 가져오므로 불필요한 데이터를 조회하지 않는다. EntityGraph는 LEFT OUTER JOIN을 사용해 연관된 Brand 엔티티가 없어도 데이터를 가져올 수 있다.
- Batch Size 조절은 OneToMany 관계에서 N+1 문제를 해결하기 유용하지만, OneToOne 관계에서는 Fetch Join을 사용할 경우에도 충분히 성능 최적화가 이루어지기 때문에 추가적인 배치 사이즈 조정이 필요하지 않았다.
다음 글: logging 옵션 대신, P6Spy로 쿼리 로깅 관리하기
SQL 쿼리와 파라미터를 한눈에 확인하기 어려운 logging 옵션 대신, P6Spy로 쿼리 로깅을 관리하는 글을 추가적으로 써보았다.
'Back-end > TroubleShooting' 카테고리의 다른 글
[Spring Boot] P6Spy 쿼리 로깅 (0) | 2024.08.31 |
---|---|
[Git] bad object {...} did not send all necessary objects (0) | 2024.08.25 |
멀티모듈 JaCoCo 집계된 커버리지 보고서를 기준으로 빌드 검증 (0) | 2024.08.02 |
[Spring Boot] 멀티모듈 'Unable to find a @SpringBootConfiguration' (0) | 2024.07.30 |
멀티 모듈 프로젝트 NoSuchBeanDefinitionException: No qualifying bean of type 'jakarta.persistence.EntityManagerFactory' available (0) | 2024.07.23 |