배움 __IL/addtionalBackEnd

N+1 문제와 Fetch 전략을 모르면 JPA를 잘못 쓰고 있을수도...

Mo_bi!e 2025. 1. 4. 15:34

I. 들어가며

JPA 를 이용하기 위해서는 기존에 사용하는 mybatis 이용보다 이점이 필요하다. JPA는 객체 중심 설계와 데이터베이스의 매핑을 손쉽게 처리하는 기능을 제공한다. 하지만 잘못된 설정이나 사용으로 예상치 못한 문제가 발생할 가능성이 상존한다. 이러한 문제를 사전에 이해하고 대비하기 위해 준비하게 되었다.

JPA에서 중요한 성능 최적화 이슈인 Fetch전략과 N + 1 문제를 중심으로 살펴보고 이를 해결하는 방법을 살펴보겠다

II. 성능 저하를 일으키는 3대 문제와 최적화 방법

1. Fetch 전략

1) Fetch 전략이란

Entity의 연관 데이터를 Eager Loading할지, Lazy Loading할지 결정하는 방식이다.

Eager Loading 은 항상 즉시 load해서 연관 데이터가 항상 필요 할때 성능적으로 유리하지만, 불필요한 데이터도 load하여 자원낭비가 있을 수 있다. 반면 Lazy Loading은 연관된 데이터가 실제로 필요할 때 load한다. 하지만 필요 시 마다 개별 쿼리를 실행하므로 N + 1 문제가 발생할 수 잇다.

 

JPA는 관계유형에 따라 Fetch전략을 자동으로 설정한다.

@OneToMany, @ManyToMany 의 경우 Lazy Loading 이고

@ManyToOne, @OneToOne 의 경우 Eager Loading 을한다.

 

Lazy Loading과 Eager Loading은 N+1 문제, 메모리 낭비라는 문제가 상존한다. 필요시 적절한 Fetch전략을 설정해서 최적화하는것이 중요하다.

 

2) Fetch 전략의 동작원리

Hibernate는 Lazy Loading을 구현하기 위하여 연관 데이터를 가르키는 프록시 객체를 생성한다. 프록시 객체는 실제 데이터 대신 가짜 객체를 반환하며, 참조가 발생하면 쿼리를 실행해서 데이터를 가져온다. 다만 LazyInitializationException 방지가 필요하다.

 

Proxy 객체의 컬렉션에 접근할 때, 그리고 연관 객체의 속성에 접근할 때 초기화 된다. 결국 실제 연관 Entity에 접근하는 시점에 쿼리가 실행되어 메모리 절약을 할 수있다. 하지만 프록시 초기화 시점에 session이 종료되면 'LazyInitializationException'가 발생한다.

 

여기서 LazyInitializationException 이란 Hibernate Session/EntityManager가 닫힌 상태에서 Lazy Loading으로 연관 Entity 에 접근하려 할 때 발생한다. 이는 DB 와의 연결이 끊어졌기 때문에 추가 쿼리를 실행할 수 없는 상황에서 발생한다. 이 문제를 해결하기 위한 방법중 하나도 Fetch Join 또는 Eager Loading 도 있다. (그 외 "Open Session in View 패턴", "트랜잭션 범위 안에서 Controller/Service에서 DTO 변환을 끝낸 뒤 영속성 컨텍스트를 닫는 방식"으로도 해결이 가능함)

 

 

3) Fetch 전략별 실행되는 쿼리의 차이

-- Lazy: 
  Hibernate: select * from post
  Hibernate: select * from comment where post_id = ?
  
-- Eager:
  Hibernate: select p.*, c.* from post p left join comment c on p.id = c.post_id
  
-- Fetch Join:
  Hibernate: select p.id, p.content, c.id, c.content from post p join comment c on p.id = c.post_id

 

 

2. N + 1 문제

(1) 문제 설명

1) 구조와 원인

