JPA를 활용하면서 주의해야 할 중요한 사항 중 하나는 N+1 문제입니다. 이 문제는 글로벌 페치 전략이 즉시로딩(EAGER)일 때 주로 발생하는 것으로 알려져 있지만, 실제로 지연로딩(LAZY)을 사용하더라도 N+1 문제가 발생할 수 있습니다.
다음은 즉시로딩 전략에서 N+1 문제가 발생하는 상황입니다.
즉시로딩은 연관된 엔티티를 조회할 때 데이터베이스에 JOIN 쿼리를 사용해서 한번에 연관된 엔티티까지 조회하는 전략입니다.
@Entity(name = "market_order")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(indexes = {
@Index(name = "idx_order_id", columnList = "id"),
@Index(name = "idx_buyer_id", columnList = "buyer_id")}
)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "buyer_id")
private User user;
// 생략
}
// --------------------------------------
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity(name = "market_user")
@Table(indexes = {
@Index(name = "idx_user_id", columnList = "id"),
@Index(name = "idx_name", columnList = "name")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
해당 엔티티를 em.find() 메서드를 사용해서 조회했을때 JPA는 내부적으로 글로벌 페치 전략을 적용합니다. 즉, em.find(Order.class, 1L);를 호출하면 아래와 같은 쿼리가 실행됩니다.
SELECT o.*, u.* FROM
MARKET_ORDER o
LEFT OUTER JOIN MARKET_USER u ON o.id=m.id
WHERE o.id=1
실행된 쿼리를 보면 즉시로딩으로 설정한 User 엔티티를 JOIN 쿼리로 함께 조회합니다. 이렇게하면 추가적인 쿼리 없이 한 번에 연관 엔티티를 모두 조회해 오기 때문에 성능적으로 얻는 이점이 있을것 같습니다.
하지만 문제는 JPQL을 사용할 때 발생합니다. 대부분의 JPA 개발환경에서는 Spring Data JPA를 사용하기 때문에 N+1 문제가 발생할 것입니다. 실제로 JPQL을 사용해서 같은 엔티티를 조회해보겠습니다.
Order foundOrder = orderRepository.findById(1L)
실행결과
SELECT * FROM ORDER // JPQL로 실행한 쿼리
SELECT * FROM USER WHERE id=?
SELECT * FROM USER WHERE id=?
SELECT * FROM USER WHERE id=?
SELECT * FROM USER WHERE id=?
SELECT * FROM USER WHERE id=?
JPQL은 JPA의 구현체가 특정 데이터베이스 벤더에 종속되지 않도록 하고, JPQL 쿼리를 분석하여 적절한 SQL을 생성하는 역할을 합니다. 이를 통해 특정 데이터베이스 시스템의 SQL 문법에 대한 지식이나 종속성을 줄일 수 있습니다.
쉽게말하면 JPQL이란 JPA의 구현체(여기서는 하이버네이트)가 JPQL을 분석해서 적절한 SQL을 생성해주는 것입니다.
JPA는 글로벌 페치 전략을 참고하지 않고 JPQL만으로 쿼리를 생성합니다. 이때 N+1문제가 발생하는데, 이유는 다음과 같습니다.
Order - User관계의 글로벌 페치 전략이 즉시로딩이므로 User 엔티티를 로딩하기 위해 영속성 컨텍스트를 조회한다.User 엔티티가 없으므로 조회 쿼리를 추가로 발생시킨다. → N+1 문제 발생이렇게 부모 엔티티를 조회할 때 그와 연관된 자식 엔티티를 함께 조회하는 상황에서 N번의 추가 쿼리가 발생하는 문제를 N+1 문제라고 합니다.
위의 시나리오를 엔티티 간의 연관 관계를 지연로딩으로 변경하더라도 N+1 문제가 발생할 수 있습니다. 즉, N+1 문제는 즉시로딩 뿐만 아니라 지연로딩일 때도 발생할 수 있습니다.
프로젝트를 진행하면서 N+1 문제가 발생한 실제 상황을 소개하고, 이를 어떻게 해결했는지 설명하겠습니다.
특정 날짜 범위에서 사용자의 주문 내역을 조회해야 합니다.
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 이하 생력
}
public class OrderProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
// 이하 생략
}
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "buyer_id")
private User user;
@OneToMany(cascade = CascadeType.PERSIST)
@JoinColumn(name = "order_id")
private List<OrderProduct> orderProduct;
// 이하 생략
}
데이터베이스 정보는 아래의 더미데이터를 사용했습니다.
market_order
| id | merchant_uid | oder_price | ordered_at | buyer_id |
|---|---|---|---|---|
| 1 | merch_63365000_1 | 61000 | 2023-11-07 17:03:42.063365 | 1 |
order_product
| id | quantity | product_id | order_id |
|---|---|---|---|
| 1 | 2 | 1 | 1 |
| 2 | 3 | 3 | 1 |
| 3 | 10 | 5 | 1 |
product
| id | description | image_url | like_count | name | price | sales_amount | seller_id | stock_amount |
|---|---|---|---|---|---|---|---|---|
| 1 | some description | urls | 0 | product1 | 1000 | 0 | 1 | 10 |
| 2 | some description | urls | 0 | product2 | 2000 | 0 | 1 | 10 |
| 3 | some description | urls | 0 | product3 | 3000 | 0 | 1 | 10 |
| 4 | some description | urls | 0 | product4 | 4000 | 0 | 1 | 10 |
| 5 | some description | urls | 0 | product5 | 5000 | 0 | 1 | 10 |
market_order 테이블과 product 테이블의 다대다 관계는 order_product(조인테이블)을 통해 일대다, 다대일 관계로 풀었습니다.
@GetMapping("/{orderId}")
public PageResponseDto<OrderGetResponseDto> getOrderDateRange(@RequestParam String startDate, @RequestParam String endDate, @RequestParam int page, @RequestParam int size) {
String email = authorizationHelper.getPrincipalEmail();
List<OrderGetResponseDto> contents = orderService.getOrderByDate(email, startDate, endDate, page, size)
.stream()
.map(Order::toOrderGetResponseDto)
.collect(Collectors.toList());
long totalElements = orderService.countOrderByDate(email, startDate, endDate);
return PageResponseDto.<OrderGetResponseDto>builder()
.size(size)
.page(page)
.content(contents)
.totalElements(totalElements)
.build();
}
email)를 획득합니다.
N+1 문제는 사용자 주문 정보 리스트를 조회하는 과정에서 발생했습니다. 각 주문(Order)은 하나 이상의 주문 상품(OrderProduct)을 가지고 있으며, 주문은 상품(Product) 정보를 참조합니다. 따라서 주문 정보를 가져오면서 주문 상품 및 연관된 상품 정보도 함께 가져와야 합니다.
주문 정보를 조회하는 쿼리를 작성하여 주문 정보를 가져온 후, 각 주문에 대한 주문 상품을 가져오기 위해 추가 쿼리가 발생했습니다. 다시 말해, 하나의 주문 정보를 조회하기 위해 해당 주문과 연관된 여러 주문 상품을 가져오는 쿼리가 추가로 실행되었습니다.
-- 주문 정보 조회
SELECT *
FROM market_order order0_
WHERE (
order0_.ordered_at BETWEEN ? AND ?
) AND order0_.buyer_id = ?
LIMIT ?
-- 주문 상품 조회
SELECT *
FROM order_product orderprodu0_
WHERE orderprodu0_.order_id = ?
-- 주문 상품 조회
SELECT *
FROM order_product orderprodu0_
WHERE orderprodu0_.order_id = ?
-- 주문 상품 조회
SELECT *
FROM order_product orderprodu0_
WHERE orderprodu0_.order_id = ?
이렇게 추가 쿼리가 발생하게 되면 성능저하로 이어질 수 있습니다. 데이터베이스 서버와의 통신이 빈번하게 발생하며 이로인해 응답시간이 길어질 수 있습니다. 또한 추가 쿼리가 데이터베이스 부하를 증가시킬 수 있습니다. 특히 대규모 트래픽을 받는 애플리케이션에서는 데이터베이스 서버에 부하가 집중되는 문제가 발생합니다. 즉 N+1 문제는 애플리케이션 성능에 직접적인 영향을 미치기 때문에 적절하게 최적화할 필요가 있습니다.
N+1 문제를 해결하고 데이터베이스에서의 효율적인 데이터 로딩을 향상시키기 위한 세 가지 방법을 고려해보았습니다:
fetchJoin은 JPA에서 제공하는 N+1 문제를 해결하는 가장 일반적인 방법으로 알려져 있습니다. 페치 조인은 SQL 조인을 활용하여 연관된 엔티티를 함께 조회할 수 있어, N+1 문제를 효과적으로 해결할 수 있습니다.
그러나 페치 조인을 자주 사용하면 특정 화면 또는 기능에 맞춘 레포지토리 메서드가 늘어나게 됩니다. 이로 인해 presentation 계층이 persistence 계층에 영향을 미치는 가능성이 있습니다. 예를 들어, 화면 A에서는 order 엔티티만 필요하고 화면 B에서는 order 엔티티와 member 엔티티가 필요한 상황이라고 가정해봅시다. 이러한 상황에서 두 화면을 모두 최적화하기 위해 레포지토리에 다음과 같은 메서드를 만들었습니다.
이러한 방식으로 각 화면은 필요한 메서드를 호출하여 사용할 수 있게 됩니다. 하지만 이렇게 메서드를 추가하면 최적화가 가능해지지만, repository와 view 간에 논리적인 의존성이 생길 수 있다는 단점이 있습니다.
@BatchSize 어노테이션은 Hibernate에서 제공하는 어노테이션으로 데이터베이스 엔티티의 컬렉선(예: @OneToMany, @ManyToMany)을 로딩할 때 Batch 크기를 설정하는 데 사용됩니다. 해당 어노테이션으로도 N+1 문제를 해결하고 성능을 향상시킬 수 있습니다.
이 설정은 LAZY 로딩 엔티티에 주로 사용되며, N+1 문제를 해결하고 성능을 향상시킵니다. 주의해야 할 점은 즉시 로딩과 지연 로딩 상황에서 실행되는 쿼리가 다르다는 것입니다.
해당 기능은 다음과 같이 사용할 수 있습니다.
@BatchSize(size = 5)
@OneToMany(cascade = CascadeType.PERSIST)
@JoinColumn(name = "order_id")
private List<OrderProduct> orderProduct;
이는 orderProduct 컬렉션을 로딩할 때 데이터베이스에서 5개의 orderProduct를 한 번에 가져온다는 것을 의미합니다.
@BatchSize 어노테이션은 SQL의 IN 절을 사용해서 조회합니다. 만약 조회해야 하는 orderProduct가 6개 인데, size=5로 지정하면 2번의 SQL만 실행되는 것입니다.
주의해야할 점은 즉시로딩과 지연로딩 상황에서 나가는 쿼리가 각각 다르다는 것입니다.
이 설정은 Hibernate에서 제공하는 기능으로, 연관 엔티티를 로딩할 때 한 번의 쿼리로 부모 엔티티와 관련된 모든 자식 엔티티를 서브쿼리를 사용하여 검색합니다. 이로써 N+1 문제가 발생하지 않고 데이터베이스에서 효율적으로 데이터를 가져올 수 있습니다. SUBSELECT 모드는 일반적으로 LAZY 로딩이 설정된 컬렉션에 사용되며, 부모 엔티티를 로딩할 때 관련된 자식 엔티티를 서브쿼리를 통해 함께 로딩합니다.
@OneToMany
@JoinColumn(name = "book_id")
@Fetch(FetchMode.SUBSELECT)
private List<Author> authors;
이렇게 설정하면 부모 엔티티를 로딩할 때 연관된 자식 엔티티들을 서브쿼리를 사용하여 한 번에 가져옵니다.
해당 문제는 LAZY 로딩을 사용하는 컬렉션에서 발생했기 때문에 @BatchSize를 설정하는 방식으로 해결했습니다.
@BatchSize(size = 5)
@OneToMany(cascade = CascadeType.PERSIST)
@JoinColumn(name = "order_id")
private List<OrderProduct> orderProduct;
@BatchSize(size = 5) 설정은 통해 Order를 조회하는 시점에 5개의 orderProduct를 로딩합니다. 즉 아래와 같은 쿼리 한번으로 5개의 컬렉션 요소를 로딩하여 추가적인 쿼리를 발생시키지 않아 N+1 문제를 해결하고 최적화를 할 수 있었습니다.
select
orderprodu0_.order_id as order_id4_3_1_,
orderprodu0_.id as id1_3_1_,
orderprodu0_.id as id1_3_0_,
orderprodu0_.product_id as product_3_3_0_,
orderprodu0_.quantity as quantity2_3_0_
from
order_product orderprodu0_
where
orderprodu0_.order_id in (
?, ?, ?, ?, ?
)
N+1 문제는 애플리케이션 성능에 직접적인 영향을 미칠수 있습니다. 이번 문제 해결 과정을 통해 불필요한 데이터베이스 쿼리를 줄이고 데이터를 효율적으로 가져와서 응답시간을 개선하는 방법에 대해 배울 수 있었습니다.
N+1 문제 해결에는 여러 전략이 존재하며 어떤 전략을 선택할지 결정하는 것이 중요하다고 생각합니다. 각 방법에 대한 장단점과 트래이드 오프를 비교하면서 애플리케이션의 요구 사항에 적절한 방식을 고민하는 시간을 가질 수 있었습니다.