트러블슈팅

JPA N+1 문제 해결 전략: 성능 최적화를 위한 완벽 가이드

devcomet 2025. 5. 24. 18:45
728x90
반응형

JPA N+1 문제 해결 전략: 성능 최적화를 위한 완벽 가이드
JPA N+1 문제 해결 전략: 성능 최적화를 위한 완벽 가이드

 

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. 단순한 1:N 관계: @EntityGraph 또는 JOIN FETCH 사용
  2. 복잡한 다중 컬렉션: @BatchSize 사용
  3. 페이징이 필요한 경우: DTO 프로젝션 또는 @BatchSize 사용
  4. 성능이 중요한 경우: 네이티브 쿼리 또는 DTO 프로젝션 사용

마무리

JPA N+1 문제는 개발 초기에는 발견하기 어렵지만, 데이터가 많아질수록 심각한 성능 문제를 야기할 수 있습니다.
따라서 개발 단계에서부터 쿼리 로그를 확인하고, 적절한 해결 전략을 적용하는 것이 중요합니다.

이 글에서 소개한 다양한 해결 방법들을 상황에 맞게 적용하여 효율적인 JPA 애플리케이션을 개발하시기 바랍니다.
특히 Fetch Join과 EntityGraph를 적절히 활용하면 대부분의 N+1 문제를 해결할 수 있으며,

배치 사이즈 최적화를 통해 추가적인 성능 향상을 얻을 수 있습니다.

성능 최적화는 지속적인 모니터링과 개선이 필요한 영역이므로,

정기적으로 쿼리 성능을 점검하고 최적화하는 습관을 기르는 것이 좋습니다.

728x90
반응형