본문 바로가기
JPA

[JPA] N+1 문제

by whereisco 2022. 12. 22.

null

개요

연관 관계가 설정된 엔티티를 사용하는 코드를 구현하다 보면, 마주하는 대표적인 문제는 N+1문제가 있을 것 입니다. 이번 포스팅에서는 N+1 문제가 무엇이고 어떠한 방법으로 해결할 수 있는지 알아보도록 하겠습니다.

N+1 문제

연관 관계에서 발생하는 이슈로, 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n)만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 문제 입니다. 말보단 코드로 이해하는 게 더 쉬울 것 같아, 코드를 통해 알아보도록 하겠습니다.

코드 예제

상황

Member와 Team 엔티티가 존재하고, 하나의 팀은 여러명의 멤버를 가지는 상황이라고 해봅시다.

Member Entity

@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id") 
    private Long id;

    private String username;

    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team")
    private Team team; 

    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        if (team!=null)
            changeTeam(team);
    }

    public Member(String username, int age) {
        this.username = username;
        this.age = age;
    }

    public void changeTeam(Team team){
        this.team = team;
        team.getMembers().add(this);
    }
}

Team Entity

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
@ToString(of = {"id", "name"})
@Entity
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public Team(String name) {
        this.name = name;
    }
}

위와 같이 엔티티 코드를 모두 설계해 보았습니다. 설계한 엔티티를 확인하시면, Member와 Team은 다대일 양방향 관계를, Coach와 Team은 다대일 단방향 관계를 맺고 있음을 알 수 있습니다.

이제 테스트 코드를 통해, N+1 문제가 발생하는 것을 직접 확인하고 해결해보도록 하겠습니다.

Test 코드

@Test
    public void findMemberLazy(){
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");

        teamRepository.save(teamA);
        teamRepository.save(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 10, teamB);

        memberRepository.save(member1);
        memberRepository.save(member2);

        em.flush();
        em.clear();

        //N+1 문제
        //쿼리를 한번 날렸는데, 결과 쿼리가 N개 나옴.(지연 로딩 설정 으로 인해, 연관된 객체를 이 때 조회하므로)
        List<Member> members = memberRepository.findAll(); //member만 긁어옴. team에는 값이 세팅안되있고, 프록시 객체값이 저장되어 있음(지연로딩이므로)

        for (Member member : members) {
            System.out.println("member = " + member.getUsername());
            System.out.println("member.team = " + member.getTeam().getName());
        }
    }

주석에서도 볼 수 있듯이, findAll() 메서드가 실행되었을 때, 멤버 엔티티만을 조회하는 쿼리가 나가지만, 멤버 엔티티와 연관된 엔티티를 사용하는 코드를 구현하면, 이와 관련된 쿼리문도 나가는 것을 확인할 수 있게 됩니다. 결과 쿼리를 통해 더 자세히 확인해보도록 하겠습니다.

Test 코드 결과 쿼리

insert into team (name, team_id) values ('teamA', 1);
insert into team (name, team_id) values ('teamB', 2);
insert into member (age, team, username, member_id) values (10, 1, 'member1', 3);
insert into member (age, team, username, member_id) values (10, 2, 'member2', 4);

Hibernate: 
    select
        member0_.member_id as member_i1_1_,
        member0_.age as age2_1_,
        member0_.team as team4_1_,
        member0_.username as username3_1_ 
    from
        member member0_

select member0_.member_id as member_i1_1_, member0_.age as age2_1_, member0_.team as team4_1_, member0_.username as username3_1_ from member member0_

select member0_.member_id as member_i1_1_, member0_.age as age2_1_, member0_.team as team4_1_, member0_.username as username3_1_ from member member0_;

member = member1
select team0_.team_id as team_id1_2_0_, team0_.name as name2_2_0_ from team team0_ where team0_.team_id=?
select team0_.team_id as team_id1_2_0_, team0_.name as name2_2_0_ from team team0_ where team0_.team_id=1;
member.team = teamA

member = member2
select team0_.team_id as team_id1_2_0_, team0_.name as name2_2_0_ from team team0_ where team0_.team_id=?
select team0_.team_id as team_id1_2_0_, team0_.name as name2_2_0_ from team team0_ where team0_.team_id=2;
member.team = teamB

위의 쿼리문은 결과 쿼리문 중 핵심 부분만 추출한 결과 쿼리 입니다.

먼저, insert문을 통해, 영속성 컨텍스트에 저장되어 있는 2개의 팀 객체와 멤버 객체를 flush 하는 시점에 디비에 반영해줍니다. 이 후, clear() 메서드를 통해 영속성 컨텍스트는 완전히 초기화 됩니다.

이 후, findAll() 메서드를 통해, 모든 멤버 객체를 들고 옵니다. 이 때, 연관 관계를 맺는 Team 엔티티와 관련된 객체는 지연 로딩으로 설정되어 있기 때문에, 실제 값을 사용하는 시점에 연관관계 쿼리가 나가게 됩니다.
따라서, 이 때는, 멤버를 조회하는 쿼리만 나가게 됩니다.

