💡 아래 실습에 진행한 모든 코드는 Github에 있습니다.
JPA N+1이란?
실무에서 JPA를 사용하다보면, N+1 쿼리를 만나게 됩니다. 여기서 N+1이란 Team(1) ↔ Member(N) 연관관계가 있다고 가정했을 때, 하나의 팀을 조회했지만 팀 내부에 있는 모든 멤버들이 함께 조회되면서 1+N 개의 쿼리가 발생하는 것을 의미합니다. 직접 테스트 코드를 통해 이러한 상황들을 해결할 수 있는 방법들을 살펴보고 각 상황이 또 어떤 사이드이펙트가 있는지도 알아보겠습니다.
Entity 및 Repository 코드
실습에 사용될 코드는 다음과 같습니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
@Builder
private Member(String name) {
this.name = name;
}
public void updateTeam(Team team) {
this.team = team;
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<Member> members = new ArrayList<>();
@Builder
private Team(String name) {
this.name = name;
}
public void addMember(Member member) {
this.members.add(member);
member.updateTeam(this);
}
}
public interface TeamRepository extends JpaRepository<Team, Long> {
Optional<Team> findTeamByName(String name);
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class Member_Lazy_Team_Lazy_Test {
@Autowired private MemberRepository memberRepository;
@Autowired private TeamRepository teamRepository;
@PersistenceContext private EntityManager em;
@BeforeEach
void setup() {
System.out.println("----------setup start-----------");
Team team1 = Team.builder().name("team1").build();
team1.addMember(Member.builder().name("member1-1").build());
team1.addMember(Member.builder().name("member1-2").build());
Team team2 = Team.builder().name("team2").build();
team2.addMember(Member.builder().name("member2-1").build());
team2.addMember(Member.builder().name("member2-2").build());
Team team3 = Team.builder().name("team3").build();
team3.addMember(Member.builder().name("member3-1").build());
team3.addMember(Member.builder().name("member3-2").build());
teamRepository.save(team1);
teamRepository.save(team2);
teamRepository.save(team3);
clearPersistenceContext();
System.out.println("----------setup end-----------");
}
@AfterEach
void clear() {
System.out.println("----------clear start-----------");
memberRepository.deleteAll();
teamRepository.deleteAll();
clearPersistenceContext();
System.out.println("----------clear end-----------");
}
private void clearPersistenceContext() {
em.flush();
em.clear();
}
@DisplayName("모든 팀을 조회하고, 지연로딩 된 멤버를 사용할 때 -> N+1이 발생한다.(team1,2,3 조회하는 쿼리 1개, team1,2,3에 대한 멤버 조회하는 쿼리 3개)")
@Test
void team_findAll_test() {
clearPersistenceContext();
System.out.println("----------team_findAll_test start-----------");
List<Team> teamList = teamRepository.findAll();
assertThat(teamList).hasSize(3);
System.out.println("----------team_findAll_test mid-----------");
teamList.stream()
.map(Team::getMembers)
.map(List::stream)
.forEach(memberStream -> memberStream
.map(Member::getName)
.forEach(System.out::println)
);
System.out.println("----------team_findAll_test end-----------");
}
@DisplayName("모든 멤버를 조회하고, 지연로딩 된 팀을 사용할 때 -> N+1이 발생한다.(모든 멤버 조회 1개, 각 팀을 조회하는 쿼리 3개)")
@Test
void member_findAll_test() {
clearPersistenceContext();
System.out.println("----------member_findAll_test start-----------");
List<Member> memberList = memberRepository.findAll();
assertThat(memberList).hasSize(6);
System.out.println("----------member_findAll_test mid-----------");
memberList.stream()
.map(Member::getTeam)
.map(Team::getName)
.forEach(System.out::println);
System.out.println("----------member_findAll_test end-----------");
}
}
위 테스트에서 확인하고자 하는 것은 다음과 같습니다.
1. 모든 팀을 조회하고, 각 팀에 속한 멤버들의 이름을 출력했을 때, 쿼리가 어떻게 나가는 (team_findAll_test)팀 조회 후, 각 팀에 속한 멤버들을 사용할 때 추가 쿼리가 나가는 것을 알 수 있습니다. (N+1) 이전에는 각 멤버의 ID로 조회하는 쿼리가 나가서 4개의 쿼리가 아닌 각 멤버를 조회하는 7개의 쿼리가 나갔었는데 언제부턴가 JPA 쪽에서 최적화를 했는지 team_id를 통해 조회하는 쿼리 덕분에 4개로 줄었습니다. (언제 최적화가 이뤄졌는지 아시는분 꼭 좀 알려주세요….)
----------team_findAll_test start-----------
Hibernate:
select
t1_0.id,
t1_0.name
from
team t1_0
----------team_findAll_test mid-----------
Hibernate:
select
m1_0.team_id,
m1_0.id,
m1_0.name
from
member m1_0
where
m1_0.team_id=?
member1-1
member1-2
Hibernate:
select
m1_0.team_id,
m1_0.id,
m1_0.name
from
member m1_0
where
m1_0.team_id=?
member2-1
member2-2
Hibernate:
select
m1_0.team_id,
m1_0.id,
m1_0.name
from
member m1_0
where
m1_0.team_id=?
member3-1
member3-2
----------team_findAll_test end-----------
2. 모든 멤버를 조회하고, 각 멤버들의 팀의 이름을 출력했을 때, 쿼리가 어떻게 나가는지 (member_findAll_test)Member의 Team은 LAZY 전략으로 되어 있기 때문에 member를 조회하는 시점에는 member 조회 쿼리만 나가고, 실제 Team을 사용할 때 Team을 조회하는 쿼리가 나갑니다.
----------member_findAll_test start-----------
Hibernate:
select
m1_0.id,
m1_0.name,
m1_0.team_id
from
member m1_0
----------member_findAll_test mid-----------
Hibernate:
select
t1_0.id,
t1_0.name
from
team t1_0
where
t1_0.id=?
team1
team1
Hibernate:
select
t1_0.id,
t1_0.name
from
team t1_0
where
t1_0.id=?
team2
team2
Hibernate:
select
t1_0.id,
t1_0.name
from
team t1_0
where
t1_0.id=?
team3
team3
----------member_findAll_test end-----------
이렇듯 실무에서 N+1 쿼리가 발생하면, 테스트 수준처럼 몇개가 아닌 몇천개에서 몇만개가 조회되는 문제가 발생하고 장애로 이어질 가능성이 큽니다. 어떻게 해결할 수 있는지 바로 알아보겠습니다.
해결방법
1. FetchJoin 사용
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query(value = "select t from Team t join fetch t.members")
List<Team> findTeamsFetchJoin();
}
@DisplayName("모든 팀을 조회하고, 지연로딩 된 멤버를 사용할 때 -> 단 1개의 쿼리만 나간다")
@Test
void team_findAll_test() {
clearPersistenceContext();
System.out.println("----------team_findAll_test start-----------");
List<Team> teamList = teamRepository.findTeamsFetchJoin();
assertThat(teamList).hasSize(3);
System.out.println("----------team_findAll_test mid-----------");
teamList.stream()
.map(Team::getMembers)
.map(List::stream)
.forEach(memberStream -> memberStream
.map(Member::getName)
.forEach(System.out::println)
);
System.out.println("----------team_findAll_test end-----------");
}
팀을 조회할 때, 팀에 속한 멤버들까지 한방에 Fetch Join 하는 방법입니다. 이렇게 되면 연관된 엔티티 또는 컬렉션을 프록시 객체가 아닌 즉시로딩으로 가져오기 때문에 쿼리는 하나만 발생하게 될 것 입니다.
----------team_findAll_test start-----------
Hibernate:
select
t1_0.id,
m1_0.team_id,
m1_0.id,
m1_0.name,
t1_0.name
from
team t1_0
join
member m1_0
on t1_0.id=m1_0.team_id
----------team_findAll_test mid-----------
member1-1
member1-2
member2-1
member2-2
member3-1
member3-2
----------team_findAll_test end-----------
이런식으로 Inner Join을 통해서 연관된 멤버들을 모두 조회해서 1차 캐시에 넣어두기 때문에, 멤버의 이름을 찍는 순간에도 추가 쿼리가 발생하지 않는 것을 확인할 수 있습니다.
2. EntityGraph 사용
Fetch Join이 좋지만 문자열로 하드 코딩을 해야한다는 단점이 있습니다. 이럴 때는 EntityGraph를 사용할 수도 있습니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@EntityGraph(attributePaths = {"members"})
@Query(value = "select t from Team t")
List<Team> findTeamsEntityGraph();
}
@DisplayName("모든 팀을 조회하고, 지연로딩 된 멤버를 사용할 때 -> 단 1개의 쿼리만 나간다")
@Test
void team_findAll_test() {
clearPersistenceContext();
System.out.println("----------team_findAll_test start-----------");
List<Team> teamList = teamRepository.findTeamsEntityGraph();
assertThat(teamList).hasSize(3);
System.out.println("----------team_findAll_test mid-----------");
teamList.stream()
.map(Team::getMembers)
.map(List::stream)
.forEach(memberStream -> memberStream
.map(Member::getName)
.forEach(System.out::println)
);
System.out.println("----------team_findAll_test end-----------");
}
엔티티 그래프의 장점은 기존 쿼리를 해치지 않고, 따로 @EntityGraph를 통해 특정 필드를 EAGER 로딩할 수 있다는 것입니다. 또한 attributePaths는 여러 개를 선언해서 가져올 수 있습니다.
----------team_findAll_test start-----------
Hibernate:
select
t1_0.id,
m1_0.team_id,
m1_0.id,
m1_0.name,
t1_0.name
from
team t1_0
left join
member m1_0
on t1_0.id=m1_0.team_id
----------team_findAll_test mid-----------
member1-1
member1-2
member2-1
member2-2
member3-1
member3-2
----------team_findAll_test end-----------
Fetch Join과 동일하게 EAGER로 가져와 member를 사용할 때 추가쿼리가 발생하지 않았습니다.
하지만 EntityGraph는 Fetch Join과 다른점이 있습니다.
----------team_findAll_test start-----------
Hibernate:
select
t1_0.id,
m1_0.team_id,
m1_0.id,
m1_0.name,
t1_0.name
from
team t1_0
join
member m1_0
on t1_0.id=m1_0.team_id
----------team_findAll_test mid-----------
member1-1
member1-2
member2-1
member2-2
member3-1
member3-2
----------team_findAll_test end-----------
----------team_findAll_test start-----------
Hibernate:
select
t1_0.id,
m1_0.team_id,
m1_0.id,
m1_0.name,
t1_0.name
from
team t1_0
left join
member m1_0
on t1_0.id=m1_0.team_id
----------team_findAll_test mid-----------
member1-1
member1-2
member2-1
member2-2
member3-1
member3-2
----------team_findAll_test end-----------
EntityGraph는 Join할 때 Outer Left Join을 한다는 점입니다. 이는 카테시안 곱(**Cartesian Product)**이 발생한다는 단점이 있기 때문에 되도록이면 Fetch Join을 사용하는 것을 추천드립니다.
마치며
N+1 해결을 위해 Fetch Join과 EntityGraph를 이용해 해결했는데요 과연 둘 중 뭘 써야 할까요? 저는 개인적으로 Fetch Join을 사용하는 것을 추천드립니다. 그 이유는 EntityGraph는 Outer Join을 사용하기 때문에 성능상 더 안좋기 때문입니다. 그렇다면 Fetch Join은 만능일까요? 다음 포스팅에는 Fetch Join이 과연 만능인가?에 대한 주제로 찾아뵙겠습니다. 부족한 글 읽어주셔서 감사합니다!
REFERENCES
JPA N+1 발생원인과 해결방법 - Yun Blog | 기술 블로그
JPA | JPA N+1 문제 및 해결방안 - 개발 블로그