[스프링 JPA] - N+1 문제

sonnng·2023년 9월 24일
0

Spring

목록 보기
12/41

JPA N+1 문제 관련

JPA를 사용함에 따라 여러 번의 SELECT문이 여러 개가 나갈 수 있다는 문제점

이러한 현상을 N+1 문제라고 한다.

왜 이러한 문제가 발생하는지
실무에서 어떤 식으로 해결을 하는지

N+1 문제란

연관관계인 엔티티 조회를 할 경우 조회된 데이터 갯수(N)만큼 조회쿼리가 추가로 발생해 데이터를 읽어오는 현상이다. 주로 @ManyToOne 연관관계를 가진 엔티티에서 주로 발생한다.(1:N 이나 N:1에서 주로 발생)

ex. 여러명의 MEMBER는 하나의 TEAM에 속할 수 있고 TEAM에는 여러 명의 MEMBER가 올 수 있다.

Fetch.EAGER 즉시로딩의 경우
@Entity
public class Member{
    @Id
    @GeneratedValue
    private Long id;
    private String firstName;
    private String lastName;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
@Entity
public class Team{
    @Id
    @GeneratedValue
    private Long id;
    private String teamName;

    @OneToMany(fetch = FetchType.EAGER)(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

teamRepository.findAll() 을 호출하면 N+1 문제가 발생한다. 즉, 소속된 member의 갯수만큼 SELECT 쿼리문이 발생하여 쿼리가 찍히는 것을 확인할 수 있다.

문제 발생 원인 순서

SELECT team FROM TEAM 이라는 JPQL구문이 생성되면 -> SELECT * FROM TEAM SQL 쿼리가 생성 및 실행
DB 결과에 따른 TEAM 엔티티 인스턴스를 생성, 연관된 Member도 로딩
영속성 컨텍스트와 연관된 Member가 있는지 확인하고 없다면 TEAM 갯수에 따라 N+1 개의 sql 구문실행

Fetch.LAZY 지연로딩의 경우

똑같은 teamRepository.findAll() 을 호출하면 SELECT 쿼리문은 하나만 발생하며 문제점이 발생하지 않는다. 하지만 team에 속한 각 Member에 접근을 시도하려 할 때 N+1 문제가 똑같이 발생한다.

Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
List<Team> teams = teamRepository.findAll();
teams.forEach(team -> {
    team.getUsers().size();
});
Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?

문제 발생 원인 순서

SELECT team FROM TEAM 이라는 JPQL구문이 생성되면 -> SELECT FROM TEAM SQL 쿼리가 생성 및 실행
DB 결과에 따른 TEAM 엔티티 인스턴스를 생성
TEAM의 Member 객체를 사용하려는 시점에서 영속성 컨텍스트에서 연관관계에 있는 Member가 있는지 확인
없다면 TEAM 갯수에 따라 N+1 개의 SELECT
FROM MEMBER WHERE TEAM_ID = ? sql 구문실행

해결방법

1) Fetch Join

1..

DB에서 데이터를 가져올 때 연관 엔티티, 컬랙션을 한꺼번에 조회하는 방법
TEAM 엔티티 조회시 MEMBER 엔티티도 같이 조회하여 n번 MEMBER을 조회하는 쿼리를 나가지 않도록 방지
@Query 어노테이션으로 join fetch 엔티티.연관관계엔티티 구문 사용
@Query("SELECT DISTINCT team FROM TEAM team JOIN FETCH team.MEMBER")
List findAll();
2..

조회 주체가 되는 엔티티와 fetch join이 걸린 엔티티도 함께 영속성 컨텍스트에서 관리
3..

inner join 발생
fetch join의 장점
-> 한번의 쿼리만 발생하도록 설계 가능
-> 특정 엔티티의 하위 엔티티(연관관계에 있는 엔티티)도 한번에 가져올 수 있다.

단점은 Pageable 기능을 사용할 수 없다.(페이징 데이터 활용 불가)
이는 batch size로 해결해서 지정한 size 만큼 sql IN 절로 조회하면 해결 가능

2) EntityGraph

1.. 엔티티들의 연관관계에서 필요한 엔티티와 컬렉션을 함께 조회시 사용하는 어노테이션

2.. OUTER JOIN을 사용하는 어노테이션이다.

3..

@EntityGraph(attributePaths={"member"})
List findAllEntityGraph();
attributePaths로 바로 가져올 필드명 지정시 LAZY가 아닌 EAGER조회로 가져오게 된다.

FetchType의 종류

FetchType.EAGER

객체 조회시 연관관계에 있는 객체까지 함께 조회한다. EAGER이라는 뜻에 맞게 열심히 관련된 객체들을 모두 조회해서 온다는 의미다.
예제 코드를 살펴보면

Team team = new Team();
team.setName("teamA");
em.persist(team);

Member member = new Member();
member.setUsername("member1");
member.setTeam(team);
em.persist(member);

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

Member findMember = em.find(Member.class, member.getId());
em.find를 통하여 id를 통해 Member를 반환받는다. 이때 로그는 다음과 같이 찍힌다.

Hibernate: 
    select
        member0_.MEMBER_ID as MEMBER_I1_0_0_,
        member0_.TEAM_ID as TEAM_ID3_0_0_,
        member0_.USERNAME as USERNAME2_0_0_,
        team1_.TEAM_ID as TEAM_ID1_1_1_,
        team1_.name as name2_1_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.MEMBER_ID=?

Member만 조회했을 뿐인데 관련된 Team까지 조인해서 찾아온다.

FetchType.LAZY

정말 필요한 객체만 조회해서 오고 연관관계에 있는 나머지 데이터 또는 객체는 조회를 미룬다는 의미에서 LAZY 라는 단어가 사용된 것이다.

Team team = new Team();
team.setName("teamA");
em.persist(team);

Member member = new Member();
member.setUsername("member1");
member.setTeam(team);
em.persist(member);

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

Member findMember = em.find(Member.class, member.getId());
Hibernate: 
    select
        member0_.MEMBER_ID as MEMBER_I1_0_0_,
        member0_.TEAM_ID as TEAM_ID3_0_0_,
        member0_.USERNAME as USERNAME2_0_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?

이처럼 연관관계에 있는 TEAM은 join을 통해 조회하지 않음을 확인할 수 있고,

findMember.getTeam().getName();
로 TEAM 데이터를 조회할 필요가 있다면

Hibernate: 
    select
        team0_.TEAM_ID as TEAM_ID1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?

이때 조회 쿼리문이 나간다.

언제 eager을 쓰고 언제 lazy를 쓰는 것이 좋을지?
Member 데이터가 필요한 곳에서 대부분 TEAM 데이터 또한 같이 사용한다면 EAGER 전략을 사용해 함께 한꺼번에 조회하는 것이 좋다.

반면 Member 데이터가 사용하는 곳에서 대부분 TEAM 데이터를 사용하지 않는다면 필요할 때에만 team 관련 쿼리문을 날려서 조회하는 것이 좋으므로 LAZY 전략을 사용한다.

⭐ 실무에서는 EAGER 로딩을 사용하지 않는 것이 권장된다.

0개의 댓글