[트러블슈팅] JPA N+1 문제 — 전문가 탐색 API 개선기

윤하빈·2026년 6월 16일

개발 공부

목록 보기
16/18

JPA N+1 문제 — 전문가 탐색 API 개선기 (619ms → 120ms)

Wedge 프로젝트에서 전문가 탐색 API를 개발하던 중 코드 리뷰에서 N+1 문제가 있다는 피드백을 받았다.
확인해 보니 총 세 곳에서 N+1(혹은 불필요한 쿼리)이 발생하고 있었다. 해당 포스팅에서는 각각 어떻게 발생했고 어떻게 해결했는지, 그리고 해결 과정에서 놓쳤던 실수까지 기록한다.


목차

  1. 프로젝트 배경
  2. N+1이란?
  3. 문제 1: 탐색 목록 조회 — 2+3N 쿼리
  4. 문제 2: 단건 조회 — LAZY 로딩
  5. 문제 3: 리뷰 컬렉션 전체 메모리 로딩
  6. 성능 측정 결과
  7. 마치며

1. 프로젝트 배경

Wedge는 예비부부와 웨딩 프리랜서를 연결하는 매칭 플랫폼이다. 전문가 탐색 API는 카테고리, 지역, 가격대, 키워드로 프리랜서를 필터링해서 목록으로 반환하는 핵심 기능이다.

기술 스택은 아래와 같다.

  • Backend: Java 21, Spring Boot 4.0.6, Spring Data JPA, MySQL
  • 주요 엔티티: FreelancerProfileMember, Category, Portfolio (별도 테이블)

엔티티 관계를 간단히 보면 이렇다.

FreelancerProfile
  ├── @OneToOne(LAZY) Member      (member_id 컬럼을 FreelancerProfile 테이블이 FK로 소유)
  ├── @ManyToOne(LAZY) Category
  └── Portfolio (별도 테이블, 1:N — 엔티티 연관관계 없이 별도 조회)

2. N+1이란?

N+1 문제는 1번의 쿼리로 N개의 엔티티를 조회한 뒤, 각 엔티티의 연관 관계를 접근할 때 N번의 추가 쿼리가 발생하는 현상이다.

// 프리랜서 10명 조회 → 1쿼리
List<FreelancerProfile> profiles = repository.findAll();

// 각 프리랜서의 member에 접근 → 10쿼리 추가 발생
profiles.forEach(p -> System.out.println(p.getMember().getName()));

// 총 1 + 10 = 11쿼리

JPA의 기본 fetch 전략이 LAZY이기 때문에, 연관 엔티티에 실제로 접근하는 시점에 SELECT 쿼리가 나간다. 프리랜서가 100명이면 100번, 1000명이면 1000번 쿼리가 추가로 나가는 구조다.


3. 문제 1: 탐색 목록 조회 — 2+3N 쿼리

문제 코드

// FreelancerSearchService.java
public Page<FreelancerProfileResponse> getFreelancers(...) {
    // ... Specification 조건 조합 ...

    return freelancerProfileRepository.findAll(spec, sortedPageable)
            .map(profile -> FreelancerProfileResponse.from(
                    profile,
                    portfolioRepository
                            // 프리랜서 1명당 포트폴리오 쿼리 1번 추가 발생
                            .findFirstByFreelancerProfileIdOrderBySortOrderAscIdAsc(profile.getId())
                            .map(Portfolio::getImageUrl)
                            .orElse(null)
            ));
}
// FreelancerProfileResponse.java
private FreelancerProfileResponse(FreelancerProfile profile, String portfolioImageUrl) {
    this.memberId = profile.getMember().getId();     // LAZY 로딩 → 쿼리 1번
    this.memberName = profile.getMember().getName();
    this.categoryId = profile.getCategory().getId(); // LAZY 로딩 → 쿼리 1번
    // ...
}

발생한 쿼리 수

프리랜서 N명 조회 시 총 쿼리 수는 이렇다.

