본문 바로가기
  • 오늘도 한걸음. 수고많았어요.^^
  • 조금씩 꾸준히 오래 가자.ㅎ
IT기술/spring

[JPA] 1+N 문제, 현상 정리

by 미노드 2024. 4. 1.

JPA N+1

(1번 조회해야할 것을 N개 종류의 데이터 각각을 추가로 조회하게 되서 총 N+1번 DB조회를 하게 되는문제이다.)

JPA의 Entity 조회시 Query 한번 내부에 존재하는 다른 연관관계에 접근할 때 또 다시 한번 쿼리가 발생하는 비효율적인 상황을 말한다.

즉시 로딩으로 데이터를 가져오는 경우 ( N+1 문제가 바로 발생 )
지연 로딩으로 데이터를 가져온 이후에 가져온 데이터에서 하위 엔티티를 다시 조회하는 경우 ( 하위 엔티티를 조회하는 시점에 발생 )

0. 내가 정리하는 N+1 문제

@Entity
public class MemberJpql extends BaseEmbeded {
    @Id   @GeneratedValue
    private Long id;
    private String username;
    private Integer age;

//    @ManyToOne(fetch = FetchType.LAZY) // 다대일 관계일 경우 항상 지연로딩되도록
    @ManyToOne // 즉시 로딩 일 경우
    @JoinColumn(name="TEAM_ID") // 이게 외래키로 있어서 설정해줌.
    private TeamJpql teamJpql;
}

@Entity
public class TeamJpql {
    @Id    @GeneratedValue
    @Column(name="TEAM_ID")
    private Long id;
    private String name;

    // 양방향 만들기위해 추가
    @OneToMany(mappedBy = "teamJpql")
    private List<MemberJpql> memberJpqlList = new ArrayList<>(); // 메모리가 사용되더라도 이렇게 new ArrayList<>(); 하는걸 권장한다고 함.
}

다음의 두 엔티티 MemberJpql 과 TeamJpql 가 있다.

이 둘은 양방향 연관관계를 가지고 있다.

db에 데이터는 다음처럼 등록되어 있다.

 

1. 즉시 로딩, join 결과

String jpql = "select m from MemberJpql m join m.teamJpql t "; // 쿼리에 on 없으면, 자동으로 넣어준다. 연관관계 외래키 기준으로
List<MemberJpql> resultList = em.createQuery(jpql, MemberJpql.class)
        .getResultList();
for (MemberJpql member : resultList) {
    System.out.println(member + ", TeamName:"+member.getTeamJpql().getName()) ;
}

일반적으로 join 쿼리를 명시해줬다. 명시한데로 join 쿼리가 날아간다. 그런데 뒤에 team_jpql 테이블에 team_id 개수만큼 추가로 select가 발생한다.

이게 1+N 현상, 이고 1+N 문제다.

join해서 값을 조회하고, 추가로 필요한 데이터를 select로 N번날려 조회하는 것이다.

이미 join을해서 결과를 얻어왔을 텐데, join을 하면서 team_jpql 테이블에 name값을 select로 내놓지 못했다. 때문에 1+N문제가 발생하는 것이다.

19:41:03.284 [main] DEBUG org.hibernate.SQL --
     select
            mj1_0.id,
            mj1_0.age,
            mj1_0.create_member,
            mj1_0.created_date,
            mj1_0.update_member,
            mj1_0.last_modified_date,
            mj1_0.team_id,
            mj1_0.username 
        from
            public.member_jpql mj1_0 
        join
            public.team_jpql tj1_0 
                on tj1_0.team_id=mj1_0.team_id
19:41:03.285 [main] DEBUG org.hibernate.SQL --
    select
        tj1_0.team_id,
        tj1_0.name 
    from
        public.team_jpql tj1_0 
    where
        tj1_0.team_id=?
19:41:03.286 [main] DEBUG org.hibernate.SQL --
    select
        tj1_0.team_id,
        tj1_0.name 
    from
        public.team_jpql tj1_0 
    where
        tj1_0.team_id=?
19:41:03.287 [main] DEBUG org.hibernate.SQL --
    select
        tj1_0.team_id,
        tj1_0.name 
    from
        public.team_jpql tj1_0 
    where
        tj1_0.team_id=?
MemberJpql{id=16, username='user04', age=23, teamid=1}, TeamName:팀1
MemberJpql{id=19, username='user02', age=25, teamid=1}, TeamName:팀1
MemberJpql{id=18, username='user01', age=33, teamid=1}, TeamName:팀1
MemberJpql{id=3, username='user05', age=32, teamid=2}, TeamName:팀2
MemberJpql{id=20, username='user03', age=37, teamid=2}, TeamName:팀2
MemberJpql{id=7, username='user06', age=40, teamid=3}, TeamName:팀3

