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 |
댓글