안녕하세요. 오늘은 평상시보다 늦게 일어나서 공부하러 밖으러 나가지 못하고 집에서 하게 되었습니다.
저번 시간에는 ORM을 통해서 JPA에 대한 이해를 해보았습니다. 이번 시간에는 저번시간에 언급했다시피 N+1문제에 대해서 얘기해보도록 하겠습니다.
앞서 언급했듯이 JPA와 N+1은 연관성이 굉장히 높습니다. 다만 N+1은 성능 문제이기에 같이 다루지 않았습니다.
N+1 문제
객체 연관관계를 조회하는 과정에서 발생하는 대표적인 성능 문제로, 데이터 건수가 많아질수록 쿼리 수가 급격히 증가하는 문제입니다.
예를 들어 게시글 10개를 조회했을 경우
- 게시글 목록 조회 : 1번
- 각 게시글의 작선자 조회 : 10번
총 11번의 쿼리가 발생합니다. 따라서 N+1로 1+N이라고 생각해도 됩니다.
발생 원인
N+1 문제는 ORM이 객체 연관관계를 조회할 때, 연관 데이터를 한 번의 쿼리로 함께 가져오지 않고 필요 시점마다 추가 조회하기 때문에 발생합니다. 그렇기에 모든 연관관계(1:1, 1:N, N:M)에서 발생할 수 있습니다.
1:1 관계에서의 N+1 문제
- 회원 100명을 조회
- 각 회원의 프로필을 접근할 때
- 회원 조회 1번 + 프로필 100번 → 여러 회원을 조회하는 순간 N+1 문제 발생
1:N 관계에서의 N+1 문제
가장 대표적입니다.
- 게시글 목록 조회 1번
- 게시글마다 댓글 조회 N번 → N+1 문제 발생
부모 엔티티 여러 개를 가져온 뒤, 각 부모의 자식 컬렉션을 접근할 때 자주 발생합니다.
N:M 관계에서의 N+1 문제
- 회원 목록 조회 1번
- 각 회원이 좋아요한 게시글 조회 N번 → N+1 문제 발생
N:M은 중간 테이블을 사용하기에 조회 구조가 더 복잡해질 수 있습니다.
그렇다면 대표적으로 발생하는 1:N의 관계에서의 N+1 문제에 대해 자세히 알아봅시다.
JPA의 기준에서는 @ManyToOne 연관관계를 가진 엔티티에서 주로 발생합니다.
JPA의 LAZY/EAGER 전략과 N+1 문제
1. 즉시(EAGER) 로딩
엔티티 조회 시 연관관계에 있는 데이터까지 한번에 조회해오는 기능으로 fetch = FetchType.EAGER 옵션으로 지정됩니다.
특징
- 연관 데이터를 바로 사용할 수 있다.
- 사용하지 않는 연관 데이터까지 조회될 수 있다.
항상 join 한 번으로 조회되는 것이 아니기에 상황에 따라 N+1 문제가 발생할 수 있습니다.
2. 지연(LAZY) 로딩
엔티티 조회 시점이 아닌 엔티티 내 연관관계를 참조할 때. 즉, 요청할때만 연관된 데이터를 조회하는 기능으로 fetch = FetchType.LAZY 옵션으로 지정됩니다.
특징
- 불필요한 연관 데이터 조회를 줄일 수 있다.
- 처음에는 프록시 객체로 참조한다.
반복 접근 시 추가 쿼리가 많이 발생하여 N+1 문제가 발생하기 쉽습니다.
지연(Lazy) 로딩에서 N+1 문제가 발생하는 이유
지연 로딩은 연관 엔티티를 바로 조회하지 않고, 실제로 해당 데이터가 필요한 시점까지 조회를 미루는 방식입니다. 겉으로 보기에는 불필요한 데이터를 미리 가져오지 않으므로 효율적으로 느껴질 수 있지만, 여러 엔티티를 조회한 뒤 연관 엔티티에 반복적으로 접근하는 상황에서는 오히려 많은 추가 쿼리가 발생할 수 있습니다.
즉, 연관 엔티티를 실제로 사용하는 시점마다 쿼리가 실행되기 때문에 지연 로딩에서 N+1 문제가 발생합니다.
예를 들어 게시글과 작성자가 연관관계로 매핑되어 있고, 게시글 목록 10개를 조회한 뒤 각 게시글의 작성자 이름을 출력하는 상황에 대해 알아봅시다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
}
List<Post> posts = postRepository.findAll();
for (Post post : posts) {
System.out.println(post.getUser().getName());
}
해당 코드를 실행할 경우 실제 SQL에서는 findAll()로 게시글을 조회하는 쿼리 1번이 먼저 실행됩니다.
이후 반복문에서 getUser().getName()이 호출될 때마다 각 게시글의 작성자 정보를 조회하는 쿼리가 추가로 실행될 수 있습니다. 즉, 게시글 조회 1번에 더해 작성자 조회가 게시글 수만큼 반복되면서 N+1 문제가 발생하게 됩니다.
즉시(Eager) 로딩으로도 N+1 문제가 해결되지 않는 이유
즉시 로딩은 부모 엔티티를 조회할 때 연관 엔티티도 함께 로딩하려는 방식입니다. 겉으로 보기에는 연관 데이터를 미리 가져오기 때문에 효율적으로 느껴질 수 있지만, 실제로는 항상 하나의 쿼리로 처리되는 것이 아니기 때문에 상황에 따라 추가 쿼리가 발생할 수 있습니다.
즉, 즉시 로딩은 연관 엔티티를 조회하는 시점을 앞당길 뿐이며, 조회 방식에 따라서는 N+1 문제가 발생할 수 있습니다.
예를 들어 게시글과 작성자가 연관관계로 매핑되어 있고, 게시글 목록을 조회하는 과정에서 작성자 정보를 함께 불러오려는 상황을 생각해볼 수 있습니다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id")
private User user;
}
@Query("select p from Post p")
List<Post> findAllPosts();
위 코드를 실행하면, 실제 SQL에서는 게시글 목록을 조회하는 쿼리 1번이 먼저 실행된 뒤, 각 게시글의 작성자 정보를 조회하는 쿼리가 추가로 실행될 수 있습니다. 즉, 즉시 로딩을 사용하더라도 연관 엔티티가 개별 조회되면 게시글 조회 1번과 작성자 조회 N번이 발생하여 N+1 문제가 나타날 수 있습니다.
이를 해결하기 위해서는 단순히 Lazy나 Eager와 같은 로딩 전략을 사용하는 것이 아닌 fetch join과 같은 방법으로 연관 엔티티를 한 번에 조회하도록 쿼리를 최적화해야 합니다.
N+1 문제의 해결 방법
1. fetch join
fetch join은 JPQL에서 연관 엔티티를 함께 조회하도록 명시하는 방식힙니다.
일반적인 JPQL 조회는 부모 엔티티만 조회하고, 연관 엔티티는 이후 접근 시점에 추가 조회가 발생할 수 있습니다.
반면 fetch join을 사용하면 부모 엔티티와 연관 엔티티를 한 번의 쿼리로 함께 조회할 수 있기에 N+1 문제를 줄일 수 있습니다.
예를 들어 게시글 목록과 작성자 정보를 함께 조회해야 하는 상황일 경우에는 아래와 같이 작성할 수 있습니다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
}
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("select p from Post p join fetch p.user")
List<Post> findAllWithUser();
}
List<Post> posts = postRepository.findAllWithUser();
for (Post post : posts) {
System.out.println(post.getUser().getName());
}
해당 코드는 Post와 User를 fetch join으로 함께 조회하기 때문에 반복문에서 getUser().getName()을 호출하더라도 추가 쿼리가 발생하지 않습니다.
즉, 기존에는 게시글 조회 1번과 작성자 조회 N번이 실행되었다면, fetch join을 사용하면 게시글과 작성자 정보를 한 번의 쿼리로 함께 가져올 수 있어 N+1 문제를 해결할 수 있습니다.
다만 fetch join은 매우 강력한 방법이지만 컬렉션(@OneToMany)과 함께 사용할 경우는 중복 데이터가 발생할 수 있고, 페이징과 함께 사용할 경우 주의가 필요합니다.
2. EntityGraph
EntityGraph는 JPA에서 연관 엔티티를 함께 조회할 대상을 선언적으로 지정하는 방식입니다.
JPQL에 직접 fetch join을 작성하지 않아도 어떤 연관 엔티티를 즉시 조회할지 명시할 수 있어 코드가 비교적 깔끔해질 수 있습니다.
특히 Spring Data JPA에서는 Repository 메서드에 @EntityGraph를 붙여 간단하게 사용할 수 있습니다.
예를 들어 게시글과 작성자를 함께 조회하고 싶다면
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = {"user"})
@Query("select p from Post p")
List<Post> findAllEntityGraph();
}
List<Post> posts = postRepository.findAllEntityGraph();
for (Post post : posts) {
System.out.println(post.getUser().getName());
}
해당 코드는 user 연관 엔티티를 함께 조회하도록 지정한 예시입니다.
실행 시점에는 Post를 조회하면서 User도 같이 불러오게 되므로, 연관 엔티티 접근 시 추가 select가 반복되는 문제를 줄일 수 있습니다.
EntityGraph는 fetch join보다 문법적으로 간단하고 재사용성이 좋지만, 복잡한 조회 조건이 많아질 경우에는 fetch join이 더 직관적일 수 있습니다.
3. DTO 조회
DTO 조회는 처음부터 화면이나 API 응답에 필요한 데이터만 조회해서 DTO로 바로 반환하는 방법입니다. 이 방식은 N+1을 피하는데 효과적일 뿐만 아니라, 불필요한 엔티티 로딩을 줄일 수 있다는 장점도 존재합니다.
예를 들어 게시글 제목과 작성자 이름만 필요하다면, 엔티티 전체를 조회할 필요 없이 아래처럼 DTO로 직접 조회할 수 있습니다.
public class PostResponseDto {
private final String title;
private final String userName;
public PostResponseDto(String title, String userName) {
this.title = title;
this.userName = userName;
}
public String getTitle() {
return title;
}
public String getUserName() {
return userName;
}
}
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("select new com.example.dto.PostResponseDto(p.title, u.name) " +
"from Post p join p.user u")
List<PostResponseDto> findPostDtos();
}
List<PostResponseDto> posts = postRepository.findPostDtos();
for (PostResponseDto post : posts) {
System.out.println(post.getTitle() + " / " + post.getUserName());
}
해당 방식은 처음부터 필요한 컬럼만 join하여 가져오기에 엔티티를 조회한 뒤 연관 객체에 접근하면서 발생하는 N+1 문제를 피할 수 있습니다.
특히 목록 조회처럼 화면에 필요한 값이 명확한 경우에는 DTO 조회가 성능과 유지보수 측면에서 매우 유용할 수 있습니다.
다만 DTO 조회는 엔티티 변경 감지 기능을 사용할 수 없고, 재사용성이 엔티티 조회보다 떨어질 수 있으므로 조회 전용 화면에 적합합니다.
4. 배치 조회
배치 조회는 연관 엔티티를 한 번에 묶어서 조회하도록 하여 N+1 문제를 완화하는 방법입니다.
이 방법은 fetch join처럼 쿼리 1번으로 완전히 해결하는 방식은 아니지만 여러 번 발생하던 추가 조회를 IN 쿼리로 묶어 실행하여 쿼리 수를 줄일 수 있습니다.
예를 들어 @BatchSize를 사용할 수 있습니다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
@BatchSize(size = 100)
private User user;
}
또는 전역 설정으로도 적용할 수 있습니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
해당 경우에는 JPA는 연관 엔티티를 하나씩 조회하는 대신에 여러 개를 모아서 한 번에 조회하려고 시도합니다.
예를 들어 작성자 10명을 각각 10번 조회하는 대신에 where id in (...) 형태로 묶어서 조회하게 되어 성능을 개선할 수 있습니다.
배치 조회는 컬렉션 조회나 페이징과 함께 사용할 때 유용하지만, fetch join처럼 완전히 한 번의 쿼리로 끝내는 방식은 아닙니다.
상황에 따른 해결 방법 선택
- 연관 엔티티를 함께 조회해야하고 구조가 단순할 경우 → fetch join
- Repository 에서 간단하게 적용하고 싶을 경우 → EntityGraph
- 조회 전용 화면이고 필요한 데이터가 명확할 경우 → DTO 조회
- 페이징이나 컬렉션 조회가 많을 경우 → 배치 조회
N+1 문제에 대해서 여태 제대로 해결하지 못한 문제라고 생각했습니다. 하지만 DTO 조회 방식을 자주 사용했기에 은연중에 N+1문제를 방지했다는 점을 깨닫게 되었습니다. 물론 완전히 의도하고, 완전한 해결은 아니지만 N+1을 줄이는 정도만 한거죠.
N+1 해결 방법에 대해서는 fetch join 밖에 몰랐는데, 이번 기회에 여러 방법에 대해 알게 되었습니다. 다음에는 데이터베이스 정규화와 같이 돌아오겠습니다. 수고하셨습니다.
'백엔드 공부' 카테고리의 다른 글
| 더 깊은 데이터베이스 지식 (5) - 인덱스와 작동 원리 (0) | 2026.04.19 |
|---|---|
| 더 깊은 데이터베이스 지식 (4) - 데이터베이스 정규화 (1) | 2026.04.17 |
| 더 깊은 데이터베이스 지식 (2) - ORM (0) | 2026.04.15 |
| 더 깊은 데이터베이스 지식 (1) - 트랜잭션과 ACID (1) | 2026.04.13 |
| 관계형 데이터베이스와 비관계형 데이터베이스 (0) | 2026.04.12 |