Spring Boot:: JPA N+1 문제 정리

류영준·2022년 11월 10일
1

Spring Framework

목록 보기
1/1
post-thumbnail

들어가며

JPA를 사용하다 보면 의도하지 않았지만 갑자기 여러 개의 select 쿼리문이 순식간에 나가는 현상을 본 적이 있을 것이다. 이러한 현상을 N+1 문제라고 부른다. 그러면 N+1 문제가 무엇이고, 왜 발생하는지 그리고 해결 방법이 어떻게 되는지에 대해서 알아보자.

JPA에서 N+1 문제란?

  • 요청이 1개의 처리로 처리되길 기대했는데 N개의 추가 쿼리가 발생하는 현상
  • 구체적으로 말하면, 조회 대상이 N개일 때 N개를 읽어오는 한 번의 쿼리와 연관된 데이터를 읽어오는 쿼리를 N번 실행하는 현상
    • 1+N 이라고 생각하는 것이 더 편함

현상 재현

DB 구조는 Member는 한 개의 Team에만 속할 수 있고, Team 하나는 여러 명의 유저가 가입할 수 있다.

테스트 데이터로는 2개의 팀당 멤버 3명씩 총 6명의 멤버를 추가했다.

@OneToMany의 N+1 문제

Fetch 모드를 LAZY(지연 로딩)으로 한 경우

@OneToMany 의 기본 FetchType은 LAZY이다.

TeamRepository에서 findAll을 호출하면

teamRepository.findAll();

N+1 문제가 발생하지 않는 것처럼 보인다.

하지만 아래와 같이 members 객체를 사용하려고 하면 N+1 문제가 발생한다.

List<Team> teams = teamRepository.findAll();

System.out.println("============== N+1 시점 확인용 ==============");

for (Team t : teams) {
  t.getMembers().forEach(m -> m.getFistName());
}

즉, 지연 로딩에서는 N+1 문제가 발생하지 않는 것처럼 보였지만 막상 객체를 탐색하려고 하면 N+1 문제가 발생한다.

Fetch 모드를 EAGER(즉시 로딩)으로 한 경우

“지연로딩으로 쿼리를 나중에 불러오는데, 그렇다면 즉시로딩으로 관계에 필요한 데이터를 바로 조회하면 되지 않을까” 라는 생각이 들어 즉시 로딩으로 바꾸어서 해보았다.

TeamRepository에서 findAll을 호출하면

teamRepository.findAll();

N+1 문제가 발생한다.

@ManyToOne의 N+1 문제

Fetch 모드를 LAZY(지연 로딩)으로 한 경우

MemberRepository에서 findAll을 호출하면

memberRepository.findAll();

N+1 문제가 발생하지 않는 것처럼 보인다.

하지만 아래와 같이 teams 객체를 사용하려고 하면 N+1 문제가 발생한다.

List<Member> members = memberRepository.findAll();

System.out.println("============== N+1 시점 확인용 ==============");

for (Member m : members) {
  m.getTeam().getName();
}

Fetch 모드를 EAGER(즉시 로딩)으로 한 경우

@ManyToOne 의 기본 FetchType은 EAGER이다.

memberRepository에서 findAll을 호출하면

memberRepository.findAll();

N+1 문제가 발생한다.

총 3개의 쿼리가 실행되는데, Member를 조회하기 위한 쿼리와 Member와 관계 있는 Team을 조회하기 위한 쿼리이다.

Member가 속한 Team만큼 추가 쿼리가 실행되기 때문에 이와 같은 현상이 발생한다.

발생 이유

Fetch 전략이 즉시 로딩(EAGER)인 경우

JPQL이 위와 같이 findAll() 메소드에서 즉시 로딩 쿼리를 만들 때는 다음과 같은 과정을 거친다.

JPQL 자체가 엔티티를 기준으로 쿼리를 만들어주는데, 처음 쿼리를 만들 때 Team에 연관관계가 있는 엔티티는 신경 안쓰고 조회 대상이 되는 Entity 기준으로만 쿼리를 만든다.

그래서 처음 조회를 할 때는 Team들만 가져온다. 그 후에 JPA가 연관된 엔티티가 있는 것을 확인하고 글로벌 패치 전략을 보고 즉시 로딩을 실시하여 N번의 추가 쿼리가 발생하는 것이다.

Fetch 전략이 지연 로딩(LAZY)인 경우

지연 로딩도 마찬가지로 조회 대상이 되는 Entity 기준으로만 쿼리를 만든다.

그 후에 코드 중에서 연관관계가 있는 엔티티를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 user가 있는지 확인하고 없다면 N번의 추가 쿼리가 발생한다.

해결방법

N+1 문제를 해결하는 방법에는 Fetch Join, EntityGraph 어노테이션, Batch Size 등의 방법이 있다.

그 중에 Fetch Join에 대해서 알아보자.

Fetch Join

  • JPQL을 사용하여 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 Select 하여 모두 영속화하는 방법이다.
  • Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 쿼리가 실행되지 않은 채로 N+1 문제가 해결된다.
  • 별도의 메소드를 만들어줘야 하며 @Query 어노테이션을 사용해서 "join fetch 엔티티.연관관계_엔티티" 구문을 만들어 주면 된다.

일반 Join과 다른점

  • 일반 Join은 Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 Select 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화한다.
  • 이는 연관관계 Entity의 데이터가 필요하지는 않지만 검색조건에 필요한 경우에 주로 사용된다.

@OneToMany에 대한 Fetch Join

List<Team> teams = teamRepository.findAllFetchJoin();

System.out.println("============== N+1 시점 확인용 ==============");

for (Team t : teams) {
  t.getMembers().forEach(m -> m.getFistName());
}

@ManyToOne에 대한 Fetch Join

List<Member> members = memberRepository.findAllFetchJoin();

System.out.println("============== N+1 시점 확인용 ==============");

for (Member m : members) {
  m.getTeam().getName();
}

join fetch 으로 join 쿼리가 실행되도록 할 수 있습니다.

별도의 지정을 안하면 JPQL에서 join fetch 구문은 inner join 구문으로 변경되어 실행된다.

left outer join으로 처리하고 싶다면 join fetch 대신 left join fetch로 변경하면 된다.

마치며

엔티티 간의 연관관계에 대한 설정이 필요한 경우에 성능 최적화를 하기 어려운 FetchType인 즉시 로딩(Eager)을 사용하는 것이 아니라, 지연 로딩(LAZY)를 사용한다. 하지만 LAZY로 사용하더라고 N+1 문제가 발생할 수 있음을 인지한다.

성능 최적화가 필요한 경우 Fetch join이나 Batch size를 이용해서 최적화한다.

Batch Size 값은 기본적으로 1000 이하로 설정한다고 한다. (대부분의 DB에서 IN절의 최대 개수 값이 1000이기 때문)

또 다른 방법으로는 연관관계 설정이 필수적이지 않다면 N+1 문제로 DB가 죽어버리는 불상사를 막기 위해서 연관관계를 끊어버리고 사용하는 것도 방법이다.

profile
Backend Developer

0개의 댓글