JPA N+1 문제 해결하기
JPA 사용시 발생하는 N+1 문제를 해결하는 방법에 대해 정리한 내용입니다.
1. N+1 문제가 발생하는 이유
N+1 문제는 1:N 또는 N:1 연관관계가 설정된 엔티티를 조회했을 때 발생하는 문제입니다. 연관관계가 설정된 엔티티를 한 번에 조회하지 않고 조회된 데이터의 개수(N) 만큼 연관관계의 엔티티에 대해 추가로 조회 쿼리가 실행되는 문제입니다. 조회한 엔티티의 데이터 개수가 10개이면 연관관계의 엔티티를 조회하는 SQL도 10번 실행되어 10번 + 1번 조회하게 됩니다. 이러한 문제는 많은 양의 쿼리가 발생했을 때 성능 저하의 원인이 됩니다.
1.1. 즉시 로딩에서 발생하는 N+1 문제
즉시 로딩이 설정된 연관관계의 엔티티를 조회하는 경우에 N+1 문제가 발생할 수 있습니다. JPA Repository의 findAll()과 같은 메서드를 사용하여 엔티티를 조회할 때 JPQL이 SQL을 생성하여 동작하게 되는데, 이러한 경우에 JPQL은 즉시 로딩이나 지연 로딩과 같은 Fetch 전략을 무시하고 생성한 SQL을 실행하게 됩니다. 먼저 조회한 엔티티에 연관관계가 설정된 다른 엔티티가 있을 때 SQL이 추가로 생성되고 실행되어 N+1 문제가 발생하게 됩니다.
즉시 로딩에서의 N+1 문제를 확인하기 위해 다음과 같이 Board, BoardComment 엔티티의 연관관계를 구성해줍니다.
@Getter
@NoArgsConstructor
@Entity(name = "board")
public class Board {
@OneToMany(mappedBy = "board", fetch = FetchType.EAGER)
private List<BoardComment> boardComments;
}
@Getter
@NoArgsConstructor
@Entity(name = "board_comment")
public class BoardComment {
@ManyToOne
@JoinColumn(name = "board_id")
private Board board;
}
테스트 코드를 작성하여 구성한 엔티티를 조회해줍니다.
@RunWith(SpringRunner.class)
@SpringBootTest()
public class BoardServiceTests {
@Autowired
private BoardRepository boardRepository;
@Autowired
private BoardCommentRepository boardCommentRepository;
@Before
public void setup() {
for (int i = 0; i < 5; i++) {
Board board = Board.builder()
.title("board title test")
.content("board content test")
.author("board author test")
.build();
boardRepository.save(board);
BoardComment boardComment = BoardComment.builder()
.board(board)
.content("board comment test " + i)
.author("board comment test " + i)
.build();
boardCommentRepository.save(boardComment);
}
}
@After
public void cleanAll() {
boardCommentRepository.deleteAll();
boardRepository.deleteAll();
}
@Test
public void contextLoads() {
List<Board> boards = boardRepository.findAll();
assertThat(boards.size(), is(5));
}
}
다음과 같이 쿼리가 추가로 실행되어 N+1 문제가 발생하는 것을 확인할 수 있습니다.
Hibernate: select board0_.board_id as board_id1_0_, board0_.author as author2_0_, board0_.content as content3_0_, board0_.title as title4_0_ from board board0_
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
1.2. 지연 로딩에서 발생하는 N+1 문제
지연 로딩이 설정된 연관관계의 엔티티를 조회하기 위해 JPQL이 SQL을 생성하여 실행하는 경우엔 N+1 문제가 발생하지 않습니다. 하지만 비즈니스 로직에서 조회한 엔티티와 연관관계가 설정된 엔티티를 탐색할 때 N+1 문제가 발생하게 됩니다.
지연 로딩에서의 N+1 문제를 확인하기 위해 다음과 같이 Board, BoardComment 엔티티의 연관관계를 구성해줍니다.
@Getter
@NoArgsConstructor
@Entity(name = "board")
public class Board {
@OneToMany(mappedBy = "board")
private List<BoardComment> boardComments;
}
@Getter
@NoArgsConstructor
@Entity(name = "board_comment")
public class BoardComment {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
}
엔티티 조회 후 연관관계의 엔티티 탐색을 위해 다음과 같이 Mock 서비스를 만들어줍니다.
@Service
@RequiredArgsConstructor
public class BoardMockService {
private final BoardRepository boardRepository;
@Transactional(readOnly = true)
public void getMockBoards() {
boardRepository.findAll().forEach(board -> {
board.getBoardComments().forEach(boardComment ->
System.out.println(boardComment.getContent())
);
});
}
}
테스트 코드에서는 위에서 작성한 getMockBoards() 메서드를 호출해줍니다.
@RunWith(SpringRunner.class)
@SpringBootTest()
public class BoardServiceTests {
@Test
public void contextLoads() {
boardMockService.getMockBoards();
}
}
지연 로딩에서는 비즈니스 로직에서 하위 엔티티를 탐색할 때 추가로 쿼리가 실행되는 것을 확인할 수 있습니다.
Hibernate: select board0_.board_id as board_id1_0_, board0_.author as author2_0_, board0_.content as content3_0_, board0_.title as title4_0_ from board board0_
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
Hibernate: select boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.author as author2_1_1_, boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.content as content3_1_1_ from board_comment boardcomme0_ where boardcomme0_.board_id=?
2. N+1 문제 해결하기
2.1. BatchSize 사용하기
하이버네이트의 BatchSize 설정을 사용하여 N+1 문제를 해결할 수 있습니다. N개의 추가 쿼리를 실행하지 않고 설정한 BatchSize 크기 만큼 SQL에서 IN 구문을 사용하여 조회해옵니다. 데이터베이스에서 일반적으로 IN 구문의 최대 개수는 1000개로 되어 있기 때문에 BatchSize 값은 그 이하로 설정해줍니다.
application.yml에서 BatchSize를 다음과 같이 설정해줍니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
앞서 작성한 테스트 코드를 실행해보면 N개의 추가 쿼리 대신에 SQL의 IN 구문이 실행되는 것을 확인할 수 있습니다.
Hibernate: select board0_.board_id as board_id1_0_, board0_.author as author2_0_, board0_.content as content3_0_, board0_.title as title4_0_ from board board0_
Hibernate: select boardcomme0_.board_id as board_id4_1_1_, boardcomme0_.board_comment_id as board_co1_1_1_, boardcomme0_.board_comment_id as board_co1_1_0_, boardcomme0_.author as author2_1_0_, boardcomme0_.board_id as board_id4_1_0_, boardcomme0_.content as content3_1_0_ from board_comment boardcomme0_ where boardcomme0_.board_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2.2. Fetch Join 사용하기
JPQL을 이용하여 Fetch Join을 사용하면 연관된 엔티티나 컬렉션을 같이 조회 할 수 있습니다. Fetch Join을 이용하면 객체 그래프를 한 번에 조회 할 수 있고 연관관계의 엔티티도 영속성 컨텍스트 관리 대상에 포함되게 됩니다. 일반 조인은 지정된 SELECT 결과만 가져올 수 있지만 패치 조인을 이용하면 연관관계의 엔티티나 컬렉션을 조회할 수 있습니다.
Fetch Join은 다음과 같은 특징이 있습니다.
- SQL을 이용하여 연관관계의 엔티티들을 한 번에 조회하며 성능 최적화에 사용.
- JPQL을 이용하기 때문에 엔티티에 적용한 Fetch 전략을 무시하고 SQL을 실행.
- SQL에서 Fetch Join 구문은 Inner Join 구문으로 변경되어 실행.
- 쿼리 한 번에 모든 데이터를 조회하기 때문에 Paging API(Pageable) 사용 불가.
- SQL에서 JOIN 이후에 WHERE 구문이 필터링 되는데 객체와 DB의 일관성이 유지되지 않아 별칭(alias) 사용 불가.
- 데이터 정합성 문제로 두 개 이상의 엔티티나 컬렉션을 대상으로는 사용 불가.
Fetch Join을 사용하기 위해 다음과 같이 repository에 메서드를 작성해줍니다.
@Repository
public interface BoardRepository extends JpaRepository<Board, String> {
@Query("SELECT b FROM Board b JOIN FETCH b.boardComments")
List<Board> findAllByFetchJoin();
}
테스트 코드에서는 위에서 작성한 findAllByFetchJoin() 메서드를 호출해줍니다.
@RunWith(SpringRunner.class)
@SpringBootTest()
public class BoardServiceTests {
@Test
public void contextLoads() {
List<Board> boards = boardRepository.findAllByFetchJoin();
}
}
테스트 코드를 실행해보면 연관관계의 엔티티를 Inner Join으로 조회해오는 것을 확인할 수 있습니다.
Hibernate: select boardentit0_.board_id as board_id1_0_0_, boardcomme1_.board_comment_id as board_co1_1_1_, boardentit0_.author as author2_0_0_, boardentit0_.content as content3_0_0_, boardentit0_.title as title4_0_0_, boardcomme1_.author as author2_1_1_, boardcomme1_.board_id as board_id4_1_1_, boardcomme1_.content as content3_1_1_, boardcomme1_.board_id as board_id4_1_0__, boardcomme1_.board_comment_id as board_co1_1_0__ from board boardentit0_ inner join board_comment boardcomme1_ on boardentit0_.board_id=boardcomme1_.board_id
2.3. EntityGraph 사용하기
EntityGraph를 사용하면 Fetch Join를 사용한 경우와 마찬가지로 연관된 엔티티나 컬렉션을 같이 조회 할 수 있습니다. JPQL과 함께 사용하며 attributePaths에 쿼리 수행 시 조회할 필드를 지정해줍니다. 지정된 필드는 지연 로딩이 아닌 즉시 로딩으로 조회됩니다. EntityGraph는 Fetch Join과는 다르게 Outer Join이 사용되어 동작합니다.
EntityGraph를 사용하기 위해 다음과 같이 repository에 메서드를 작성해줍니다.
@Repository
public interface BoardRepository extends JpaRepository<Board, String> {
@EntityGraph(
attributePaths = {"boardComments"},
type = EntityGraph.EntityGraphType.LOAD
)
@Query("SELECT b FROM Board b")
List<Board> findAllByEntityGraph();
}
@EntityGraph 어노테이션의 type 프로퍼티는 다음과 같이 2가지 패치 유형을 제공합니다.
- EntityGraph.EntityGraphType.FETCH
attributePaths에 명시한 엔티티는 EAGER로 패치, 나머지 엔티티는 LAZY로 패치. - EntityGraph.EntityGraphType.LOAD
attributePaths에 명시한 엔티티는 EAGER로 패치, 나머지 엔티티는 각 엔티티에 명시한 FetchType 또는 기본 FetchType으로 패치. (@OneToMany, @ManyToMany 기본 패치 전략은 LAZY 이고 @OneToOne, @ManyToOne 기본 패치 전략은 EAGER)
테스트 코드에서는 위에서 작성한 findAllByEntityGraph() 메서드를 호출해줍니다.
@RunWith(SpringRunner.class)
@SpringBootTest()
public class BoardServiceTests {
@Test
public void contextLoads() {
List<Board> boards = boardRepository.findAllByEntityGraph();
}
}
테스트 코드를 실행해보면 연관관계의 엔티티를 Outer Join으로 조회해오는 것을 확인할 수 있습니다.
Hibernate: select boardentit0_.board_id as board_id1_0_0_, boardcomme1_.board_comment_id as board_co1_1_1_, boardentit0_.author as author2_0_0_, boardentit0_.content as content3_0_0_, boardentit0_.title as title4_0_0_, boardcomme1_.author as author2_1_1_, boardcomme1_.board_id as board_id4_1_1_, boardcomme1_.content as content3_1_1_, boardcomme1_.board_id as board_id4_1_0__, boardcomme1_.board_comment_id as board_co1_1_0__ from board boardentit0_ left outer join board_comment boardcomme1_ on boardentit0_.board_id=boardcomme1_.board_id
2.4. Fetch Join 및 EntityGraph 사용 시 발생하는 카테시안 곱 문제
Fetch Join 또는 EntityGraph를 사용한 경우엔 카테시안 곱(Cartesian Product)으로 인하여 중복 문제가 발생할 수 있습니다. 카테시안 곱은 연관관계의 엔티티에 Join 규칙을 사용하지 않아 결합 조건이 주어지지 않았을 때 모든 데이터의 경우의 수를 결합(M * N)하여 반환하는 문제입니다. 즉, 조인 조건이 없는 경우에 대한 두 테이블의 결합 결과를 반환해야 하기 때문에 존재하는 모든 행의 개수를 곱한 결과를 반환하게 됩니다. 카테시안 곱 문제를 해결하기 위해서는 다음과 같은 방법을 사용합니다.
2.4.1. JPQL에 DISTINCT 사용하기
JPQL을 이용한 쿼리를 사용할 때 다음과 같이 DISTINCT를 사용하여 중복을 제거합니다.
@Repository
public interface BoardRepository extends JpaRepository<Board, String> {
@Query("SELECT DISTINCT b FROM Board b JOIN FETCH b.boardComments")
List<Board> findAllByFetchJoin();
@EntityGraph(
attributePaths = {"boardComments"},
type = EntityGraph.EntityGraphType.LOAD
)
@Query("SELECT DISTINCT b FROM Board b")
List<Board> findAllByEntityGraph();
}
2.4.2. 연관관계의 필드 타입에 Set 사용하기
Set은 중복을 허용하지 않기 때문에 중복 데이터가 들어가지 않지만 순서가 보장되지 않습니다. 이러한 경우엔 다음과 같이 연관관계의 필드에 LinkedHashSet을 사용하여 중복을 제거하고 순서를 보장 해줄 수 있습니다.
@Getter
@NoArgsConstructor
@Entity(name = "board")
public class Board {
@OneToMany(mappedBy = "board")
private Set<BoardComment> boardComments = new LinkedHashSet<>();
}
이상으로 JPA 사용 시 발생하는 N+1 문제를 해결하는 방법에 대해 알아봤습니다.
※ References
- 김영한 지음, 『자바 ORM 표준 JPA 프로그래밍』, 에이콘 (2015), 13장 웹 애플리케이션과 영속성 관리 (p586 ~ p588)
- 김영한 지음, 『자바 ORM 표준 JPA 프로그래밍』, 에이콘 (2015), 15장 고급 주제와 성능 최적화 (p675 ~ p681)
- jojoldu.tistory.com, JPA N+1 문제 및 해결방안, https://jojoldu.tistory.com/165
- dev-coco.tistory.com, [JPA] N+1 문제 원인 및 해결방법 알아보기, https://dev-coco.tistory.com/165
- jh2021.tistory.com, JPA n+1 문제는 왜 생기는걸까?, https://jh2021.tistory.com/21
- lucas-owner.tistory.com, 내가 보려고 만든 개발 (Tech) blog, https://lucas-owner.tistory.com/52
- velog.io/@sweet_sumin, JPA N+1 이슈는 무엇이고, 해결책은 무엇인가요?, https://velog.io/@sweet_sumin/JPA-N1-%EC%9D%B4%EC%8A%88%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%ED%95%B4%EA%B2%B0%EC%B1%85%EC%9D%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80%EC%9A%94
- maivve.tistory.com, [Spring] JPA N+1 문제에 대한 고찰. (원인, 테스트, 해결방법), https://maivve.tistory.com/340
- loosie.tistory.com, [JPA] 페치 조인(Fetch Join)의 일관성 - 대상에 별칭 사용하면 안되는 이유, https://loosie.tistory.com/750#%ED%8E%98%EC%B9%98_%EC%A1%B0%EC%9D%B8(Fetch_Join)%EC%97%90%EC%84%9C_%EB%8C%80%EC%83%81%EC%97%90%EA%B2%8C_%EB%B3%84%EC%B9%AD%EC%9D%84_%EA%B1%B8%EB%A9%B4_%EC%95%88%EB%90%98%EB%8A%94_%EC%9D%B4%EC%9C%A0