이 글은 토이 프로젝트인 게시판 프로젝트를 진행하면서 나온 n+1 문제에 대한 글이다.
문제 상황
토이 프로젝트로 게시판을 개발하면서 N+1 문제가 발생했다. 기존에 fetch = FetchType.LAZY와 Fetch Join만 사용하면 N+1 문제가 해결될 거라고 생각했지만, 실제로는 그렇지 않았다. 왜 이런 문제가 발생하는지, 그리고 어떻게 해결할 수 있는지 정리해보았다.
JPQL(Java Persistence Query Language)
JPQL은 테이블이 아닌 엔티티 객체를 대상으로 조회하는 객체지향 쿼리를 의미한다.
SELECT * FROM Member; -- SQL 문
SELECT m FROM Member m; -- JPQL 문
fetch = FetchType.LAZY, FetchType.EAGER 란?
EAGER (즉시 로딩)
- 엔티티를 조회할 때 연관된 엔티티도 즉시 함께 로딩한다.
- 한 번의 쿼리 또는 조인을 통해 관련된 모든 데이터를 가져온다.
어떤 쿼리를 날리든 연관된 모든 데이터를 조회하고 가져오기에 성능을 저하시킬 수 있다. 이점이 JPQL과 만나면 문제가 되는데 예를 들어서 Member와 Post가 1:N으로 만들어져 있는 엔티티가 있는데 여기서 Member의 findAll()이라는 메서드를 사용하면 이 코드를 포함한 관련된 코드를 N번 가져온다. 즉 EAGER 로딩을 사용해도 N+1문제가 발생할 수 있다.
LAZY (지연 로딩)
- 연관된 엔티티를 실제로 사용할 때 필요할 때만 로딩한다.
- 기본적으로 프록시 객체로 감싸서, 실제 데이터가 필요할 때 쿼리를 실행한다.
위 특징처럼 실제 데이터가 필요할 때 추가적인 쿼리가 발생하여 N+1문제가 발생할 수 있다. 하지만 불필요한 데이터를 조회하지 않기에 성능을 최적화가 가능하다.
fetch join 이란?
JPA에서 연관된 엔티티나 컬렉션을 SQL 한 번의 쿼리로 함께 조회하는 방법이다. Fetch Join은 실제로 Inner Join으로 SQL문이 실행된다. 설명에 보이듯이 LAZY로딩에서 즉시로딩이 되어 한 번에 쿼리로 연관된 데이터를 조회한다. 한번에 쿼리로 연관된 데이터를 가져오기에 LAZY로딩에서 N+1문제를 해결할 수 있다.
현재 프로젝트를 간단하게 그림으로 표현하면 아래와 같다.