@Entity(name = "POST")
public class Post extends BaseEntity{

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User author;

//@OneToMany 기본 Fetch 전략은 LAZY
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL) 
    private List<Comment> comments;
}
@Entity(name = "COMMENT")
public class Comment extends BaseEntity{

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String content;

    @ManyToOne
    @JoinColumn(name = "post_id")
    private Post post;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User author;
}

Post entity 와 Comment entity 는 1:N 관계이다.

 

Post entity 를 조회할 때 연관된 entity 인 Comment 를 Lazy Loading 으로 설정한 경우에 하나의 쿼리로 가져오지 않고 추가로 N개의 쿼리가 실행이 됨

 

Lazy Loading 은 연관 entity를 조회할 때 마다 참조 할 때 마다 별도의 쿼리를 실행한다. Post-Comment 관계에서 보면 Post를 로드 한 뒤 각 Post마다 Comment를 개별적으로 로드하므로 N+1 문제가 발생한다.

 

2) 결과

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Commit
public class PostRepositoryTest {

    @Autowired
    private PostRepository postRepository;
    
    @Test
    @DisplayName("n+1 문제")
    public void testSelectPostProblem() {
        List<Post> postAndComments = postRepository.findAll(); // Post와 연관된 Comment는 Lazy Loading
        for (Post postAndComment : postAndComments) {
            System.out.println("postAndComment.getContent() = " + postAndComment.getContent());
            System.out.println(" --- n + 1 쿼리 시작 지점 --- ");
            System.out.println("postAndComment.getComments() = " + postAndComment.getComments().size()); // Comment 조회 시 N개의 쿼리 발생
            
        }
    }

List<Post> postAndComments = postRepository.findAll();

를 실행하면 아래와 같은 log 가 출력이 된다.

 

Hibernate: 
    select
        p1_0.id,
        p1_0.user_id,
        p1_0.content,
        p1_0.created_at,
        p1_0.status,
        p1_0.title,
        p1_0.updated_at 
    from
        post p1_0
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        u1_0.status,
        u1_0.updated_at,
        u1_0.username 
    from
        user u1_0 
    where
        u1_0.id=?

findAll()을 할 경우 postAndComment.getComments().size() 할 때 

아래와 같이 반복된 N + 1 쿼리들을 확인이 가능하다.

 

반복된 수만 큼 매번 쿼리가 생성이 된다.

--- 시작 지점 --- 
postAndComment.getContent() = content 수정임
 --- n + 1 쿼리 --- 
Hibernate: 
    select
        c1_0.post_id,
        c1_0.id,
        a1_0.id,
        a1_0.created_at,
        a1_0.email,
        a1_0.password,
        a1_0.status,
        a1_0.updated_at,
        a1_0.username,
        c1_0.content,
        c1_0.created_at,
        c1_0.status,
        c1_0.updated_at 
    from
        comment c1_0 
    left join
        user a1_0 
            on a1_0.id=c1_0.user_id 
    where
        c1_0.post_id=?
postAndComment.getComments() = 2
 --- 시작 지점 --- 
postAndComment.getContent() = nice to meet you1
 --- n + 1 쿼리 --- 
Hibernate: 
    select
        c1_0.post_id,
        c1_0.id,
        a1_0.id,
        a1_0.created_at,
        a1_0.email,
        a1_0.password,
        a1_0.status,
        a1_0.updated_at,
        a1_0.username,
        c1_0.content,
        c1_0.created_at,
        c1_0.status,
        c1_0.updated_at 
    from
        comment c1_0 
    left join
        user a1_0 
            on a1_0.id=c1_0.user_id 
    where
        c1_0.post_id=?
postAndComment.getComments() = 2
 --- 시작 지점 --- 
postAndComment.getContent() = nice to meet you2
 --- n + 1 쿼리 --- 
Hibernate: 
    select
        c1_0.post_id,
        c1_0.id,
        a1_0.id,
        a1_0.created_at,
        a1_0.email,
        a1_0.password,
        a1_0.status,
        a1_0.updated_at,
        a1_0.username,
        c1_0.content,
        c1_0.created_at,
        c1_0.status,
        c1_0.updated_at 
    from
        comment c1_0 
    left join
        user a1_0 
            on a1_0.id=c1_0.user_id 
    where
        c1_0.post_id=?
postAndComment.getComments() = 2
.
.
.
생략

 

(2) 해결 방법

해결 방법은 3가지가 있다.

EAGER Loading 으로 변경 / JPQL에서 fetch join 이용 / Hibernate 의 @EntityGraph 활용

 

1) EAGER Loading

