[JPA] N+1 문제
N+1 문제?
N+1 문제란 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하는 현상을 말한다.
예를 들어, Member와 여러 Member를 가지는 MemberGroup의 관계가 있다고 하자.
Member와 MemberGroup Entity는 다음과 같이 구성되어 있다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "group_id", nullable = false)
private MemberGroup memberGroup;
public Member(String name, MemberGroup memberGroup) {
this.name = name;
setMemberGroup(memberGroup);
}
public void setMemberGroup(MemberGroup memberGroup) {
if (this.memberGroup != null) {
this.memberGroup.getMembers().remove(this);
}
this.memberGroup = memberGroup;
memberGroup.getMembers().add(this);
}
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "memberGroup", fetch = FetchType.EAGER)
private List<Member> members = new ArrayList<>();
public MemberGroup(String name) {
this.name = name;
}
}
fetch는 즉시 로딩(EAGER)으로 되어있는 상황이다. 이때 MemberGroup을 전체 조회한다면 어떻게 될까?
MemberGroup 5개 Member는 각 그룹마다 5개씩 저장되어 있다고 가정한다.
그럼 다음과 같이 조회 쿼리가 추가로 발생하는 것을 볼 수 있다.
Hibernate: select m1_0.id,m1_0.name from member_group m1_0
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
이를 어떻게 해결할 수 있을까?
지연 로딩(LAZY)
한 번 지연 로딩(LAZY)을 적용해보자.
위 코드에서 FetchType을 LAZY로 바꾸고 다음 코드를 실행해보자.
List<MemberGroup> all = memberGroupRepository.findAll();
이렇게 지연로딩으로 바꾸고 나니 쿼리가 하나만 호출된 것을 확인할 수 있다.
Hibernate: select m1_0.id,m1_0.name from member_group m1_0
그런데 여기서 문제점이 있다.
다음 코드를 다시 실행해보자.
List<MemberGroup> all = memberGroupRepository.findAll();
System.out.println("----------forEach----------");
all.forEach(memberGroup -> memberGroup.getMembers().size());
그럼 다음과 같이 쿼리가 출력되는 것을 확인할 수 있다.
Hibernate: select m1_0.id,m1_0.name from member_group m1_0
----------forEach----------
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
Hibernate: select m1_0.group_id,m1_0.id,m1_0.name from member m1_0 where m1_0.group_id=?
즉, 연관된 객체를 탐색하려고 하면 쿼리가 발생하여 N+1문제가 해결된 상태가 아니라, 단지 호출 시점만 늦췄을 뿐이었던 것이다.
그럼 정확한 해결방법은 없을까? 그 방법을 한 번 살펴보자
해결
N+1 문제 해결 방법은 여러가지가 있는데 하나씩 살펴보자.
Fetch Join
JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 JOIN 하여 가져오는 방법이다.
별도의 메서드를 선언하여 @Query 어노테이션을 통해 JPQL 구문을 적어준다.
public interface MemberGroupRepository extends JpaRepository<MemberGroup, Long> {
@Query("select mg from MemberGroup mg join fetch mg.members")
List<MemberGroup> findAllFetchJoin();
}
그럼 다음과 같이 실행되는 것을 확인할 수 있다.
Hibernate: select m1_0.id,m2_0.group_id,m2_0.id,m2_0.name,m1_0.name from member_group m1_0 join member m2_0 on m1_0.id=m2_0.group_id
----------forEach----------
따로 지정하지 않는다면 SQL의 INNER JOIN 구문으로 바뀌어 실행된다.
@EntityGraph
메서드에 @EntityGraph를 선언하고 attributePaths에 같이 조회할 연관 엔티티가 있는 필드명을 적는다.
콤마를 통해 여러 개를 작성할 수도 있다.
public interface MemberGroupRepository extends JpaRepository<MemberGroup, Long> {
@EntityGraph(attributePaths = {"members"})
@Query("select mg from MemberGroup mg")
List<MemberGroup> findAllEntityGraph();
}
그럼 다음과 같이 실행되는 것을 확인할 수 있다.
Hibernate: select m1_0.id,m2_0.group_id,m2_0.id,m2_0.name,m1_0.name from member_group m1_0 left join member m2_0 on m1_0.id=m2_0.group_id
----------forEach----------
Fetch Join & EntityGraph 주의점
Fetch Join과 EntityGraph는 JPQL을 사용하여 join문을 호출한다는 공통점이 있다. 때문에, 두 테이블 사이에 유효한 join 조건을 적지 않았을 때 카테시안 곱이 발생하여 중복이 생길 수 있다.
카테시안 곱은 해당 테이블에 대한 모든 데이터를 전부 결합하여 테이블에 존재하는 행 개수를 곱한만큼의 결과 값이 반환되는 것을 의미한다.
또한, Entity Graph는 left outer join을 사용한다.
JPQL의 fetch은 Inner Join으로 테이블 간의 교집합을 반환하지만, Entity Graph는 left outer join으로 join 오른쪽 테이블 조회 결과에 null이 있을 수 있다.
BatchSize
hibernate가 제공하는 @BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정된 size 만큼 SQL의 IN 적을 사용해서 조회한다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@BatchSize(size = 5)
@OneToMany(mappedBy = "memberGroup", cascade = CascadeType.ALL)
private List<Member> members = new ArrayList<>();
public MemberGroup(String name) {
this.name = name;
}
}
이렇게 하면 개수만큼 추가 쿼리를 날리지 않고 조회한 members의 id들을 모아서 SQL IN 절을 날린다.
size는 IN 절에 올 수 있는 최대 인자 개수를 뜻한다. 만약 members의 개수가 10개라면 쿼리가 2번 실행될 것이다.
실무에서는 기본적으로 EAGER이 아닌 LAZY를 통해 조회하는 것을 사용한다고 한다.
문제가 발생하여 성능에 이상이 있는 경우 fetch join을 통해 해결해보자. 하지만 양방향 연관관계가 굳이 필요한 상황이 아니라면 단방향만 연결하는 것이 좋다.
'Backend > JPA' 카테고리의 다른 글
[JPA] OSIV(Open Session In View) (0) | 2023.05.09 |
---|