N+1 문제 해결 후기
두 엔티티 간에 영속성 전이가 제대로 이루어지려면?현재 진행하고 있는 개인 프로젝트에서 다음과 같이 엔티티들의 연관관계가 설정되어 있다. Member -- (OneToMany) -- History -- (OneToMany) -- Question
dockerel.tistory.com
정확하게 반년 전에 N+1 문제를 맞닥뜨렸습니다
이때 당황한 기억이 아니 그 당황했다기보다는 그 유명한 N+1 문제를 직접 겪었단 사실에 가슴이 두근두근 했던 기억이 나네요
반년이 지난 지금 저는 N+1 문제에 대해 얼마나 더 자세히 알게 됐을지 알아보겠습니다
스따뜨
우선 N+1 문제의 정의는 그거죠
JPA에서 연관 관계가 설정된 엔티티를 조회할 경우, 조회된 데이터 개수 N만큼 연관관계의 조회 쿼리가 추가로 발생하는 현상입니다.
예시로 알아볼까요?
호텔 예약 사이트에 호텔 엔티티와 그 호텔에 있는 방 엔티티들이 1-N 관계로 설정되어 있습니다
물론 성능을 생각하여 지연 로딩으로 설정해놨다고 생각해봅시다
만약에 근데 방이 남아 있는 호텔만 알고 싶다면?
@Transactional(readOnly = true)
public List<Hotel> getAvailableHotels() {
List<Hotel> hotels = repository.findAll();
return hotels.stream()
.filter(hotel -> !hotel.getRooms().isEmpty())
.collect(Collectors.toList());
}
이런식으로 작성할 수 있겠죠?
근데 이러면 N+1 문제 가 발생합니다
왜냐하면
1. 모든 호텔들을 조회하는 쿼리 1개 발생
2. hotel.getRooms().isEmpty() 조회 시 프록시로 로드되어 있던 rooms들을 초기화 하기 위해 select * from room where room.hotel_id = hotel_id 쿼리 발생
근데! 2번에서 호텔 개수(N) 만큼 N개의 쿼리가 발생하는겁니다
그래서 ~~ N+1 문제다
근데 그럼 "물론 성능을 생각하여 지연 로딩으로 설정해놨다고 생각해봅시다" <- 이게 문제 아니냐고요?
즉시 로딩으로 하면 되지 않냐고요?
실제로 해보시면 안됩니다
왜 안될까요? 코드가 틀렸을까요? Spring-Data-JPA팀에 이슈 올리고 pr 넣어서 오픈소스 기여하겠다고요?
헉
결론은 findAll() 같은 경우 jpql 구문을 만들어서 실행하기 때문에 글로벌 패치 전략을 고려하지 않고 쿼리를 실행합니다
그러면 즉시 로딩은 적용이 안된걸까요?
적용되긴 했습니다
엔티티를 조인 쿼리로 가져오는 것이 아닌 현재 엔티티를 조회하면 관련된 엔티티도 같은 시점에 가져온다. 하지만 뭐 N+1 문제가 발생하던 말던 일단 가져온다
이런식으로 해석이 됩니다
그럼 어떤식으로 해결하면 좋을까요?
첫 번째는 배치 사이즈 설정입니다
@BatchSize(size = 2)
@OneToMany(mappedBy = "hotel", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<Room> rooms = new ArrayList<>();
배치 사이즈를 지정하면 지정한 개수만큼 연관 엔티티를 IN 쿼리로 한꺼번에 조회가 가능합니다
select
r1_0.hotel_id,
r1_0.id,
r1_0.name
from
ch5_room r1_0
where
r1_0.hotel_id in (?,?)
이렇게 2개씩 조회가 되는걸 볼 수 있습니다
근데 배치 사이즈를 설정하면 그만큼 필요 없어도 항상 지정한 크기만큼 가져오기 때문에 절한 사이즈 설정이 필요합니다
그렇지 않으면 불필요하게 메모리 및 자원을 소비하는 문제가 발생할 수 있겠죠?
두 번째는 fetch join 입니다
가장 유명한 해결방법이죠?
public interface HotelRepository extends JpaRepository<Hotel, Long> {
@Query("SELECT h FROM Hotel h JOIN FETCH h.rooms")
List<Hotel> findAllWithRooms();
}
이렇게 패치 조인을 설정해주면 내부적으로 조인 쿼리를 만들어 같이 로드해줍니다
그럼 쿼리 한번으로도 모든 데이터를 가져올 수 있죠
하지만 패치 조인도 무적은 아닙니다
크게 3가지 문제가 있는데요 패치 조인을 사용하면
1. 중복 row가 발생할 수 있습니다
만약 호텔1,2에 각각 방 1,2가 존재한다면 db내에서 가져올 때 호텔1-방1, 호텔1-방2, 호텔2-방1, 호텔2-방2 처럼 중복 row가 생길 수 있습니다
호텔1
방1
호텔1
방2
호텔2
방1
호텔2
방2
...
...
이때 jpa는 중복 엔티티를 1차 캐시에 올리지 않고 중복 검사를 한 후 하나의 엔티티만 올립니다
이때 db에서 중복된 row가 많이 반환될 수록 많은 중복 검사를 해야하니 부하가 많이 발생될 수 있고 대규모 데이터 사용 시 불필요하게 많은 row를 가져와 메모리에서 중복을 제거하는데 자원이 낭비될 수 있습니다
2. 여러 컬렉션(1:N, N:M) 관계를 연쇄적으로 패치 조인하면 기하급수적인 row가 생겨 하이버네이트가 예외를 던지거나 심각한 성능 문제가 발생할 수 있습니다
MultipleBagFetchException 해결할래 말래
안할래 농담이고요. 해결기 보시죠. 레지고 문제는 그렇게 발생했습니다. 일단 엔티티간 연관관계부터 보시죠. 지금 개발하고 있는 챗봇 서비스는 회원 한명이 여러 히스토리를, 히스토리 하나
dockerel.tistory.com
그 문제에 대해서는 위 글을 참고해주면 됩니다
3. 페이징 문제
패치 조인은 페이징을 지원하지 않습니다
정확히는 표준 JPA 스펙에서 컬렉션 패치 조인에 페이징 조합을 보장하지 않습니다
이는 페이징 기준에 따라 달라지는 결과를 보장할 수 없기 때문이고, 서버측에서 페이징의 기준이 되는 엔티티만 우선 페이징 처리하여 연관 엔티티들을 로드하는 방식으로 해결할 수 있습니다
이런 문제들이 있고 계속 N+1 문제 해결 방식에 대해 알아봅시다
세 번째는 @EntityGraph 방식입니다
public interface HotelRepository extends JpaRepository<Hotel, Long> {
@EntityGraph(attributePaths = "rooms")
List<Hotel> findAll();
}
JPA 표준 기능으로, JPQL이나 기본 CRUD 메서드에 적용할 수 있으며, 이렇게 사용해주면 내부적으로 패치 조인을 사용해서 조인해줍니다
그 말은 즉슨 기존 패치 조인의 한계점이 동일하게 적용되겠죠?
정리하자면 정답은 없고 현재 서비스의 비즈니스 요구사항에 맞게 적절한 해결 방법을 선택하는 것이 좋을 것 같습니다
자 그럼 이게 과연 N+1 문제의 끝일까요?
VIDEO
끝난건 아니고 정말 특이한 케이스가 존재합니다
최근에 정말 기묘한 사례를 봤습니다
만약 OneToOne으로 대응되는 Question 엔티티와 Answer 엔티티가 존재하고 Answer쪽에 FK가 존재한다고 해보겠습니다
물론 지연 로딩으로 설정해놓았구요
QuestionRepository에서 findAll()로 조회하면?
N+1 문제 가 발생합니다
영문을 알 수가 없는데 내막은 이렇습니다
OneToOne도 연관 엔티티에 대해 지연 로딩 시 프록시 객체를 사용합니다
그런데 OneToOne은 프록시 객체 사용 시 해당 객체의 PK를 알고 있어야 합니다
즉 Question 엔티티 입장에서 Answer 엔티티를 프록시로 사용하는 대신 PK라도 알고 있겠다는 겁니다
그리고 나중에 실제 엔티티 조회 시 PK를 사용해서 로드하겠다는 겁니다
근데 Answer쪽에 FK가 존재하기 때문에 Question쪽에서는 Answer의 PK를 알 수 있는 방법이 없고 결국 이를 알기 위한 쿼리가 Question마다 나가게 됩니다. 그래서 N+1 문제가 발생하는거죠
그럼 어케 해결하나요
그냥 머~ 패치 조인 써도 되겠지만 그럼 결국 필요 없는 Answer 엔티티도 같이 불러오게 되죠?
그래서 결국 전략적인 FK의 배치가 필요합니다
Question 쪽에 Answer의 PK를 FK로 두면?
필요한 Question 엔티티만 불러올 수 있고 패치 조인을 쓰지 않아도 되지만, 만약에 추후 Question-Answer 관계가 1:N으로 발전할시에 Answer쪽에 FK를 재배치해야합니다. 즉 엔티티 구조를 바꿔야 한다는 뜻이죠
근데 그렇다고 Answer쪽에 두면?
앞에서 봤듯이 N+1문제가 발생하거나 아니면 패치 조인으로 쓸대없는 정보까지 한번에 불러와야 하지만 추후 1:N으로 발전시킬 수 있겠죠
정리하자면 데이터 사용 패턴에 맞게 fk위치와 패치 조인 전략을 잘 고려해서 설계하는것이 중요하다라는 겁니다
정리에 정리에 정리하자면 결국 N+1 문제를 해결할 때에는 단순한 기술적인 설정으로 해결하는 것이 아닌 서비스의 비스니스 요구사항에 맞게 데이터 흐름과 연관관계 설정을 함으로써 해결 가능합니다
이예이~~~~~~~~~~
오늘도 문제 하나 해결 성공~~~~
네
마치겠습니다