Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
Tags
- 맥
- VI
- 알고리즘
- zsh
- 인프런워밍업클럽
- 티스토리챌린지
- spring boot
- Pager
- 오블완
- CS스터디
- 이진탐색
- cs
- 데이크스트라
- 오일러 경로
- MySQL
- mysql 표
- 동적 프로그래밍 방법
- 욕심쟁이 방법
- mycli
- zsh theme
- 스터디2기
- 터미널
- 인프런
- 순차탐색
- 네트워킹데이
- mysql 표 출력
- table status
- Less
- oh-my-zsh
- 분할정복 방법
Archives
- Today
- Total
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로 두고 필요할때만 가져오는게 효율적임
- 왜 LAZY 를 기본전략으로 쓸까?
@ManyToOne(fetch = FetchType.LAZY) // 기본값
private User user;
LAZY는 "필요할 때 가져와"라는 의미입니다.
- Post 조회 시점에는 User를 가져오지 않음
- post.getUser().getName() 호출 시점에 User 조회
- 각 게시글마다 별도로 조회하니 N번 발생!
해결 방법
크게 3가지 방법이 있습니다:
- @EntityGraph - 간단한 경우
- Fetch Join - 복잡한 조건이 필요한 경우
- @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 하면 될것 같습니다.
- 테스트 코드를 짜고 직접 실행해보면서 나가는 쿼리의 양을 확인하니 앞으로 꼭 적용해야겠다는 생각이 들었습니다.

'백엔드 > 게시판 만들기' 카테고리의 다른 글
| [Spring Boot] 게시판 - 14. Spring Security 로그인 구현 (0) | 2025.12.14 |
|---|---|
| [Spring Boot] 게시판 - 13. 코드 리팩토링과 배포 파이프라인 개선 (0) | 2025.12.11 |
| [Spring Boot] 게시판 - 11. 동적 정렬과 필터링으로 사용자 경험 개선하기 (0) | 2025.12.09 |
| [Spring Boot] 게시판 - 10. QueryDSL로 동적 검색 기능 구현하기 (0) | 2025.12.05 |
| 초보 백엔드가 무중단 배포와 CI/CD를 직접 구축하면서 만난 에러들 (0) | 2025.12.04 |