안녕하세요. N+1 주제로 발표하게 된 홍주광입니다.

사례 먼저 보시겠습니다.

1

클라이언트에서 서버로 초당 5회의 요청을 보냈고 평균 30ms속도로 응답을 받았습니다.

조금 더 올려볼까요?

2

초당 1000회의 요청을 보내봤습니다. 어떻게 되었을까요?

3

초당 1000회의 요청을 보냈고 평균 4000ms속도의 응답을 받았습니다. 서버는 과부하가 되었습니다.

왜 이런 현상이 벌어졌을까요?

바로 이 사례에서는 N+1 문제 때문이였습니다. (물론 항상 위 현상이 N+1 문제로 일어나는 것은 아닙니다..)

그만큼 N+1 문제가 서버에 영향을 줄 수 있다는 뜻입니다.

N+1

N+1 문제란 무엇일까요?

4

데이터 조회 시, 1개의 쿼리로 요청이 처리될 것이라고 생각했는데 N개의 추가 쿼리가 더 발생하는 문제입니다.

N+1 문제 예시

N+1 문제의 정의만 듣고는 이해가 어려울 수도 있어 예시를 준비해보았습니다.

5

주인와 고양이가 1:N 인 일대다 관계로 있습니다.

한 명의 주인이 여러 고양이를 키울 수 있습니다.

주인은 10명이고 각 주인이 10마리의 고양이를 키운다고 가정하고

모든 주인을 조회해보겠습니다.

우리는 모든 주인을 조회하는 쿼리이니 당연히 1개의 쿼리가 나올 것이라고 생각할 것입니다.

6

그러나 1개의 쿼리가 아닌 총 11개의 쿼리가 더 나왔습니다.

여기서 조금 더 자세히 짚고 넘어가자면

빨간 박스인 예상 쿼리를 보시면 모든 주인을 조회하는 쿼리가 나왔습니다. 10명의 주인이 조회되었습니다.

노란 박스인 추가 쿼리를 보시면 10개가 더 나왔는데 이것은 고양이의 숫자와 상관없이 10명의 주인이 각 1개의 쿼리가 더 생긴 것입니다.

즉,

  • 1번주인, 2번주인, 3번주인, 4번주인, 5번주인, 6번주인, 7번주인, 8번주인, 9번주인, 10번주인

이 조회 되었고

  • 1번주인의 고양이를 조회하는 쿼리, 2번주인의 고양이를 조회하는 쿼리, 3번주인의 고양이를 조회하는 쿼리, 4번주인의 고양이를 조회하는 쿼리, 5번주인의 고양이를 조회하는 쿼리, 6번주인의 고양이를 조회하는 쿼리, 7번주인의 고양이를 조회하는 쿼리, 8번주인의 고양이를 조회하는 쿼리, 9번주인의 고양이를 조회하는 쿼리, 10번주인의 고양이를 조회하는 쿼리

이렇게 각 주인마다 1개의 쿼리가 추가로 나와 총 10개의 쿼리가 추가로 나오게 된 것입니다.(고양이 수는 중요하지 않음!)

다른 예시로

주인이 5명이고 각 주인이 고양이를 2마리씩 키우고 있다고 하면

N+1문제가 발생했을 때는 1(모든 주인 조회 쿼리) + 5(각 주인의 고양이 조회 쿼리) 해서 총 6개의 쿼리가 나오게 됩니다.

N+1 발생 이유

JPQL 이 처음 쿼리를 만들 때, 연관관계가 있는 엔티티는 신경 쓰지 않고 조회 대상이 되는 엔티티 기준으로만 쿼리를 만들기 때문에 발생합니다.

이는 JPA 뿐만 아니라 ORM 은 다 발생하는 문제입니다.

글로벌 페치 전략

즉시로딩

즉시로딩(EAGER) 은 부모 엔티티를 조회할 때 연관된 자식 엔티티를 함께 가져오는 방식

지연로딩

지연로딩(LAZY) 은 부모 엔티티를 조회할 때 연관된 자식 엔티티를 필요한 시점까지 로딩하지 않고 지연하여 가져오는 방식

페치 전략으로 해결 가능할까?

구글링을 해보면 많은 사람들이 페치 전략을 통해 N+1 문제를 해결하려는 시도를 볼 수 있습니다.

즉시 로딩 페치 전략

즉시 로딩으로 한번에 불러오면 되는 거 아닌가요?

@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
private List<Cat> cats = new ArrayList<>();

모든 주인을 조회해보겠습니다.

7

여전히 N+1문제가 발생합니다.

  1. JPQL에서 만든 SQL을 통해 데이터를 조회
  2. 이후 JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
  3. 2번 과정으로 N+1 문제 발생

즉시로딩으로는 N+1 문제를 해결할 수 없습니다.

지연 로딩 페치 전략

지연 로딩으로 하면 한 줄만 나오던데요?

@OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
private List<Cat> cats = new ArrayList<>();

모든 주인을 조회해보겠습니다.