JPQL문을 보면 String jpql = "select m from MemberJpql m join m.teamJpql t "

이처럼 m으로 select해오는데, 외래키가 아닌 MemberJpql.teamJpql 객체로 참조될 것으로 보여 MemberJpql.teamJpql.name값이 들어가져야 정상인데, 생성된 쿼리엔 MemberJpql.teamJpql.name 이 빠져있다.

그래서 뒤에 team_id마다 select teamJpql 이 N번 더 수행되는 것이다.

왜라고 물으면, 연관관계로 참조되어 있다고 해도, Member테이블 기준으로 출력하는데 있어 team테이블의 정보가 필요하다는 보장이 없기 때문이다.
때문에, 적힌 데로 Member 값을 우선적으로 보여주도록 JPA가 설정한 것이고, teamJpql 엔티티의 다른 값이 필요할 경우 별도 select 쿼리를 날려서 알아서 매핑 해주면 되다보니, 결론적으로 값은 정상적으로 출력이 가능한 것이다.
JPA 프록시를 통해서 teamJpql이 잡하는 부분을 말로 풀어보면 이렇다.

다만 Select가 저렇게 예측 불가할 정도로 많이 날아가는 건 DB에 부담이 될 수 있다보니 2차적인 문제가 생길 수 있다.

2. 즉시 로딩, join fetch 결과

그러니 join을 한다고 해도 select 처음 할 때 teamJpql의 name같은 teamJpql테이블의 모든정보를 다 불러오도록 해야하는데 JPQL에서 join에 fetch를 붙이면 이게 지원이 된다.

String jpql = "select m from MemberJpql m join fetch m.teamJpql t "; // 쿼리에 on 없으면, 자동으로 넣어준다. 연관관계 외래키 기준으로
List<MemberJpql> resultList = em.createQuery(jpql, MemberJpql.class)
        .getResultList();
for (MemberJpql member : resultList) {
    System.out.println(member + ", TeamName:"+member.getTeamJpql().getName()) ;
}

결과를 보면 위와는 다르게 추가적인 select 가 발생하지 않는다. select 보면, member.team.name 도 조회에 포함된게 확인된다. (fetch)를 붙이면 이게 가능해진다.

 select
            mj1_0.id,
            mj1_0.age,
            mj1_0.create_member,
            mj1_0.created_date,
            mj1_0.update_member,
            mj1_0.last_modified_date,
            tj1_0.team_id,
            tj1_0.name,
            mj1_0.username 
        from
            public.member_jpql mj1_0 
        join
            public.team_jpql tj1_0 
                on tj1_0.team_id=mj1_0.team_id
MemberJpql{id=16, username='user04', age=23, teamid=1}, TeamName:팀1
MemberJpql{id=19, username='user02', age=25, teamid=1}, TeamName:팀1
MemberJpql{id=18, username='user01', age=33, teamid=1}, TeamName:팀1
MemberJpql{id=3, username='user05', age=32, teamid=2}, TeamName:팀2
MemberJpql{id=20, username='user03', age=37, teamid=2}, TeamName:팀2
MemberJpql{id=7, username='user06', age=40, teamid=3}, TeamName:팀3

3. 지연 로딩, join 결과

자 그럼 즉시로딩이 아닌 지연로딩으로 연관관계를 설정해둔 상태일 때 1+N문제가 일어날 까 안일어날 까 테스트 해보겠다.

@Entity
public class MemberJpql extends BaseEmbeded {
    @Id   @GeneratedValue
    private Long id;
    private String username;
    private Integer age;

    @ManyToOne(fetch = FetchType.LAZY) // 다대일 관계일 경우 항상 지연로딩되도록
    @JoinColumn(name="TEAM_ID") // 이게 외래키로 있어서 설정해줌.
    private TeamJpql teamJpql;
}

아래 소스로 테스트 해보면 결과가 다음처럼 나오게 된다.

String jpql = "select m from MemberJpql m join m.teamJpql t "; // 쿼리에 on 없으면, 자동으로 넣어준다. 연관관계 외래키 기준으로
List<MemberJpql> resultList = em.createQuery(jpql, MemberJpql.class)
        .getResultList();