1 (목록 조회, content 쿼리)
+ 1 (Page 반환 시 Spring Data JPA가 자동으로 추가하는 count 쿼리)
+ N (포트폴리오 이미지, 프리랜서 1명당 1번)
+ N (member LAZY 로딩)
+ N (category LAZY 로딩)
= 2 + 3N 쿼리

findAll(spec, sortedPageable)JpaSpecificationExecutor가 제공하는 메서드인데, Page<T>를 반환하는 페이징 쿼리는 항상 content 쿼리와 별개로 전체 개수를 세는 count 쿼리를 한 번 더 실행한다. 이 부분을 처음에 빠뜨리고 "1+3N"으로 계산했었는데, 정확히는 "2+3N"이다.

예를 들어, 프리랜서가 131명이 존재할 경우 2 + 3×131 = 395쿼리가 나간다.

해결 방법: id 페이징 → fetch join → IN 쿼리

핵심 아이디어: 먼저 id만 페이징해서 가져오고, 그 id 목록으로 연관 엔티티를 한 번에 조회한 뒤 Map으로 조립한다.

Step 1 — Repository에 fetch join 쿼리 추가

id 페이징 자체는 별도 쿼리 메서드를 만들지 않고, 인터페이스가 이미 상속하고 있는 JpaSpecificationExecutor<FreelancerProfile>.findAll(Specification, Pageable)을 그대로 재사용한다.

// FreelancerProfileRepository.java

// id 목록으로 member + category 한 번에 fetch join
@Query("""
    SELECT p FROM FreelancerProfile p
    JOIN FETCH p.member
    JOIN FETCH p.category
    WHERE p.id IN :ids
    """)
List<FreelancerProfile> findByIdInWithMemberAndCategory(@Param("ids") List<Long> ids);

정정 — @Query + Specification 조합의 함정

처음에는 아래처럼 직접 작성한 JPQL에 Specification을 파라미터로 얹어서 필터까지 적용되는 것처럼 구현했다.

// 실제로는 동작하지 않는 코드
@Query(value = "SELECT p.id FROM FreelancerProfile p")
Page<Long> findAllIds(Specification<FreelancerProfile> spec, Pageable pageable);

Spring Data JPA는 @Query에 명시된 JPQL을 그대로 실행할 뿐, 메서드 인자로 넘어온 Specification을 조건에 자동으로 병합해주지 않는다. 즉 이 메서드는 keyword/category/region/price 필터를 전혀 반영하지 못한 채 전체 목록만 페이징하는 코드였다. Specification으로 동적 조건을 조합하고 싶다면, JpaSpecificationExecutor가 제공하는 findAll(Specification, Pageable)을 그대로 쓰고 결과에서 필요한 필드만 뽑아 쓰는 방식으로 우회해야 한다(아래 Step 2).

Step 2 — Service 수정

// FreelancerSearchService.java
public Page<FreelancerProfileResponse> getFreelancers(...) {
    // ... Specification 조건 조합 ...

    // 1. Specification 조건이 반영된 페이징 쿼리에서 id만 추출 (content + count, 2쿼리)
    Page<Long> idPage = freelancerProfileRepository.findAll(spec, sortedPageable)
            .map(FreelancerProfile::getId);
    List<Long> ids = idPage.getContent();

    if (ids.isEmpty()) {
        return Page.empty(sortedPageable);
    }

    // 2. member + category fetch join으로 한 번에 조회 (1쿼리)
    Map<Long, FreelancerProfile> profileMap = freelancerProfileRepository
            .findByIdInWithMemberAndCategory(ids)
            .stream()
            .collect(Collectors.toMap(FreelancerProfile::getId, p -> p));

    // 3. 포트폴리오 이미지 IN 쿼리로 한 번에 조회 (1쿼리)
    Map<Long, String> imageMap = portfolioRepository
            .findByFreelancerProfileIdInOrderByFreelancerProfileIdAscSortOrderAscIdAsc(ids)
            .stream()
            .collect(Collectors.toMap(
                    p -> p.getFreelancerProfile().getId(),
                    Portfolio::getImageUrl,
                    (first, second) -> first  // 첫 번째 포트폴리오만
            ));

    // 4. id 순서 보장하며 조립
    List<FreelancerProfileResponse> content = ids.stream()
            .map(profileMap::get)
            .filter(Objects::nonNull)
            .map(profile -> FreelancerProfileResponse.from(
                    profile, imageMap.get(profile.getId())))
            .toList();

    return new PageImpl<>(content, sortedPageable, idPage.getTotalElements());
}

