프로젝트 리팩토링 중 역방향으로 발생하는 N + 1 문제를 확인하게 되었다.

역 N + 1이라고 명칭하기에는 정식 명칭이 아니라고 하지만, 그렇다고 마땅한 명칭이 있는것도 아니라고 하기에 그냥 편하게 역 N + 1이라고 생각하고자 한다.

 

 

문제점과 해결방안

기본적으로 N + 1 문제가 발생하는 이유는 상위 엔티티 리스트를 조회한 뒤 연관관계에 있는 하위 엔티티 필드에 접근 시 상위 엔티티 리스트 크기만큼의 하위 엔티티 조회 쿼리가 발생하는 것을 말한다.

 

N + 1이 발생하는 원인으로는 상위 엔티티 리스트 조회 시 하위 엔티티들에 대한 프록시 객체를 생성해두었다가 접근 시 초기화하는 구조로 동작하게 된다.

N + 1 문제는 FETCH JOIN으로 한번에 조회를 해오는 방법을 택하거나, 상위 엔티티 리스트에서 id 값들만을 리스트화 해 하위 엔티티에 대한 IN 절을 사용한 쿼리로 문제를 해결할 수 있는 방법이 있다.

로직을 어떻게 작성하느냐에 따라 동일한 조회를 하면서도 N + 1을 회피할 수 있는 방법이 존재한다는건데 이 역 N + 1 문제는 상황이 조금 달랐다.

 

우선 문제가 된 부분을 보기 위해 간단한 엔티티 코드를 예시로 든다.

 

@Entity
@Getter
//...
public class ProductOrder {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    @OneToMany(mappedBy = "productOrder", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    private final List<ProductOrderDetail> productOrderDetailList = new ArrayList<>();
    
    //...
}


@Entity
@Getter
//...
public class ProductOrderDetail{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "productId")
    private Product product;
    
    @ManyToOne
    @JoinColumn(name = "productOptionId")
    private ProductOption productOption;
    
    @ManyToOne
    @JoinColumn(name = "orderId")
    private ProductOrder productOrder;
    
    //...
}

 

이렇게 주문 관련된 두 엔티티가 존재한다.

서로 양방향 매핑 구조이며, 1 : N 관계에 있다.

그리고 ProductOrderDetail 엔티티의 경우 단방향 매핑으로 Product, ProductOption을 참조하고 있는 상황이다.

 

여기서 기능상 List<ProductOrderDetail> 을 조회하는 쿼리가 있었다.

SELECT *
FROM productOrderDetail
WHERE orderId IN (?, ?, ?, ?, ?)

 

그럼 여기서 예상할 수 있는 JPA의 쿼리 수행은 위 쿼리 단 하나였다.

하지만 실제 로그에 찍히는 쿼리는 달랐다.

 

orderId 리스트 크기에 해당하는 만큼 productOrder를 조회하는 쿼리가 발생했고, 이후 Product, ProductOption을 조회하는 쿼리가 발생했다.

즉, 조회하는 orderId 리스트 크기가 20이었다면 productOrder를 20번 조회하고, Product, ProductOption을 조회하는 쿼리가 발생해 실제 수행된 쿼리 개수는 23개가 됐다.

 

이런 현상때문에 역 N + 1이라는 생각을 하게 된 것이다.

이 문제는 일반적인 N + 1 문제와 다르게 코드레벨에서의 접근이 전혀 없었는데도 불구하고 쿼리 수행 하나만으로 이런 문제가 발생했다는 것이다.

 

N + 1은 코드레벨에서 접근하지 않는 방법 또는 애초에 같이 조회하는 방법을 통해 문제를 회피할 수 있었지만 이건 쿼리 실행 즉시 발생하기 때문에 당황스러웠다.

 

하지만 문제 해결은 의외로 간단하게 처리할 수 있는데 엔티티 리스트를 조회하는 것이 아닌 DTO 리스트로 매핑하도록 처리하면 이 문제는 해결된다.

일반적인 N + 1처럼 엔티티 자체를 조회하는 경우에 발생하는 문제이기 때문에 DTO 매핑으로 처리한다면 이런 문제는 발생하지 않는다.

 

 

발생 원인

그럼 이 문제는 왜 발생하는걸까?

 

이건 영속성 컨텍스트의 특징때문에 발생하는 문제다.

우선 N + 1을 예로 들어보자면 10의 크기를 갖는 ProductOrder 리스트를 조회했고 그 리스트에서 productOrderDetailList 필드에 접근하려고 하면 조회한 모든 ProductOrder의 하위 엔티티를 조회하게 된다.

이때 ProductOrder 리스트 조회 시점에서 이미 각 ProductOrderDetailList에 대한 프록시 객체가 생성되고 접근 시 그 프록시 객체가 초기화 되면서 발생하는 이슈다.

 

그럼 결과적으로 리스트만 조회한 시점에서 영속성 컨텍스트는 10개의 각 ProductOrder를 관리하면서 하위 엔티티인 ProductOrderDetail에 접근할때를 대비해 준비를 하고 있는 상태까지 만들어놓는 것이다.

 

