🗃️ 내가 다시 볼 것

N+1문제와 그에 대한 해결 방안 정리중..

전호영 2025. 1. 21. 16:50

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를 전부 가져온 것을 볼 수 있다.