findAll(spec, sortedPageable)은 엔티티를 조회하긴 하지만, .map(FreelancerProfile::getId)에서는 이미 로딩되어 있는 PK(id)만 꺼내 쓰므로 member/category에 대한 추가 LAZY 쿼리는 발생하지 않는다. 대신 필터 조건은 그대로 살아있다.

결과

구분쿼리 수응답 시간
수정 전2 + 3N619ms
수정 후4 (고정)120ms

131명 기준 395쿼리 → 4쿼리, 응답 속도 5.2배 개선
(쿼리 수 자체는 약 99배 감소했지만, 전체 응답 시간에는 네트워크·직렬화 등 다른 오버헤드도 섞여 있어 개선 배율이 그대로 비례하지는 않는다.)


4. 문제 2: 단건 조회 — LAZY 로딩

문제 코드

프로필 상세 조회(getProfile)와 내 프로필 조회(getMyProfile)도 같은 문제가 있었다.

// FreelancerProfileService.java
@Transactional(readOnly = true)
public FreelancerProfileResponseDto getProfile(Long profileId) {
    // findById → member, category LAZY 로딩으로 추가 쿼리 2번 발생
    FreelancerProfile profile = freelancerProfileRepository.findById(profileId)
            .orElseThrow(() -> new FreelancerNotFoundException("프로필을 찾을 수 없습니다."));
    return new FreelancerProfileResponseDto(profile);
}

단건 조회임에도 프로필 1번 + member 1번 + category 1번 = 3쿼리가 나갔다.

해결 코드

membercategory를 함께 JOIN FETCH하는 단건 조회 쿼리를 Repository에 추가했다.

// FreelancerProfileRepository.java

// id로 단건 조회 시 member + category fetch join
@Query("""
    SELECT p FROM FreelancerProfile p
    JOIN FETCH p.member
    JOIN FETCH p.category
    WHERE p.id = :id
    """)
Optional<FreelancerProfile> findByIdWithMemberAndCategory(@Param("id") Long id);

// memberId로 조회 시에도 동일하게 적용
@Query("""
    SELECT p FROM FreelancerProfile p
    JOIN FETCH p.member
    JOIN FETCH p.category
    WHERE p.member.id = :memberId
    """)
Optional<FreelancerProfile> findByMemberIdWithMemberAndCategory(@Param("memberId") Long memberId);
// FreelancerProfileService.java
@Transactional(readOnly = true)
public FreelancerProfileResponseDto getProfile(Long profileId) {
    // member, category 한 번에 조회 → 1쿼리
    FreelancerProfile profile = freelancerProfileRepository
            .findByIdWithMemberAndCategory(profileId)
            .orElseThrow(() -> new FreelancerNotFoundException("프로필을 찾을 수 없습니다."));

    int reviewCount = reviewRepository.countByFreelancerProfileId(profileId);
    double averageRating = reviewRepository.avgRatingByFreelancerProfileId(profileId);

    return new FreelancerProfileResponseDto(profile, reviewCount, averageRating);
}

결과

구분쿼리 수
수정 전3 (프로필 + member + category)
수정 후1 (fetch join)

5. 문제 3: 리뷰 컬렉션 전체 메모리 로딩

문제 코드

이건 N+1보다는 불필요한 데이터 로딩 문제다.

// FreelancerProfileResponseDto.java
public FreelancerProfileResponseDto(FreelancerProfile profile) {
    // ...
    // 리뷰 개수, 평균 평점을 위해 리뷰 전체를 메모리로 올림
    List<Review> reviews = profile.getReviews();  // LAZY 로딩 — 전체 조회
    this.reviewCount = reviews.size();
    this.averageRating = reviews.isEmpty() ? 0.0
            : reviews.stream()
                    .mapToInt(Review::getRating)
                    .average()
                    .orElse(0.0);
}

