JPA : N+1 문제 및 해결방법 정리

    N+1문제


    N+1은 1번의 쿼리만 의도를 했었는데, 실제 쿼리가 실행되는 시점에서는 쿼리가 N개가 더 나가는 문제다. 이 문제가 일어나게 되면 당연한 이야기지만, 의도하지 않은 쿼리가 나가게 되면서 급격히 성능이 안 좋아지는 것을 느낄 수 있다고 한다. 공부하는 입장에서는 와닿지 않지만, DBA분에게 바로 이상하다고 연락이 온다고 한다.

     

    N+1 문제 발생 상황


    N+1 문제는 주로 다대일 연관관계의 엔티티를 여러 개를 불러왔을 때 생기는 것 같다. 이를테면 위와 같은 상태에서 자주 발생하는 것으로 보인다. 코드로 하나하나 살펴보려고 한다. 먼저 셋팅 코드를 공유한다.

    Team teamA = new Team();
    teamA.setName("teamA");
    em.persist(teamA);
    
    Team teamB = new Team();
    teamB.setName("teamB");
    em.persist(teamB);
    
    Member member1 = new Member();
    member1.setName("member1");
    member1.setTeam(teamA);
    em.persist(member1);
    
    Member member2 = new Member();
    member2.setName("member2");
    member2.setTeam(teamA);
    em.persist(member2);
    
    
    Member member3 = new Member();
    member3.setName("member3");
    member3.setTeam(teamB);
    em.persist(member3);

    위 코드를 보면 TEAM은 A,B가 있으며, MEMBER는 1,2,3이 있다. 여기서 MEMBER1,2는 A 팀에 속해있고, MEMBER3은 B팀에 속해있다. 이제 아래에 테스트 코드를 하나하나보면서 정확히 알아봤다. 

    Member member = em.find(Member.class, member1.getId());

    위 코드처럼 Member 엔티티 하나만을 가져오는 경우를 실행해보자. 

    실행 결과는 Member table을 한번 Select 하는데, 이 때 같은 TEAM_ID를 가지는 녀석과 JOIN되어 그 결과값을 전달해주었다. 영속성 컨텍스트의 1차 캐쉬에는 Member1이 불러지면서, Member1이 속한 Team이었던 엔티티도 함께 불러와 영속화된다. 

    List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();

    두번째 코드는 DB에 저장된 모든 Memeber를 찾아와 Colletion에 저장하는 코드를 작성했다. 

    위 코드를 실행하면 총 쿼리가 세 번 나간다. /* select m from Member m*/에 대한 쿼리에서는 Member Table에 대해서 싹다 뒤지는 것을 볼 수 있다. 그 이후, Team에 대한 select 쿼리가 2번 나간 것을 볼 수 있다. 

    좌 : SELECT M 실행 시점 / 우 : SELECT TEAM 실행 시점

    처음 MEMBER에 대한 SELECT 쿼리가 나가게 되면, 왼쪽과 같은 상태일 것이다. MEMBER에 대해서 다 영속화를 하고 나서 봤더니, TEAM에 대한 정보가 전혀 없었던 것이다. 그래서 각 MEMBER가 가지고 있는 TEAM의 FK값을 확인해서 SELECT 쿼리를 보냈고, TEAM이 2개가 있으니 2번의 SELECT 쿼리가 추가적으로 나간 것이다. 이 2번의 쿼리가 완료되면 오른쪽 그림처럼 TEAMA, TEAMB도 영속화가 완료된다.

    이런 문제를 N+1 문제라고 한다. 우리는 MEMBER를 전체를 한번에 가져오는 SELECT 쿼리를 보냈다. 즉, 1번의 SELECT 쿼리만 실행되기를 원했다. 그러나 실행 시점에는 각 TEAM 엔티티를 가져오는 쿼리가 2번 더 나가게 되면서 총 3번의 쿼리가 나가게 되었다. 이런 원치않게 여러 번 쿼리가 나가는 경우를 N+1 문제라고 한다.

    N+1이 주로 발생하는 상황은 이처럼 연관관계가 있는 엔티티를 Collection 형태로 불러오게 되면서 주로 발생하는 것 같다. 

     

    STEP1 : 지연 로딩으로 N+1 문제 해결하기


    N+1 문제를 해결하기 위해 가장 먼저 생각해볼 수 있는 것은 지연로딩이다. 기본적으로 ManyToOne 어노테이션의 Fetch 전략은 즉시 로딩으로 되어있다. 즉시로딩은 엔티티를 찾을 때, 연관된 엔티티면 한꺼번에 불러오는 전략이다.

    위의 경우를 한번 되돌려보면, Member 엔티티들을 집합시켰을 때 그에 대응되는 Team 엔티티가 없는 것을 인지하고 JPA가 '즉시 로딩' 전략을 구현해서 바로 모든 Team을 한번에 집합시켰다. 즉. 즉시 로딩 전략을 사용하게 되면서 원치않는 쿼리가 나가 성능 저하가 발생하게 된 것이다. 그렇다면 지연 로딩으로 이 문제를 해결해봄직하다.

    List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();

    지연 로딩을 설정한 후 위의 쿼리를 다시 한번 실행해본다.

    실행 결과는 쿼리가 1회만 나간 것을 알 수 있다. 지연 로딩은 불러와야 할 객체가 실제로 호출되는 시점에 불러와준다. 호출되는 시점까지는 실제 엔티티의 참조값을 가지며 엔티티를 상속한 프록시가 객체에 저장되기 때문에 오른쪽 그림처럼 되어있을 것이다. 따라서, Member에 대한 단 한번의 Select 쿼리가 나가는 것이 맞다.

    System.out.println("====================");
    Team findTeam1 = em.find(Team.class, teamA.getId());
    Team findteam2 = em.find(Team.class, teamB.getId());
    System.out.println("====================");

    그렇다면 이걸로 문제가 해결될까? 아니다. 이유는 언젠가는 저 놈들을 하나하나 다 불러와야 한다. 위 코드를 쿼리 코드 아래에 작성해서 실행해본다.

    TEAMA와 TEAMB를 SELECT를 한번씩 하게 된다. 왜냐하면 영속성 컨텍스트의 1차 캐쉬에 없기 때문이다. JPA는 없는 것을 확인했기 때문에 TeamA, TeamB 데이터를 불러와 영속화 해주고 참조를 이어주게 된다. 따라서 오른쪽 그림과 같이 되는데, 결국은 N+1 문제를 풀고자 했으나 언젠가는 하나하나 조회를 다 해주어야 한다는 것을 알 수 있다. 

    그럼에도 불구하고 지연 로딩은 기본적으로 Default를 깔고 가는게 좋다고 한다. 위의 것도 최선의 방법은 아닐 수 있으나, 쿼리 튜닝의 효율을 올리는 좋은 방법이 될 수 있기 때문이다.

     

    STEP2 : Fetch Join으로 해결하기


    Fetch Join은 JPQL에서 지원하는 문법이다. 위에서 언급한 즉시로딩과는 다르게 Fetch Join은 내가 필요할 때, 동적으로 가져오는 기능을 지원한다. 가져올 때는 조건을 만족하는 테이블들을 Join해서 가져오는 것으로 이해할 수 있다. 먼저 코드부터 실행해보겠다

    .

    List<Member> members = em.createQuery("select m from Member m join fetch m.team", Member.class).getResultList();

    Fetch Join은 'SELECT m FROM MEMBER m [LEFT] [INNER/ OUTER] JOIN FETCH m.team' 형태로 사용한다. 위의 코드를 실행한 결과를 살펴보자.

    좌 : FETCH JOIN / 우 : NO FETCH JOIN

    오른쪽 이미지가 FETCH JOIN 결과이다. MEMBER에 대한 SELECT 쿼리는 동일하게 나가는 것을 볼 수 있다. 그런데 단서가 붙는데, '찾아온 MEMBER 엔티티의 TEAM_ID를 가지는 TEAM TABLE'을 JOIN한다고 한다. 실제로 한번에 JOIN이 되어 가져오는지를 쿼리로 확인해봤다. 

    System.out.println("====================");
    Team findTeam1 = em.find(Team.class, teamA.getId());
    System.out.println("findTeam1 = " + findTeam1.getName());
    Team findteam2 = em.find(Team.class, teamB.getId());
    System.out.println("findteam2 = " + findteam2.getName());
    System.out.println("====================");

    Fetch Join으로 MEMBER 엔티티들을 영속화 한 후에 위 코드를 실행했다. MEMBER들이 속한 TEAM은 함께 다 가져왔기 때문에 이들을 출력할 때, SELECT 쿼리가 추가로 나가서는 안된다. 

    실제로 TEAM을 출력했을 때, 또 다른 SELECT 쿼리 없이 TEAM이 바로 찾아져서 출력되는 것을 볼 수 있다. 즉, MEMBER들을 전부 불러올 때 그에 대응되는 TEAM을 모두 JOIN해서 함께 가져온 것이다. 어떤 방식으로 이렇게 동작한 것일까? 

    "select m from Member m join fetch m.team"
    
    SELECT
    m*,t*
    FROM MEMBER m
    INNER JOIN TEAM T ON M.TEAM_ID = T.ID

    위의 FETCH JOIN 쿼리는 SQL에서 위 쿼리가 나간 것과 동일하다. MEMBER와 TEAM에서 같은 값을 가지는 녀석들을 INNER JOIN해서 가지고 온다. 이것들을 테이블로 살펴보면 다음과 같다.

    MEMBER와 TEAM을 JOIN하는데, 이 때 INNER JOIN이기 때문에 같은 FK값을 가지지 않는 회원은 없어진다. 따라서 회원4번이 없어진 INNER JOIN TABLE이 만들어지는데, DB에서는 이 INNER JOIN TABLE을 RETURN한다. 그렇다면 엔티티 관점에서는 어떨까? 

    엔티티 관점에서는 위 그림처럼 이해할 수 있다. Collection에는 회원들이 들어있고, 각 회원들은 함께 불러와진 TeamA와 TeamB의 객체의 참조를 가지고 있는 것으로 이해할 수 있다. 

    대부분의 N+1 문제는 Fetch Join으로 해결된다고 한다. 그렇지만 만능같아 보이는 Fetch Join해도 한계점은 있다. 예를 들어 컬렉션을 Fetch Join할 경우, 그것들에 대해서 페이징을 할 수 없다고 한다. 페이징 자체는 가능하지만 정확도가 의심되기 때문에 사실상 불가능하다고 한다. 그렇다면 이런 경우에는 어떻게 해결을 해야할까? (https://ojt90902.tistory.com/745)

     

    STEP3. BATCH SIZE 조절로 해결하기


    가장 좋은 방법은 Lazy Loading + Batch Size 조절로 해결하는 방법이다. JPA에는 Batch Size라는 기능이 있다. Batch Size를 설정해두면 JPA에서 지연로딩을 할 때, 한번에 최대 Batch Size만큼의 엔티티를 where절에 "in"으로 가져온다. 따라서 N+1 문제가 완벽히 해결되는 것은 아니지만, 쿼리가 아주 많이 줄어들기 때문에 가장 실용적인 방법이다. 

     

    먼저 Lazy Loading으로 엔티티를 가져오는 것을 고려해보자. 기존에는 반드시 Join을 했어야 했으나, 이제는 지연로딩이 목적이기 때문에 Join을 하지 않아도 된다. 따라서 Member만 가져온다. 

    Member 엔티티만 가져오기(Join을 하지 않기) 때문에 Member 테이블의 Row 뻥튀기는 발생하지 않는다. 따라서 Member 테이블에 대한 페이징 쿼리는 Member를 조회하는 시점에 정상적으로 나간다. 이 말은 DB로 나가는 쿼리에서 offset, limit가 적혀있는 것을 볼 수 있다는 이야기다. 

     

    이후 실제 연관관계의 엔티티가 필요한 시점에 BatchSize를 통해 최소한의 쿼리를 보내 효율적으로 지연로딩을 할 수 있다. 

     

    Batch Size 설정하는 방법

    jpa.properties.hibernate.default_batch_fetch_size=100

    application.properties에서 다음 문구를 튜닝해서 한번에 불러올 사이즈를 설정할 수 있다. 100으로 설정하면, 한번 지연 로딩에서 가져올 엔티티의 숫자는 100개가 된다. 

     

    Batch Size는 얼마를 설정하는 것이 적절한가?

    Batch Size가 너무 과도하면 한번에 너무 많은 엔티티가 메모리에 로딩된다. 즉, 프로그램에 오류가 발생할 수 있다. 반면 Batch Size가 너무 작으면 더 많이 쿼리가 나가야한다. 따라서 적절한 값을 설정할 필요가 있다. 100 ~ 1000개 수준이 적당하다고 한다. 

     

    코드로 확인

    테이블 맵핑 확인

     

    테스트 코드 - 1 

    @Test
    @DisplayName("N+1 문제 해결 -> Batch Size")
    void test3() {
    
        List<Member> members = queryFactory.selectFrom(member)
                .fetch();
        int cnt = 0;
        for (Member member : members) {
            log.info("cnt = {} size = {}", cnt, member.getOrderList().size());
            cnt++;
        }
    
    }

    위 테스트 코드에서 Batch Size를 이용한 Bulk 지연로딩으로 N+1 문제를 해결하는 것을 볼 수 있다. 위 쿼리를 실행하면 Members에는 총 3명의 Member가 저장되어있다. Member를 한번 불러오고 orderList의 크기를 확인하는 쿼리에서 총 3번의 쿼리가 나가야한다. 그렇지만 Batch Size가 100으로 설정되어있기 때문에 한번 쿼리가 더 나가고 문제 없이 해결된다.

    앞서 말했던 것처럼 Batch Size가 설정되면, JPA는 지연로딩 시점에 필요한 지연로딩 엔티티의 PK값을 알아와서 where절에 in 검색조건을 넣어서 가져온다. 

     

     

    테스트 코드 - 2 (페이징 기능도 가능)

    @Test
    @DisplayName("N+1 문제 해결 -> Batch Size + 페이징 쿼리 가능")
    void test4() {
    
        List<Member> members = queryFactory.selectFrom(member)
                .offset(0)
                .limit(2)
                .fetch();
        int cnt = 0;
        for (Member member : members) {
            log.info("cnt = {} size = {}", cnt, member.getOrderList().size());
            cnt++;
        }
    
    }

    Join 없이 Member만 불러오기 때문에 Member의 Row는 뻥튀기 되지 않고 불러와진다. 따라서 페이징 쿼리가 가능해진다. Members에는 3명의 Member가 불러와지는데, Member와 연관된 OrderList가 필요한 시점에 Batch Size를 Batch 지연로딩이 가능해진다. 결국 지연로딩을 할 때의 쿼리 수도 최소화할 수 있으며, 페이징 기능까지 사용할 수 있게 된다.

    페이징 쿼리가 나감

    먼저 Member 엔티티를 불러올 때, Limit 절이 나가는 것을 볼 수 있다. Collection Join에서는 이런 페이징 쿼리가 나가지 않았는데, 사실상 Collection을 불러오는 상황인데도 페이징 쿼리가 정상적으로 먹히는 것을 확인할 수 있다.

    이후 Batch Size를 통해 where + in절로 DB 쿼리를 최소화해서 가져오는 것을 볼 수 있다.

     


    정리

    em.find()를 통해 즉시로딩을 할 경우, 한방 쿼리로 값을 가져올 수 있다. 그렇지만 JPQL을 통한 다중 조회 시, 즉시로딩을 설정하면 N+1 문제가 발생한다. JPQL은 SELECT 절에 있는 엔티티만 가져오기 때문이다. SELECT 절에 있는 엔티티를 가져오고 봤더니, 연관관계 엔티티가 모두 프록시 객체였던 것이다. 그래서 나머지 프록시 객체를 한땀 한땀 가져오기 때문에 N+1 문제가 발생한다. 

    이 문제를 해결하기 위해 지연로딩이 추가된다. 지연로딩을 추가하면, JPQL을 통해 다중 조회를 하더라도 연관관계 엔티티는 프록시 상태로 존재한다. 그렇지만 필요한 시점에서 프록시 객체를 불러와야하기 때문에 N+1 문제 자체를 피할 순 없다. 오는 시점이 늦춰진다.

    N+1 문제 자체를 해결하기 위한 한 방편은 Fetch Join이 있다. Fetch Join은 연관된 엔티티를 모두 Join해서 한방에 Select절로 다 불러오는 것이다. 실제로는 Select 절에 있는 Member만 반환되지만, 한방 쿼리를 통해 가져온 연관관계 엔티티들은 Member 내부에 프록시 객체가 아닌 실제 객체로 갈무리 되어있다. 그렇지만 Fetch Join 시 One To Many의 Collection Join의 경우 페이징 기능이 되지 않는 단점이 생긴다. 또한, 한방에 불필요한 필드도 다 가져오기 때문에 메모리 누수 문제가 발생할 수 있다.

    가장 권장되는 방법으로는 필요 엔티티만 불러온 후, 지연로딩 + Batch Size를 설정해서 필요할 때 최소한의 쿼리로 불러오는 방법이다. Batch Size를 설정하면 지연로딩을 할 때, 지연로딩과 관련된 엔티티를 Batch Size만큼 나누어 Where 절에 in 조건으로 불러온다. 이 때, 필요 엔티티만 불러오기 때문에 Join을 통한 Row 뻥튀기가 없어 페이징 기능도 정상적으로 가능하고, In 절로 필요한만큼 한번에 불러오기 때문에 Select 쿼리 자체도 줄어드는 효과가 있다.

     

    결론은 지연로딩 전략 + Batch Size를 이용해 N+1 문제 최소화 + 페이징 기능까지 잡을 수 있다.

     


    코드 

    https://github.com/chickenchickenlove/JpaSelfStudy/blob/1f0730d436ed283208a2936f1be4d2506adba44f/studyJpa/test/java/selfjpa/studyjpa/repository/NPlusOneTest.java

     

     

     

    댓글

    Designed by JB FACTORY