1. 해결하고자 하는 문제 상황과 해결의 필요성
문제 상황
저희 팀 프로젝트에서 리뷰조회 기능을 구현하면서 N+1 문제에 직면했습니다.
이 문제는 특히 다음과 같은 상황에서 발생했습니다
(1) 리뷰 목록
Review 엔티티는 Member(작성자)와 Content(리뷰 대상 콘텐츠) 엔티티와 각각 다대일(ManyToOne) 관계를 맺고 있습니다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id", nullable = false)
private Content content;
기본적으로 JPA의 @ManyToOne 관계는 FetchType.LAZY(지연로딩)로 설정되어 있습니다.
이는 Review 엔티티를 조회할 때 member와 content객체는 프록시 객체로 로딩되고, 실제 member 또는 content의 데이터가 필요할 때 (예 : review.getMember().getNickname()을 호출하는 시점)에 비로소 추가적인 쿼리가 발생한다는 것을 의미합니다 (지연로딩 Lazy)
페이징 조회 시 N+1 문제 :
ReviewController의 getReviewByIsbn이나 getAllReviews 와 같이 페이지 단위로 리뷰 목록을 조회하는 API를 호출할 경우,
다음과 같은 현상이 발생했습니다.
(1) . 첫번째 쿼리 (N) : 리뷰 엔티티 목록을 가져오기 위한 쿼리 1번이 실행됩니다. (예 : SELECT*FROM reviews LIMIT 10)
(2). N개의 추가 쿼리 (+1) : 조회된 N개의 각 Review 엔티티에 대해 member와 content정보를 가져오기 위해 각각2번씩, 총 2*N번의 추가 쿼리가 발생합니다. 예를 들어, 한 페이지에 10개의 리뷰가 있다면, 총1(리뷰 목록) + 10 (각 리뷰의 멤버) + 10 (각 리뷰의 콘텐츠) = 21번의 쿼리가 실행되어 비효율성이 매우 컸습니다.
문제 해결의 필요성:
N+1 문제로 인해 API응답속도가 느려지고, 쿼리 수 과다로 DB부하가 증가하게 되었습니다. 안정적이고 빠른 서비스를 제공하기 위해서는 이 문제를 반드시 해결해야 했습니다.
2. 선택한 기술 및 적합성
N+1 문제를 해결하기 위해 JPA의 @EntityGraph 어노테이션을 사용하기로 결정했습니다.
@EntityGraph가 적합한 이유:
(1) 선택적 즉시 로딩 (Selective Eager Loading)
@EntityGraph는 특정 쿼리 메서드가 실행될 때만 연관된 엔티티를 즉시 로딩하도록 명시할 수 있습니다.
이는 전역적으로 FetchType.EAGER를 설정하여 발생하는 불필요한 즉시 로딩(모든 상황에서 연관 엔티티를 가져오는 것)문제를 방지하고, 필요한 상황에서만 성능을 최적화할 수 있도록 합니다.
(2) 유연성:
attributePaths 속성을 사용하여 로딩할 연관 엔티티의 필드를 정확하게 지정할 수 있어, 필요한 데이터만 가져오고 불필요한 조인을 피할 수 있습니다.
(3) 코드의 가독성 향상:
JPQL 쿼리에 직업 JOIN FETCH 구문을 작성하는 것보다 @EntityGraph를 사용하는 것이 코드의 가독성을 높여줍니다.
(4)
스프링 데이터 JPA와의 통합:
스프링 데이터 JPA의 리포지토리 인터페이스에 @EntityGraph를 쉽게 적용할 수 있어 개발 생산성이 높습니다.
대안으로 FetchType.EAGER 설정, JPQL의 JOIN FETCH등을 고려할 수 있었지만,
FetchType.EAGER는 전역적인 설정으로 불필요한 성능 저하를 야기할 수 있고, JPQL의 JOIN FETCH는 쿼리가 복잡해질수록 관리하기 어렵다는 단점이 있습니다. 따라서 필요한 시점에 연관엔티티를 즉시 로딩하면서 코드의 가독성과 유지보수성을 해치지 않는 @EntityGraph가 가장 적합한 해결책이라고 판단했습니다.
3. 전체 구현 과정
N+1 문제 해결을 위해 ReviewJpaRepository 인터페이스에 @EntityGraph 어노테이션을 적용했습니다.
(1) ReviewJpaRepository 인터페이스 수정:
Review 엔티티를 조회하는 모든 Repository메서드에 @EntityGraph를 추가하여 member와 content엔티티를 즉시 로딩하도록 설정했습니다.
findByContentIdAndId (특정 리뷰 단건 조회)
@EntityGraph(attributePaths = {"member", "content"})
Optional<Review> findByContentIdAndId(Long contentId, Long reviewId);
이 메서드는 특정 contentId와 reviewId로 리뷰를 조회할 때, 해당 리뷰의 작성자(member)정보와 리뷰 대상 콘텐츠(content)정보를 함께 가져오도록 합니다.
findByContent_Isbn (ISBN 기준 페이징 조회):
@EntityGraph(attributePaths = {"member","content"})
Page<Review> findByContent_Isbn(String isbn, Pageable pageable);
ISBN으로 필터링된 리뷰 목록을 페이지 단위로 조회할 때, 각 리뷰의 member와 content정보를 한 번의 쿼리로 가져오게 합니다.
findAll(모든 리뷰 페이징 조회)
@EntityGraph(attributePaths = {"member", "content"})
@Override
Page<Review> findAll(Pageable pageable);
전체 리뷰를 페이징하여 조회할 때도 마찬가지로 member와 content를 즉시 로딩합니다
findById(리뷰 ID로 단건 조회 - 작성자 확인용)
@EntityGraph(attributePaths = {"member"})
@Override
Optional<Review> findById(Long reviewId);
특히 리뷰 수정/삭제 시 작성자 권한을 확인해야 하므로, member 정보만 즉시 로딩하도록 하여 불필요한 content 로딩을 피했습니다.
(2) ReviewService 및 ReviewController 유지 :
서비스계층(ReviewService) 와 컨트롤러 계층 (ReviewController)의 로직은 변경할 필요가 없었습니다.
ReviewJpaRepository에서 @EntityGraph를 통해 N+1 문제가 해결되었으므로,
기존의 비즈니스 로직은 그대로 유지하면서 성능 향상을 얻을 수 있었습니다.
(3) findTopReviewsWithLikeCount 별도 처리:
좋아요 수가 많은 리뷰 상위 10개를 조회하는 findTopReviewsWithLikeCount 쿼리는
ReviewLike 엔티티와의 LEFT JOIN 및 COUNT, GROUP BY 를 포함하는 복합 쿼리 입니다.
이러한 집계 쿼리는 @EntityGraph 적용이 제한적이므로 별도 처리하였습니다.
@Query("""
SELECT r, COUNT(rl.id) as likeCount
FROM Review r
LEFT JOIN ReviewLike rl ON r.id = rl.review.id
GROUP BY r.id
ORDER BY likeCount DESC, r.createdAt DESC
""")
Page<Object[]> findTopReviewsWithLikeCount(Pageable pageable);
===
위 쿼리에 대한 해설을 다음과 같습니다
(1). SELECT r, COUNT(rl.id) as likeCount :
r :
Review 엔티티 자체를 의미합니다. 이 쿼리의 결과는 Review 객체를 직접 포함하게 됩니다.
COUNT(rl.id) :
ReviewLike 엔티티의 id를 기준으로 개수를 세어 좋아요의 총 개수를 집계합니다
rl.id 는 각 좋아요의 고유 식별자이므로, 이를 세는 것이 좋아요 개수를 파악하는 가장 일반적인 방법입니다.
as likeCount :
집계된 좋아요 개수에 likeCount 라는 별칭을 부여합니다. 이렇게 함으로써 쿼리 결과에서 해당 값을 쉽게
참조할 수 있습니다.
(2) FROM Review r :
쿼리의 주된 대상은 Review 엔티티입니다. r은 Review엔티티에 대한 별칭입니다
(3) LEFT JOIN ReviewLike rl ON r.id = rl.review.id :
LEFT JOIN : 이 조인 방식은 왼쪽 엔티티(Review)의 모든 행을 유지하면서, 오른쪽 엔티티(ReviewLike)에서 일치하는 행을 찾습니다.
만약 특정 리뷰에 대한 좋아요가 없다면(ReviewLike 테이블에 해당 review.id와 일치하는 레코드가 없다면), ReviewLike 관련 필드들은 NULL로 채워집니다. 이는 좋아요가 하나도 없는 리뷰도 결과에 포함시키기 위해 중요합니다.
만약 INNER JOIN을 사용했다면, 좋아요가 없는 리뷰는 결과에서 제외되었을 것입니다.
ReviewLike rl :
ReviewLike 엔티티에 rl 이라는 별칭을 부여합니다.\
ON r.id = rl.review.id :
조인 조건입니다. Review 엔티티의 id 와 ReviewLike엔티티 내부의 review 필드 (이 review 필드는 Review 엔티티를 참조하는 외래 키)의 id 와 일치하는 경우를 연결합니다.
(4) GROUP BY r.id :
COUNT(rl.id) 와 같은 집계 함수를 사용할 때, 어떤 기준으로 집계를 할지 명시해야 합니다. 여기서는 각 Review 엔티티(r.id) 별로 좋아요 개수를 집계하도록 그룹화합니다.
(5) ORDER BY likeCount DESC, r.createdAt DESC :
likeCount DESC :
가장 중요한 정렬기준입니다. 집계된 likeCount(좋아요 개수)를 기준으로 내림차순 (DESC) 정렬합니다. 즉, 좋아요가 많은 리뷰가 결과의 상위에 나타나게 됩니다.
r.createdAt DESC :
두 번째 정렬 기준입니다. 만약 여러 리뷰의 likeCount가 동일하다면, createdAt(생성일)을 기준으로 내림차순 정렬합니다. 이는 최신 리뷰가 더 상위에 나타나도록 하여, 좋아요 수가 같은 리뷰들 사이에서 최신성을 반영합니다.
Page<Object[]> findTopReviewsWithLikeCount(Pageable pageable);
Page<Object[]>
쿼리의 반환 타입입니다. JPQL 쿼리가 Review 엔티티(r)와 long 타입의 likeCount(COUNT(rl.id))를 함께 반환하므로,
이는 Object 배열(Object[]) 형태로 맵핑됩니다.
Page 타입은 Spring Data JPA에서 페이징 처리된 결과를 담는 데 사용됩니다.
Pageable pageable :
Spring Data JPA 에서 제공하는 Pageable 인터페이스를 파라미터로 받습니다.
이를 통해 클라이언트에서 요청한 페이지 번호, 페이지당 항목 수, 정렬 정보 등을 쿼리에 적용할 수 있습니다.
예를 들어, "가장 좋아요가 많은 리뷰 10개를 2페이지부터 가져와라" 와 같은 요청을 처리할 수 있게 됩니다.
======
4. 개발 과정에 대한 회고 및 향후 개선 아이디어
회고:
해결 방법 학습 및 적용: JPA의 다양한 fetch 전략과 JOIN FETCH, @EntityGraph의 차이점을 학습하고, 우리 프로젝트의 상황에 가장 적합한 @EntityGraph를 선택하여 적용하는 과정에서 JPA 심화 학습의 필요성을 느꼈습니다.
향후 개선 아이디어:
쿼리 성능 모니터링 강화: 더 체계적인 성능 모니터링 도구(예: Spring Boot Actuator, Prometheus + Grafana, New Relic 등)를 도입하여 실시간으로 데이터베이스 쿼리 성능을 추적하고 병목 현상을 식별할 수 있도록 개선하고 싶습니다.
'트러블슈팅,기술적의사결정' 카테고리의 다른 글
| 프로그래밍을 하실 예정이라면, 계정명은 반드시 영문으로 설정하시는 것을 강력히 권장드립니다. (0) | 2025.07.21 |
|---|---|
| 분노의삽질 postgreSQL 재설치 에러 극복기 (1) | 2025.07.18 |
| 리뷰 수정 시 존재하지 않는 리뷰인데 500에러 발생(트러블 슈팅) (1) | 2025.07.09 |
| 좋아요 많은 리뷰 Top 10 캐싱 처리 (Redis)를 통한 성능 개선과 부하테스트 결과 (기술적 의사 개선 트러블 슈팅) (3) | 2025.07.09 |
| 일정관리디벨롭 CalendarDevelop 프로젝트 회고와 트러블 슈팅 (0) | 2025.04.04 |