리뷰가 1000개라면 1000개를 전부 메모리로 올린 뒤 size()average()를 계산한다. DB에서 COUNT, AVG로 숫자만 받아오면 충분한데 낭비가 심한 구조다.

해결 코드

ReviewRepository에 집계 쿼리를 추가하고, DTO 생성자가 컬렉션 대신 집계값을 직접 받도록 변경했다.

// ReviewRepository.java

// 리뷰 수 집계
@Query("SELECT COUNT(r) FROM Review r WHERE r.freelancerProfile.id = :profileId")
int countByFreelancerProfileId(@Param("profileId") Long profileId);

// 평균 평점 집계 (리뷰 없을 시 0.0 반환)
@Query("SELECT COALESCE(AVG(r.rating), 0.0) FROM Review r WHERE r.freelancerProfile.id = :profileId")
double avgRatingByFreelancerProfileId(@Param("profileId") Long profileId);
// FreelancerProfileResponseDto.java — 수정 후
// reviews 컬렉션 대신 집계값을 직접 받는 생성자
public FreelancerProfileResponseDto(FreelancerProfile profile,
                                    int reviewCount,
                                    double averageRating) {
    // ...
    this.reviewCount = reviewCount;       // DB COUNT 결과
    this.averageRating = averageRating;   // DB AVG 결과
}
// FreelancerProfileService.java — 수정 후
int reviewCount = reviewRepository.countByFreelancerProfileId(profileId);
double averageRating = reviewRepository.avgRatingByFreelancerProfileId(profileId);
return new FreelancerProfileResponseDto(profile, reviewCount, averageRating);

결과

구분방식
수정 전리뷰 전체 컬렉션 메모리 로딩 후 애플리케이션에서 COUNT/AVG
수정 후DB COUNT / AVG 집계 쿼리로 숫자만 조회

6. 성능 측정 결과

로컬 환경(프리랜서 131명 기준)에서 PowerShell로 측정했다.

Measure-Command {
  Invoke-WebRequest -Uri "http://localhost:8080/api/freelancers?sortType=ALL" -UseBasicParsing
} | Select-Object TotalMilliseconds
구분응답 시간
N+1 수정 전619ms
N+1 수정 후120ms

약 5.2배 응답 속도 개선

이 벤치마크 요청에는 keyword/category 등 필터 파라미터가 없어서, 3번 문단에서 정정한 "Specification이 반영되지 않던 버그"와는 무관하게 측정값 자체는 유효하다.


7. 마치며

이번 경험에서 네 가지를 배웠다.

1. fetch join 대상은 실제로 접근하는 연관 엔티티 전체를 처음부터 확인해야 한다.

이번에도 초기에 category를 fetch join 대상에서 빠뜨렸다가 뒤늦게 추가했다. DTO 생성자에서 어떤 필드에 접근하는지 꼼꼼히 확인하는 습관이 중요하다.

2. 집계값만 필요하다면 DB에서 집계하라.

리뷰 전체를 메모리로 올려서 size()를 부르는 건 낭비다. COUNT, AVG는 DB가 훨씬 잘한다.

3. N+1은 코드만 봐서는 잘 안 보인다.

application.ymlshow-sql: true를 켜고 실제로 어떤 쿼리가 몇 번 나가는지 확인하는 습관이 중요하다. 눈으로 보기 전까지는 체감이 안 된다.

4. Specification@Query는 섞어 쓸 수 없다.

동적 조건이 필요해서 Specification을 쓰고 있다면, @Query로 직접 짠 JPQL에 Specification 파라미터를 얹는 방식으로는 조건이 반영되지 않는다. @Query는 명시된 쿼리를 그대로 실행할 뿐이다. 동적 조건과 "필요한 필드만 뽑기"를 동시에 원한다면, JpaSpecificationExecutor가 제공하는 메서드를 그대로 쓰고 결과를 .map()으로 가공하는 편이 안전하다.

# application.yml
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

참고

0개의 댓글