-
Notifications
You must be signed in to change notification settings - Fork 0
N 1문제 해결(FetchJoin)
JPA를 사용할 때 자주 발생하는 N+1 문제와 그 해결 방법에 대해 설명합니다.
JPA는 객체 지향 프로그래밍(OOP)과 데이터베이스 간의 불일치 문제를 해소하고,
복잡한 SQL 쿼리를 작성할 필요 없이 데이터를 쉽게 다룰 수 있게 도와줍니다. 또한, PSA를 통해
특정 DBMS에 종속되지 않고 다양한 데이터베이스와 호환이 가능해 유지보수성도 뛰어납니다.
그러나 이러한 장점에도 불구하고, N+1 문제라는 성능 저하 문제가 발생할 수 있습니다.
N+1 문제는 연관된 데이터를 조회할 때 원하지 않는 다수의 추가 쿼리가 발생하는 현상을 말합니다.
쿼리의 횟수가 많아질수록 성능이 저하되고, DB에 대한 비용이 증가하는 문제가 있습니다.
JPA에서는 연관된 데이터를 한 번에 조회할 수 있는 즉시 로딩 기능이 있습니다. 이를 통해 불필요한 추가 쿼리를 방지할 수 있다고 생각할 수 있지만, 즉시 로딩도 N+1 문제를 일으킬 수 있습니다.
즉시 로딩은 연관된 데이터를 한 번의 쿼리로 모두 가져오는 것이 이상적이지만,
실제로는 일대다(N:1), 다대일(1) 관계에서는 문제가 발생할 수 있습니다.
즉, 먼저 주 객체(예: Item)에 대한 조회 쿼리가 실행된 후, Hibernate가 즉시 로딩 설정을 확인하고, 연관된 각 객체에 대해 개별적으로 쿼리를 실행하게 됩니다.
예를 들어, Item
객체를 조회할 때, Item
과 여러 연관 관계가 설정되어 있다면, JPA는 Item
조회 쿼리 외에도 연관된 데이터(예: Category
, Address
등)에 대해 추가 쿼리를 실행합니다. 연관 관계가 많아질수록 이러한 쿼리도 그만큼 늘어나게 되므로 불필요한 추가 쿼리가 많이 발생하게 됩니다.
따라서, 즉시 로딩을 사용하더라도, 연관된 데이터가 많을 경우 성능 문제가 생길 수 있으므로 주의가 필요합니다.
즉시로딩으로 설정되어 있는경우 FetchJoin을 사용해서 해결했습니다.
FetchJoin 같은경우 JPQL에서 연관된 엔티티가 fetchJoin에 걸린 엔티티라면
영속화되서 연관된 엔티티를 사용할때 다시 쿼리를 하지않으므로 N+1문제를 해결할수 있엇습니다.
대신 JPQL을 직접 작성해야 하므로 단점이 있엇고
Entity Graph 같은 경우 JPQL을 직접 작성하지않고 애너테이션으로 선언해주면 되서
간단하지만 복잡한 조건이나 다양한 Join방식을 함께 사용할때
JPQL처럼 세밀한 제어를 제공하지 않으므로 추후 확장성을 고려해 Fetch Join을 사용했습니다.
📓 문제점
Fetch Join을 통해 N+1 문제를 해결했지만, 페이징과 함께 사용하면서 새로운 문제가 발생했습니다.
상품 리스트에서 Item
엔티티를 페이징으로 조회하는 상황이었고, Item
은 ItemImage
와 일대다(N:1) 연관 관계가 있었습니다. 처음에는 Item
과 ItemImage
를 Fetch Join으로 한 번에 조회하려고 했으나, 일대다 관계에서 Fetch Join을 사용하면 교차곱(Cartesian Product) 문제가 발생합니다.
이러한 교차곱 상태에서 페이징을 사용하면 데이터 누락이 발생할 수 있기 때문에, Hibernate는 페이징 쿼리에 limit
나 offset
을 적용하지 않고 모든 Item
을 조회한 후 메모리에서 페이징을 처리합니다. 만약 Item
데이터가 수백만 개 이상일 경우, 모든 데이터를 메모리에 올리면 OOM(Out Of Memory) 문제가 발생할 수 있으며, 페이징의 장점인 일부 데이터만 조회하는 효율성도 사라집니다.
이 문제를 해결하기 위해 다음 두 가지 방법을 고려했습니다:
1,Batch Size를 이용한 해결:
Item
과 ItemImage
를 Fetch Join하지 않고, ItemImage
를 조회할 때 Batch Size를 설정해 IN
절을 사용해 한 번에 조회하는 방법입니다.
그러나 상품 리스트에서 대표 이미지 1개만 필요함에도 불구하고, 최대 60개의 불필요한 ItemImage
가 함께 조회되는 문제가 있었습니다.
2,대표 이미지만 조회:
상품 1개당 대표 이미지 1개만 필요하므로, JOIN
을 사용해 대표 이미지만 조회하고 이를 DTO로 매핑하는 방법을 선택했습니다. 이 방법은 필요한 데이터만 조회하므로 효율적이며, 불필요한 데이터를 조회하지 않아 성능상 이점이 있었습니다.
최종적으로, 대표 이미지 1개만 필요한 상황이기 때문에 두 번째 방법을 사용하여 문제를 해결했습니다.
@Query(value = "SELECT new me.leeminsoo.usedpark.dto.item.ItemListResponseDTO(i, size(i.carts), img) " +
"FROM Item i " +
"JOIN FETCH i.category c " +
"JOIN FETCH i.address a " +
"LEFT JOIN i.images img " +
"WHERE img.isRepresentative = true OR img IS NULL",
countQuery = "SELECT COUNT(i) FROM Item i"
)
Page<ItemListResponseDTO> findAllWithRepresentativeImage(Pageable pageable);
그리고 판매자가 ItemImage를 업로드 안할수도있으므로 대표이미지가 NULL일떄도 Item이 조회되게
LEFT JOIN을 사용했으며,
category와 address는 Item과 일대다 관계이지만 Item당 1개의 row만 있는게 보장되어
교차곱이 일어나지 않으므로 FetchJoin을 사용했습니다.
그리고 이 방법을 사용해 결과적으로 ItemImage
조회 데이터를 67% 감소하였습니다.
쿼리 횟수를 확인하기위해 Hibernate Statistics
를 사용해 테스트를 진행하였습니다.
38개의 쿼리에서 1개의 쿼리로 줄이고
의도 했던 대로 쿼리가 변환되는걸 확인했습니다
N+1 문제가 발생했을땐 38개의 JDBC statemen
가 실행되었고
N+1 문제를 해결했을땐 1개의 JDBC statement
가 실행되었습니다.
JDBC statement
수 만보고 성능개선이 되었는지 확신하기 어렵기에 성능 메트릭스를 통해 비교를 해봤습니다.
N+1발생
JDBC 연결 획득 시간: 57,400 ns (0.0574 ms)
JDBC 쿼리 준비 시간: 2,039,100 ns (2.0391 ms)
JDBC 쿼리 실행 시간: 9,854,000 ns (9.854 ms)
총 소요시간
11.9505 ms
N+1문제 해결
JDBC 연결 획득 시간: 82,500 ns (0.0825 ms)
JDBC 쿼리 준비 시간: 154,100 ns (0.1541 ms)
JDBC 쿼리 실행 시간: 742,800 ns (0.7428 ms)
총 소요시간
0.9794 ms
약 91.84% 의 성능개선이 개선됨을 확인하였습니다
JMeter
를 이용해 총 100개의 쓰레드가 동시에 접근했을때
N+1문제 해결전과 후의 응답시간 처리량등의 차이를 확인하기 위해 테스트를 진행해봤습니다.
N+1문제 해결전
N+1문제 해결후
- N+1 문제를 해결한 후, 성능 지표에서 다음과 같은 개선이 있었습니다
- 평균 응답 시간 약 78% 감소
- 최소 응답 시간 약 94% 감소
- 최대 응답 시간 약 70% 감소
- 표준편차 약 65% 감소
- 처리량 약 143% 증가
- 수신 KB/초 약 143% 증가
- 전송 KB/초 약 143% 증가