N+1 문제란 일단 2테이블에 대한 join에서 발생하는 문제이다. left join 1번으로 끝낼수 있는 쿼리조회가 N번이나 더 추가적으로 더 발생하는 상황을 말한다.
상황
글쓴이테이블(author)과 게시글테이블(post)이 존재한다. jpa의 findAll()을 통해 모든 글쓴이에 대한 모든 게시글 정보를 JSON형태로 조회하고자 한다.
문제발생
아래와 같이 글쓴이테이블(author)이 존재한다. 글쓴이는 여러개의 글을 쓸수가 있고, 글쓴이에 대한 글 목록들을 조회하기 위해서는 post(글목록)테이블과 1 : N의 관계인 oneToMany를 설정해줘야 한다. 즉, author테이블과 post테이블을 join 해야 한다는 말이다.
@Data
@Entity
@Table(name="author")
public class Author {
@Id @Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "author", fetch = FetchType.EAGER)
@JsonIgnore
private List<Post> posts = new ArrayList<>();
}
}
그런데, 위와 같은 entity를 가지고, 모든 글쓴이의 모든 글목록까지 전체를 join하여 조회하기 위해서 author에 findAll()을 실행하게 되면 어떤 일이 벌어지게 될까? 내부적인 로직을 순서대로 봐보자.
1. 먼저 select * from author; 수행
2. 각각의 author row에는 위의 OneToMany Entity설정으로 인해 Post정보가 즉시 필요한 상황(FetchType-EAGER(즉시로딩))
3. 1번 단계에서 조회한 author의 데이터에서 필요한 post를 순차적으로 개별 쿼리로 조회
select * from post where author_id = 1;
select * from post where author_id = 2;
...
4. 모든 author n개의 갯수만큼 post테이블 조회 반복
사실은, post와의 연관관계가 없었다면 query는 select * from author; 이 1번으로 족하다. 그러나, post와의 관계로 인해 1+n(post테이블 조회횟수)만큼의 쿼리 조회가 추가적으로 발생하게 된다.
그런데, sql을 아는 사람이라면 이 조회방식이 다소 이상하다는 것을 느낄 것이다. left outer join을 하면 될것 같은데, 왜 이렇게 하지? 라는 의문이 들겠지만, 좀 더 읽어보자.
해결
일단, 1~4의 step에서 문제가 되는 부분은 post정보가 필요하다고 해서 즉시 해당 데이터를 조회하는것이 문제인 것 같다. 그러면 즉시 조회를 설정하는 옵션이 EAGER이므로, eager를 아래와 같이 lazy로 바꾸면 되지 않을까?(사실 OneToMany는 default가 LAZY이므로, 지워도 무관하다. ManyToOne은 EAGER가 Default)
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
eager vs lazy
eager와 lazy는 사실 이 이슈와는 상관이 없는 이슈이다.
lazy는 post테이블의 값이 당장 필요한 상황이 아니라면, post를 조회하지 않겠다는 의미이기에 entity에 oneToMany가 있고, findAll()을 하더라도 post와의 join을 하지 않게 된다. 만약 특정 코드에서 post테이블의 값이 필요해지면 그제서야 post테이블을 조회하게 된다.
쓸모없는 것은 조회하지 않겠다는 것이 되므로 lazy전략이 일반적으로 더 효율적이긴 하나, join된 두테이블의 모든 데이터가 필요한 상황이라면 동일하게 N+1이슈가 발생하므로 여기서는 해결책이 되지 않는다.
fetch join vs 일반 join
결론을 말하자면, 두 테이블의 join에서 발생하는 N+1의 해결책으로는 EntityGraph , Batch Size 등 여러 방법이 있지만 가장 직관적이고 쉬운 방법인 Fetch Join을 사용하면 된다.
결국 SQL쿼리 실행의 관점에서 봤을때 굳이 N+1의 여러번 쿼리가 아닌, 1번의 left outer join으로 해결이 가능한 상황이기에 일반적인 sql문으로 해결하는 방식이 fetch join이라고 생각하면 되겠다.
아래와 같은 코드를 통해, left join fetch를 함으로서 N+1 횟수의 쿼리가 아닌 1번의 left join으로 모든 데이터를 가져올 수 있게 된다.
@Query("select distinct a from Author a left join fetch a.posts")
List<Author> findAllFetchJoin();
fetch join vs 일반 join
그렇다면 fetch를 붙인것과 fetch를 붙이지 않은 것의 차이는 무엇일까?
일반JOIN을 하게 될 경우, join을 걸긴 걸지만 post(글목록)에 대한 정보는 가져 오지 않는다. 즉, select a from author a left join post p on a.id = p.author_id 와 같은 쿼리라고 보면 된다. p(post)는 조회대상에 포함되지 않는다.
그러다가 특정 코드에서 post에 대한 데이터를 요청을 하게 되면, 다시 POST테이블로 쿼리를 날리게 되는데 이때에도 N+1문제가 발생하게 된다. 일반JOIN의 경우는 당장에 사용하지 않을 불필요한 데이터를 컨텍스트에 담지 않음으로서 메모리상의 이점이 있다. 데이터가 필요해지기 전까지는 post테이블을 호출하지 않는 다는 점에서 일종의 Lazy전략이라 볼 수 있겠다.
그러나 fetch는 조회시점에 일단 관련된 모든 post 데이터를 가져오게 된다. 당장에 글쓴이+글목록 데이터가 모두 필요하다고 하면, 결국 여기서는 fetch를 사용하는 것이 가장 효율적일 것이다.
그렇다면 단일쿼리/join, lazy/eager, 일반join/fetch join 등 다소 복잡해보이고 혼란스러워 보이는데, 각각 어떤 상황에 어떤식의 사용전략을 가져가면 되는 것일까?
사용전략
Entity
일단, 불필요한 join을 만들어낼 필요는 없으므로 1개의 테이블만을 조회해도 문제가 없다면 1개의 테이블만을 조회하는 것이 맞다. 그러므로 왠만하면 Entity에서는 아래와 같은 LAZY형태를 Default로 두어도 문제는 없을 것이다.(그래서 사실은 lazy를 지정안해도 onetomany는 디폴트가 lazy)
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
service
단일 테이블 호출의 경우
service에서 repository를 호출할때에 단일 테이블의 데이터만을 필요로 한다면, findBy컬럼명, findAll()를 호출하여 사용하면 된다. Entity에서 이미, Lazy전략을 따르겠다 설정하였으므로, findAll을 할경우 join자체를 하지 않으므로 N+1 이슈가 발생하지 않을 것이다.
테이블 JOIN의 경우
만일 service단에서 두 테이블의 join된 데이터가 필요한 상황이라 하자. 이때에는 findAll()이나 일반join을 호출하고 난 뒤에, post 객체의 데이터를 사용하고자 할 것이다. 이때에는 entity의 설정이 Lazy라 하더라도 post데이터를 N번 조회 호출하게 될 것이고, N+1 문제가 발생하게 된다.
그러므로, 이런 경우 service단에서 findAll() 또는 일반 join을 호출하는 것이 아닌, 아래와 같이 fetch join을 하는 repository 메서드를 호출하는 식으로 서비스 구성을 하면 되겠다.
@Query("select distinct a from Author a join fetch a.posts where a.id = :id")
List<Author> findAllFetchJoinById(@Param("id")Long id);
@Query("select distinct a from Author a left join fetch a.posts")
List<Author> findAllFetchJoin();
'프로그래밍 > java, spring' 카테고리의 다른 글
Web server failed to start. Port 8080 was already in use. (0) | 2023.01.23 |
---|---|
@JsonIgnore, @JsonBackReference, @JsonManagedReference의 차이 및 FetchType.LAZY와의 관계 (0) | 2023.01.21 |
spring 세션에서 email정보, 권한정보 꺼내기 (0) | 2023.01.21 |
spring 예외처리 정리(exception기본, 중첩된 예외, 멀티서버간 예외처리) (0) | 2023.01.11 |
Spring JWT 토큰 서버 구현(+ ajax 프론트테스트) (0) | 2023.01.08 |