N+1 문제 트러블 슈팅

김태현·2023년 11월 7일

들어가면서

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문제가 발생하는데, 이유는 다음과 같습니다.

  1. JPQL을 분석해서 SELECT 쿼리를 날린다.
  2. 데이터베이스로부터 결과를 받아 엔티티 인스턴스를 생성한다.
  3. 생성된 엔티티와 연관관계(User)의 글로벌 페치 전략을 확인한다.
  4. Order - User관계의 글로벌 페치 전략이 즉시로딩이므로 User 엔티티를 로딩하기 위해 영속성 컨텍스트를 조회한다.
  5. 영속성 컨텍스트에User 엔티티가 없으므로 조회 쿼리를 추가로 발생시킨다. → N+1 문제 발생

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

idmerchant_uidoder_priceordered_atbuyer_id
1merch_63365000_1610002023-11-07 17:03:42.0633651

order_product

idquantityproduct_idorder_id
1211
2331
31051

product

iddescriptionimage_urllike_countnamepricesales_amountseller_idstock_amount
1some descriptionurls0product110000110
2some descriptionurls0product220000110
3some descriptionurls0product330000110
4some descriptionurls0product440000110
5some descriptionurls0product550000110

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();
    }
  • 파라메터로 날짜범위와 페이징 정보를 받습니다.
  • SecurityContextHolder를 통해 요청을 보낸 사용자 정보(email)를 획득합니다.
  • 비즈니스 로직 처리후 결과를 반환합니다.

응답 결과

N+1 문제 발생 상황

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 문제 트러블 슈팅

N+1 문제를 해결하고 데이터베이스에서의 효율적인 데이터 로딩을 향상시키기 위한 세 가지 방법을 고려해보았습니다:

1. fetchJoin

fetchJoin은 JPA에서 제공하는 N+1 문제를 해결하는 가장 일반적인 방법으로 알려져 있습니다. 페치 조인은 SQL 조인을 활용하여 연관된 엔티티를 함께 조회할 수 있어, N+1 문제를 효과적으로 해결할 수 있습니다.

그러나 페치 조인을 자주 사용하면 특정 화면 또는 기능에 맞춘 레포지토리 메서드가 늘어나게 됩니다. 이로 인해 presentation 계층이 persistence 계층에 영향을 미치는 가능성이 있습니다. 예를 들어, 화면 A에서는 order 엔티티만 필요하고 화면 B에서는 order 엔티티와 member 엔티티가 필요한 상황이라고 가정해봅시다. 이러한 상황에서 두 화면을 모두 최적화하기 위해 레포지토리에 다음과 같은 메서드를 만들었습니다.

  • 화면 A를 위한 orderRepository.findOrderById() 메서드
  • 화면 B를 위한 orderRepository.findOrderAndMember() 메서드

이러한 방식으로 각 화면은 필요한 메서드를 호출하여 사용할 수 있게 됩니다. 하지만 이렇게 메서드를 추가하면 최적화가 가능해지지만, repository와 view 간에 논리적인 의존성이 생길 수 있다는 단점이 있습니다.

2. BatchSize

@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만 실행되는 것입니다.

주의해야할 점은 즉시로딩과 지연로딩 상황에서 나가는 쿼리가 각각 다르다는 것입니다.

  • 즉시로딩으로 설정하면 조회 시점에 6개의 데이터를 모두 조회해야 하기 때문에 SQL이 두 번 실행 됩니다.
  • 지연로딩으로 설정하면 컬렉션을 최초 사용하는 시점에 SQL을 실행해서 5건의 데이터를 미리 로딩합니다. 이후 6개 째 데이터를 사용하면 추가로 SQL이 실행됩니다.

3. 서브쿼리

이 설정은 Hibernate에서 제공하는 기능으로, 연관 엔티티를 로딩할 때 한 번의 쿼리로 부모 엔티티와 관련된 모든 자식 엔티티를 서브쿼리를 사용하여 검색합니다. 이로써 N+1 문제가 발생하지 않고 데이터베이스에서 효율적으로 데이터를 가져올 수 있습니다. SUBSELECT 모드는 일반적으로 LAZY 로딩이 설정된 컬렉션에 사용되며, 부모 엔티티를 로딩할 때 관련된 자식 엔티티를 서브쿼리를 통해 함께 로딩합니다.

@OneToMany
@JoinColumn(name = "book_id")
@Fetch(FetchMode.SUBSELECT)
private List<Author> authors;

이렇게 설정하면 부모 엔티티를 로딩할 때 연관된 자식 엔티티들을 서브쿼리를 사용하여 한 번에 가져옵니다.

BatchSize 선택

해당 문제는 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 문제 해결에는 여러 전략이 존재하며 어떤 전략을 선택할지 결정하는 것이 중요하다고 생각합니다. 각 방법에 대한 장단점과 트래이드 오프를 비교하면서 애플리케이션의 요구 사항에 적절한 방식을 고민하는 시간을 가질 수 있었습니다.

profile
안녕하세요. Java&Spring 기반 백엔드 개발자 김태현입니다.

0개의 댓글