N+1 문제란?
N+1문제란 연관 관계가 설정된 엔티티를 조회할 때 ,엔티티를 조회하는 쿼리 1번에 해당 엔티티에 연관된 N번의 쿼리가 추가로 실행되는 것을 말한다.
예를 들어, 멤버 한명이 10개의 글을 썼다고 가정할 때, 멤버를 조회하면 이 멤버와 연관된 10개의 글을 가져오기 위해 추가로 10개의 쿼리가 실행되는 것이다. (1+N이라 하는 것이 더 이해하기 쉬울듯!)
왜 발생할까?
멤버와 게시글을 예시로 코드를 보며 이해해보자.
멤버와 게시글의 관계는 1 : N 이다.
@Entity
@Data
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "member")
private List<Post> posts = new ArrayList<>();
public void addPost(Post post) {
posts.add(post);
post.setMember(this);
}
}
@Entity
@Data
public class Post {
@Id
@GeneratedValue
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "member_id")
private Member member;
}
N+1 문제를 발생시켜 보자.
public class NPlusOneMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("nplusone");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member = new Member();
member.setName("MemberA");
Post postA = new Post();
postA.setTitle("PostA");
Post postB = new Post();
postB.setTitle("PostB");
member.addPost(postA);
member.addPost(postB);
em.persist(member);
em.persist(postA);
em.persist(postB);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
List<Post> posts = findMember.getPosts();
for (Post post : posts) {
System.out.println(post.getTitle());
}
tx.commit();
} catch (Exception e) {
tx.rollback();
System.out.println("e = " + e);
} finally {
em.close();
}
emf.close();
}
}
로그를 보면 현재 멤버를 가져오는 쿼리(1회) 와 member가 가지고 있는 post를 가져오는 쿼리(1회)가 발생해 총 2회 발생했다.
이렇게 연관관계에서 연관된 객체를 가져올 때 추가적인 쿼리가 발생하는 것이 N+1 문제이다.
그래서 이 문제가 왜 발생할까 ?
1. 지연 로딩(Lazy Loading)
지연 로딩이란 엔티티 조회 시 엔티티와 연관된 데이터를 실제 사용할 때 로딩하는 것을 의미한다.
코드를 보고 어떻게 동작이 일어나는지 생각해보자.
1. Member 조회
먼저 member 객체와 연관된 post가 proxy 객체로 영속성 컨텍스트에 저장된다. (@OneToMany는 기본 전략이 지연로딩이다.)
2. Member가 가지고있는 글을 가져오는 쿼리 발생
post의 이름을 출력하고자 할 때 영속성 컨텍스트에 posts는 프록시 객체로 존재한다.
그러므로 영속성 컨텍스트가 DB에 posts를 가져오는 쿼리를 날린다.(추가 쿼리 발생)
위 로직이 N+1 문제가 발생하는 과정이다.
2. 즉시 로딩(Eager Loading)
지연로딩은 필요한 데이터를 나중에 가져온다.
한번에 연관된 모든 데이터를 가져오는 즉시 로딩을 사용하면 이 문제가 해결될까?
@Entity
@Data
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "member_id")
private Member member;
}
테스트 코드를 돌려보면,
한번에 다 가져올 수 있다.
하지만 성능 상 좋을까? 생각을 해보면..
현재는 member와 member가 작성한 글이 적어서 많은 쿼리가 날아가지 않는다.
그러나 member가 많아지고 member가 작성한 글들이 많아지면 성능에 악영향을 미칠 것이 분명하다.
해결 방안
LazyLoading + Fetch Join
위 방법으로 문제를 해결할 수 있다.
Fetch Join이 무엇일까?
Fetch Join이란 JPQL에서 성능 최적화를 위해 제공하는 기능으로, 연관된 엔티티나 컬렉션을 한 번에 조회하는 방법이다.
즉 , LazyLoading으로 필요한 엔티티만 가져오고, 연관된 엔티티까지 필요할 땐 fetch join을 사용해 한 번에 가져오는 방법이다.
public class NPlusOneMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("nplusone");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member = new Member();
member.setName("MemberA");
Post postA = new Post();
postA.setTitle("PostA");
Post postB = new Post();
postB.setTitle("PostB");
member.addPost(postA);
member.addPost(postB);
em.persist(member);
em.persist(postA);
em.persist(postB);
em.flush();
em.clear();
List<Member> members = em.createQuery(
"SELECT DISTINCT m FROM Member m JOIN FETCH m.posts", Member.class)
.getResultList();
tx.commit();
} catch (Exception e) {
tx.rollback();
System.out.println("e = " + e);
} finally {
em.close();
}
emf.close();
}
}
member와 member가 작성한 post를 전부 가져온 것을 볼 수 있다.