들어가기 전

이번 글에서는 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 문제에 해당된다는 것을 깨달았습니다.