그러나, 쿼리 문단의 마지막 부분을 보면, member.getTeam().getName()을 통해, 멤버 테이블을 통해 연관된 엔티티인 팀 엔티티 객체를 실제로 사용하는 시점에 N+1 문제가 발생하는 것을 알 수 있습니다.

그렇다면, 연관 관계가 설정된 엔티티 조회 시, 조회된 데이터 갯수(N)만큼 연관관계 조회 쿼리가 추가로 발생하는 문제인 N+1 문제는 어떻게 해결할 수 있을까요?

해결 방안

1. Fetch Join

Fetch Join은 연관 관계가 설정된 엔티티 조회 시, 연관 관계를 맺은 엔티티 클래스와 Join 하여, 한번에 조회하는 쿼리문을 가능하게 만들어 주는데요.

위 예시에 적용해보면, 멤버 엔티티 조회 시, 멤버와 연관관계를 맺은 팀 엔티티를 조회하는 쿼리를 Fetch Join을 통해 표현하면 아래와 같이 표현해볼 수 있습니다.

@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();

Fetch Join - 테스트 코드 결과

select member0_.member_id as member_i1_1_0_, team1_.team_id as team_id1_2_1_, member0_.age as age2_1_0_, member0_.team as team4_1_0_, member0_.username as username3_1_0_, team1_.name as name2_2_1_ from member member0_ left outer join team team1_ on member0_.team=team1_.team_id
select member0_.member_id as member_i1_1_0_, team1_.team_id as team_id1_2_1_, member0_.age as age2_1_0_, member0_.team as team4_1_0_, member0_.username as username3_1_0_, team1_.name as name2_2_1_ from member member0_ left outer join team team1_ on member0_.team=team1_.team_id;
member = member1
member.team = teamA
member = member2
member.team = teamB

위의 결과 쿼리는, 팀 객체를 실제로 사용하는 시점에 나가는 쿼리문이다. 위와 같이, Member와 Team을 Join 하여, 쿼리문이 N번 발생하지 않고, 1번 발생하는 것을 알 수 있습니다.
그러나, Fetch Join을 사용하게 되면, 데이터 호출 시점에 모든 연관 관계의 데이터를 가져오기 때문에 FetchType을 Lazy로 해놓는 것이 무의미해집니다,
또한, 페이징 쿼리를 사용할 수 없게 되는데, 이는 하나의 쿼리문으로 가져오다 보니 페이징 단위로 데이터를 가져오는 것이 불가능하기 때문입니다.

2. EntityGraph

EntityGraph는 Fetch Join의 간편 버전으로, attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 되는데요.
Fetch Join과 동일하게 JPQL을 사용하여 Query문을 작성하고 필요한 연관관계를 EntityGraph 어노테이션에 설정하면 됩니다.
Fetch Join과 다른 점은, left outer join을 사용합니다.

@EntityGraph(attributePaths = {"team"})
    @Query("select m from Member m")
    List<Member> findMemberByFetchJoin();

Fetch Join - 테스트 코드 결과

select member0_.member_id as member_i1_1_0_, team1_.team_id as team_id1_2_1_, member0_.age as age2_1_0_, member0_.team as team4_1_0_, member0_.username as username3_1_0_, team1_.name as name2_2_1_ from member member0_ left outer join team team1_ on member0_.team=team1_.team_id
select member0_.member_id as member_i1_1_0_, team1_.team_id as team_id1_2_1_, member0_.age as age2_1_0_, member0_.team as team4_1_0_, member0_.username as username3_1_0_, team1_.name as name2_2_1_ from member member0_ left outer join team team1_ on member0_.team=team1_.team_id;
member = member1
member.team = teamA
member = member2
member.team = teamB

EntityGraph도 마찬가지로, Member 엔티티를 조회 하는 경우, 연관 관계를 가진 Team 엔티티와 조인하여 연관관계를 가진 엔티티의 값을 한번에 가져오는 것을 알 수 있습니다.

결론

N+1 문제는 JPA를 사용하면서 연관관계를 맺는 엔티티를 사용한다면 한번 쯤은 부딪힐 수 있는 문제입니다.
쿼리문이 너무 복잡하다면, JPQL을 통해 쿼리문을 작성하고 fetch를 직접 작성해주고, 비교적 간단한 쿼리문이라면, @EntityGraph를 활용해주는 것이 권장된다고 합니다.
Fetch Join이나 Entity Graph를 사용한다면 Join문을 이용하여 하나의 쿼리로 해결할 수 있지만, 중복 데이터 관리가 필요하고 Fetch Type을 어떻게 사용할지에 따라 달라질 수 있습니다.
N+1 문제를 해결하는 방법으로 SUBSELECT나 BatchSize 조절이 있다고도 합니다.

'JPA' 카테고리의 다른 글

[JPA] @SQLDelete와 @Where를 통한 SOFT DELETE 처리  (0) 2023.01.05