@OneToMany 관계에 있는 필드의 Fetch Type 을 Eager로 변경하는 방식이다

@Entity(name = "POST")
public class Post extends BaseEntity{

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User author;

// FetchType.EAGER 로 방식 변경
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
    private List<Comment> comments;
}

comments의 Fetch Type 을 Eager로 변경했다.

@Test
@DisplayName("n+1 문제 해결 방법 1 : Eager Loading으로 N+1 문제 해결")
public void testSelectPostWithEagerLoading() {
    List<Post> postAndComments = postRepository.findAll(); // Post와 Comment를 한 번에 가져옴
    for (Post postAndComment : postAndComments) {
        System.out.println("postAndComment.getContent() = " + postAndComment.getContent());
        System.out.println(" --- Eager 로딩 확인 --- ");
        System.out.println("postAndComment.getComments() = " + postAndComment.getComments().size()); // 추가 쿼리 없음
    }
}

마차가지로 테스트 코드로 조회를 한다.

Hibernate: 
    select
        p1_0.id,
        p1_0.user_id,
        p1_0.content,
        p1_0.created_at,
        p1_0.status,
        p1_0.title,
        p1_0.updated_at 
    from
        post p1_0
Hibernate: 
    select
        c1_0.post_id,
        c1_0.id,
        a1_0.id,
        a1_0.created_at,
        a1_0.email,
        a1_0.password,
        a1_0.status,
        a1_0.updated_at,
        a1_0.username,
        c1_0.content,
        c1_0.created_at,
        c1_0.status,
        c1_0.updated_at 
    from
        comment c1_0 
    left join
        user a1_0 
            on a1_0.id=c1_0.user_id 
    where
        c1_0.post_id in (1, 2, 3, ...)

위와 같이 Post 와 연관된 모든 Comment 데이터 들을 한번에 가져옴

다만 기억해두면 중요한 점은 Eager 이 항상 join은 아니다. 그러므로 프록시를 통해 여러번 가져올 수도있고, 즉시 로딩이 아닐수도 있다.

postAndComment.getContent() = content 수정임
--- Eager 로딩 확인 ---
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you1
--- Eager 로딩 확인 ---
postAndComment.getComments() = 2
...

postAndComment.getComments() 을 하더라도 더이상 추가쿼리 실행없이, 이미 로드된 데이터를 사용하게 됨

 

하지만 Eager Loading은 모든 데이터를 한번에 가져오지만, 연관된 데이터가 많을경우 메모리 사용량이 증가하게 되는 한계점이 있다.

또한 불필요한 데이터도 로드되는 문제도 있을 수 있다.

 

특히 Entity가 1:N, N:M 구조로 많이 얽혀있는 경우 Eager로 과도한 조회가 발생할 수 있다. 가급적 꼭 필요한 경우가 아니라면 지양하는것이 바람직하다. 결국에 Eager를 기본으로 한다면 대규모 트래픽에서는 감당할 수 없을 여지가 있다.

 

2) JPQL에서 fetch join 이용

- Fetch join은 JPQL에서 연관된 entity를 함께 조회하기 위해 사용하는 방법임

- Fetch join을 이용하면 한 번의 쿼리로 연관 데이터 가져 올 수있음

public interface PostRepository extends JpaRepository<Post, Long> {
    @Query("SELECT p FROM POST p JOIN FETCH p.comments WHERE p.status = true")
    List<Post> fetchPostsAndCommentsUsingJoin();
}

 

