JPA를 사용하다 보면 가장 자주 겪는 성능 문제 중 하나가 바로 N+1 문제다.
회원(Member)과 팀(Team)이 지연 로딩 관계라고 할 때, 단순히 회원 목록을 조회했는데 이후 각 회원의 팀을 가져오는 순간마다 추가 쿼리가 실행된다. 회원이 100명이라면 1(회원 조회) + 100(팀 조회) = 101번의 SQL이 실행되는 꼴이다.
작은 데이터에서는 체감이 안 될 수 있지만, 실제 서비스에서는 쿼리 폭발로 이어져 치명적인 성능 문제가 된다.
N+1 문제 예시
@Test
public void findMemberLazy() throws Exception {
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
memberRepository.save(new Member("member1", 10, teamA));
memberRepository.save(new Member("member2", 20, teamB));
em.flush();
em.clear();
List<Member> members = memberRepository.findAll();
for (Member member : members) {
member.getTeam().getName(); // 여기서 매번 추가 쿼리 발생
}
}
실행되는 SQL 흐름
- memberRepository.findAll() → 회원 목록 조회 (SQL 1번)
- 반복문 돌면서 member.getTeam().getName() 실행 → 회원마다 팀을 지연 로딩 (SQL N번)
즉, 전체적으로 1 + N번의 쿼리가 실행된다. 회원이 100명이라면 101번의 SQL이 실행되는 것이다.
→ 이것이 바로 N+1 문제다.
해결책 1: JPQL 페치 조인
이 문제를 해결하는 전통적인 방법은 fetch join이다. (지연로딩과 조회 성능 최적화 장 참고)
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
이렇게 작성하면 Member와 Team을 한 번의 SQL로 가져올 수 있다.
실행되는 SQL
select m.*, t.*
from member m
left join team t on m.team_id = t.id;
회원과 팀이 함께 조회되므로 N+1 문제가 발생하지 않는다.
해결책 2: @EntityGraph
JPQL을 직접 쓰지 않고도 fetch join을 적용할 수 있는 방법이 있다.
바로 스프링 데이터 JPA의 @EntityGraph다.
// 공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
// JPQL + EntityGraph
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
// 메서드 이름 기반 쿼리에도 적용 가능
@EntityGraph(attributePaths = {"team"})
List<Member> findByUsername(String username);
- attributePaths = {"team"}을 지정하면 JPA가 내부적으로 LEFT OUTER JOIN FETCH를 붙여서 실행한다.
- 코드에 join fetch를 직접 쓰지 않아도 되니 훨씬 간단하다.
왜 EntityGraph가 더 간편한가?
- JPQL을 직접 작성할 필요가 없다.
→ findAll, findById 같은 공통 메서드에도 바로 적용 가능하다. - 가독성이 높다.
→ @EntityGraph(attributePaths = {"team"})만 봐도 어떤 연관관계를 함께 가져오는지 바로 알 수 있다. - 중복 코드 감소
→ 여러 리포지토리 메서드에서 반복적으로 fetch join을 쓰는 대신 어노테이션만 붙이면 된다.
언제 EntityGraph, 언제 JPQL?
- EntityGraph
- 단순히 연관 엔티티 한두 개만 같이 조회하면 충분한 경우
- 예: Member와 Team 같이 단순한 fetch join
- JPQL fetch join
- 조건이 복잡한 쿼리
- 예: 회원 나이가 20 이상이고, 팀 이름이 'teamA'인 경우
즉, 간단한 경우는 EntityGraph, 복잡한 경우는 JPQL이 정석이다.
NamedEntityGraph 활용 (재사용)
엔티티에 그래프를 정의해두고 재사용할 수도 있다.
@NamedEntityGraph(
name = "Member.all",
attributeNodes = @NamedAttributeNode("team")
)
@Entity
public class Member {}
@NamedEntityGraph(
name = "Member.all",
attributeNodes = @NamedAttributeNode("team")
)
@Entity
public class Member {}
(하지만 실무에서 자주 사용되진 않는다 한다.)
마무리하며
N+1 문제는 JPA에서 가장 많이 마주치는 성능 이슈다.
이를 해결하기 위해 전통적으로는 JPQL fetch join을 사용했지만, 스프링 데이터 JPA는 이를 더 단순화한 @EntityGraph를 제공한다.
- 단순한 연관관계 조회라면 @EntityGraph를 사용하는 것이 간단하고 직관적이다.
- 조건이 복잡하거나 여러 엔티티를 한꺼번에 묶어야 한다면 JPQL fetch join이 여전히 필요하다.
즉, 두 기능은 서로 보완적인 도구이기에 상황에 맞게 선택하는 것이 중요할 것이다.
감사합니다.
'스프링 데이터 JPA' 카테고리의 다른 글
| [스프링] 스프링 Data JPA(마지막) Auditing (2) | 2025.08.26 |
|---|---|
| [스프링] 스프링 Data Jpa(7) 사용자 정의 리포지토리 구현 (1) | 2025.08.26 |
| [스프링] 스프링 Data JPA(5) 벌크성 수정쿼리 (0) | 2025.08.21 |
| [스프링] 스프링 Data JPA(4) JPA 페이징과 정렬 (0) | 2025.08.21 |
| [스프링] 스프링 Data JPA(3) @Query, 리포지토리 메소드에 쿼리 정의하기 (0) | 2025.08.21 |
