refactor: 피드 조회 성능 개선 및 N + 1 문제 해결 #368
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📌 관련 이슈
피드 조회 API 성능 개선 및 쿼리 최적화 #367
1. 개요
피드 목록 조회 API (
GET /feeds)에서 발생하는 N+1 쿼리 문제를 해결하여 응답 성능을 개선했습니다.현재 페이지 크기 (N=10) 기준으로 N+1 문제로 인해 50회의 추가 쿼리가 발생했습니다.
본 리팩토링을 통해 DTO 프로젝션, Batch Size 적용, Bulk 조회를 도입하여 N+1 관련 쿼리 수를 4회로 감소시켰습니다.
문제 해결을 위해 먼저
Feed엔티티의 연관 관계를 분석했습니다.Feed는Student,Category,University와@ManyToOne관계를 맺고,FeedImage,FeedLike,Bookmark등과@OneToMany관계를 맺고 있었습니다.2. 문제 원인 분석
쿼리 분석 결과, N+1 문제는 DTO 변환 과정에서 발생하는 다수의 LAZY 로딩과 반복적인 로직 호출로 인해 발생했습니다.
N+1 유발 지점
@ManyToOneLAZY 로딩:feed.getStudent()접근 시 N+1 발생.연쇄 LAZY 로딩:feed.getStudent().getUniversity()접근 시 추가 N+1 발생.@OneToManyLAZY 로딩:feed.getImages()접근 시 N+1 발생좋아요/북마크 여부 확인:feedLikeRepository.exists...와bookmarkRepository.exists...를 반복문 내에서 피드마다 호출.3. 최적화 과정
3-1. 시도 1: Fetch Join 적용
N+1 문제를 해결하기 위한 첫 번째 시도로 가장 표준적인 방식인 Fetch Join을 선택했습니다.
@EntityGraph를 활용하여Feed엔티티를 조회할 때 연관된Student와FeedImage까지 한 번에 가져오고자 했습니다.하지만
@OneToMany관계인FeedImage을 Fetch Join에 포함하자마자페이지네이션 기능과 충돌하는 예상치 못한 문제에 부딪혔습니다.
Hibernate는
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory!라는 경고 로그를 출력했습니다.이는 데이터베이스 레벨에서
LIMIT절을 통해 효율적으로 페이징하는 것이 아니라,모든 조회 결과를 애플리케이션 메모리로 가져온 뒤 직접 페이징 처리를 한다는 의미입니다.
이러한 In-Memory 페이징 방식은 데이터가 많아질수록 심각한 성능 저하와 OOM(Out Of Memory)을 유발할 수 있으므로,
FeedImage를 가져올 때 Fetch Join 전략은 포기해야 했습니다.3-2. 시도 2: Batch Size 적용
Fetch Join의 대안으로
@BatchSize를 적용하는 전략을 시도했습니다.FeedImage컬렉션에@BatchSize를 설정하자, N개의 개별 쿼리가 1개의IN절을 사용하는 쿼리로 성공적으로 통합되었습니다.하지만
Student에서는 동일한 최적화가 적용되지 않았습니다.@BatchSize설정에도 불구하고 여전히Student엔티티를 조회하기 위한 개별 쿼리가 N번 실행되는 현상이 지속되었습니다.원인 분석 결과,
Student엔티티 내부의 다수 연관관계가 문제였습니다.Student엔티티는 여러 다른 엔티티와@OneToOne관계를 맺고 있었으며,해당 관계들의 FetchType이 기본값인
EAGER로 설정되어 있었습니다.Hibernate가
Student프록시를 배치로 초기화하는 과정에서 이 Eager Loading 설정들이 연쇄적으로 동작하면서 충돌이 발생했고,결과적으로 배치 처리가 실패하여 개별 쿼리로 폴백(Fallback)되는 것으로 확인되었습니다.
3-3. 시도 3: DTO 프로젝션 적용
Student엔티티는@OneToOne관계에서 연관 관계의 주인이 아니도록 설계되었습니다.이로 인해 FetchType을 LAZY로 명시적으로 변경하더라도 Eager Loading이 강제되는 경향이 있어
엔티티 조회 자체의 복잡성이 높다고 판단했습니다.
따라서 엔티티를 직접 조회하는 대신, 필요한 데이터만 선별하여 DTO로 바로 조회하는 DTO 프로젝션 방식으로 전략을 변경했습니다.
결과적으로 이 방식을 통해 Eager Loading 연쇄 문제를 원천적으로 회피하였고,
N개의
Student조회 쿼리를 단 1개의 DTO 프로젝션 쿼리로 대체하여 최적화를 완료했습니다.4. N+1 쿼리 수 비교 (페이지 크기 = 10 기준)