각 ProductOrder 하나 당 3개의 ProductOrderDetail을 갖고 있다고 가정한다면

N + 1 문제가 발생한 이후 영속성 컨텍스트에서는 10개의 ProductOrder와 30개의 ProductOrderDetail의 id와 필드들을 갖고 있는 상태로 관리하게 되는 것이다.

 

그럼 하위 엔티티를 조회하는 시점에서는 영속성 컨텍스트가 어떻게 관리하려고 할까? 라는 것을 알아야 한다.

IN절을 제외하고 하나의 ProductOrderDetail을 조회한다고 가정해보자.

그럼 ProductOrderDetail에서는 ProductOrder, Product, ProductOption을 참조하고 있는 상황이다.

 

ProductOrderDetail이 조회된 시점에 영속성 컨텍스트에서는 이 엔티티에 대한 관리를 시작하게 된다.

근데 이 내부에는 연관관계에 있는 상위 엔티티가 3개가 존재한다.

그럼 영속성 컨텍스트는 이 상위 엔티티 데이터가 무결성을 위배하지 않는지 확인하기 위해 전부 조회해서 체크해보게 된다.

그래서 이렇게 리스트 크기만큼의 추가적인 조회가 발생하게 되는 것이다.

 

여기서 좀 더 들어가보면, N + 1문제의 경우 상위 엔티티 리스트 조회 시 하위 엔티티들에 대한 프록시 객체를 생성하고 접근 시 초기화를 수행해 조회를 처리한다.

역 N + 1의 경우도 동일하게 하위 엔티티를 조회했을 때 상위 엔티티들에 대한 프록시 객체를 생성한다.

그러나, 무결성 위배를 체크하기 위해 프록시 객체를 즉시 초기화하고 체크하기 때문에 접근이라는 트리거가 없더라도 프록시 객체가 초기화 되는 것이다.

 

 

이건 JSON으로 작성해보니까 좀 더 이해하기가 편했다.

 

{
    "productOrderId1" : {
        "productOrderField1" : "productOrderField1 value",
        "productOrderField2" : "productOrderField2 value",
        "productOrderDetailList" : [
            ?
        ]
    }
}


{
    "productOrderId1" : {
        "productOrderField1" : "productOrderField1 value",
        "productOrderField2" : "productOrderField2 value",
        "productOrderDetailList" : [
            "productOrderDetailId1" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            },
            "productOrderDetailId2" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            },
            "productOrderDetailId3" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            }
        ]
    }
}

 

이렇게 보면 영속성 컨텍스트에서 어떻게 관리하는지 이해하기가 좀 편하다.

 

ProductOrder를 조회한다면 첫번째 블록과 같은 구조로 관리하게 된다.

? 부분을 프록시 객체라고 볼 수 있으며 접근 시 초기화 되어 조회가 발생하고 영속성 컨텍스트에 올라가게 되는 것이다.

 

하지만 ProductOrderDetail을 조회한다면?

구조상 productOrder 블록 부분이 전부 ? 가 될수는 없다.

 

이런 구조처럼 영속성 컨텍스트에서도 엔티티 관리를 하기 위한 필수적인 절차를 거치는 것이라고 볼 수 있다.

애초에 테이블 구조만 놓고 보더라도 상위 테이블인 ProductOrder 테이블에서는 ProductOrderDetail의 값이 뭔지 관심도 없다. 데이터 자체도 안갖고 있고.

단지 JPA Entity에서만 양방향 매핑으로 ' 나 이 하위 엔티티 리스트 갖고 있을거야 ' 라고 정의한 것 뿐.

그렇기에 상위 엔티티 조회시에는 당장 이 데이터를 확인해야 할 필요가 없지만,

ProductOrderDetail 테이블에서 외래키로 ProductOrder의 id를 갖고 있는 것 처럼 영속성 컨텍스트에서도 이 상위 데이터가 있는 데이터 맞나? 하고 바로 확인할 수 밖에 없으므로 발생하는 문제다.

 

ProductOrder에 대한 예시만 들었지만 마찬가지로 Product, ProductOption도 같은 이유에서 조회한다고 볼 수 있다.

 

 

마무리

제목은 뭔가 거창하게 역 N + 1이라고 지었지만 이해하고 보면 당연한 처리구나 싶기도 한 문제였다.

하지만 분명히 지연시간이 발생할 수 있는 충분한 원인이 되기 때문에 확실하게 이해하고 왜 이런 문제가 발생하는지에 대해 알아야 한다고 생각했다.

이전에 정리했던 CascadeType에 대한 문제, 그리고 이번 역 N + 1 문제를 거치면서 JPA 사용에 대한 생각이 많아졌다.

분명 JPA를 사용함으로써 편의성에서 오는 이점이 크긴 하지만, 무분별하게 사용했다가는 오히려 독이 될 수 있겠다는 생각을 많이 하게 되었다.

특히 엔티티 자체를 조회하는 경우에 대해 좀 더 여러 상황을 고려해보고 사용해야겠다.

+ Recent posts