[JPA] N+1 원인 및 해결방법
들어가기 전
이번 글에서는 N+1이 무엇이고 발생 원인과 N+1을 방지하기 위한 임시방편, 해결방법에 대해 알아보겠습니다.
그리고 예시로 Person, House 엔티티가 있습니다.
Person(N) : House(1) 관계로 이루어져 있습니다. 즉 Person이 해당 예시에서 연관관계 주인입니다.
@Entity
@Getter
@Setter
public class House {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String address;
}
@Entity
@Getter
@Setter
public class Person {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
private House house;
}
N+1 이란?
N+1은 어떤 특정 엔티티를 조회를 할때 1개의 쿼리가 발생해야 하는데 특정 엔티티와 연관관계가 맺어져 있는 다른 엔티티의 대한 N개의 쿼리가 발생하는 문제입니다.
N+1 발생 원인
- 특정 엔티티를 조회를 해올때 연관 되어있는 엔티티를 함께 조회해오지 않고 연관되어 있는 엔티티의 값을 실제로 사용할 때 조회되기 때문에 발생됩니다.
- JPQL을 사용하게 되면 즉시 로딩, 지연 로딩과 같은 글로벌 패치 전략을 무시하고 JPQL만 사용해서 SQL을 생성합니다.
N+1 방지하기 위한 임시 방편
1. ManyToOne, OneToOne의 Fetch전략을 LAZY로 설정
ManyToOne과 OneToOne의 기본 Fetch전략은 EAGER입니다. N+1 문제를 방지하기 위한 임시방편으로 Fetch전략을 LAZY로 설정을 합니다. LAZY로 설정을 했다고 해서 N+1 문제가 해결이 되는 것이 아니고 방지하기 위한 임시방편이라는 것을 명심해야 합니다. 단점은 앞에서 설명한 N+1 발생 원인 중 특정 엔티티를 조회해 올 때 연관되어 있는 엔티티를 함께 조회해오지 않고 연관되어 있는 엔티티의 값을 실제로 사용할 때 N+1이 발생한다는 단점이 있습니다. 그럼에도 불구하고 EAGER에서 LAZY로 설정을 바꾸는 이유는 특정 엔티티를 조회를 해올 때 연관된 엔티티들에 대한 쿼리가 발생하지 않고 자기 자신만 조회해 오는 쿼리가 발생합니다. 이렇게 함으로써 불필요한 정보를 안 가져온다는 장점이 있습니다.
예시
@SpringBootTest
@Transactional
public class NplusOneTest {
@Autowired
PersonRepository personRepository;
@Autowired
HouseRepository houseRepository;
@BeforeEach
public void savePersonAndHouse() {
House house = new House();
house.setAddress("경기도");
house.setName("행복아파트");
houseRepository.save(house);
Person person = new Person();
person.setHouse(house);
person.setName("철수");
personRepository.save(person);
}
- 테스트 코드를 이용하였고 @BeforeEach를 하여 메서드 실행 전에 Person과 House을 사전에 저장하는 코드입니다.
A-1 Fetch전략이 EAGER이고 JpaRepository에서 제공하는 메서드 사용했을 때
@Test
@DisplayName("ManyToOne Fetch 전략 Eager JpaRepository 인터페이스에서 제공해주는 메서드 사용했을떄")
void manyToOneNotFetchTypeUseJpaRepositoryMethod() {
Person person = personRepository.findById(1L).orElse(null);
}
쿼리 결과
A-1 Fetch전략이 EAGER이고 JPQL을 사용했을 때
@Test
@DisplayName("ManyToOne Fetch 전략 Eager JPQL을 이용해서 조회를 할 때 ")
void manyToOneNotFetchTypeUseJpql() {
Person person = personRepository.findByPerson(1L).orElse(null);
}
----------------------------JPQL--------------------------------
@Query("SELECT p FROM Person p where p.id = :personId")
Optional<Person> findByPerson(@Param("personId") Long personId);
쿼리 결과
B-1 Fetch전략이 LAZY이고 JpaRepository에서 제공하는 메서드 사용했을 때
@Test
@DisplayName("ManyToOne fetchType = Lazy JpaRepository 인터페이스에서 제공해주는 메서드 사용했을때 ")
void manyToOneFetchTypeLazyJpaRepositoryMethod() {
Person person = personRepository.findById(1L).orElse(null);
}
쿼리 결과
B-1-1 Person과 연관관계 맺어져 있는 House 엔티티의 값을 실제로 사용할 때
@Test
@DisplayName("ManyToOne fetchType = Lazy JpaRepository 인터페이스에서 제공해주는 메서드 사용했을때 ")
void manyToOneFetchTypeLazyJpaRepositoryMethod() {
Person person = personRepository.findById(1L).orElse(null);
House house = person.getHouse(); // 프록시 객체라 아직 House 쿼리 발생 X
System.out.println("==============House쿼리 발생===========");
System.out.println(house.getAddress());
}
쿼리 결과
B-2 Fetch전략이 LAZY이고 JPQL을 사용했을 때
@Test
@DisplayName("ManyToOne fetchType = Lazy JPQL을 이용해서 조회를 할때")
void manyToOneFetchTypeLazyJpql() {
Person person = personRepository.findByPerson(1L).orElse(null);
}
---------------------JPQL---------------------------
@Query("SELECT p FROM Person p where p.id = :personId")
Optional<Person> findByPerson(@Param("personId") Long personId);
쿼리 결과
B-2-1 Person과 연관관계 맺어져 있는 House 엔티티의 값을 실제로 사용할 때
@Test
@DisplayName("ManyToOne fetchType = Lazy JPQL을 이용해서 조회를 할때")
void manyToOneFetchTypeLazyJpql() {
Person person = personRepository.findByPerson(1L).orElse(null);
House house = person.getHouse(); // 프록시 객체라 아직 House 쿼리 발생 X
System.out.println("==============House쿼리 발생===========");
System.out.println(house.getAddress());
}
쿼리 결과
N+1 해결방법
1. Fetch Join 사용
Fetch Join은 데이터베이스 문법이 아닌 JPA에서 제공해 주는 문법입니다. Fetch Join은 Inner Join으로 쿼리가 발생합니다.
Fetch Join을 이용해서 N+1 문제를 해결할 수 있습니다.
A-1 Fetch 전략이 EAGER일 때 Fetch Join 사용
@Test
@DisplayName("Fetch 전략이 EAGER 일때 Fetch Join을 이용하여 N+1 문제 해결")
void manyToOneFetchTypeEagerUseFetchJoin() {
Person person = personRepository.findPersonFetchJoin(1L).orElse(null);
House house = person.getHouse();
System.out.println("==============House쿼리 발생===========");
System.out.println(house.getAddress()); // Fetch Join으로 House에 대한 조회 쿼리 발생하여 쿼리 발생 X
}
------------------------Fetch Join-------------------------
@Query("SELECT p FROM Person p JOIN FETCH p.house where p.id = :personId")
Optional<Person> findPersonFetchJoin(@Param("personId") Long personId);
쿼리결과
A-2 Fetch 전략이 LAZY일 때 Fetch Join 사용
@Test
@DisplayName("Fetch 전략이 LAZY일때 Fetch Join을 이용하여 N+1 문제 해결")
void manyToOneFetchTypeLazyUseFetchJoin() {
Person person = personRepository.findPersonFetchJoin(1L).orElse(null);
House house = person.getHouse();
System.out.println("==============House쿼리 발생===========");
System.out.println(house.getAddress()); // Fetch Join으로 House에 대한 조회 쿼리 발생하여 쿼리 발생 X
}
------------------------Fetch Join-------------------------
@Query("SELECT p FROM Person p JOIN FETCH p.house where p.id = :personId")
Optional<Person> findPersonFetchJoin(@Param("personId") Long personId);
쿼리결과
2. @EntityGraph 사용
@EntityGraph의 attributePaths는 같이 조회할 연관 엔티티명을 입력을 하고 , (콤마)를 통해 여러 개를 줄 수도 있습니다.
Fetch join과 동일하게 JPQL을 사용해 Query문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 됩니다.
A-1 Fetch 전략이 EAGER일 때 @EntityGraph 사용
@Test
@DisplayName("Fetch 전략이 EAGER 일때 @EntityGraph를 이용하여 N+1 문제 해결")
void manyToOneFetchTypeEagerUseEntityGraph() {
Person person = personRepository.findByPersonEntityGraph(1L).orElse(null);
House house = person.getHouse();
System.out.println("==============House쿼리 발생===========");
System.out.println(house.getAddress()); // @EntityGraph로 House에 대한 조회 쿼리 발생하여 쿼리 발생 X
}
---------------------JPQL----------------------
@EntityGraph(attributePaths = {"house"})
@Query("SELECT p FROM Person p where p.id = :personId")
Optional<Person> findByPersonEntityGraph(@Param("personId") Long personId);
쿼리결과
A-2 Fetch 전략이 LAZY일 때 @EntityGraph 사용
@Test
@DisplayName("Fetch 전략이 LAZY일때 @EntityGraph를 이용하여 N+1 문제 해결")
void manyToOneFetchTypeLazyUseEntityGraph() {
Person person = personRepository.findByPersonEntityGraph(1L).orElse(null);
House house = person.getHouse();
System.out.println("==============House쿼리 발생===========");
System.out.println(house.getAddress()); // @EntityGraph로 House에 대한 조회 쿼리 발생하여 쿼리 발생 X
}
---------------------JPQL----------------------
@EntityGraph(attributePaths = {"house"})
@Query("SELECT p FROM Person p where p.id = :personId")
Optional<Person> findByPersonEntityGraph(@Param("personId") Long personId);
●참고
Fetch Join은 inner join을 이용하고 @EntityGraph는 outer join을 이용합니다.
성능 최적화는 inner join이 유리합니다.
Fetch Join과 @EntityGraph 사용 시 주의 사항 및 해결 방법
주의 사항
Fetch Join과 @EntityGraph를 사용하면 카테시안 곱이 일어나 중복이 발생할 수 있습니다.
해결 방법
1. JPQL을 이용할 시 DISTINCT를 사용하여 중복을 방지합니다.
2. @OneToMany일 경우 Set을 이용하여 중복을 방지합니다.
깨달은 점
처음에는 Fetch 전략을 LAZY로 설정을 하게 되면 N+1 문제가 해결된다고 생각을 했었습니다. 이유는 LAZY로 하면 자기 자신에 대한 쿼리는 나오고 연관되어 있는 엔티티는 해당 엔티티의 실제 값을 이용해야 쿼리가 발생해서 이 경우에는 N+1 문제가 아니라고 생각을 했었습니다. 그런데 맡은 업무 중에서 N+1 문제를 해결하여 속도를 개선하는 업무를 맡게 되면서 N+1에 대해 더 자세히 알아보고 직접 해보니
LAZY로 설정을 하고 연관된 엔티티의 값을 이용할 때 나오는 쿼리도 N+1 문제에 해당된다는 것을 깨달았습니다.
'JPA' 카테고리의 다른 글
[JPA] Cascade 옵션 종류 및 예제 (0) | 2023.04.12 |
---|---|
[JPA] Kotlin을 이용하여 Soft Delete 구현 (0) | 2023.03.04 |
[Querydsl] 스프링 부트에서 Querydsl 설정 (0) | 2022.06.14 |
[JPA] 프록시, 즉시 로딩, 지연 로딩 (0) | 2022.01.30 |
[JPA] 단방향, 양방향, 연관관계 주인 (0) | 2022.01.26 |
댓글
이 글 공유하기
다른 글
-
[JPA] Cascade 옵션 종류 및 예제
[JPA] Cascade 옵션 종류 및 예제
2023.04.12 -
[JPA] Kotlin을 이용하여 Soft Delete 구현
[JPA] Kotlin을 이용하여 Soft Delete 구현
2023.03.04 -
[Querydsl] 스프링 부트에서 Querydsl 설정
[Querydsl] 스프링 부트에서 Querydsl 설정
2022.06.14 -
[JPA] 프록시, 즉시 로딩, 지연 로딩
[JPA] 프록시, 즉시 로딩, 지연 로딩
2022.01.30