JPQL 쿼리에서 JOIN FETCH 를 명시하면 JPA는 Post 와 Comment데이터를 Join 하고, 결과를 메모리에 함께 load한다.

`JOIN FETCH p.comments` : Post 와 Comment 를 join하여 즉시 로드 한다.

 

다음과 같이 test 코드를 작성한다.

@Test
@DisplayName("n+1 문제 해결 방법 2 : fetch join")
public void testSelectPostSol1() {
    List<Post> postAndComments = postRepository.fetchPostsAndCommentsUsingJoin();
    for (Post postAndComment : postAndComments) {
        System.out.println("postAndComment.getContent() = " + postAndComment.getContent());
        System.out.println("postAndComment.getComments() = " + postAndComment.getComments().size());
    }
}

아래를 확인하면 추가적인 쿼리가 더 이상 보이지 않음

n + 1 쿼리문제가 해결 되었다.

Hibernate: 
    select
        p1_0.id,
        p1_0.user_id,
        c1_0.post_id,
        c1_0.id,
        c1_0.user_id,
        c1_0.content,
        c1_0.created_at,
        c1_0.status,
        c1_0.updated_at,
        p1_0.content,
        p1_0.created_at,
        p1_0.status,
        p1_0.title,
        p1_0.updated_at 
    from
        post p1_0 
    join
        comment c1_0 
            on p1_0.id=c1_0.post_id 
    where
        p1_0.status=1
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        u1_0.status,
        u1_0.updated_at,
        u1_0.username 
    from
        user u1_0 
    where
        u1_0.id=?
postAndComment.getContent() = nice to meet you1
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you2
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you3
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you4
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you5
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you6
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you7
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you8
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you9
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you0

이런점을 비추어 보면 N + 1 문제를 해결하여 성능 최적화 해결을 했고, Fetch Join으로 필요한 데이터만 가져오므로 추가적인 코드가 필요 없으므로 간편하다. 하지만 중복데이터 문제(DISTINCT 로 해결 가능)와 복잡한 연관관계의 경우 비효율적일 수있다.

 

하지만 컬렉션 Fetch Join + 페이징이 있을 경우 매우 주의해야한다,

즉 @Query에서 JOIN FETCH로 컬렉션을 Join 하면서 Pageable 을 사용할 경우, Hibernate가 메모리에 가져 온 뒤 페이징 처리를 하거나, 쿼리를 변형하는 상황이 있다.

즉 한번에 너무 많은 ROW를 가져온다는 문제이다.

 

이 경우 실무에서는 SubQuery로 처리하거나, "부분조회 + 따로 연관관계"를 이용하기도 한다.

 

 

3) Hibernate 의 @EntityGraph 활용

@EntityGraph 는 JPA표준으로서 JPQL 없이 연관 데이터를 Fetch join 방식으로 한번에 load하는 방식이다.

public interface PostRepository extends JpaRepository<Post, Long> {
    @EntityGraph(attributePaths = {"comments"})
    List<Post> findAllByStatusTrue();
}

@EntityGraph 전략을 이용하면 Entity의 EAGER, LAZY loading 방식과 상관없이 명시된 속성만으로 Fetch join 으로 즉시 Load한다.

이렇게 findAll()메소드 호출 시 명시적으로 comments의 특정 데이터를 명시적으로 가져올 수 있다.

 

이 경우 주의사항은 메소드 명을 지정 할 때 spring data jpa는 메소드 이름을 자동으로 해석하여 쿼리문을 만든다. @EntityGraph는 기본 쿼리를 수정하지 않고 Fetch 전략만 수정하므로 이름 준수 규칙을 준수해야한다.

 

@Test
@DisplayName("n+1 문제 해결 방법 3 : EntityGraph")
public void testSelectPostSol2() {
    List<Post> postAndComments = postRepository.findAllByStatusTrue();
    for (Post postAndComment : postAndComments) {
        System.out.println("postAndComment.getContent() = " + postAndComment.getContent());
        System.out.println("postAndComment.getComments() = " + postAndComment.getComments().size());
    }
}

