Overview
자녀가 좋아요 한 책을 조회하는 API를 개발하던 중, Postman으로 API를 테스트하면서 콘솔에 출력된 쿼리를 확인했습니다. 그런데, 여러 번의 SELECT 문이 반복적으로 실행되는 N+1 문제가 발생하는 것을 발견했습니다. 이번 글에서는 이 N+1 문제의 원인과 해결 방법에 대해 정리해보겠습니다.
N+1 문제란?
연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상
아래 코드는 N + 1 문제를 겪었던 상황에 대한 코드입니다.
테스트 코드를 통해 N+1 문제를 다시 발생시켜 보겠습니다.
문제 상황
DB
우선, DB에 자녀가 좋아요를 누른 책 15개를 저장했습니다.
그리고 이 데이터 기반으로 자녀가 좋아요 한 책을 조회하는 상황을 가정해 보겠습니다.
엔티티
Kid(자녀)
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Kid extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "kid_id")
private Long id;
@Enumerated(EnumType.STRING)
private Gender gender;
private String name;
private LocalDate birth;
private boolean isDeleted = Boolean.FALSE;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@OneToOne(mappedBy = "kid", fetch = FetchType.LAZY)
private KidMbti kidMbti;
...
}
LikeMbti(좋아요, 싫어요 정보를 담은 엔티티)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "likes_mbti")
public class LikeMbti extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "like_mbti_id")
private Long id;
@Enumerated(EnumType.STRING)
private LikeStatus likeStatus;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "kid_id")
private Kid kid;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id")
private Book book;
...
}
book(책)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Book extends BaseTimeEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "book_id")
private Long id;
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
@JoinColumn(name = "mbti_id", nullable = false)
private BookMbti bookMbti;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "genre_id", nullable = false)
private Genre genre;
@Column(name = "title", length = 50, nullable = false)
private String title;
@Column(name = "summary", columnDefinition = "TEXT")
private String summary;
...
}
LikeBookResponse
@Getter
@Setter
public class LikeBookResponse {
private Long id;
private Long genreId;
private String title;
private String summary;
private String author;
private String publisher;
private String imageUrl;
private Integer age;
private MbtiType mbtiType;
@Builder
public LikeBookResponse(Long id, Long genreId, String title, String summary, String author, String publisher,
String imageUrl, Integer age, MbtiType mbtiType) {
this.id = id;
this.genreId = genreId;
this.title = title;
this.summary = summary;
this.author = author;
this.publisher = publisher;
this.imageUrl = imageUrl;
this.age = age;
this.mbtiType = mbtiType;
}
public static LikeBookResponse from(Book book) {
return LikeBookResponse.builder()
.id(book.getId())
.genreId(book.getGenre().getId())
.title(book.getTitle())
.summary(book.getSummary())
.author(book.getAuthor())
.publisher(book.getPublisher())
.imageUrl(book.getImageUrl())
.age(book.getAge())
.mbtiType(book.getBookMbti().getBookMbtiType())
.build();
}
}
LikeMbtiRepository
public interface LikeMbtiRepository extends JpaRepository<LikeMbti, Long> {
@Query("select lb from LikeMbti lb "
+ "JOIN FETCH lb.book b "
+ "where lb.kid.id = :kidId and b.isDeleted = false and lb.likeStatus = :likeStatus")
List<LikeMbti> findBooksBy(@Param("kidId") Long kidId,
@Param("likeStatus") LikeStatus likeStatus);
}
테스트 코드
@Test
void getBooks() {
List<LikeMbti> likeMbtis = likeMbtiRepository.findBooksBy(11L, LikeStatus.LIKE);
List<LikeBookResponse> bookResponses = likeMbtis.stream()
.map(likeMbti -> LikeBookResponse.from(likeMbti.getBook()))
.toList();
assertThat(bookResponses).hasSize(15);
}
먼저, 사용자가 좋아요를 누른 책 15개를 잘 가져오는지 확인하기 위해 테스트를 작성하고 실행했습니다. 테스트는 정상적으로 통과했지만, 콘솔에 출력된 쿼리를 확인해 보니 15개의 추가 SELECT 쿼리가 발생한 것을 확인할 수 있었습니다. 이는 N+1 문제가 발생했음을 의미합니다.
Hibernate:
select
lm1_0.like_mbti_id,
b1_0.book_id,
b1_0.age,
b1_0.author,
b1_0.mbti_id,
b1_0.created_at,
b1_0.genre_id,
b1_0.image_url,
b1_0.is_deleted,
b1_0.publisher,
b1_0.summary,
b1_0.title,
b1_0.updated_at,
lm1_0.created_at,
lm1_0.kid_id,
lm1_0.like_status,
lm1_0.updated_at
from
likes_mbti lm1_0
join
book b1_0
on b1_0.book_id=lm1_0.book_id
where
lm1_0.kid_id=?
and b1_0.is_deleted=0
and lm1_0.like_status=?
Hibernate:
select
bm1_0.mbti_id,
bm1_0.book_mbti_type,
bm1_0.created_at,
bm1_0.e_score,
bm1_0.f_score,
bm1_0.i_score,
bm1_0.is_deleted,
bm1_0.j_score,
bm1_0.n_score,
bm1_0.p_score,
bm1_0.s_score,
bm1_0.t_score,
bm1_0.updated_at
from
book_mbti bm1_0
where
bm1_0.mbti_id=?
...
Hibernate:
select
bm1_0.mbti_id,
bm1_0.book_mbti_type,
bm1_0.created_at,
bm1_0.e_score,
bm1_0.f_score,
bm1_0.i_score,
bm1_0.is_deleted,
bm1_0.j_score,
bm1_0.n_score,
bm1_0.p_score,
bm1_0.s_score,
bm1_0.t_score,
bm1_0.updated_at
from
book_mbti bm1_0
where
bm1_0.mbti_id=?
쿼리를 보면 likes_mbti 테이블에서 book 테이블을 조인하여 데이터를 가져옵니다.
문제는 book을 조인해서 데이터를 가져올 때 book과 연관관계에 있는 book_mbti 데이터도 같이 가져오는 쿼리가 발생합니다.
아래는 Book 엔티티인데 현재 BookMbti와 1:1 관계에 있습니다. FetchType을 LAZY로 설정했지만 왜 추가 쿼리가 발생한 걸까요?
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Book extends BaseTimeEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "book_id")
private Long id;
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
@JoinColumn(name = "mbti_id", nullable = false)
private BookMbti bookMbti;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "genre_id", nullable = false)
private Genre genre;
...
}
그 이유는 아래 코드에서 .mbtiType(book.getBookMbti().getBookMbtiType()) 부분 때문입니다. LikeMbti와 Book은 페치 조인을 사용하여 한 번에 데이터를 가져왔지만 BookMbti 같은 경우에는 함께 가져오지 못했습니다.
BookMbti를 가져오지 못한 상황에서 book.getBookMbti().getBookMbtiType()을 호출하면 bookMbti의 실제 값을 사용하려 하기 때문에 BookMbti 데이터를 가져오기 위해 book_mbti 테이블로 추가적인 SELECT 쿼리가 발생하게 된 것입니다.
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class BookResponse {
private Long id;
private Long genreId;
private String title;
private String summary;
private String author;
private String publisher;
private Integer age;
private String imageUrl;
private MbtiType mbtiType;
public static BookResponse from(Book book) {
return BookResponse.builder()
.id(book.getId())
.genreId(book.getGenre().getId())
.title(book.getTitle())
.summary(book.getSummary())
.author(book.getAuthor())
.publisher(book.getPublisher())
.age(book.getAge())
.imageUrl(book.getImageUrl())
.mbtiType(book.getBookMbti().getBookMbtiType())
.build();
}
}
한번 book.getBookMbti().getBookMbtiType()을 주석 처리하고 다시 테스트 코드를 실행해 보겠습니다.
실행하면 테스트 코드도 잘 통과되고 콘솔에 찍힌 쿼리도 하나의 쿼리가 찍히는 것을 확인할 수 있습니다.
즉 N + 1 문제가 발생하지 않았습니다.
해결 방법
N+1 문제를 해결하는 방법에는 Fetch Join, EntityGraph 어노테이션, Batch Size 등의 방법이 있습니다.
이번 글에서는 Fetch Join과 Batch Size를 활용하여 문제를 해결하는 방법에 대해 알아보겠습니다.
1. Fetch Join
JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법입니다.
(SQL Join 문을 생각하면 된다. )
별도의 메서드를 만들어줘야 하며 @Query 어노테이션을 사용해서 "join fetch 엔티티.연관관계_엔티티" 구문을 만들어 주면 됩니다. 현재 상황에서는 아래 코드와 같이 bookMbti에 대한 페치 조인을 추가해 주면 됩니다.
public interface LikeMbtiRepository extends JpaRepository<LikeMbti, Long> {
@Query("select lb from LikeMbti lb "
+ "JOIN FETCH lb.book b "
+ "JOIN FETCH b.bookMbti "
+ "where lb.kid.id = :kidId and b.isDeleted = false and lb.likeStatus = :likeStatus")
List<LikeMbti> findBooksBy(@Param("kidId") Long kidId,
@Param("likeStatus") LikeStatus likeStatus);
}
다시 테스트 코드를 돌려보면 잘 통과되고 콘솔에 찍힌 쿼리도 한개만 출력되는 것을 확인할 수 있습니다.
2. Batch Size
이 옵션은 정확히 말해 N+1 문제를 완전히 해결하는 방법은 아닙니다. 다만, N+1 문제가 발생하더라도 SELECT * FROM user WHERE team_id IN (?, ?, ?) 방식으로 한 번의 추가 조회로 성능을 최적화하는 방법입니다. 이렇게 설정하면 원래 100번 발생할 N+1 문제가 1번의 추가 조회로 줄어들어 성능이 개선됩니다.
application.yml 쪽에 default_batch_fetch_size: 1000을 추가해 줍니다.
위 설정을 추가해 주고 테스트 코드를 실행해 보겠습니다.
테스트는 잘 통과되고 총 2개의 쿼리가 발생한 것을 볼 수 있습니다.
첫 번째 쿼리는 페치 조인이 적용된 두 엔티티 likes_mbti와 book을 조인해서 가져옵니다.
두 번째 쿼리는 book_mbti에 대한 정보를 가져오는데 where 절을 보면 아래와 같이 IN 절이 추가된 것을 확인할 수 있습니다.
where
bm1_0.mbti_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
실무에서 N+1문제로 DB가 죽어버리는 문제를 방지하기 위해서는 어떻게 해야 할까?
우선 연관관계에 대한 설정이 필요하다면 FetchType을 성능 최적화를 하기 어려운 즉시 로딩(EAGER)을 사용하는 게 아니라 지연 로딩 (LAZY) 모드로 사용을 하고 성능 최적화가 필요한 부분에서는 Fetch 조인을 사용합니다. 또한 기본적으로 Batch Size의 값을 1000 이하로 설정합니다. (대부분의 DB에서 IN절의 최대 개수 값 : 1000)
그리고 꼭 연관관계 설정이 필요 없다면 N+1 문제로 인하여 DB가 죽어버리는 불상사를 막기 위해 연관관계를 끊어버리고 사용하는 것도 방법이라고 합니다.
마무리
이번 글에서는 N+1 문제에 대한 설명과 이를 해결하는 방법에 대해 알아보았습니다.
사실 키즈핑 프로젝트 이전에도 N+1 문제를 접한 적이 있었지만, 별도로 정리하지 않았습니다.
이번 키즈핑 프로젝트에서 다시 이 문제를 경험하게 된 것을 계기로, 해당 내용을 블로그에 정리하게 되었습니다.
참고
https://velog.io/@xogml951/JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EC%B4%9D%EC%A0%95%EB%A6%AC
'프로젝트 > Kidsping' 카테고리의 다른 글
개발 기록 - 선착순 응모 시스템 이슈 및 해결 과정 (0) | 2024.10.30 |
---|---|
트러블 슈팅 - AWS 프리티어 EC2 인스턴스 메모리 부족 현상 해결하기 (0) | 2024.10.25 |
개발 기록 - Java에서 Enum 의 비교는 '==' 인가? 'equals' 인가? (0) | 2024.10.22 |
개발 기록 - 자녀 성향 진단 로직 구현 및 리팩터링 (0) | 2024.10.22 |
개발 기록 - @RequestBody DTO에 값이 안들어오는 이유 (Jackson, Lombok) (0) | 2024.10.22 |