- Published on
Spring Boot JPA N+1 폭발, EntityGraph로 끝내기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능 이슈를 파고들다 보면 의외로 자주 만나는 게 JPA의 N+1 문제입니다. 처음엔 “쿼리가 조금 더 나가네?” 정도로 보이지만, 트래픽이 붙거나 목록 API가 커지는 순간 DB 커넥션과 CPU가 급격히 소모되며 장애로 이어질 수 있습니다. 특히 @OneToMany / @ManyToOne 연관이 섞인 도메인에서, 화면 요구사항(목록에 작성자, 댓글 수, 최근 댓글 등)을 맞추려다 보면 N+1이 “폭발” 형태로 나타납니다.
이 글에서는 N+1의 정확한 발생 원리와 흔한 오해를 짚고, EntityGraph로 필요한 연관만 “명시적으로” 페치해서 쿼리 수를 안정적으로 제어하는 방법을 정리합니다.
참고로 이런 유형의 문제는 데이터 처리에서도 비슷하게 나타납니다. 예를 들어 pandas merge에서 키 중복으로 행이 폭증하는 현상은, 원인(카디널리티)과 증상(행/쿼리 폭발)이 닮아 있습니다. 원리 중심으로 접근하면 해결이 빨라집니다: pandas merge 후 행 폭증·NaN - 키 중복 5분 진단
N+1이 생기는 진짜 이유
N+1은 보통 아래 조건이 겹칠 때 발생합니다.
- 루트 엔티티 목록을 조회하는 쿼리 1번 실행
- 루트 엔티티의 연관 필드가
LAZY로딩 - 루트 목록을 순회하면서 연관 필드에 접근
- 접근 시점에 프록시 초기화가 일어나며 엔티티마다 추가 쿼리 실행
즉, “연관을 LAZY로 해뒀으니 안전하다”가 아니라, “LAZY인데 접근하면 그 순간 쿼리가 나간다”가 정확한 이해입니다.
폭발 패턴: N+1이 아니라 N*M+1이 되는 경우
예를 들어 게시글 목록 20개를 조회하고, 각 게시글의 작성자(다대일)와 댓글들(일대다)을 화면에 보여준다고 합시다.
- 게시글 목록: 1
- 작성자 로딩(게시글마다 1번): +20
- 댓글 로딩(게시글마다 1번): +20
- 댓글의 작성자까지 접근(댓글마다 1번): 댓글 총 200개면 +200
이렇게 되면 단순 N+1이 아니라, 연쇄 접근으로 쿼리가 계단식으로 늘어납니다.
재현용 예제 도메인
아래는 전형적인 게시판 도메인입니다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
private Member author;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
}
@Entity
public class Comment {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
private Member author;
private String content;
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
}
서비스에서 흔히 하는 코드가 아래처럼 생깁니다.
@Transactional(readOnly = true)
public List<PostSummaryDto> listPosts() {
List<Post> posts = postRepository.findTop20ByOrderByIdDesc();
return posts.stream()
.map(p -> new PostSummaryDto(
p.getId(),
p.getTitle(),
p.getAuthor().getName(),
p.getComments().size()
))
.toList();
}
이 코드는 겉보기엔 깔끔하지만,
p.getAuthor().getName()에서 작성자 로딩p.getComments().size()에서 댓글 컬렉션 로딩
이 두 지점이 N+1 트리거가 됩니다.
EntityGraph란 무엇이고 왜 유효한가
EntityGraph는 “이번 쿼리에서는 어떤 연관을 함께 로딩할지”를 선언적으로 지정하는 기능입니다.
핵심 장점은 다음과 같습니다.
- 코드 레벨에서 페치 전략을 제어할 수 있음(글로벌
EAGER를 피함) - JPQL에
fetch join을 남발하지 않고도 특정 조회에 최적화 가능 - 리포지토리 메서드 단위로 적용 가능하여 영향 범위가 좁음
즉, 화면/유스케이스별로 “필요한 연관만” 가져오게 만들기 좋습니다.
해결 1: @EntityGraph로 다대일(작성자) N+1 제거
다대일(@ManyToOne)은 조인으로 함께 가져오기 쉬워서 EntityGraph의 효과가 즉시 보입니다.
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = {"author"})
List<Post> findTop20ByOrderByIdDesc();
}
이제 작성자는 게시글 조회 쿼리에서 조인으로 함께 로딩됩니다. 결과적으로 작성자 때문에 발생하던 추가 쿼리가 사라집니다.
주의: 컬렉션까지 한 번에 가져오면 항상 좋은가
@OneToMany 같은 컬렉션을 무턱대고 함께 페치하면 다른 문제가 생길 수 있습니다.
- 조인으로 인해 결과 행이 “댓글 수만큼” 늘어남(루트 엔티티 중복)
- 페이징이 깨질 수 있음(특히
fetch join+OneToMany조합) - 중복 제거를 위해
distinct가 필요하거나, 메모리 사용량이 늘어남
그래서 목록 화면에서 댓글 “내용”이 아니라 “개수”만 필요하다면, 컬렉션 자체를 페치하는 대신 집계 쿼리로 푸는 게 더 안전합니다.
해결 2: 댓글 개수는 컬렉션 페치 대신 집계로
목록에서 comments.size() 때문에 컬렉션 로딩이 발생한다면, 다음처럼 집계로 분리하는 접근이 실무에서 안정적입니다.
(1) 게시글 목록은 작성자만 EntityGraph로 가져오기
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = {"author"})
List<Post> findTop20ByOrderByIdDesc();
}
(2) 댓글 수는 group by로 한 번에 가져오기
public interface CommentRepository extends JpaRepository<Comment, Long> {
@Query("""
select c.post.id, count(c)
from Comment c
where c.post.id in :postIds
group by c.post.id
""")
List<Object[]> countByPostIds(@Param("postIds") List<Long> postIds);
}
(3) 서비스에서 매핑
@Transactional(readOnly = true)
public List<PostSummaryDto> listPosts() {
List<Post> posts = postRepository.findTop20ByOrderByIdDesc();
List<Long> postIds = posts.stream().map(Post::getId).toList();
Map<Long, Long> commentCountMap = commentRepository.countByPostIds(postIds).stream()
.collect(Collectors.toMap(
row -> (Long) row[0],
row -> (Long) row[1]
));
return posts.stream()
.map(p -> new PostSummaryDto(
p.getId(),
p.getTitle(),
p.getAuthor().getName(),
commentCountMap.getOrDefault(p.getId(), 0L)
))
.toList();
}
이 방식은 쿼리 수를 1 + 1로 고정시키고, 컬렉션 로딩을 원천 차단합니다.
해결 3: 정말로 컬렉션이 필요하면 EntityGraph + 설계 제약을 이해하기
상세 화면처럼 “게시글과 댓글 목록”이 꼭 필요하다면 컬렉션 페치가 유효합니다. 다만 페이징과 중복 행 문제를 이해하고 써야 합니다.
Named EntityGraph로 명시하기
@Entity
@NamedEntityGraph(
name = "Post.withAuthorAndComments",
attributeNodes = {
@NamedAttributeNode("author"),
@NamedAttributeNode("comments")
}
)
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
private Member author;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
}
리포지토리에서 사용:
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(value = "Post.withAuthorAndComments")
Optional<Post> findById(Long id);
}
이렇게 하면 상세 조회에서 작성자/댓글을 한 번에 가져올 수 있습니다.
한계: 댓글의 작성자까지 필요하면
comments.author처럼 2단계 연관까지 필요하면 subgraph가 필요합니다.
@Entity
@NamedEntityGraph(
name = "Post.detail",
attributeNodes = {
@NamedAttributeNode("author"),
@NamedAttributeNode(value = "comments", subgraph = "commentsSub")
},
subgraphs = {
@NamedSubgraph(
name = "commentsSub",
attributeNodes = {
@NamedAttributeNode("author")
}
)
}
)
public class Post {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member author;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
}
그리고:
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(value = "Post.detail")
Optional<Post> findWithDetailById(Long id);
}
이 구성은 상세 화면에선 강력하지만, 목록 + 페이징에는 여전히 주의가 필요합니다.
EntityGraph와 fetch join의 선택 기준
둘 다 “연관을 한 번에 가져온다”는 점은 같지만 운영 관점에서 트레이드오프가 있습니다.
EntityGraph- 장점: 리포지토리 메서드에 선언적으로 붙여 재사용 가능, JPQL 변경 없이 적용 가능
- 단점: 복잡한 조건/튜닝(특정 조인 조건, 별칭 활용 등)은 JPQL이 더 유연
fetch join- 장점: 쿼리를 명시적으로 통제 가능, 복잡한 조회에서 강력
- 단점: 남발하면 리포지토리 계층이 쿼리 문자열로 오염되고 유지보수가 어려움
실무에서는 “기본은 EntityGraph, 정말 복잡한 조회만 fetch join 또는 DTO 직접 조회”로 가는 경우가 많습니다.
운영에서 N+1을 놓치지 않는 체크리스트
N+1은 로컬에선 데이터가 적어 티가 안 나다가 운영에서 터지는 경우가 많습니다. 아래를 습관화하면 조기 발견에 도움이 됩니다.
- Hibernate SQL 로그로 쿼리 수 확인
- API 한 번 호출에 동일 패턴의
select가 반복되는지 확인 - 목록 API는 특히 “연관 접근”이 있는 DTO 매핑 코드를 의심
- 컬렉션 페치는 페이징과 중복 행(카디널리티) 문제를 같이 검토
장애 대응 관점에서는 “증상이 폭발하는 패턴”을 빠르게 파악하는 게 중요합니다. 인프라에서도 비슷한 폭발이 자주 보이는데, 예를 들어 컨테이너가 죽었다 살아나며 부하가 증폭되는 케이스는 원인 분석 루틴이 정형화돼 있습니다: K8s CrashLoopBackOff 진단 - OOMKilled·Probe
결론: EntityGraph는 ‘필요한 연관만’ 가져오게 만드는 안전장치
정리하면,
N+1은 LAZY 자체가 아니라 “LAZY 연관에 접근하는 코드”에서 발생합니다.EntityGraph는 유스케이스 단위로 페치 전략을 통제하는 가장 실용적인 방법 중 하나입니다.- 목록 화면에서 컬렉션이 필요 없다면, 컬렉션 페치 대신 집계 쿼리로 쿼리 수를 고정시키는 게 더 안전합니다.
- 상세 화면처럼 정말 필요한 경우에만 컬렉션/다단계 연관을 그래프로 명시하고, 페이징/중복 행 리스크를 이해한 상태에서 적용해야 합니다.
이 원칙대로만 정리해도 “개발 환경에서는 멀쩡한데 운영에서 DB가 터지는” 전형적인 N+1 폭발을 상당 부분 예방할 수 있습니다.