[JPA] N+1 문제

N + 1 문제와 해결 방법

N + 1 문제

처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하는 것

  • 쿼리가 N + 1번 발생하는 문제
  • Eager / Lazy 모두 발생함
    1. Eager : JPA에서는 문제 없으나 JPQL을 이용할 시 문제 발생.
      • JPQL은 즉시 로딩과 지연 로딩에 대해서 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성
    2. Lazy : 프록시 객체로 가지고 있던 객체가 실제 사용될 때 쿼리가 발생함
      • JPQL에서는 N+1 문제 발생하지 않음
      • 엔티티 내부 컴포지션으로 갖고 있던 해당 컬렉션에 접근할 때마다 쿼리 발생

uml

Team 클래스

@Entity
@Getter
@Setter
public class Team {
	@Id
	@GeneratedValue
	@Column(name = "team_id")
	private Long id;

	private String name;

	@OneToMany(mappedBy = "team")
	private List<TestMember> testMembers = new ArrayList<>();
}

Member 클래스

@Entity
@Getter
@Setter
public class TestMember {

	@Id
	@GeneratedValue
	@Column(name = "member_id")
	private Long id;

	private String name;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "team_id")
	private Team team;

}

N + 1 발생 테스트 코드

@Test
	public void N플러스1_테스트() throws Exception {
		// given
		Team teamA = new Team();
		teamA.setName("팀A");
		em.persist(teamA);

		Team teamB = new Team();
		teamB.setName("팀B");
		em.persist(teamB);

		TestMember testMember1 = new TestMember();
		testMember1.setName("회원1");
		testMember1.setTeam(teamA);
		em.persist(testMember1);

		TestMember testMember2 = new TestMember();
		testMember2.setName("회원2");
		testMember2.setTeam(teamA);
		em.persist(testMember2);

		TestMember testMember3 = new TestMember();
		testMember3.setName("회원3");
		testMember3.setTeam(teamB);
		em.persist(testMember3);

		em.flush();
		em.clear();

		// when
		String query = "select m from TestMember m";
		List<TestMember> resultList = em.createQuery(query, TestMember.class).getResultList();

		for (TestMember member : resultList) {
			System.out.println("member = " + member.getName() + ", " + member.getTeam().getName());
		}

		// then
		assertThat(resultList.size()).isEqualTo(3);
	}

N + 1 발생 로그

  • 처음에 TestMember를 조회하는 쿼리 - 1회
  • TestMember 조회 결과 반복문을 돌면서 Team 정보 조회 - 2회
    • 왜 3회가 아니라 2회? 기존 팀A 엔티티가 영속성 컨텍스트에 1차 캐싱되었기 때문
Hibernate: 
    select
        testmember0_.member_id as member_i1_8_,
        testmember0_.name as name2_8_,
        testmember0_.team_id as team_id3_8_ 
    from
        test_member testmember0_

Hibernate: 
    select
        team0_.team_id as team_id1_7_0_,
        team0_.name as name2_7_0_ 
    from
        team team0_ 
    where
        team0_.team_id=?
member = 회원1, 팀A
member = 회원2, 팀A

Hibernate: 
    select
        team0_.team_id as team_id1_7_0_,
        team0_.name as name2_7_0_ 
    from
        team team0_ 
    where
        team0_.team_id=?
member = 회원3, 팀B

해결 방법

해결방법 1) Fetch Join

  • 대부분의 N + 1 문제는 Fetch Join으로 해결
  • fetch join을 사용하면 프록시가 아닌 실제 엔티티가 담김. 영속성 컨텍스트에 올라가 있는 객체.
  • 일반 조인과 차이점
    • 일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않음. 조인만 해줌. select 절에 연관 엔티티 정보가 포함 되지 않음
    • JPQL은 결과를 반환할 때 연관관계 고려 ❌
    • 단지 SELECT 절에 지정한 엔티티만 조회
    • 페치 조인을 사용할 때만 연관된 엔티티 조회 (사실상 즉시로딩)
    • 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념
  • Join 쿼리문 실행으로 인해 중복 결과 발생할 수 있음
    • DISTINCT를 사용해서 중복을 제거하는 것이 좋음 : 쿼리문에 distinct가 추가되지만 전체 칼럼에 대한 distinct이기 때문에 사실상 무의미. 하지만, 하이버네이트가 조회 결과에 대한 distinct를 해주기 때문에 중복이 제거되는 것임
	@Test
	public void N플러스1_테스트() throws Exception {
    
    // ... 상단은 동일하기 때문에 생략합니다.

		String query = "select m from TestMember m join fetch m.team";
		List<TestMember> resultList = em.createQuery(query, TestMember.class).getResultList();

		for (TestMember member : resultList) {
			System.out.println("member = " + member.getName() + ", " + member.getTeam().getName());
		}

    // ... 하단 생략
  }
Hibernate: 
    select
        testmember0_.member_id as member_i1_8_0_,
        team1_.team_id as team_id1_7_1_,
        testmember0_.name as name2_8_0_,
        testmember0_.team_id as team_id3_8_0_,
        team1_.name as name2_7_1_ 
    from
        test_member testmember0_ 
    inner join
        team team1_ 
            on testmember0_.team_id=team1_.team_id
member = 회원1, 팀A
member = 회원2, 팀A
member = 회원3, 팀B

해결방법 2) 하이버네이트 @BatchSize

  • 연관된 엔티티를 조회할 때 지정한 size 만큼 SQL의 IN 절을 사용해서 조회
  • 즉시로딩 시, (총 건수 / 사이즈) + 1 횟수만큼 쿼리가 실행됨
  • 지연로딩 시, 처음 사이즈만큼을 가져오는 쿼리 1회를 실행해 미리 로딩. 사이즈 이상의 데이터에 접근하면 그 때 SQL을 추가 실행
@Entity
@Getter
@Setter
public class Team {
	@Id
	@GeneratedValue
	@Column(name = "team_id")
	private Long id;

	private String name;

	@org.hibernate.annotations.BatchSize(size = 5)
	@OneToMany(mappedBy = "team")
	private List<TestMember> testMembers = new ArrayList<>();
}

해결방법 3) 하이버네이트 @Fetch(FetchMode.SUBSELECT)

  • 연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N + 1 문제 해결
  • 즉시 로딩 시, 조회 시점 쿼리 실행
  • 지연 로딩 시 ,지연 로딩된 엔티티를 사용하는 시점에 쿼리 실행
    @Entity
    @Getter
    @Setter
    public class Team {
      @Id
      @GeneratedValue
      @Column(name = "team_id")
      private Long id;
    
      private String name;
    
      @org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
      @OneToMany(mappedBy = "team")
      private List<TestMember> testMembers = new ArrayList<>();
    }
    

JPA 글로벌 Fetch 전략 - @OneToOne, @ManyToOne : 즉시로딩 - @OneToMany, @ManyToMany : 지연로딩

⭐️ 기본값이 즉시로딩인 @OneToOne, @ManyToOnefetch = FetchType.LAZY 로 설정해서 지연 로딩 전략을 사용하자!!

참조