반응형

JPA를 사용하면서 발생할 수 있는 N+1 문제는 어플리케이션의 성능을 저하시킬 수 있는 문제 이다

이 문제에 대해 발생하는 이유와 해결 방법에 대해 알아보고자 한다

 

N+1 문제 그리고 발생 원인

최초 실행한 한 개의 쿼리 + N개의 쿼리라고하여 N+1 문제라고 한다

 

JPA의 FetchType이 Lazy인 경우 발생 하는 것이다 (기본은 FetchType Eager)

N+1 문제는 하나의 쿼리를 실행 후, 조회된 엔티티에 대한 연관된 엔티티를 조회하기 위해 추가로 N개의 쿼리가 실행되는 것을 말한다

 

FetchType이 Lazy인 것이 자체가 문제가 되는 것은 아니다

Lazy로 연관 엔티티를 조회하는 경우 불필요한 데이터를 조회하지 않아도 되는 이점이 있다

그러나 이를 잘못 사용하여 연관된 데이터가 많은 엔티티에 접근하게 되면 해당 연관 엔티티의 수 만 큼 N번 조회가 발생하게 된다

 

이러한 N번의 조회에 의해 아래와 같은 문제점이 발생하게 되고 어플리케이션의 성능 저하가 발생할 수 있다

1. 쿼리 수가 급격히 증가

    : 연관 엔티티가 100개면 총 101개의 쿼리가 실행된다

2. DB 커넥션 낭비

    : 하나의 커넥션을 오래 점유하기 때문에 커넥션 풀 고갈 위험이 있다

 

N+1 문제가 발생할 수 있는 예시

@Entity
@Table(name = "orders")
@EntityListeners(AuditingEntityListener::class)
class Order(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
	
    .......
    
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST])
    val orderItems: MutableList<OrderItem> = mutableListOf(),
	
    .......
    
    @CreatedDate
    val createdAt: Instant? = null,

    @LastModifiedDate
    val modifiedAt: Instant? = null
)


@Entity
@Table(name = "orderitems")
class OrderItem(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
	
    @Column(nullable = false)
    val productId: Long? = null,
	
    .......
    
    @CreatedDate
    val createdAt: Instant? = null,

    @LastModifiedDate
    val modifiedAt: Instant? = null
)

 

위 예시를 보면 Order 안에 연관 엔티티로 OrderItem이 있는 것을 볼 수 있다

만약 Order에 연관된 OrderItem이 1000개 라고 가정하고 Order 엔티티를 조회한 후 OrderItem 엔티티에 접근하게 되면

총 1000 + 1으로 1001 번의 쿼리가 수행되게 된다

 

해결 방법

1. Fetch Eager 사용

JPA는 기본적으로 연관 관계에 대해 Fetch Eager를 기본으로 한다

Eager를 사용하게 되면 조회할 때 연관 엔티티도 함께 조회를 하게 되는데, 연관 엔티티를 함께 조회하기 때문에 N+1문제가 발생하지 않는다

하지만 실제 필요하지 않은 엔티티도 함께 조회된다

 

2. Fetch Join 활용

Fetch Join을 하게되면 필요한 연관 엔티티를 한 쿼리로 조회할 수 있다

@Query("SELECT o FROM Order o JOIN FETCH o.items")
fun findOrders(): List<Order>

 

3. EntityGraph 활용

Fetch Join 과 비교해서 단순화된 방법이다

명시적으로 Eager로 수행되도록 하는 방식 이다

@EntityGraph(attributePaths = ["items"])
@Query("SELECT o FROM Order o")
fun findOrders(): List<Order>

 

4. DTO Projection 활용

필요한 컬럼의 데이터만 조회하는 방법이다

data class OrderInfoDto (
    val orderId: Long,
    val itemName: String
)

@Query("""
    SELECT new com.example.dto.OrderInfoDto(o.orderId, i.itemName)
    FROM Order o
    JOIN o.items i
""")
fun findOrderItem(): List<OrderInfoDto>

위 코드처럼 필요한 항목의 데이터만 조회할 수 있다

Dto의 package 경로 전체를 기입해야 하기 때문에 작성해야하는 구문이 길어질 수 있다

 

data class OrderInfoDto (
    val orderId: Long,
    val itemNames: List<String>
)

아래와 같은 Dto의 구성으로 List 항목으로 데이터를 조회할 수 는 없다

따라서 단일 항목으로 조회하고 조회 이후에 별도 가공을 해야한다

 

N+1 문제를 해결하기 위한 다양한 방법이 존재하는데 상황에 맞게 적절한 방법을 사용할 필요가 있다

반응형

'공부 > JPA' 카테고리의 다른 글

JPA 조회 함수에서 @Transactional이 필요한 경우(LazyInitializationException)  (1) 2025.07.06
JPA 순환 참조  (1) 2025.06.24
JPA 시작  (0) 2021.04.25
반응형

JPA를 사용하여 개발할 때 Entity간 연관관계를 지정하는 것은 일반적이다

 

이러한 연관관계를 지정하는 경우 N+1문제가 발생하는 경우 때문에 아래와 같이 FetchType을 Lazy로 한다

(물론 JPA에서는 Eager가 기본)

 

연관관계의 FetchType을 Lazy로 한 경우 조회 기능을 개발할 때 아래와 같이 LazyInitializationException 가 발생하는 경우가 있다