쿼리가 진짜 하나만 나옵니다. 그러나 고양이 이름도 같이 조회하게 되면

8

여전히 N+1 문제가 발생합니다.

연관된 데이터를 찾지 않는 경우(고양이 이름을 검색하지 않을 때)에는 한 줄로 나옵니다. -> 그래서 기본적으로 연관관계는 지연로딩(LAZY)가 좋습니다.

  1. JPQL에서 만든 SQL을 통해 데이터를 조회
  2. JPA에서 Fetch 전략을 가지지만, 지연 로딩이기 때문에 추가 조회는 하지 않음
  3. 하지만, 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하기 때문에 결국 N+1 문제 발생

임시적으로 나오지 않게 할 수는 있지만 근본적으로 지연로딩도 N+1 문제를 해결할 수 없습니다.

둘의 차이는 언제 쿼리를 발생시키냐의 차이입니다.

N+1 문제 해결 방법

대표적인 해결 방법으로는 3가지가 있습니다.

  • 페치 조인

  • 엔티티 그래프

  • 배치 사이즈

그 중 페치 조인과 배치 사이즈를 알아보겠습니다.

페치 조인

  • JPQL에서 성능 최적화를 위해 제공되는 기능

  • JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법

@Query("select o from Owner join fetch o.cats")
List<Owner> findAllJoinFetch();

9

1개의 쿼리가 나오며 inner join 이 사용됩니다.

페치 조인 주의점

  • JPA 가 제공하는 Paging사용 불가능(OneToMany,ManyToMany 관계)

  • 1:N 관계가 두 개 이상인 경우 사용 불가(OneToMany,ManyToMany 관계)

  • 페치 조인 대상에게 별칭 부여 불가

  • 중복 데이터 발생 가능성

배치 사이즈

  • 하이버네이트가 제공하는 @BatchSize 어노테이션을 이용

  • 연관된 엔티티를 조회할 때 지정된 size 만큼 SQL의 IN절을 사용해서 조회

  • 완전한 N+1문제 해결은 아님

  • 1000번 일어날 N+1 문제를 1번만 더 조회하는 방식으로 성능을 최적화(size=1000)

select * from user where team_id in (?,?,?)

배치사이즈를 사용하게 되면 위처럼 in 절로 나오게 됩니다.

배치사이즈는 무조건 1개의 쿼리가 나오는 것은 아닙니다.

배치 사이즈의 크기는 프로젝트마다 최적의 사이즈가 다르지만 보통 1000이 MAX 로 둔다고 알려져 있습니다.

이 부분이 궁금하시면 찾아보시길 권장드립니다.

10

yml 파일에서 위처럼 설정할 수도 있고

11

엔티티에서 바로 설정할 수도 있습니다.

배치사이즈 예시

조금 더 이해를 돕기 위해 예시를 들어 설명해보겠습니다.

@BatchSize(size=5)
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)

12

주인이 10명 고양이가 10명은 일대다 관계에서 사이즈가 5이면

처음 조회쿼리 1개 + 배치사이즈로 인한 in절 쿼리(size5) 2개 = 총 3개의 쿼리가 나오게 됩니다.

@BatchSize(size=20)
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)

사이즈를 20으로 늘리면 어떻게 될까요?

13

in절 쿼리가 20개까지 커버 가능하니

처음 조회쿼리 1개 + 배치사이즈로 인한 in절 쿼리(size20) 1개 = 총 2개의 쿼리가 나오게 됩니다.

정리

  1. N+1 문제는 데이터 조회 시, 1개의 쿼리로 요청이 처리될 것이라고 생각했는데 N개의 추가 쿼리가 더 발생하는 문제이다.
  2. N+1 문제의 발생 원인은 JPQL 이 처음 쿼리를 만들 때 연관관계가 있는 엔티티는 신경 안쓰고 조회 대상이 되는 엔티티 기준으로만 쿼리를 만들기 때문이다.
  3. N+1 문제는 글로벌 페치 전략으로 해결할 수 없다.
  4. 페치 조인은 JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법이다.
  5. 배치사이즈는 연관된 엔티티를 조회할 때 지정된 size 만큼 SQL의 IN절을 사용해서 조회하는 방법이다.

N+1 문제는 면접에서 대답을 못하면 절대 안되는 문제로 꼭 숙지하고 가시길 바랍니다. 또한 N+1 문제를 고려하면서 연관관계와 쿼리를 작성하고 본인에게 맞는 방법으로 해결하시길 바랍니다.

출저

https://velog.io/@qf9ar8nv/%EB%B6%80%ED%95%98%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%99%80-N1%EB%AC%B8%EC%A0%9C/

https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1/

https://youtu.be/u69GNLILgzM?si=f2bX9tiQiV7_H7O0/

https://youtu.be/ni92wUkAmQI?si=xwtRDpAJghsoVN_E/

https://ttl-blog.tistory.com/1135#%F0%9F%93%99%C2%A0%40EntityGraph%20%EC%82%AC%EC%9A%A9-1/