JPA의 N + 1

urur-27·2025년 4월 20일

잡다한

목록 보기
9/17

N + 1 문제 발생 원인

JPA에서 N + 1 문제가 발생하는 원인은 지연 로딩(lazy loading) + 잘못된 쿼리 작성 방식이다.


N + 1 문제 예시

예를 들어, 데이터베이스에 User가 10명 있고, 각 User는 여러개의 Post가 있다고 가정한다.(UserPost는 1:N 관계)

List<User> users = userRepository.findAll(); // (1) User 10명 조회. 1번 쿼리
for (User user : users) {
    System.out.println(user.getPosts().size()); // (2) User마다 Post 조회. 10번 쿼리
}

이때 User 엔티티에서 posts는 다음처럼 지연 로딩으로 설정돼 있다고 가정한다.

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts;

실제 발생하는 쿼리

(1) User를 가져올 때 한 번의 쿼리

SELECT * FROM users;

(2) for 루프를 돌면서 각 user.getPosts()가 호출될 때마다 매번 발생하는 쿼리(이 쿼리가 10명의 User에 대해 10번 실행됨)

SELECT * FROM posts WHERE user_id = ?;

-> 결과적으로 1 + N 번 쿼리가 실행됨

userRepository.findAll()로 가져온 User들이 연관 데이터를 사용할 것이라면, 이걸 미리 한 번의 쿼리로 가져오게 해야 성능 문제가 발생하지 않는다. 그런데, findAll()만 써놓고 아무 처리도 안 해주는 게 "잘못된 쿼리 작성 방식"


해결 방법

1.Fetch Join

JPQL에서 연관 엔티티를 함께 즉시 로딩(Eager Loading)으로 가져오도록 명시하는 방법

@Query("SELECT u FROM User u JOIN FETCH u.posts")
List<User> findAllWithPosts();
  • User와 연관된 Post들을 한 번에 Join해서 가져옴
  • 실행되는 SQL:
SELECT u.*, p.* 
FROM users u 
JOIN posts p ON u.id = p.user_id;

결과:
User + Post를 한 번에 메모리에 로딩함
user.getPosts()를 호출해도 추가 쿼리가 없음


2. @EntityGraph

Spring Data JPA에서 사용하는 방식.
쿼리 메서드에 fetch 설정을 더해주는 것

@EntityGraph(attributePaths = "posts")
List<User> findAll();  // @EntityGraph에 의해 posts도 즉시 로딩됨

JPQL을 작성하지 않고도 Fetch Join과 동일한 효과를 낼 수 있음
내부적으로는 LEFT OUTER JOIN FETCH로 동작

SELECT u FROM User u LEFT JOIN FETCH u.posts

즉, posts 컬렉션을 LEFT OUTER JOIN FETCH로 한 번에 가져옴.
LEFT이기 때문에 User는 다 가져오고, posts가 없어도 null로 채워서 리턴
Repository에서 선언적 방식으로 Fetch 설정을 하고 싶을 때 자주 사용


3. 배치 사이즈 조정 (JPA 설정)

지연 로딩(Lazy Loading) 자체는 유지하면서,
여러 개의 프록시 객체를 하나의 쿼리로 묶어서 가져오도록 최적화하는 설정 방법

배치 사이즈 설정법
application.yml또는 application.properties를 통해서 배치 사이즈 설정

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 10

예시
예를 들어 10명의 User를 가져왔고, 각각의 user.getPosts()를 호출할 때 기본 설정이면 10번의 쿼리가 발생함
이때 default_batch_fetch_size를 설정하면 JPA는 이런 식으로 쿼리를 묶어서 날림

SELECT * FROM posts WHERE user_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

-> 즉, 지연 로딩이지만 배치로 한 번에 가져오므로 N + 1 문제 해결 가능


상세 예시(dafault_batch_fetch_size=10)

spring.jpa.properties.hibernate.default_batch_fetch_size=10
List<User> users = userRepository.findAll();  // 1번 쿼리
for (User user : users) {
    System.out.println(user.getPosts().size());  // 실제로는 여기서 1번 쿼리만 추가 발생
}

쿼리 흐름
1. findAll() 실행
-> SELECT * FROM users; - User 10명 메모리에 올라옴
2. 루프가 돌기 시작하면 첫 번째 user.getPosts() 호출됨
-> Hibernate가 "얘네 다 LAZY 프록시 상태네?"하고 감지함
3. default_batch_fetch_size=10이 설정되어 있으면
-> user_id 10개를 한 번에 IN절로 묶어서 쿼리함

SELECT * FROM posts WHERE user_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
  1. 이 쿼리 1번만 실행되고, 결과를 적절히 매핑해서 각 Uset의 posts에 채워 넣음

예외 상황 예시

  • 만약 User가 30명이고, default_batch_fetch_size=10이면?
    • 첫 10명 → IN (10명) → 쿼리 1번
    • 다음 10명 → 쿼리 1번
    • 마지막 10명 → 쿼리 1번
    • 총 3번의 쿼리 발생
profile
끄아악

0개의 댓글