LazyInitializationExceptionorg.hibernate.LazyInitializationException: 
failed to lazily initialize a collection of role: 
kr.co.kimga.order.domain.entity.order.Order.orderItems: 
could not initialize proxy - no Session at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:635) 
at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:219) 
at org.hibernate.collection.spi.AbstractPersistentCollection.readSize(AbstractPersistentCollection.java:150) 
at org.hibernate.collection.spi.PersistentBag.size(PersistentBag.java:353) 
at kotlin.collections.CollectionsKt__IterablesKt.collectionSizeOrDefault(Iterables.kt:39) 
at kr.co.kimga.order.infrastructure.service.order.OrderService.findOrderDetails(OrderService.kt:82) 
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) 
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) 
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) 
at java.base/java.lang.reflect.Method.invoke(Method.java:568)

 

 

 

발생 이유

왜 이런 문제가 발생하는 것일까?

LazyInitializationException이 발생하는 코드를 살펴보자

Order (
	.....

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST])
    val orderItems: MutableList<OrderItem> = mutableListOf
    
	.....
) {
	.....
}
fun findOrderDetails(orderId: Long): FindOrderDetailsDto {

    val findOrder = orderRepository.findById(orderId)
        .orElseThrow { throw CanNotFindOrder() }

    return FindOrderDetailsDto(
        orderId = findOrder.id!!,
        orderStatus = findOrder.status,
        orderDate = findOrder.orderDate,
        items = findOrder.orderItems.map {
            FindOrderItemDto(
                productId = it.productId!!,
                productName = it.productName,
                quantity = it.remainQuantity()
            )
        }.toList(),
        payedAmount = findOrder.orderPays.sumOf { it.amount },
        discountAmount = findOrder.orderPays.sumOf { it.discountAmount },
        totalAmount = findOrder.orderPays.sumOf { it.amount + it.discountAmount }
    )
}

 

위 코드는 주문 정보를 조회하고 이를 DTO에 맞춰 가공한 후 전 반환하는 코드이다

코드 자체로만 보면 특별히 문제가 없다고 볼 수 도 있지만, 실행하면 LazyInitializationException이 발생하게 된다

 

그 이유는 FetchType이 Lazy이기 때문이다

 

해당 함수의 트랜잭션 범위는 orderRepository.findById로 조회를 하는 전후 이다

findById로 조회가 끝나는 경우 트랜잭션이 종료가 되는데, DTO객체로 Entity를 가공하는 과정에서 Lazy로 지정된 객체에 접근하게 된다

Lazy로 지정된 객체에 접근하게 되면 JPA는 해당 객체의 값을 데이터베이스로 부터 읽어오게 된다

그러나 이미 트랜잭션의 범위가 끝났기 때문에 조되된 Entity는 영속 상태가 아니게 되고 이 때문에 LazyInitializationException이 발생하게 된다

 

 

 

해결 방법

이를 해결하는 대표적인 두가지 방법은 Eager로 설정하는 방법과 Transaction 범위를 넗히는 것이다

 

1. FetchType Eager

Entity 조회할 때 연관관계의 Entity도 함께 조회하는 방법이다

연관관계의 Entity를 한번에 조회하게 되면 조회 이후에 연관관계 Entity를 추가로 조회할 필요가 없기 때문에 영속 상태가 아니여도 데이터를 활용할 수 있다

Order (
	.....

    @OneToMany(mappedBy = "order", fetch = FetchType.EAGER, cascade = [CascadeType.PERSIST])
    val orderItems: MutableList<OrderItem> = mutableListOf
    
	.....
) {
	.....
}

 

❗️Eager를 사용하게 되면 한번에 많은 데이터를 가져오는 것을 조심해야한다

Entity를 조회할 때 연관관계 Entity를 모두 가져오게 되면 자칫 많은 데이터를 가져올 수 있고, Out Of Memory Exception이 발생할 수 있다

 

2. @Transactional 활용

Fetch Lazy 상태에서 연관관계 Entity의 데이터에 접근하게 되면 해당 Entity의 데이터를 DB로 부터 읽어오게 된다

이를 위해서 Entity가 영속 상태인 것이 중요한데, 영속 상태를 유지하기 위해 Transaction의 범위 확장할 필요가 있다

이를 위해 메소드에 @Transactional 어노테이션을 활용할 수 있다

 

메소드에 @Transactional 어노테이션을 붙이게 되면 트랜잭션 범위를 함수 내로 확장할 수 있다

이를 통해 Entity는 영속 상태를 유지하게 되고 연관관계의 Entity를 조회해올 수 있게 된다

@Transactional(readOnly = true)
fun findOrderDetails(orderId: Long): FindOrderDetailsDto {

    val findOrder = orderRepository.findById(orderId)
        .orElseThrow { throw CanNotFindOrder() }

    return FindOrderDetailsDto(
        orderId = findOrder.id!!,
        orderStatus = findOrder.status,
        orderDate = findOrder.orderDate,
        items = findOrder.orderItems.map {
            FindOrderItemDto(
                productId = it.productId!!,
                productName = it.productName,
                quantity = it.remainQuantity()
            )
        }.toList(),
        payedAmount = findOrder.orderPays.sumOf { it.amount },
        discountAmount = findOrder.orderPays.sumOf { it.discountAmount },
        totalAmount = findOrder.orderPays.sumOf { it.amount + it.discountAmount }
    )
}

 

* Transactional 어노테이션의 readOnly 속성을 true로 지정하게 되면 조회만을 수행하는 함수에 불필요한 동작(flush, drity checking 등)을 수행하지 않아 성능 최적화를 할 수 있다

 

 

반응형

'공부 > JPA' 카테고리의 다른 글

JPA N+1 문제 원인  (0) 2025.07.06
JPA 순환 참조  (1) 2025.06.24
JPA 시작  (0) 2021.04.25

+ Recent posts