개요
이번 글에서는 Spring Data JPA에서 @ManyToOne 관계를 가지는 엔티티를 조회할 때 발생하는 N + 1 문제를 해결하는 방법에 대해 이야기해 보려고 합니다.
배경
최근에 사내에 ORM을 사용하는 프로젝트가 많아지면서 @ManyToOne 관계를 가지는 엔티티를 조회할 때 N + 1 문제에 대해 고려하지 않고 개발되어 조회 쿼리가 추가적으로 발생되는 이슈가 있어 N + 1 문제가 무엇인지 알아보고 어떻게 해결하는지에 대해 설명해 보겠습니다.
N + 1 문제란?
연관 관계가 설정된 Entity를 조회할 경우에 조회된 데이터 개수(N) 만큼 연관관계의 조회 쿼리가 추가로 발생하는 문제이다.
N + 1 문제 예시 - Lazy Loading
@Entity
@Getter
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "category_id")
private Long id;
private String name;
}
@Entity
@Getter
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
}
위 코드와 같이 1:N 관계를 가지는 카테고리와 게시글 테이블이 있다고 가정하겠습니다.
게시글 테이블이 이 연관 관계의 주인이 될 수 있도록 설정해 주고(게시글 테이블이 카테고리 테이블의 PK를 FK로 가지고 있기 때문) JPA에서 기본이 EAGER로 되어 있기 때문에 Hibernate에서도 스펙에 따르고 있지만 기본적으로 LAZY로 변경해서 사용하길 권장하고 있어 FetchType을 Lazy로 설정해 줍니다.
FetchType을 Lazy로 권장하는 이유 또한 N+1 문제와 관련이 있습니다.
Lazy Loading은 연관된 엔티티가 필요하지 않으면 추가 쿼리가 실행되지는 않아 LAZY로 변경해서 사용하길 권장하고 있지만 카테고리를 proxy 객체로 가지고 있고 연관된 엔티티가 필요한 시점에 쿼리가 실행되게 됩니다.


위와 같이 데이터가 저장되어 있고 boardRepository에서 findAll 쿼리를 실행하여 Category의 데이터를 참조하려고 할 때 어떤 일이 발생하는지 알아보겠습니다.
@Test
@DisplayName("N+1 발생")
void test1() {
List<Board> result = boardRepository.findAll();
result.forEach(board -> {
System.out.println("Board Title: " + board.getTitle());
System.out.println("Category Name: " + board.getCategory().getName());
});
}
이렇게 테스트 코드를 작성하고 테스트를 실행해보겠습니다.

위 로그와 같이 N + 1 문제가 발생한 걸 확인할 수 있습니다.
쿼리를 상세하게 살펴보자면
boardRepository.findAll();
위 코드를 통해 Board 테이블을 조회했는데 카테고리 테이블에 대한 조회가 3건이나 추가로 발생한 것을 확인할 수 있습니다.
모든 Board 테이블에서 FK로 가지고 있는 category_id가 1 ~ 3 범위를 가지기 때문에 추가 쿼리가 3건이 발생했습니다.
이렇게 Lazy Loading에서도 N+1 문제가 발생하는 것을 알아낼 수 있습니다.
위 예시에서는 기본 1건 + 추가 3건이 발생하여 사소하다고 생각될 수 있지만 엔티티 연관관계나 테이블에 적재되어 있는 데이터의 양에 따라 추가 쿼리가 엄청나게 발생할 수 있습니다.
N + 1 문제 해결하기 - Fetch Join
N + 1 문제를 해결하기 위한 여러 방법들이 존재합니다.
이 중 Fetch Join을 통해 N + 1 문제를 해결하는 방법에 대해 설명해 보겠습니다.
우선 Fetch Join이 무엇인지부터 알아보겠습니다.
Fetch Join은 JPQL에서 성능 최적화를 위해 제공하는 조인의 종류로 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능입니다.
JPA는 JPQL을 기반한 쿼리들을 지원해 주기 때문에 Fetch Join 또한 사용할 수 있습니다.
이번 포스팅에서는 QueryDSL을 통해 Fetch Join을 사용하는 예시를 작성해보겠습니다.
public class BoardCustomRepositoryImpl implements BoardCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
public BoardCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public List<Board> findAllByFetchJoin() {
return jpaQueryFactory.selectFrom(board)
.innerJoin(board.category, category)
.fetchJoin()
.fetch();
}
}
이렇게 QueryDSL을 통해 Fetch Join을 사용하여 테스트를 다시 실행해보겠습니다.

