Develop

[Spring Boot] 게시판 - 12. JPA N+1 문제 해결하기 (EntityGraph vs Fetch Join) 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 12. JPA N+1 문제 해결하기 (EntityGraph vs Fetch Join)

230801 2025. 12. 9. 18:34

안녕하세요 ~ .ᐟ

 

게시글 목록을 조회할 때마다 DB 쿼리가 몇 번이나 실행될까요?

단순히 생각하면 게시글 목록 1번 조회하면 끝일 것 같지만, 실제로는 예상보다 훨씬 많은 쿼리가 실행됩니다.

이것이 바로 유명한 N+1 문제입니다.

 

이번 글에서는 N+1 문제가 무엇인지, 그리고 @EntityGraph와 Fetch Join 두 가지 해결 방법을 실제로 적용하고 비교해보겠습니다.

 


 

N+1 문제란?

문제 상황

게시글 목록을 조회하면서 작성자 정보도 함께 보여줘야 하는 상황을 생각해봅시다.

// 게시글 조회 (1번)
List<Post> posts = postRepository.findAll();

// 각 게시글의 작성자 이름 출력
posts.forEach(post -> {
    System.out.println(post.getUser().getName()); // N번 추가 쿼리!
});

 

실행되는 쿼리:

-- 1. 게시글 조회 (1번)
SELECT * FROM posts;

-- 2. 각 게시글마다 User 조회 (N번)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 1;
...

게시글이 100개라면? 1 + 100 = 101번의 쿼리가 실행됩니다!

 

왜 발생할까?

  • JPA의 기본 전략이 LAZY (지연 로딩)이기 때문입니다.
    • 왜 LAZY 를 기본전략으로 쓸까?
      • EAGER 로 항상 모든 연관 데이터를 가져오면 불필요한 데이터까지 조회하게 됨
      • ex) 게시글 1개만 보는데 작성자의 모든 게시글까지 다 가져옴
      • 그래서 LAZY로 두고 필요할때만 가져오는게 효율적임
@ManyToOne(fetch = FetchType.LAZY)  // 기본값
private User user;

 

LAZY는 "필요할 때 가져와"라는 의미입니다.

  • Post 조회 시점에는 User를 가져오지 않음
  • post.getUser().getName() 호출 시점에 User 조회
  • 각 게시글마다 별도로 조회하니 N번 발생!

 

해결 방법

크게 3가지 방법이 있습니다:

  1. @EntityGraph - 간단한 경우
  2. Fetch Join - 복잡한 조건이 필요한 경우
  3. @BatchSize - 컬렉션 관계 최적화

이번 글에서는 1, 2번을 적용해보겠습니다.

 


 

1. @EntityGraph 적용하기

가장 간단한 방법입니다. 어노테이션만 추가하면 됩니다.

 

PostRepository

  • attributePaths에 함께 가져올 연관(user, category) 엔티티를 지정하면 끝!
@EntityGraph(attributePaths = {"author", "category"})
    @Query("SELECT p FROM Post p")
    Page<Post> findAllWithUserAndCategory(Pageable pageable);

 

 

PostService

// N+1 해결 - EntityGraph
public Page<PostListResponse> getPostsOptimized(Pageable pageable) {
        return postRepository.findAllWithUserAndCategory(pageable)
            .map(PostListResponse::from);
    }

 

PostController

@GetMapping("/optimized")
    public ResponseEntity<Page<PostListResponse>> getPostsOptimized(
        @PageableDefault(size = 10) Pageable pageable) {
        return ResponseEntity.ok(postService.getPostsOptimized(pageable));
    }

 

실행되는 쿼리

  • 쿼리가 1번으로 줄었습니다!
  • @EntityGraph은 기본적으로 LEFT JOIN 을 사용해 조회 합니다.
  • LEFT JOIN 은 왼쪽 테이블에 있는 데이터를 다 가져오라는 뜻 입니다.
    • 왼쪽에 있는 post 테이블의 정보는 다 가져오고, 오른쪽 테이블에 있는 user 가 없다면 null 로 표시 됩니다.