test코드 작성에서 마찬가지로 getComments()를 호출한다. 

 

그렇다면 log는 어떻게 나올까?

Hibernate: 
    select
        p1_0.id,
        p1_0.user_id,
        c1_0.post_id,
        c1_0.id,
        c1_0.user_id,
        c1_0.content,
        c1_0.created_at,
        c1_0.status,
        c1_0.updated_at,
        p1_0.content,
        p1_0.created_at,
        p1_0.status,
        p1_0.title,
        p1_0.updated_at 
    from
        post p1_0 
    left join
        comment c1_0 
            on p1_0.id=c1_0.post_id 
    where
        p1_0.status

@EntityGraph 전략으로 단한번 쿼리가 실행되었다.

postAndComment.getContent() = content 수정임
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you1
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you2
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you3
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you4
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you5
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you6
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you7
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you8
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you9
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you0
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you1
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you2
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you3
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you4
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you5
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you6
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you7
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you8
postAndComment.getComments() = 2
postAndComment.getContent() = nice to meet you9
postAndComment.getComments() = 2
postAndComment.getContent() = content임
postAndComment.getComments() = 2
postAndComment.getContent() = content임
postAndComment.getComments() = 2
postAndComment.getContent() = content임
postAndComment.getComments() = 2

모든 연관 데이터가 메모리에서 load 할 수 있게 되었다.

 

이렇게 비추어보면 @EntityGraph 이점은 JPQL없이도 어노테이션만으로 Fetch Join을 간단히 실행시켜서 코드 가독성에 좋다. 또한 Entity 에서 Fetch전략에 대한 설정과 무관하게 메소드 단위에서 제어할 수 있어서 유연하게 설정이 가능하다. 하지만 복잡한 조건 처리나 동적 쿼리는 지원하지 않는다. 한편 앞서 JPQL 부분과 마찬가지로 중복데이터 문제(상황에 따라 리턴타입을  List에서 Set 컬렉션으로 변경)가 발생할 수 있다. 다만 이 경우는 권장되는 방식은 아니다.

 

이 경우도 마찬가지로 Join 하면서 Pageable 을 사용할 경우 Hibernate가 메모리에 가져 온 뒤 페이징 처리를 하거나, 쿼리를 변형하는 상황이 마찬가지로 발생할 수 있다.

 

III. 마무리하며

특징 @EntityGraph JPQL Fetch Join Eager Loading
설정 방식 어노테이션으로 Fetch 전략 설정 JPQL로 직접 Fetch Join 작성 엔티티 필드에서 Fetch 전략 설정
조건 처리 가능 여부 복잡한 조건 처리 불가 복잡한 조건 처리 가능 조건 처리 불가능
적용 범위 특정 메서드에서만 Fetch Join 적용 특정 JPQL 쿼리에서만 Fetch Join 적용 모든 메서드에서 Fetch Join 적용
성능 필요 시 Fetch Join 적용, 불필요한 데이터 로드 방지 최적화 가능, 그러나 JPQL 작성 필요 항상 Fetch Join 적용, 메모리 낭비 가능
중복 데이터 문제 조인으로 인해 중복 데이터 발생 가능 조인으로 인해 중복 데이터 발생 가능 없음
사용 대상 연관 데이터 로드 전략이 상황에 따라 달라질 때 복잡한 조건이나 동적 필터링이 필요할 때 항상 연관 데이터를 함께 사용할 때

JPA Fetch 전략은 단순한 설정 이상의 영향을 미치는 요소이다. 올바른 전략 선택과 문제 해결방법은 성능최적화의 중요한 방향이다.

특히 N+1 문제와 같으 대표적인 성능 저하 요인을 사전에 방지하고, 필요에 따라 JPQL Fetch Join이나 @EntityGraph를 활용하는 것이 중요하다