이제 거의 실전 프로젝트가 마무리되어간다. 저번엔 Redis를 활용하여 수강신청의 성능을 개선해 봤는데, 이번에는 ORM에서 자주 발생하는 N+1 문제를 개선하였다. 문제가 발생하는 부분을 확인하고, 개선하는 작업을 진행하였다.
✔️ N+1 문제 발생
우리 프로젝트에서 N+1이 발생한 부분은 장바구니 페이지를 조회할 때 발생했다. 페이지 조회 시, 과목 정보에 대한 쿼리가 여러 번 요청되는 것을 확인하였다.
💬 원인 분석
장바구니 페이지에서 N+1 문제가 발생하는 원인을 찾아보자. RegisteredSubjectRepository에서, findAllByStudentId() 메서드로 학생이 장바구니에 담은 과목들의 정보를 Spring Data JPA의 쿼리메서드로 불러오도록 되어있다.
[ RegisteredSubjectRepository.java ]
// RegisteredSubjectRepository - 해당하는 학생의 장바구니 정보 조회
List<RegisteredSubject> findAllByStudentId(Long studentId);
RegisteredSubject 엔티티 코드의 일부이다. findAllByStudentId()로 정보를 불러올 때, Student의 정보와 Subject의 정보가 @ManyToOne 연관관계로 매핑되어있다. @xxxToOne 관계는 기본으로 즉시 로딩(fetchType = EAGER)으로 설정된다.
findById()와 같이 한 개를 조회하는 경우에는 하이버네이트가 join을 이용해서 쿼리 최적화를 해주지만, findAll() 메서드 같은 경우는 즉시로딩 이더라도 N+1 문제가 발생하게 된다.
그렇기 때문에, RegisteredSubject 엔티티에서 findAll()로 정보를 불러올 때, 해당하는 학생의 정보와 해당하는 과목의 쿼리가 추가적으로 N번 요청되고 있는 상황인 것이다.
[ RegisteredSubject.java ]
✔️ 해결 및 개선
N번의 추가적인 쿼리를 개선하기 위하여 fetch join을 활용하였다. 과목 정보를 가져올 때, 연관된 학생의 정보와 과목의 정보를 한 번의 쿼리로 모두 가져오도록 개선하였다. 더 이상 연관관계에 대한 쿼리문이 요청되지 않고, 단 1개의 쿼리만 요청되는 것을 확인할 수 있었다.
✔️ 성능 비교 테스트 결과 - 개선 전 vs 개선 후
성능이 얼마나 개선됐는지 확인하기 위해, 장바구니에 페이지에 Jmeter로 요청하여 응답시간을 비교해 보았다.
한 학생이 장바구니에 50개의 과목을 담아뒀다고 가정하고, 장바구니에 50개의 과목을 넣어두고 테스트를 진행했다. 장바구니 페이지에 총 10회 GET 요청을 보내서 평균값으로 결과를 비교하였다.
❗️ 개선 전 (N+1 문제 발생)
개선 전 10회 평균 응답시간 : 50ms
🔧 개선 후 (fetch join 적용)
개선 후 10회 평균 응답시간 : 34ms
결과적으로 10회 평균 응답 시간이 50ms -> 34ms로 약 32% 개선된 것을 확인 할 수 있었다.