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 |