위 사진과 같이 Fetch Join을 적용한 후에는 1개의 쿼리만 실행되는 것을 확인할 수 있습니다.
@OneToMany 상황에서는 Category 테이블에서 조인을 통해 Board 테이블을 참조하는 상황이라면 추가로 Distinct를 사용하여 데이터의 중복을 제거할 수 있습니다.
N + 1 문제의 또 다른 해결 방법
N + 1 문제를 해결하기 위한 방법은 여러 가지가 존재합니다.EntityGraph를 사용하거나 Batch Size를 설정하는 방법, 연관관계 자체를 맺지 않는 방법 등 여러 방법으로 N + 1 문제를 해결할 수 있는 방법들이 존재합니다.
각각의 장단점을 잘 확인하고 프로젝트의 환경에 맞는 방법을 사용하면 될 거 같습니다.
또 다른 해결 방법들이나 OneToOne, xToMany 관계에 대한 해결 방법들은 추후에 필요한 상황이 오면 테스트 및 포스팅 진행하겠습니다.
'Spring' 카테고리의 다른 글
개요
이번 글에서는 Spring Data JPA에서 @ManyToOne 관계를 가지는 엔티티를 조회할 때 발생하는 N + 1 문제를 해결하는 방법에 대해 이야기해 보려고 합니다.
배경
최근에 사내에 ORM을 사용하는 프로젝트가 많아지면서 @ManyToOne 관계를 가지는 엔티티를 조회할 때 N + 1 문제에 대해 고려하지 않고 개발되어 조회 쿼리가 추가적으로 발생되는 이슈가 있어 N + 1 문제가 무엇인지 알아보고 어떻게 해결하는지에 대해 설명해 보겠습니다.
N + 1 문제란?
연관 관계가 설정된 Entity를 조회할 경우에 조회된 데이터 개수(N) 만큼 연관관계의 조회 쿼리가 추가로 발생하는 문제이다.
N + 1 문제 예시 - Lazy Loading
@Entity
@Getter
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "category_id")
private Long id;
private String name;
}
@Entity
@Getter
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
}
위 코드와 같이 1:N 관계를 가지는 카테고리와 게시글 테이블이 있다고 가정하겠습니다.
게시글 테이블이 이 연관 관계의 주인이 될 수 있도록 설정해 주고(게시글 테이블이 카테고리 테이블의 PK를 FK로 가지고 있기 때문) JPA에서 기본이 EAGER로 되어 있기 때문에 Hibernate에서도 스펙에 따르고 있지만 기본적으로 LAZY로 변경해서 사용하길 권장하고 있어 FetchType을 Lazy로 설정해 줍니다.
FetchType을 Lazy로 권장하는 이유 또한 N+1 문제와 관련이 있습니다.
Lazy Loading은 연관된 엔티티가 필요하지 않으면 추가 쿼리가 실행되지는 않아 LAZY로 변경해서 사용하길 권장하고 있지만 카테고리를 proxy 객체로 가지고 있고 연관된 엔티티가 필요한 시점에 쿼리가 실행되게 됩니다.


위와 같이 데이터가 저장되어 있고 boardRepository에서 findAll 쿼리를 실행하여 Category의 데이터를 참조하려고 할 때 어떤 일이 발생하는지 알아보겠습니다.
@Test
@DisplayName("N+1 발생")
void test1() {
List<Board> result = boardRepository.findAll();
result.forEach(board -> {
System.out.println("Board Title: " + board.getTitle());
System.out.println("Category Name: " + board.getCategory().getName());
});
}
이렇게 테스트 코드를 작성하고 테스트를 실행해보겠습니다.

위 로그와 같이 N + 1 문제가 발생한 걸 확인할 수 있습니다.
쿼리를 상세하게 살펴보자면
boardRepository.findAll();
위 코드를 통해 Board 테이블을 조회했는데 카테고리 테이블에 대한 조회가 3건이나 추가로 발생한 것을 확인할 수 있습니다.
모든 Board 테이블에서 FK로 가지고 있는 category_id가 1 ~ 3 범위를 가지기 때문에 추가 쿼리가 3건이 발생했습니다.
이렇게 Lazy Loading에서도 N+1 문제가 발생하는 것을 알아낼 수 있습니다.
위 예시에서는 기본 1건 + 추가 3건이 발생하여 사소하다고 생각될 수 있지만 엔티티 연관관계나 테이블에 적재되어 있는 데이터의 양에 따라 추가 쿼리가 엄청나게 발생할 수 있습니다.
N + 1 문제 해결하기 - Fetch Join
N + 1 문제를 해결하기 위한 여러 방법들이 존재합니다.
이 중 Fetch Join을 통해 N + 1 문제를 해결하는 방법에 대해 설명해 보겠습니다.
우선 Fetch Join이 무엇인지부터 알아보겠습니다.
Fetch Join은 JPQL에서 성능 최적화를 위해 제공하는 조인의 종류로 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능입니다.
JPA는 JPQL을 기반한 쿼리들을 지원해 주기 때문에 Fetch Join 또한 사용할 수 있습니다.
이번 포스팅에서는 QueryDSL을 통해 Fetch Join을 사용하는 예시를 작성해보겠습니다.
public class BoardCustomRepositoryImpl implements BoardCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
public BoardCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public List<Board> findAllByFetchJoin() {
return jpaQueryFactory.selectFrom(board)
.innerJoin(board.category, category)
.fetchJoin()
.fetch();
}
}
이렇게 QueryDSL을 통해 Fetch Join을 사용하여 테스트를 다시 실행해보겠습니다.

위 사진과 같이 Fetch Join을 적용한 후에는 1개의 쿼리만 실행되는 것을 확인할 수 있습니다.
@OneToMany 상황에서는 Category 테이블에서 조인을 통해 Board 테이블을 참조하는 상황이라면 추가로 Distinct를 사용하여 데이터의 중복을 제거할 수 있습니다.
N + 1 문제의 또 다른 해결 방법
N + 1 문제를 해결하기 위한 방법은 여러 가지가 존재합니다.EntityGraph를 사용하거나 Batch Size를 설정하는 방법, 연관관계 자체를 맺지 않는 방법 등 여러 방법으로 N + 1 문제를 해결할 수 있는 방법들이 존재합니다.
각각의 장단점을 잘 확인하고 프로젝트의 환경에 맞는 방법을 사용하면 될 거 같습니다.
또 다른 해결 방법들이나 OneToOne, xToMany 관계에 대한 해결 방법들은 추후에 필요한 상황이 오면 테스트 및 포스팅 진행하겠습니다.