Spring Boot JPA N+1 문제, Fetch Join만으로 해결되지 않던 쿼리 폭탄 디버깅기

최근 사내 블로그 서비스의 메인 피드 로딩 속도가 평균 200ms에서 3.5초까지 치솟는 이슈가 발생했습니다. 트래픽이 몰리는 점심시간 대에 DB CPU 사용률이 90%를 상회했고, 로그를 확인해보니 끔찍한 광경이 펼쳐지고 있었습니다. 단일 API 호출 한 번에 수백 개의 SELECT 쿼리가 발생하고 있었던 것입니다. 전형적인 JPA N+1 문제였습니다. 개발 초기 단계에서는 데이터가 적어 눈에 띄지 않던 문제가, 데이터가 수만 건으로 늘어나자마자 서비스 전체의 병목 구간으로 돌변한 케이스입니다.

N+1 문제의 발생 메커니즘과 현장 분석

당시 시스템 환경은 Spring Boot 3.2, Java 21, Hibernate 6.x 버전을 사용 중이었으며, 데이터베이스는 MySQL 8.0을 AWS RDS t3.large 인스턴스에서 구동하고 있었습니다. 문제가 된 비즈니스 로직은 매우 단순했습니다. User(사용자) 목록을 조회하고, 각 사용자가 작성한 최신 Post(게시글) 제목을 노출하는 기능이었습니다.

실제 발생한 로그 (축약):
Hibernate: select u1_0.id, u1_0.name from users u1_0
Hibernate: select p1_0.id, p1_0.title from posts p1_0 where p1_0.user_id=1
Hibernate: select p1_0.id, p1_0.title from posts p1_0 where p1_0.user_id=2
Hibernate: select p1_0.id, p1_0.title from posts p1_0 where p1_0.user_id=3
... (사용자 수만큼 반복)

JPA의 FetchType.LAZY(지연 로딩) 전략은 엔티티를 조회할 때 연관된 엔티티를 프록시 객체로 가져옵니다. 이후 실제 데이터를 사용하는 시점(예: user.getPosts().getTitle())에 DB에 쿼리를 날립니다. 즉, 사용자 100명을 조회하는 쿼리(1)와 각 사용자의 게시글을 조회하는 쿼리(N)가 합쳐져 1+N개의 쿼리가 실행되는 구조적 한계입니다.

많은 주니어 개발자들이 Hibernate 공식 문서를 보고 "지연 로딩이 성능에 유리하다"고 배우지만, 리스트 조회 상황에서의 지연 로딩은 오히려 성능의 주범이 됩니다. 그렇다고 무작정 FetchType.EAGER(즉시 로딩)를 사용하는 것은 더 큰 재앙을 불러옵니다. 전혀 필요 없는 연관 관계까지 모두 조인하여 가져오기 때문에 예측 불가능한 쿼리가 발생하기 때문입니다.

첫 번째 실패: 단순 Join의 오해

처음 이 문제를 해결하기 위해 팀원 중 한 명이 단순히 JPQL에 JOIN을 명시하는 방법을 시도했습니다. 쿼리를 select u from User u join u.posts p 형태로 변경한 것입니다.

하지만 이는 실패했습니다. 일반 JOIN은 실제 쿼리에서는 조인이 발생하지만, SELECT 절에서 연관된 엔티티(Post)의 데이터를 함께 가져오지 않습니다. 따라서 영속성 컨텍스트에는 User 정보만 로딩되고, Post 정보는 여전히 초기화되지 않은 상태로 남습니다. 결과적으로 Post 정보를 조회하는 시점에 다시 N+1 문제가 그대로 발생했습니다. 우리는 단순히 관계를 맺는 것과 데이터를 함께 가져오는 것(Fetch)의 차이를 명확히 구분해야 했습니다.

해결책: Fetch Join과 BatchSize의 전략적 선택

N+1 문제를 해결하는 가장 확실하고 직관적인 방법은 FETCH JOIN을 사용하는 것입니다. 이는 SQL 조인을 활용하여 연관된 엔티티까지 한 번의 쿼리로 모두 가져와 영속성 컨텍스트에 담아두는 기법입니다.

// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {

    // 일반 Join이 아닌 'JOIN FETCH'를 사용해야 함
    // distinct는 중복된 User 엔티티가 생성되는 것을 방지 (Hibernate 6부터는 자동 보정되나 명시 권장)
    @Query("SELECT distinct u FROM User u JOIN FETCH u.posts")
    List<User> findAllWithPosts();
}

