본문 바로가기
Spring

Spring Data Jpa의 N+1 문제

by setung 2022. 2. 7.

N+1 문제란 DB에서 데이터를 조회할 때 연관된 데이터를 추가로 쿼리문을 실행해 조회하는 현상을 뜻한다.

쿼리문이 추가로 실행되는게 무슨 대수냐 할 수 있지만 대부분의 DB는 Disk I/O와 Network I/O가 발생함으로 불필요한 쿼리가 실행되지 않게 해야 성능의 부담을 줄 수 있다.

 

JPA에서 N+1 문제가 언제 발생하는지 알아보고 해결해 보겠다.

예를 위한 Person, Team 그리고 각각 Repository를 만들어 봤다.

 

Person.class

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Person {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

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

 

Team.class

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Team {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

}

 

PersonRepository.class

public interface PersonRepository extends JpaRepository<Person, Long> {
}

TeamRepository.class

public interface TeamRepository extends JpaRepository<Team,Long> {
}

 

테스트 코드

@SpringBootTest
@Transactional
class PersonRepositoryTest {

    @Autowired
    PersonRepository personRepository;

    @BeforeAll
    static void beforeAll(@Autowired PersonRepository personRepository,
                           @Autowired TeamRepository teamRepository) {

        Team t1 = Team.builder().name("team1").build();
        Team t2 = Team.builder().name("team2").build();

        Person p1 = Person.builder().name("person1").team(t1).build();
        Person p2 = Person.builder().name("person2").team(t1).build();
        Person p3 = Person.builder().name("person3").team(t2).build();

        teamRepository.save(t1);
        teamRepository.save(t2);

        personRepository.save(p1);
        personRepository.save(p2);
        personRepository.save(p3);
    }

    @Test
    void findAll() {
        List<Person> all = personRepository.findAll();

        for (Person person : all) {
            System.out.println("-----------------------");
            System.out.println("Person = " + person + " person.getTeam() = " + person.getTeam());
            System.out.println("-----------------------");
        }

        Assertions.assertThat(all.size()).isEqualTo(3);
    }
    
 }

 

Person과 Team은 다대일 관계이다.

테스트 코드의 beforeAll() 메서드가 실행되면 아래처럼 데이터베이스에 데이터가 들어가게 된다.

 

테스트 코드의 findAll() 메서드를 실행하게 되면 아래처럼 출력이 된다.

빨간색은 반복문 순회를 의미하고, 노란색은 쿼리를 의미한다.

쿼리는 총 3번 발생했다.

1. Person 테이블의 전체 데이터를 조회하는 쿼리

2. person1 순회 때 team1의 조회를 위한 쿼리

3. person3 순회 때 team2의 조회를 위한 쿼리

 

person2 순회 때 team1을 조회하기 위한 쿼리가 발생하지 않은 이유는 영속성 컨텍스트에서 person1 순회 때 조회한 team1를 이미 캐싱하고 있기 때문이다.

 

Person 클래스에서 Team을 지연 로딩(fetch = FetchType.LAZY)으로 설정했다. 지연 로딩 시 Person 조회 때 Team에는 프록시 객체가 임시로 들어가게 된다. 그리고 Team에 접근할 때 쿼리를 실행해 데이터를 조회하는 메커니즘이다.

그렇다면 즉시 로딩이면 어떻게 될까?

Team을 조회하는 쿼리가 실행되는 시점만 달라질 뿐 N+1 문제는 여전히 발생한다.

 

해결 방법 : 패치 조인

jpql에서 지원하는 패치 조인을 사용하여 N+1을 해결한다.

 

PersonRepository.class

public interface PersonRepository extends JpaRepository<Person, Long> {

    @Query("SELECT p FROM Person p INNER JOIN FETCH p.team t")
    List<Person> findAllFetchJoin();
}

PersonRepository에 패치 조인으로 조회하는 메서드를 만들었다.

 

 

테스트 코드

@Test
void fetchJoin() {
    List<Person> all = personRepository.findAllFetchJoin();

    for (Person person : all) {
        System.out.println("-----------------------");
        System.out.println("Person = " + person + " person.getTeam() = " + person.getTeam());
        System.out.println("-----------------------");
    }

    Assertions.assertThat(all.size()).isEqualTo(3);
}

 

 select person0_.id as id1_0_0_, team1_.id as id1_1_1_, person0_.name as name2_0_0_, person0_.team_id as team_id3_0_0_, team1_.name as name2_1_1_ from person person0_ inner join team team1_ on person0_.team_id=team1_.id

 

 join을 통해 Person과 Team을 한번에 조회하는 쿼리를 한번 실행하게 된다.

'Spring' 카테고리의 다른 글

Spring의 @Transactional  (0) 2021.12.15
JDK Dynamic Proxy와 CGLIB  (0) 2021.12.07
Spring AOP  (0) 2021.12.02
Spring Boot에 Redis Cache 적용해보기  (0) 2021.11.25
Spring JPA Specification을 사용해 유연하게 조회 API 만들기  (3) 2021.11.08

댓글