먼저 위 그림을 이해하면 Member와 Post가 1:N관계를 가지고 있다. 그리고 왼쪽 그림을 보면 Fetch Join을 실행한 결과이다. 실제로 Fetch Join을 하면 Inner Join으로 실행되기에 다음과 같은 결과 값이 나온다.
Post 관점에서 보면 Post는 8개의 값을 가지고 있고 결과 값도 8개가 나온다. 이는 ManyToOne의 관점에서는 실행 결과와 내가 원하는 값에 개수의 차이가 없다.
하지만 Member 관점에서 보면 문제가 발생한다. Member를 보면 총 3개로 나의 조회 결과수는 3개를 기대한다 하지만 실제로 결과수는 위 그림처럼 8개가 조회된다. 나는 member 3개를 원하는데 실행되는 결과는 8개인 개수차이가 생기게 된다. 이점을 이해하고 다음 문제 해결 방법에 대해서 보길 바란다.
문제 해결
N+1문제를 해결을 하기 위해서는 먼저 ToOne인지 ToMany인지가 중요하다.
ToOne
ToOne관계에서는 N+1문제가 발생할 때 지연로딩과 Fetch Join을 사용하면 위 그림에서 봤던 그림처럼 8개를 조회하면 8개의 결과가 나온다. 즉 중복되는 값도 없고 또한 Fetch Join으로 원하는 값을 나오게 한다. 여기서 중요한 게 8개를 조회하면 8개가 나온다. ToOne 테이블을 조인하면 데이터의 수가 변하지 않는다 것을 인지하자.
이 때문에 페이징을 사용해도 상관이 없다.
위 그림을 봤듯이 ToOne의 관계에서는 테이블의 수가 변하지 않기에 10개를 조회하면 10개를 가지고 온다. 그렇기에 페이징을 한다고 해도 문제가 없다. 가끔 페이징을 하면 그냥 @Batchsize를 해야 하는 그런 글이 있는데 ToOne관계에서는 그럴 필요가 없다. 위 그림처럼 값의 크기가 정해지고 내가 원하는 값을 가져오기에 정해진 테이블 수에 LIMIT를 하는 거라 그냥 Fetch Join에 LIMIT를 걸어서 페이지를 해도 된다.
ToMany
ToMany관계에서도 지연로딩, Fetch Join을 해도 된다. 이것도 위 그림을 보면 Member관점에서 Post를 가져오는데 Member 3명의 값을 원하지만 실제 테이블의 수는 8개이다. ToMany관계에서는 중복이 발생한다. 그렇기에 DISTINCT를 사용하여 중복을 방지해야 한다.
이 때문에 페이징에서 Fetch Join을 사용하면 JPA에서 N+1문제로 LIMIT가 없는 쿼리가 나간다. 이는 위에 있는 Fetch Join에 관한 그림을 보면 이해가 쉽다. 먼저 Fetch Join을 사용하면 LIMIT보다 Fetch Join이 먼저 실행이 된다. 그래서 위 그림에서 보듯이 ToMany관계에서 페이징을 하면 원하는 값의 크기보다 더 많은 값들인 3이 아닌 8을 가져오게 된다. 즉 내가 원하는 값인 3에서 LIMIT이나 OFFSET을 하는 게 아니라 8에서 하는 거다. 여기서 JPA 어디에 LIMIT 걸어야 하는지? 하고 쿼리에 LIMIT를 없애어서 보내는 문제가 발생한다. 그래서 이를 해결하기 위해서는 @Batchsize를 사용해야 한다.
@Batchsize
여러 개의 프록시 객체를 조회할 때 WHERE 절이 같은 여러 개의 SELECT 쿼리들을 하나의 IN 쿼리로 만들어준다. 그래서 @Batchsize를 사용하여 IN쿼리로 만들어서 가져오는 값이 정해져 있기 때문에 LIMIT를 사용할 수 있고 한 번에 쿼리로 여러 개의 데이터를 가져올 수 있다.
여기에서 N+1문제가 하나 더 남았는데 바로 다중 Collections Fetch Join을 하게 되면 N+1문제가 나온다.
다중 Collections Fetch Join
이는 ToMany관계를 가지는 Collections을 2개 이상 fetch join을 하면 문제가 발생한다. JPA에서 Fetch Join의 조건이 있는데 ToOne는 몇 개를 사용해도 괜찮지만 ToMany는 1개만 가능하다. 그래서 다중 Collections Fetch Join을 사용하면 MultipleBagFetchException이 발생한다. 다중 Collections Fetch Join는 위에서 @Batchsize를 사용하여 특정 개수를 미리 정해서 가져와 다중 Collections Fetch Join에 N+1문제를 해결할 수 있다.
마지막으로 @EntityGraph로 해결이 가능하지 않나?라는 생각하시는 분들도 있다. Join Fetch는 Inner Join을 사용하지만 @EntityGraph는 Outer Join을 사용한다. 이는 카테시안 곱이 발생하여 엄청나게 많은 결괏값을 반환한다. 이는 중복이 발생하기도 하는데 이에 @EntityGraph보다는 상황에 맞게 Fetch Join이나 @Batchsize사용하는 게 더 좋은 판단이라고 생각한다.
결론
- ToOne 관계에서는 Fetch Join을 사용해도 데이터 개수가 변하지 않으므로 페이징이 가능하다.
- ToMany 관계에서는 중복 데이터가 발생할 수 있어 Fetch Join에 DISTINCT를 사용해야 하며, 페이징에는 @BatchSize를 사용해야 N+1문제를 해결한다.
- 다중 Collections Fetch Join에서는 MultipleBagFetchException이 발생하므로 @BatchSize를 사용하여 N+1문제를 해결한다.
참고
https://stackoverflow.com/questions/13048436/manytoone-and-batchsize
https://jojoldu.tistory.com/165