JPA에는 N+1 문제라는 것이 존재합니다.
N+1문제란 DB에서 불러올 때 1개의 쿼리가 아니라 연관관계 객체를 불러오기 위한 N개의 쿼리가 발생하여 성능이 저하되는 문제입니다.
JPA를 사용하여 엔티티간 연관관계를 맺으며 프로젝트를 진행하면 필연적으로 N+1 문제에 직면하게 됩니다.
JPA N+1 문제를 해결한 경험을 포스팅해보겠습니다!
우선, Post, Board 엔티티가 존재합니다.
Post는 게시물, Board는 게시물이 작성되는 게시판(ex. 1,2,3,4번 게시판)입니다.
연관없는 필드는 생략하겠습니다!
Post
@ManyToOne
@JoinColumn(name = "board_id")
private Board board;
Board
@OneToMany(mappedBy = "board")
private List<Post> post;
저는 양방향 관계로 게시물, 게시판 테이블의 연관관계를 맺었습니다.
관계를 그림으로 나타내면 아래와 같습니다.
먼저, 양방향 관계를 이해하려면 두 가지 개념을 알아야합니다.
- 양방향 관계란 존재하지 않습니다
양방향 관계를 썼다고 작성을 했는데 뜬금없이 양방향 관계는 없다고?
양방향 연관관계란 사실은 양방향 관계가 아니라 서로 다른 단방향 관계가 2개가 있는 것입니다.
그러면 이 객체 세상에서 단방향 관계를 양방향 연관관계로 어떻게 표현을 할 수 있을까요?
테이블 세상에서 연관관계를 만들 때는 외래키라는 것을 이용합니다. 객체 세상에서도 이 외래키와 같은 장치를 만들어주기 위해 각각의 객체에 관계를 맺고 싶은 객체의 정보를 설정해줘야합니다.
여기서 2번째 개념을 추가로 알아야 합니다.
- 연관관계의 주인
양방향 연관관계에서는 각각의 객체에 관계를 맺고 싶은 객체의 정보를 설정해줘야합니다. 여기서 연관관계의 주인을 설정해줘야 합니다.
주인을 설정하는 이유는 무엇일까요?
테이블 세상에서는 연관관계를 사용할 때 외래키를 이용합니다. 객체 세상에서 연관관계의 주인은 이 외래키를 가질 수 있게 됩니다. 따라서 외래키가 저장되는 테이블을 연관관계의 주인으로 설정해줘야 합니다.
연관관계의 주인을 설정하는 방법은 @OneToMany(mappedBy = "")
속성으로 설정해주면 됩니다. 그러면 주인은 @JoinColumn(name="")
어노테이션으로 해당 외래키를 지정해주면 됩니다.
즉, 위 구조도에서 봤듯이, Post 엔티티가 양방향 매핑에서 주인이고, Board엔티티는 양방향 매핑에서 노예(지양하는 표현) 신하 입니다.
자, 그러면 양방향 매핑을 맺었다는 가정하에 이제부터 N+1 테스트를 해보겠습니다.
@Test
@DisplayName("N+1 발생 테스트")
void nPlusOneTest(){
System.out.println("-------- Post 전체 조회 요청 ---------");
List<Post> posts = postRepository.findAll();
System.out.println("-------- Post 전체 조회 완료 [ 쿼리는 1개만 발생하겠지 ? ] ---------");
}
총 몇 개의 쿼리가 발생했을까요?
저는 총 21개의 쿼리가 발생했습니다 ㅠㅠ
Post Select 쿼리 20개
Board Select 쿼리 1개
여기서 2가지 현상을 발견할 수 있었습니다.
저는 Board 테이블만 select 하고 싶었지만, 외래키를 가지고 있는 Post 테이블을 같이 select 한다는 것이었습니다. 즉, N+1(정확하게는 20 + 1) 문제가 발생했습니다 ㅠㅠ
쿼리의 개수는 참조하고 있는 데이터의 개수와 일치하는 것입니다.
현재 저는 총 20개의 게시물(Post)가 있기 때문에 Post Select 쿼리가 20개 나갔습니다.
21개의 쿼리를 1개의 쿼리로 바꾸기 위해 주인 엔티티(Post)에 FetchType.Lazy를 설정하도록 하겠습니다.
기본적으로 @ManyToOne 의 FetchType은 Eager이기 때문에 Lazy로 변경해보겠습니다.
Post(FetchType Lazy로 변경)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
Eager 전략(즉시 로딩)과 Lazy 전략(지연 로딩)의 차이는 언제 쿼리를 발생시키느냐의 차이입니다.
Eager는 Post를 검색하기만 해도 Board를 검색합니다. 반대로 Lazy는 Board를 검색한다는 요청이 있어야 Board를 검색합니다
이것과 관련해서는 포스트 후반부에 또 예시와 함께 살펴보겠습니다!
FetchType을 Lazy로 변경해서 다시 한 번 테스트해보겠습니다.
결과는 대성공입니다! 쿼리가 Post 1개만 발생한 것을 확인할 수 있습니다!
그러면 이번에는 Post에서 Board를 찾는
@Test
@DisplayName("N+1 발생 테스트")
void nPlusOneTest(){
System.out.println("-------- 게시판 전체 조회 요청 ---------");
List<Post> posts = postRepository.findAll();
System.out.println("-------- 게시판 전체 조회 완료 [ 쿼리는 1개만 발생하겠지 ? ] ---------");
System.out.println("-------- 게시물(Post)과 연관된 게시판 조회 요청 [쿼리가 게시판 개수만큼 발생] ---------");
for (Post post : postList) {
String boardName = post.getBoard().getName();
String postTitle = post.getPostTitle();
}
System.out.println("-------- 게시물과 연관된 게시판 조회 완료 [쿼리가 게시판 개수만큼 발생] ---------");
}
게시물과 연관된 게시판을 조회했더니 이번에는 게시판의 개수만큼 추가적인 쿼리가 발생했습니다. 😂
게시판은 총 4개(1,2,3,4번 게시판)이 있기 때문에 4개의 추가적인 쿼리가 발생했습니다.
Post를 찾는 쿼리가 총 4개 발생
이 말은 FetchType.Lazy로의 변경은 결국 쿼리 발생 시점만 늦춰줄 뿐 궁극적인 해결책이 될 수 없다는 뜻입니다.
FetchType.Lazy로 Post만 찾을 때는 추가 쿼리가 발생하지 않았지만 결국 연관된 Board를 찾을 때 Board 개수만큼 쿼리가 추가로 발생하게 되는 것입니다.
이 2가지 경우에 대한 해결책을 살펴보겠습니다!
@ManyToOne 관계로 연관된 엔티티가 조회되는 경우
(Fetch Join or Entity Graph 적용)
Post(주인)을 조회하면서 Board를 함께 조회하는 경우입니다.
@Test
@DisplayName("N+1 발생 테스트")
void nPlusOneTest(){
System.out.println("-------- 게시물(Post) 전체 조회 요청 ---------");
List<Post> postList = postRepository.findAll();
System.out.println("-------- 게시물 전체 조회 완료 ---------");
System.out.println("-------- 게시물(Post)과 연관된 게시판 조회 요청 ---------");
for (Post post : postList) {
String boardName = post.getBoard().getName();
String postTitle = post.getPostTitle();
}
System.out.println("-------- 게시물과 연관된 게시판 조회 완료 ---------");
}
-------- 게시물(Post) 전체 조회 요청 ---------
Select Post 쿼리 1개 발생
-------- 게시물(Post)과 연관된 게시판 조회 요청 ---------
Select Board 쿼리 4개(Board 개수) 발생
Fetch Join을 사용하면 DB에서 데이터를 가져올때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법입니다.
즉, 지연 로딩이 걸려있는 연관관계에 대해서 한번에 같이 즉시로딩을 해주는 구문입니다. 따라서 쿼리를 날릴 때 연관 엔티티를 한 번에 모두 가져올 수 있습니다.
Fetch Join 사용
Fetch Join은 @Query 어노테이션에 join fetch로 별도의 구문을 만들어줘야 합니다.
PostRepository
@Repository
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom {
@Override
@Query("select p from Post p join fetch p.board")
List<Post> findAll();
}
@EntityGraph 어노테이션은 하드 코딩을 해야 하는 fetch join의 단점을 극복할 수 있는 방법입니다.
attributePaths로 연관관계를 맺어줘서 해당 엔티티를 fetch join으로 조회할 수 있게 해줍니다.
Entity Graph 사용
PostRepository
@Repository
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom {
@Override
@EntityGraph(attributePaths = {"board"})
List<Post> findAll();
}
게시물 전체 조회 요청시에만 select 쿼리가 발생하고 게시물과 연관된 게시판을 요청할 때는 쿼리를 요구하지 않아 총 1개의 쿼리만 발생합니다.
@OneToMany 관계로 연관된 엔티티가 조회되는 경우
(Batch Size 적용)
Board를 조회하면서 Post(주인)을 함께 조회하는 경우입니다.
-------- 게시판(Board) 전체 조회 요청 ---------
Select Board 쿼리 1개 발생
-------- 게시물(Post)과 연관된 게시판 조회 요청 ---------
Select Post 쿼리 4개(Board 개수) 발생
batch size 사용
batch size를 적용하면 쿼리를 batch size개를 날리게 되어 쿼리 개수를 줄일 수 있습니다. 예를 들어, 100으로 설정하면 100개만큼 쿼리를 한 번에 가져올 수 있습니다.
Board
@BatchSize(size = 4)
@OneToMany(mappedBy = "board")
private List<Post> post;
batch size를 게시판(Board)의 개수만큼 설정하면 where 절의 in 연산자(?,?,?,?)를 통해 select 쿼리가 하나만 나가게 됩니다.
@ManyToOne 관계로 연관된 엔티티가 조회되는 경우
FetchType.LAZY만 적용했을 때
Fetch Join을 함께 사용했을 때
@OneToMany 관계로 연관된 엔티티가 조회되는 경우
Batch Size 10000 으로 설정했을 때
쿼리의 개수가 10001(10000 + 1) -> 7(1 + 6)으로 줄어서 속도가 비약적으로 줄었습니다.
이론상으로는 배치 사이즈를 10000으로 잡았기 때문에 쿼리 개수가 2( 1 + 1 )이 나와야하는데 5개의 쿼리가 더 발생했습니다.
이 점에 대해서는 추가적인 포스트를 작성하겠습니다 😂
경우 | 쿼리 개수 | 속도 |
---|---|---|
Post에서 Board 찾기 | 10001 | 761ms |
Post에서 Board 찾기 (Fetch Join) | 1 | 591ms |
---------- | -------- | -------- |
Board에서 Post 찾기 | 10001 | 20202ms |
Board에서 Post 찾기 (Batch Size) | 7 | 1309ms |
Post에서 Board를 찾는 경우 (@ManyToOne 관계로 연관된 엔티티가 조회되는 경우) 약 28% 속도 개선 !
Board에서 Post를 찾는 경우 (@OneToMany 관계로 연관된 에티티가 조회되는 경우) 약 1440% 속도 개선 !