-- LEFT JOIN으로 한 번에 조회
SELECT p.*, u.*, c.*
FROM posts p
LEFT JOIN users u ON p.user_id = u.id
LEFT JOIN categories c ON p.category_id = c.id

 

 


 

2. Fetch Join 적용하기

  • 더 세밀한 제어가 필요할 때는 JPQL로 직접 작성합니다.

 

PostRepository

@Query("SELECT p FROM Post p " +
        "LEFT JOIN FETCH p.author " +
        "LEFT JOIN FETCH p.category " +
        "ORDER BY p.createdAt DESC")
    List<Post> findAllWithFetchJoin();

 

PostService

// N+1 해결 - Fetch Join
public List<PostListResponse> getPostsWithFetchJoin() {
        return postRepository.findAllWithFetchJoin()
            .stream()
            .map(PostListResponse::from)
            .toList();
    }

 

PostController

// N+1 해결 - Fetch Join
@GetMapping("/fetch-join")
    public ResponseEntity<ApiResponse<List<PostListResponse>>> getPostsWithFetchJoin() {
        List<PostListResponse> posts = postService.getPostsWithFetchJoin();
        return ResponseEntity.ok(ApiResponse.success(posts, "게시글 목록 조회 성공"));
    }

 

 


 

 

LEFT JOIN vs INNER JOIN

Fetch Join을 사용할 때 JOIN 타입을 선택할 수 있습니다.

 

LEFT JOIN (기본, 추천)

  • 왼쪽(Post)은 무조건 다 가져옴
  • 작성자가 탈퇴해도 게시글은 조회됨
  • user 정보만 NULL로 표시
LEFT JOIN FETCH p.user

 

INNER JOIN (엄격)

  • 양쪽 다 있는 것만 가져옴
  • 작성자가 없는 게시글은 아예 제외됨
  • 실무에서는 대부분 LEFT JOIN을 사용합니다. 데이터가 없어도 보여줘야 하는 경우가 많기 때문입니다.
INNER JOIN FETCH p.user

 

 

 


 

성능 비교 결과


기존 방식 (N+1 발생)

쿼리 실행 횟수: 4개
- posts 조회: 1번
- post_images 배치 조회: 1번  
- users 조회: 1번 (각 게시글마다 발생)
- categories 조회: 1번 (각 게시글마다 발생)

 

최적화 방식 (EntityGraph / Fetch Join)

쿼리 실행 횟수: 2개
- posts + users + categories 조인 조회: 1번
- post_images 배치 조회: 1번

 

결과 : 성능 개선 50% 

 


 


만약 게시글이 100개였다면?
기존: 1 + 1 + 100 + 100 = 202개
최적화: 1 + 1 = 2개

결과 : 성능 개선 99% 

 

 


 

EntityGraph vs Fetch Join 비교

  • 둘 다 LEFT JOIN을 사용하기 때문에 성능은 거의 동일합니다.
구분 EntityGraph Fetch Join
작성 방법 어노테이션 JPQL
JOIN 제어 자동 (LEFT JOIN) 직접 선택 가능
조건 추가 불가능 가능 (WHERE 등)
성능 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
추천 상황 간단한 조회 복잡한 조건 필요

 

 


최적화가 불필요한 경우

  • 단건 조회 일 경우
  • 연관 데이터를 안쓰는 경우
  • 이미 빠른 경우

 

 

느낀 점

  • N+1 문제는 JPA를 사용하면서 거의 100% 마주치는 문제였는데, 오늘 학습으로 조회전략이 세워졌습니다.
  • 기본 정책은 LAZY로 두고 간단한 경우 @EntityGraph 를 사용하고, 복잡한 조건이 필요하면 @Query로 Fetch Join 하면 될것 같습니다.
  • 테스트 코드를 짜고 직접 실행해보면서 나가는 쿼리의 양을 확인하니 앞으로 꼭 적용해야겠다는 생각이 들었습니다.