본문 바로가기
서버 인프라 실무

JPA N+1 완전 정복: 자동 감지부터 fetch join·EntityGraph·batch_size 실전 해결 전략

by yamoojin83 2025. 10. 9.

JPA N+1 완전 정복: 자동 감지부터 fetch join·EntityGraph·batch_size 실전 해결 전략

운영 중인 서비스에서 가장 많이 겪는 퍼포먼스 문제 중 하나가 바로 N+1 문제입니다.

제가 실제로 겪었던 한 사건은, 한 페이지 조회가 로컬에서는 몇 ms였는데
운영에서는 갑자기 3~5초로 느려지며 장애 직전까지 갔던 일이었습니다.

원인은 단순했습니다. 리스트 조회 → 연관 엔티티 지연 로딩 → 수십~수백 건의 추가 쿼리 발생.
튜닝하기 전까지는 도대체 왜 이런 일이 생기는지 이해조차 어려운 문제였습니다.

그래서 이 글에서는 단순히 “N+1이 뭔지” 설명하는 수준을 넘어서,

실제 서비스에서 어떻게 자동 감지하고, 어떻게 해결했는지까지 모두 정리해 봅니다.

 

 

JPA N+1 문제 발생 구조 다이어그램

1. N+1은 왜 생기는가? (지연 로딩의 본질 이해)

JPA 기본 동작은 대부분 LAZY(지연 로딩)입니다.

예를 들어 아래처럼 글 목록을 조회한다고 해봅시다.

List<Post> posts = postRepository.findAll();

여기까진 문제가 없습니다. 하지만, 뷰 단에서 아래 코드가 실행되는 순간 문제가 터집니다.

for (Post p : posts) {
    System.out.println(p.getComments().size());
}

이 한 줄 때문에 다음 쿼리가 실행됩니다:

SELECT * FROM comment WHERE post_id = ?
(반복...)

즉, 메인 쿼리 1번 + 연관 엔티티 n번 = N+1 구조가 됩니다.

문제가 되는 이유는 간단합니다.

“초기에는 작아 보여도, 데이터가 늘어나면 기하급수적으로 느려진다.”



2. 운영 환경에서 N+1을 자동으로 감지하는 방법

제가 운영 서버에서 N+1을 자동으로 잡아서 Slack으로 알림을 보내는 코드를 실제로 쓰고 있습니다.
핵심은 아래 두 가지입니다.

  • Hibernate의 쿼리 인터셉터/통계 기능 활용
  • 요청 1건에서 특정 조건 이상 쿼리가 실행되면 경고

아래는 제가 쓰는 “N+1 자동 감지용 Interceptor” 축약버전입니다. (실서비스에서도 크게 다르지 않게 사용)

@Component
public class QueryCountInterceptor implements StatementInspector {

    private static final ThreadLocal<AtomicInteger> count =
            ThreadLocal.withInitial(AtomicInteger::new);

    @Override
    public String inspect(String sql) {
        count.get().incrementAndGet();
        return sql;
    }

    public static int getCount() {
        return count.get().get();
    }

    public static void clear() {
        count.remove();
    }
}

그리고 Controller 레벨에서 다음과 같이 검출합니다.

@Around("execution(* com.example..*Controller.*(..))")
public Object detectNPlusOne(ProceedingJoinPoint pjp) throws Throwable {

    QueryCountInterceptor.clear();

    Object result = pjp.proceed();

    int queries = QueryCountInterceptor.getCount();

    if (queries > 30) { // 서비스 기준에 따라 조절
        log.warn("Possible N+1 detected: {} queries in {}", queries, pjp.getSignature());
        // Slack, Sentry 등으로 전송 가능
    }

    return result;
}

이 방법을 사용하면, 특정 요청에서 쿼리가 과도하게 실행될 때 즉시 감지할 수 있습니다.
운영에서 정말 큰 도움이 됩니다.



3. N+1 해결 전략 4가지(실무 기준 장단점 포함)

3-1. fetch join (가장 직관적이고 강력)

@Query("select p from Post p join fetch p.comments")
List<Post> findAllWithComments();

장점:

  • 한 번에 필요한 데이터를 모두 가져옴
  • N+1을 즉시 해결하는 가장 간단한 방법