for (MemberJpql member : resultList) {
    System.out.println(member + ", TeamName:"+member.getTeamJpql().getName()) ;
}
19:59:06.369 [main] DEBUG org.hibernate.SQL --
    /* select
        m 
    from
        MemberJpql m 
    join
        m.teamJpql t  */ select
            mj1_0.id,
            mj1_0.age,
            mj1_0.create_member,
            mj1_0.created_date,
            mj1_0.update_member,
            mj1_0.last_modified_date,
            mj1_0.team_id,
            mj1_0.username 
        from
            public.member_jpql mj1_0 
        join
            public.team_jpql tj1_0 
                on tj1_0.team_id=mj1_0.team_id
19:59:06.371 [main] DEBUG org.hibernate.SQL --
    select
        tj1_0.team_id,
        tj1_0.name 
    from
        public.team_jpql tj1_0 
    where
        tj1_0.team_id=?
MemberJpql{id=16, username='user04', age=23, teamid=1}, TeamName:팀1
MemberJpql{id=19, username='user02', age=25, teamid=1}, TeamName:팀1
MemberJpql{id=18, username='user01', age=33, teamid=1}, TeamName:팀1
19:59:06.371 [main] DEBUG org.hibernate.SQL --
    select
        tj1_0.team_id,
        tj1_0.name 
    from
        public.team_jpql tj1_0 
    where
        tj1_0.team_id=?
MemberJpql{id=3, username='user05', age=32, teamid=2}, TeamName:팀2
MemberJpql{id=20, username='user03', age=37, teamid=2}, TeamName:팀2
19:59:06.372 [main] DEBUG org.hibernate.SQL --
    select
        tj1_0.team_id,
        tj1_0.name 
    from
        public.team_jpql tj1_0 
    where
        tj1_0.team_id=?
MemberJpql{id=7, username='user06', age=40, teamid=3}, TeamName:팀3

join을 명시적으로 해줬지만, memberJpql 기준으로만 정보가 나오며, memberJpql.teamJpql.name은 select 처음에 포함되지 않았다.

지연로딩 이다보니, 실제로 team.getName()이 호출되었을 경우 추가적으로 select가 일어난다.

(즉시 로딩 땐, 처음 로딩시 select가 한번에 다 일어났지만 지금은 그렇진 않다.)

(즉 시작부터 1+N은 안일어나게 된것이라, 지연로딩 만으로도 어느정도 해결은 가능하다. 다만 완전 해결은 아닐 것이다.)

4. 지연 로딩, join fetch 결과

아예 1+N이 일어나지 않고 처음부터 한번에 값을 읽어들이고 싶다면 fetch를 써야 한다.

애초에 join을 쓰는 이유가 이건데, 위에 fetch를 안 붙이니 제대로 join이 일어나지 않은 것으로 보일 수도 있을 것이다. MemberJpqlEntity 를 기준으로 필요한 데이터만 보여주기 위해 jpa가 그렇게 쿼리를 만든 것이니 어쩔 수 없는 현상이다.

그러므로 한번에 모든 값을 읽어들이는 join을 쓰려면 fetch를 붙여야 한다. 그래야 1+N문제도 안생긴다.

String jpql = "select m from MemberJpql m join fetch m.teamJpql t "; // 쿼리에 on 없으면, 자동으로 넣어준다. 연관관계 외래키 기준으로
List<MemberJpql> resultList = em.createQuery(jpql, MemberJpql.class)
        .getResultList();
for (MemberJpql member : resultList) {
    System.out.println(member + ", TeamName:"+member.getTeamJpql().getName()) ;
}

결과를 보면 select 한번만 발생하며, 두 테이블의 값이 조회가 되는 것을 알 수 있다.

19:59:06.293 [main] DEBUG org.hibernate.SQL --
    /* select
        m 
    from
        MemberJpql m 
    join
        
    fetch
        m.teamJpql t  */ select
            mj1_0.id,
            mj1_0.age,
            mj1_0.create_member,
            mj1_0.created_date,
            mj1_0.update_member,
            mj1_0.last_modified_date,
            tj1_0.team_id,
            tj1_0.name,
            mj1_0.username 
        from
            public.member_jpql mj1_0 
        join
            public.team_jpql tj1_0 
                on tj1_0.team_id=mj1_0.team_id
MemberJpql{id=16, username='user04', age=23, teamid=1}, TeamName:팀1
MemberJpql{id=19, username='user02', age=25, teamid=1}, TeamName:팀1
MemberJpql{id=18, username='user01', age=33, teamid=1}, TeamName:팀1
MemberJpql{id=3, username='user05', age=32, teamid=2}, TeamName:팀2
MemberJpql{id=20, username='user03', age=37, teamid=2}, TeamName:팀2
MemberJpql{id=7, username='user06', age=40, teamid=3}, TeamName:팀3