N + 1문제

허준기·2025년 3월 16일
1

스프링

목록 보기
10/10

나도 드디어 N+1 문제를 만났다!

N+1 문제

연관 관계에서 발생하는 문제로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수만큼 조회 쿼리가 추가로 발생해서 데이터를 읽어오게 되는 문제

사실 쿼리 1개를 날렸을 때, N개의 쿼리가 추가적으로 발생하는 거라서 순서상으로는 1 + N 문제다.
나는 1개의 쿼리만 날렸을 뿐인데 해당하는 데 추가적으로 수 많은 쿼리들이 날아가는 상황이 발생할 수 있다.
이로 인해 조회 성능이 하락하는 등 성능에 영향을 미칠 수 있다

문제 상황

해당 문제에 대해 이론으로는 알고 있었는데 직접 만나는 건 처음이었다.

발생한 부분의 코드는 아래와 같다

@Query("SELECT p FROM Plan p WHERE p.endDate = :targetDate AND p.status IN :statuses")
List<Plan> findPlansWithUsers(@Param("targetDate") LocalDate targetDate,
    @Param("statuses") List<Plan.Status> statuses);

코드를 보기에 앞서 프로젝트 구조에 대한 설명부터 해봐야겠다

프로젝트 일정관리를 위한 프로젝트로 Plan이 중간 정도의 목표(ex: 검색 기능 개발)이고 Do는 해당 목표(ex: 검색 API 명세 전달 등)를 위한 세부 목표로 보면 된다

팀/프로젝트 별로 관리하기 때문에 User, Team, TeamMember 등 다른 개념들도 있지만 위의 문제는 Plan 을 불러올 때 해당 Plan을 가지고 있는 User 도 같이 불러오기 때문에 발생하는 문제이다.

그래서 위에 있는 문제가 생긴 코드를 보면 쿼리가 하나만 날아가야 하는데 실제로 실행을 한 후 확인을 해보면 아래 사진과 같은 결과가 나오는 것을 볼 수 있다

위의 쿼리들은 추가적으로 발생한 쿼리들이고 9269는 Plan의 개수다
그리고 AOP를 이용해서 시간 측정을 해본 결과 9269개의 Plan 을 가져오는 데에 7019ms가 소요된 것을 볼 수 있다!!! 그리고 추가적으로 쿼리의 개수도 세봤는데 10740개의 쿼리가 발생한 것을 볼 수 있다.

나는 분명히 1개의 쿼리를 날렸는데 10739개의 쿼리가 추가로 발생한것이다. 이로 인해 조회에만 7초가 소요된다..

위와 같은 N+1 문제를 @EntityGraph 를 이용해서 해결했다

N + 1 문제 해결

기존 코드에 어노테이션 하나를 추가해줬다

@EntityGraph(attributePaths = {"teamMember.user"})
@Query("SELECT p FROM Plan p WHERE p.endDate = :targetDate AND p.status IN :statuses")
List<Plan> findPlansWithUsers(@Param("targetDate") LocalDate targetDate,@Param("statuses") List<Plan.Status> statuses);

위에 있는 코드와 달라진 부분은
@EntityGraph(attributePaths = {"teamMember.user"}) 밖에 없다

그런데 결과를 보면?

똑같이 9269개의 Plan을 조회하는데 314ms 밖에 걸리지 않고 쿼리도 1개만 생기는 것을 볼 수 있다!!!

기존 7000ms 걸리던게 어노테이션 하나만으로 314ms로 줄었다.

도대체 @EntityGraph가 뭐길래 이렇게 되는걸까?

Fetch Join

@EntityGraph에 대해 알기 전에 우선 Fetch Join 부터 알아야 한다.

일반 Join 을 사용하면 쿼리 상에서는 Join이 일어나지만, JPA에서 연관된 엔티티를 별도로 다시 로딩할 수 있다. 이로 인해 나중에 사용될 때 추가 쿼리가 발생해 문제가 생길 수 있다.

이것 때문에 Plan을 조회할 때, 연관이 있는 다른 것들을 조회하기 위한 쿼리가 생기면서 문제가 생긴것 같다.

Fetch Join 은 SQL 조인 종류는 아니다!!
단지 JPQL에서 성능 최적화를 위해 제공하는 기능일 뿐이다.
연관된 엔티티나 컬렉션을 SQL 로 한번에 조회하는 기능으로 JOIN FETCH 라는 명령어를 사용한다.

일반 JoinFetch Join의 차이를 한 번 보자

일반 Join

먼저 일반 Join 이다

@Query("""
        SELECT p
        FROM Plan p
        JOIN p.project pr
        JOIN pr.team t
        WHERE p.endDate = :targetDate
        AND p.status IN :statuses
    """)
List<Plan> findPlansWithUsers(@Param("targetDate") LocalDate targetDate,
    @Param("statuses") List<Plan.Status> statuses);

위의 코드를 실행시켰고 결과는 아래 사진과 같다.

똑같이 9269개의 Plan을 조회하는데 10740개의 쿼리가 발생한 것을 볼 수 있다!

그럼 위의 코드에 Fetch 만 추가해보자

@Query("""
        SELECT p
        FROM Plan p
        JOIN FETCH p.project pr
        JOIN FETCH pr.team t
        WHERE p.endDate = :targetDate
        AND p.status IN :statuses
    """)
List<Plan> findPlansWithUsers(@Param("targetDate") LocalDate targetDate,
    @Param("statuses") List<Plan.Status> statuses);

변한건 JOIN 뒤에 붙은 FETCH 밖에 없다

와우..
바로 쿼리 하나로 해결이 된다!!
실행시간도 줄어들었다.
이게 Fetch Join 이다!

@EntityGraph

그럼 이제 진짜 @EntityGraph를 보자

@EntityGraphJPA가 제공하는 엔티티 그래프 기능을 편하게 사용하는걸 도와주는 어노테이션이다.

이 어노테이션 하나만으로 Fetch Join을 직접 작성하지 않고 해결할 수 있다. → Fetch Join의 간편 버전이라고 보면 될것 같다.

그런데 @EntityGraphLeft_Outer_Join 만을 지원해서 다른 방식이 필요하면 JPQL을 직접 작성해서 사용하면 된다고 한다.

후기

대용량 이메일 전송하는 기능 개발하면서 조회할 때 7초씩 걸리는게 거슬려서 알아봤더니 알고보니 그 유명한 N+1 문제였다
개선하고 바로 효과가 나타나서 좋다!
근데 아직 좀 더 깊게 들어갈 부분이 있는것 같아서 조만간 파봐야겠다.

profile
나는 허준기

0개의 댓글