단점:

  • 컬렉션 fetch join은 페이징이 불가능
  • 연관 엔티티가 여러 개면 중복 로우 폭발 가능

운영에서는 “1:N을 fetch join하는 상황”이면 반드시
Row 폭발과 메모리 사용량을 주의해야 합니다.

 

3-2. @EntityGraph (명령형 코드가 줄어듦)

@EntityGraph(attributePaths = {"comments"})
List<Post> findAll();

장점:

  • fetch join과 동일한 효과
  • 쿼리 메서드와 한곳에서 관리 가능

단점:

  • 조건에 따라 EntityGraph를 복수로 관리하면 분산됨

 

3-3. batch_size (지연 로딩 + 일괄 로딩)

hibernate.default_batch_fetch_size=100

장점:

  • LAZY를 유지하면서 쿼리를 묶어서 가져옴
  • 페이징 가능
  • 다양한 연관 엔티티가 있을 때 가장 안정적

단점:

  • 적절한 batch size를 찾기 위해 운영 데이터 분석 필요

 

3-4. DTO Projection (성능 최적화의 최종 형태)

@Query("select new com.example.PostDto(p.id, p.title, c.content) " +
       "from Post p join p.comments c")
List<PostDto> findAllDto();

장점:

  • 최소 데이터만 조회 → 성능 최고
  • JPQL 결과가 바로 Response 객체로 변환

단점:

  • 유연성이 떨어짐(요구사항 변경에 취약)



JPA N+1 해결 전략 fetch join EntityGraph batch_size DTO 비교 테이블

4. 실무에서 자주 터지는 “예상치 못한 N+1” 3가지

4-1. JSON 직렬화 과정에서 터지는 N+1

컨트롤러에서 엔티티를 그대로 반환하면
Jackson이 getter를 호출하면서 LAZY 로딩 발동 → N+1

4-2. @Transactional 누락으로 프록시 초기화 실패

LAZY 로딩 해야 하는 시점에 트랜잭션이 없으면
초기화를 못 해서 예외가 터지거나,
반대로 엉뚱한 시점에 초기화하면서 N+1 유발.

4-3. Stream API loop에서 의도치 않게 조회

posts.stream()
     .map(p -> p.getComments().size()) // 이 한 줄 때문에 N+1
     .toList();



5. 운영에서 N+1을 관리하는 체크리스트

  • 페이지 단위 조회는 반드시 DTO Projection 고려
  • 1:N fetch join은 꼭 Row 폭발 체크
  • default_batch_fetch_size는 100~500 사이에서 조정
  • API 응답 구조가 엔티티를 직접 반환하지 않는지 확인
  • 통계 로그로 1요청당 쿼리 수 모니터링 (30 이상이면 경고)



운영 환경에서 JPA 쿼리 수 모니터링하는 대시보드 예시

 



마무리

N+1 문제는 JPA를 쓰면 누구나 겪는 문제지만,
정확한 원리와 해결책을 알고 있으면
장애 이전에 감지하고, 장애 없이 운영할 수 있습니다.

이 글에서 소개한 자동 감지, fetch join, EntityGraph, batch_size, DTO 전략을 조합하면
대부분의 실무 상황에서 충분히 효과적으로 대응할 수 있습니다.

앞으로도 실제 서비스 운영하면서 겪는 문제를 계속 공유하겠습니다.

👉 1편: Ubuntu 24.04에서 Nginx로 무료 SSL(HTTPS) 적용

👉 2편: UFW 방화벽 실전 규칙

👉 3편: systemd 서비스로 스프링부트 배포

👉 4편: Tomcat 10 설치+튜닝 체크리스트

👉 5편: Gradle 빌드 최적화로 빌드 50% 줄이기

👉 6편: Spring Security 6 JWT 로그인/리프레시 토큰

👉 7편: JPA N+1 완전 정복: 자동 감지부터 fetch join·EntityGraph·batch_size 실전 해결 전략

👉 8편: Docker로 PostgreSQL 운영(백업/복구/업그레이드)

👉 9편: Micrometer+Prometheus+Grafana 대시보드

👉 10편: Testcontainers로 DB 통합테스트