JPA를 사용하여 개발하다 보면 예상보다 많은 쿼리가 실행되어 성능 문제가 발생하는 경우가 있습니다.
이러한 문제의 대표적인 원인이 바로 'N+1 문제'입니다.
이 글에서는 JPA N+1 문제가 무엇인지, 왜 발생하는지, 그리고 어떻게 해결할 수 있는지에 대해 실제 코드 예제와 함께 상세히 알아보겠습니다.
JPA N+1 문제란 무엇인가?
JPA N+1 문제는 연관된 엔티티를 조회할 때 발생하는 성능 문제입니다.
처음에 1개의 쿼리로 N개의 데이터를 가져온 후, 각각의 연관된 데이터를 조회하기 위해 추가로 N개의 쿼리가 실행되는 현상을 말합니다.
예를 들어, 사용자 10명의 정보와 각 사용자가 작성한 게시글을 조회한다고 가정해봅시다.
N+1 문제가 발생하면 다음과 같은 쿼리가 실행됩니다:
-- 1. 사용자 10명 조회 (1개 쿼리)
SELECT * FROM users LIMIT 10;
-- 2. 각 사용자별 게시글 조회 (10개 쿼리)
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;
...
SELECT * FROM posts WHERE user_id = 10;
총 11개의 쿼리가 실행되어 데이터베이스에 과도한 부하를 주게 됩니다.
N+1 문제가 발생하는 원인
지연 로딩(Lazy Loading)과 즉시 로딩(Eager Loading)
JPA의 연관관계 매핑에서 기본적으로 @OneToMany
, @ManyToMany
는 지연 로딩으로 설정됩니다.
반면 @OneToOne
, @ManyToOne
은 즉시 로딩이 기본값입니다.
지연 로딩으로 설정된 연관관계는 실제로 해당 데이터에 접근할 때 쿼리가 실행됩니다.
이때 반복문 등을 통해 각 엔티티의 연관된 데이터에 접근하면 N+1 문제가 발생합니다.
실제 코드 예제
다음은 N+1 문제가 발생하는 전형적인 예제입니다:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
// getter, setter
}
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
// getter, setter
}
// N+1 문제가 발생하는 코드
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public List<UserDto> getAllUsersWithPosts() {
List<User> users = userRepository.findAll(); // 1개 쿼리
return users.stream()
.map(user -> {
// 각 user마다 posts에 접근할 때마다 쿼리 실행 (N개 쿼리)
List<String> postTitles = user.getPosts().stream()
.map(Post::getTitle)
.collect(Collectors.toList());
return new UserDto(user.getName(), postTitles);
})
.collect(Collectors.toList());
}
}
N+1 문제 해결 전략
1. Fetch Join 사용하기
Fetch Join은 JPA N+1 문제를 해결하는 가장 효과적인 방법 중 하나입니다.
JPQL에서 JOIN FETCH
키워드를 사용하여 연관된 엔티티를 한 번에 조회할 수 있습니다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u JOIN FETCH u.posts")
List<User> findAllWithPosts();
// 페이징과 함께 사용할 때는 주의가 필요합니다
@Query(value = "SELECT u FROM User u JOIN FETCH u.posts",
countQuery = "SELECT count(u) FROM User u")
Page<User> findAllWithPostsPaging(Pageable pageable);
}
수정된 서비스 코드:
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public List<UserDto> getAllUsersWithPosts() {
// 1개의 쿼리로 모든 데이터 조회
List<User> users = userRepository.findAllWithPosts();
return users.stream()
.map(user -> {
List<String> postTitles = user.getPosts().stream()
.map(Post::getTitle)
.collect(Collectors.toList());
return new UserDto(user.getName(), postTitles);
})
.collect(Collectors.toList());
}
}
2. @EntityGraph 애노테이션 활용
@EntityGraph
는 Spring Data JPA에서 제공하는 기능으로, 어떤 연관관계를 함께 조회할지 명시할 수 있습니다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = {"posts"})
List<User> findAll();
@EntityGraph(attributePaths = {"posts"})
Optional<User> findById(Long id);
// 복잡한 연관관계도 한 번에 조회 가능
@EntityGraph(attributePaths = {"posts", "posts.comments"})
List<User> findAllWithPostsAndComments();
}
엔티티에서 @NamedEntityGraph
를 사용하여 미리 정의할 수도 있습니다:
@Entity
@NamedEntityGraph(
name = "User.withPosts",
attributeNodes = @NamedAttributeNode("posts")
)
public class User {
// 엔티티 내용
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph("User.withPosts")
List<User> findAll();
}
3. 배치 사이즈 최적화
@BatchSize
애노테이션을 사용하여 지연 로딩 시 한 번에 가져올 데이터의 개수를 지정할 수 있습니다.
이 방법은 N+1 문제를 완전히 해결하지는 못하지만 쿼리 수를 크게 줄일 수 있습니다.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 10) // 10개씩 배치로 조회
private List<Post> posts = new ArrayList<>();
}
또는 글로벌 설정으로 적용할 수 있습니다:
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 10
배치 사이즈를 사용하면 다음과 같은 쿼리가 실행됩니다:
-- 사용자 10명 조회
SELECT * FROM users LIMIT 10;
-- 배치 사이즈 10으로 게시글 조회 (1개 쿼리)
SELECT * FROM posts WHERE user_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
4. 서브쿼리 활용
복잡한 조건이 있는 경우 서브쿼리를 활용하여 N+1 문제를 해결할 수 있습니다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT DISTINCT u FROM User u " +
"LEFT JOIN FETCH u.posts p " +
"WHERE u.id IN " +
"(SELECT u2.id FROM User u2 WHERE u2.active = true)")
List<User> findActiveUsersWithPosts();
}
5. DTO 프로젝션 사용
필요한 데이터만 조회하여 성능을 최적화할 수 있습니다.
public class UserPostDto {
private String userName;
private String postTitle;
public UserPostDto(String userName, String postTitle) {
this.userName = userName;
this.postTitle = postTitle;
}
// getter, setter
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT new com.example.dto.UserPostDto(u.name, p.title) " +
"FROM User u JOIN u.posts p")
List<UserPostDto> findUserPostDtos();
}
성능 측정 및 모니터링
쿼리 로그 활성화
N+1 문제를 확인하고 해결 효과를 측정하기 위해 SQL 로그를 활성화해야 합니다.
# application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
P6Spy를 활용한 쿼리 모니터링
더 자세한 쿼리 분석을 위해 P6Spy 라이브러리를 사용할 수 있습니다.
<!-- pom.xml -->
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
<version>1.8.1</version>
</dependency>
# application.yml
decorator:
datasource:
p6spy:
enable-logging: true
실제 프로젝트 적용 사례
Before: N+1 문제가 있는 코드
@RestController
public class BoardController {
@Autowired
private BoardService boardService;
@GetMapping("/api/boards")
public ResponseEntity<List<BoardDto>> getBoards() {
List<BoardDto> boards = boardService.getBoardsWithComments();
return ResponseEntity.ok(boards);
}
}
@Service
public class BoardService {
@Autowired
private BoardRepository boardRepository;
public List<BoardDto> getBoardsWithComments() {
List<Board> boards = boardRepository.findAll(); // 1개 쿼리
return boards.stream()
.map(board -> {
// 각 게시글마다 댓글 조회 (N개 쿼리)
List<CommentDto> comments = board.getComments().stream()
.map(comment -> new CommentDto(comment.getContent()))
.collect(Collectors.toList());
return new BoardDto(board.getTitle(), comments);
})
.collect(Collectors.toList());
}
}
After: 문제를 해결한 코드
@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
@EntityGraph(attributePaths = {"comments", "comments.author"})
List<Board> findAllWithComments();
@Query("SELECT DISTINCT b FROM Board b " +
"LEFT JOIN FETCH b.comments c " +
"LEFT JOIN FETCH c.author " +
"WHERE b.status = 'ACTIVE'")
List<Board> findActiveBoardsWithComments();
}
@Service
public class BoardService {
@Autowired
private BoardRepository boardRepository;
public List<BoardDto> getBoardsWithComments() {
// 1개의 쿼리로 모든 데이터 조회
List<Board> boards = boardRepository.findAllWithComments();
return boards.stream()
.map(board -> {
List<CommentDto> comments = board.getComments().stream()
.map(comment -> new CommentDto(
comment.getContent(),
comment.getAuthor().getName()
))
.collect(Collectors.toList());
return new BoardDto(board.getTitle(), comments);
})
.collect(Collectors.toList());
}
}
주의사항 및 베스트 프랙티스
카테시안 곱 문제 주의
여러 컬렉션을 동시에 Fetch Join하면 카테시안 곱이 발생할 수 있습니다.
이 경우 @BatchSize
를 사용하거나 별도의 쿼리로 나누어 조회하는 것이 좋습니다.
// 문제가 될 수 있는 코드
@Query("SELECT u FROM User u " +
"JOIN FETCH u.posts " +
"JOIN FETCH u.orders") // 카테시안 곱 발생 가능
List<User> findUsersWithPostsAndOrders();
페이징과 Fetch Join
Fetch Join과 페이징을 함께 사용할 때는 주의가 필요합니다.
메모리에서 페이징이 처리되어 성능 문제가 발생할 수 있습니다.
// 올바른 페이징 처리 방법
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query(value = "SELECT DISTINCT u FROM User u JOIN FETCH u.posts",
countQuery = "SELECT count(DISTINCT u) FROM User u")
Page<User> findAllWithPosts(Pageable pageable);
}
적절한 해결 방법 선택 가이드
- 단순한 1:N 관계:
@EntityGraph
또는JOIN FETCH
사용 - 복잡한 다중 컬렉션:
@BatchSize
사용 - 페이징이 필요한 경우: DTO 프로젝션 또는
@BatchSize
사용 - 성능이 중요한 경우: 네이티브 쿼리 또는 DTO 프로젝션 사용
마무리
JPA N+1 문제는 개발 초기에는 발견하기 어렵지만, 데이터가 많아질수록 심각한 성능 문제를 야기할 수 있습니다.
따라서 개발 단계에서부터 쿼리 로그를 확인하고, 적절한 해결 전략을 적용하는 것이 중요합니다.
이 글에서 소개한 다양한 해결 방법들을 상황에 맞게 적용하여 효율적인 JPA 애플리케이션을 개발하시기 바랍니다.
특히 Fetch Join과 EntityGraph를 적절히 활용하면 대부분의 N+1 문제를 해결할 수 있으며, 배치 사이즈 최적화를 통해 추가적인 성능 향상을 얻을 수 있습니다.
성능 최적화는 지속적인 모니터링과 개선이 필요한 영역이므로, 정기적으로 쿼리 성능을 점검하고 최적화하는 습관을 기르는 것이 좋습니다.
'트러블슈팅' 카테고리의 다른 글
IntelliJ에서 Gradle 버전 충돌 해결하기: 완벽한 트러블슈팅 가이드 (0) | 2025.05.24 |
---|---|
Spring Boot에서 발생하는 OutOfMemoryError 완벽 해결 가이드 (0) | 2025.05.24 |
JPA LazyInitializationException 해결 사례 정리 (0) | 2025.05.21 |
REST API 요청을 최적화하기 위한 Caching 전략 3가지 (1) | 2025.01.19 |
레거시 오라클 쿼리 리팩토링: 주문번호 부분입력으로 편의성 추가(Feat. 성능 최적화) (0) | 2024.04.19 |