[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
댓글을 사용할 수 없습니다.