위 코드는 쿼리 한 방으로 User와 Post를 모두 조회합니다. 하지만 이 방식에도 치명적인 단점이 존재합니다. 바로 페이징(Pagination) 처리 불가 문제입니다. OneToMany 관계(Collection Fetch Join)에서 페이징을 시도하면, Hibernate는 경고 로그를 남기고 모든 데이터를 메모리로 퍼올린 뒤 애플리케이션 레벨에서 페이징을 수행합니다. 이는 OutOfMemoryError로 이어지는 지름길입니다.

고급 해결책: Global Batch Size 설정

리스트 조회이면서 페이징이 필요한 경우, 혹은 여러 곳에서 범용적으로 N+1 문제를 방어하고 싶은 경우, 가장 추천하는 방식은 Batch Size 설정입니다. 이는 연관된 엔티티를 조회할 때 1건씩(N번) 조회하는 것이 아니라, WHERE IN 절을 사용하여 설정한 크기만큼 묶어서 조회합니다.

// application.yml (Spring Boot 설정)
spring:
  jpa:
    properties:
      hibernate:
        # 한 번에 최대 1000개의 연관 데이터를 IN 쿼리로 로딩
        default_batch_fetch_size: 1000

이 설정을 적용하면, 100명의 사용자를 조회할 때 게시글을 조회하는 쿼리는 100번이 아니라 단 1번(또는 데이터 양에 따라 소수)만 실행됩니다. 쿼리는 다음과 같이 최적화됩니다.

SELECT * FROM posts WHERE user_id IN (1, 2, 3, ..., 1000)

이 방식은 쿼리 수를 N+1에서 1+1로 획기적으로 줄여주면서도, Fetch Join이 가진 페이징 메모리 문제를 피할 수 있는 가장 안전한 타협점입니다. 자세한 옵션 설명은 Spring Boot 공식 프로퍼티 가이드를 참고하시기 바랍니다.

전략 쿼리 수 (User 100명 기준) 장점 단점/위험요소
Lazy Loading (기본) 101회 (N+1) 설정이 간단함 성능 최악, 네트워크 지연 발생
Join Fetch 1회 가장 확실한 데이터 로딩 페이징 시 메모리 오버플로우 위험
@BatchSize 설정 2회 (1+1) 페이징 가능, 쿼리 수 최소화 옵션 설정 필요, Fetch Join보다 쿼리 1회 더 발생

위 표에서 볼 수 있듯이, Fetch Join은 단건 상세 조회나 전체 목록(Non-paging) 조회에서 가장 강력하며, BatchSize는 리스트 페이징 조회에서 필수적인 전략입니다. 실제 운영 환경 적용 후, 메인 피드 로딩 속도는 3.5초에서 120ms로 약 2900% 개선되었습니다.

Spring Data JPA GitHub 예제 확인하기

주의할 점 및 Edge Cases

Fetch Join 사용 시 'Multiple BagFetchException'에 주의해야 합니다. 이는 두 개 이상의 컬렉션(List)을 동시에 Fetch Join 하려고 할 때 발생합니다. Hibernate는 두 개 이상의 컬렉션을 조인하면 Cartesian Product(곱집합)가 발생하여 데이터 뻥튀기가 심각해지기 때문에 이를 원천 차단합니다. 이 경우 List 대신 Set을 사용하거나, 위에서 언급한 BatchSize를 활용하여 해결해야 합니다.

또한, @EntityGraph를 사용하는 방법도 있습니다. 이는 JPQL을 직접 작성하지 않고 어노테이션만으로 Fetch Join 효과를 낼 수 있어 편리하지만, 쿼리의 복잡도가 높아지면 결국 JPQL이나 QueryDSL로 넘어가게 되므로, 복잡한 통계성 쿼리보다는 간단한 관계 조회에만 사용하는 것을 권장합니다.

Best Practice: 기본적으로 default_batch_fetch_size를 100~1000 사이로 전역 설정하여 대부분의 N+1 문제를 예방하고, 성능이 극도로 중요한 특정 조회 메서드에만 Fetch Join을 적용하는 혼합 전략을 사용하십시오.

결론

JPA N+1 문제는 ORM을 사용하는 한 피할 수 없는 숙명과도 같습니다. 하지만 원인을 정확히 이해하고 상황(단건 조회 vs 리스트 페이징)에 맞는 해결책을 적용한다면 오히려 SQL Mapper보다 더 생산적이고 성능 좋은 애플리케이션을 구축할 수 있습니다. 무조건적인 Fetch Join 사용보다는 Batch Size 전역 설정을 베이스로 두고, 쿼리 플랜을 지속적으로 모니터링하는 습관을 들이시길 바랍니다.

Post a Comment