Published on
👁️

JPA N+1 문제 해결에 따른 성능 변화 분석 - 6. 결론 및 Insight

Authors
  • avatar
    Name
    River
    Twitter

목차 페이지로 이동

데이터 규모에 따른 JPA N+1 문제 진단과 해결 전략 비교
(Fetch Join, Entity Graph, Batch Fetching)

6. 결론 및 Insight
6.1. K6 동시 접속자 수와 인프라 성능

K6 VU(Virtual User) 설정 경험

  • 그러나 최적화된 버전(optimized)에서도 최대 VU의 1/3도 못 가서 K6 기본 타임아웃(60초)을 초과하는 요청이 다수 발생

    • 임계값을 설정한 의미가 사라졌고, 테스트 자체도 무의미해졌다
  • 이는 개인 노트북이라는 테스트 환경의 제약과 데이터 크기, 쿼리 복잡성 등을 간과한 결과였다

    • 이전 K6 실습에서 간단한 API로 실험했던 기억만으로 설정한 수치였는데, 현실적으로 무리였다
  • 반복 테스트를 통해 대규모 데이터 기준에서 실패율이 0이고, Fetch Join을 적용한 최적화 버전이 간신히 임계값을 만족하는 최대 동시 사용자 수를 100명으로 결정할 수 있었다

Connection Pool과 인프라 성능

  • K6 테스트 중 가장 의아했던 점은

    • Postman으로 호출할 때의 단일 API 응답 속도와 K6 부하 테스트 시 평균 응답 속도 간에 큰 차이가 있었다는 것이다
    • 동시 요청 증가에 따른 영향이 있다는 것은 알고 있었지만, 그 차이가 왜 그렇게 컸는지는 이해되지 않았다
      • optimized : 82 ms vs 209.73 ms
    • 조사 결과, 가장 큰 원인은 Connection Pool 설정에 있었다
      • 테스트 당시 Connection Pool은 기본 설정인 10개로 설정되었다.
  • JPA Hibernate의 간단한 작동 방식

    1. 트랜잭션으로 설정된 메서드가 시작되면 Hibernate Session이 생성된다
    2. 이 Session은 Connection Pool에서 DB Connection 하나를 가져와 사용한다
    3. 메서드 종료 시 Connection은 Pool로 반환된다
  • 위 구조에 따라 하나의 요청은 하나의 Connection을 사용하게 되므로, Pool이 10개일 경우 동시에 처리 가능한 요청은 최대 10개이다

    • 동시 접속자 수가 많아지면 처리는 제한되고, 나머지 요청은 대기 상태에 머무르게 된다
    • 이 대기가 쌓일수록 전체 응답 시간이 급격히 증가한다

N+1 문제와 처리 지연

  • 이러한 병목은 N+1 문제가 발생하는 상황에서 더욱 심각해진다

    • 예를 들어, 카페에 직원 10명과 손님 50명이 있는 상황을 생각해보자
    • 손님들은 일정 주기로 주문을 요청한다
  • 아메리카노처럼 단순한 음료는 에스프레소에 물만 추가하면 되므로 빠르게 처리된다

    (단순한 쿼리 1~2회로 처리되는 요청)

  • 반면, 자바칩 프라푸치노는 우유, 시럽, 얼음 블렌딩, 휘핑크림, 초코 토핑 등 복잡한 과정을 거친다

    (여러 연관 엔티티를 반복 조회해야 하는 N+1 상황)

  • 이처럼 복잡한 요청이 몰리면 해당 직원은 한 주문에만 매달리게 되고, 다른 손님들의 대기는 점점 길어진다

Connection Pool 증가 시도와 역효과

  • 이런 문제를 고려해, Connection Pool을 동시 사용자 수(VU 100)의 약 30%인 30개로 늘려보았다

    • 처리 가능한 요청 수가 늘어나 성능이 향상될 것으로 예상했다
  • 당연히 결과가 좋아질 것이라 예상과 다르게 오히려 API의 응답 속도가 오히려 느려졌다. (성능저하)

  • 이런 결과는 개인 노트북 환경 때문이라고 판단했다

    • 현재 노트북에서 데이터베이스와 애플리케이션이 같은 자원을 공유하고 있다.

    • Pool을 늘리면 DB의 부하가 증가하고, 이는 애플리케이션과 자원 경쟁으로 이어지게 되고

      전체 애플리케이션 성능이 오히려 저하되었다는 판단이다.

6.2. 쿼리 모니터링의 필요성
  • 이번 테스트를 하면서 놀랐던 사실은 실제 프로젝트 마지막 상태인 Original 버전의 상태이다.
  • Original 버전의 경우 일부 Fetch Join이 적용되었음에도 불구하고 N+1 문제가 여전히 심각했다.

Original 버전의 상태가 심각한 이유

  1. 필요 없는 연관 엔티티까지 Fetch Join을 한 것
  2. Fetch Join으로 가져온 연관 엔티티에 EAGER 타입 연관 엔티티가 2개 존재 ⇒ 추가 쿼리 2개 발생
    • 즉, Fetch Join을 했더니 오히려 N+1 문제가 더 심각해진 것
  3. 루프 내 반복되는 쿼리를 최적화 못한 것
  4. 쓸모없는 Fetch Join을 적용하여 Batch Fetching 전역 설정이 무시된 것
  • 이런 중요한 문제를 개발 당시는 인지하지 못했다. 아니 다른 기능 구현을 하느라 관심이 없었다는 것이 맞다.
    • 테스트 데이터의 크기는 작았으며 쿼리 모니터링을 하지 않았기 때문에 이런 상태를 알지 못하고 Fetch Join으로 N+1 문제를 해결했다고 생각했다.
    • Batch Fetching (default_batch_fetch_size)에 대하여 제대로 알지 못했다. 이게 무엇을 하는 것인지, 어떤 기능을 하는지, Fetch Join과 EntityGraph를 적용한 경우 무시되는 것인지 알지 못했다.

앞으로의 다짐

  1. 실제 운영 환경과 유사한 데이터 규모에서 테스트하기
  2. 최적화를 위해서 쿼리 수, 응답 시간, 메모리 사용량, GC 활동을 종합적으로 분석하기
  3. 개발 초기 단계부터 쿼리 모니터링 체계 구축하기
    • 개발 초기부터 쿼리 로그 확인 습관화
    • p6spy와 같은 도구를 활용한 쿼리 모니터링
    • N+1 문제 조기 발견 및 해결
6.3. N+1 문제의 영향과 해결 중요성

N+1 문제의 심각성

  • 실험 결과를 통해 N+1 문제가 애플리케이션 성능에 미치는 영향이 매우 크다는 것을 확인할 수 있었다.
  • 특히 대규모 데이터에서 연관 데이터가 많을 수록 문제는 기하급수적으로 심각해진다.

소규모 데이터 vs 대규모 데이터

항목N+1 미해결Batch Fetching차이증가율
요청당 GC 발생 비율0.070.030.04-57.14%
요청당 메모리 회수량(MB)34.5613.91-20.65-59.75%
API TPS13.5245.8832.36+239.35%
API P95 응답 시간(ms)4135.30167.67-3967.63-95.95%
API P99 응답 시간(ms)4482.05254.32-4227.73-94.33%
GC 영향 비율(%)388.7837.09-351.69-90.46%
  • 대규모 데이터에서 N+1 문제가 남아 있는 경우와 Batch Fetching을 적용한 경우 비교
  1. 쿼리 수 급증

    • Case 3의 Origianl은 잘못된 Fetch Join 적용으로 N+1 문제가 해결되지 않고 오히려 악화되었다.

      ⇒ Original 버전(1012개) vs 최적화 버전(4~10개)으로 쿼리 수가 약 99% 감소

    • Pure 버전의 경우 대규모 데이터에서 N+1 문제가 해결되지 않은 경우 505개 쿼리와 Batch Fetching으로 해결한 경우 10개 쿼리로 98% 감소했다.

  2. 응답 시간 차이

    • 단일 요청 시 N+1 문제가 있는 경우(658ms)와 해결한 경우(94ms)로 약 6배 차이
    • VU 100일 때 N+1 문제가 있는 경우(2094ms)와 해결한 경우(95ms)로 평균 응답 시간 차이가 20배 이상으로 트래픽이 높아질수록 심각해짐
  3. 메모리 사용량

    • N+1 문제 해결 시 요청당 메모리 사용량이 60% 감소

      (Pure : 34.56MB/요청 ⇒ Batch Fetching 버전 : 약 13.91MB/요청)

  4. GC 발생 비율

    • N+1 문제 해결 시 GC 영향 비율이 최대 90% 감소

  • 이러한 결과로 알 수 있는 것은 N+1 문제 해결이 단순히 성능 최적화의 목적이 아니라 실제 서비스에서는 필수 사항이라는 것이다.
  • 개발 시 대규모 데이터 테스트를 하지 않았기 때문에 간과하고 넘어가서 성능 문제를 인지하지 못한 부분이 크다.
  • N+1 문제는 응답 시간뿐만 아니라 메모리 사용과 GC 부담 등 전반적인 애플리케이션 성능에 큰 영향을 미치므로, 개발 초기 단계부터 확실하게 하고 가는 것이 좋겠다.
6.4. 각 최적화 전략의 특징과 권장 사용 패턴

N+1문제 해결 전략 비교

  • 테스트 결과에서는, 세 가지 최적화 전략(Fetch Join, Entity Graph, Batch Fetching) 모두 N+1 해결 전보다 월등한 차이를 보였다.

성능 지표

항목N+1 미해결Fetch JoinEntityGraphBatch Fetching
API P95 응답 시간(ms)4135.30336.67 (-91.86%)235.35 (-94.31%)167.67 (-95.95%)
API P99 응답 시간(ms)4482.05431.99 (-90.36%)302.49 (-93.25%)254.32 (-94.33%)
API TPS13.5241.41 (206.39%)44.48 (229.05%)45.88 (239.41%)
GC 영향 비율(%)388.7861.07 (-84.29%)45.49 (-88.30%)37.09 (-90.46%)
요청당 GC 발생 비율0.070.03 (-51.16%)0.03 (-52.56%)0.03 (-60.87%)
요청당 메모리 회수량34.5616.47 (-52.33%)16.49 (-52.29%)13.91 (-59.76%)
  • 하지만, 전략 간 차이는 상대적으로 크지 않았기 때문에 무엇을 꼭 써야 한다는 부분은 없는 것 같다.
  • 다만, 1장에서 말한 각 전략의 장단점이 존재하기 때문에 앞으로는 다음과 같은 전략으로 접근하려 한다.

기본 최적화 전략

  1. 연관 관계는 최대한 LAZY 로딩 설정
    • 불필요한 EAGER 로딩은 N+1 문제를 야기해 성능 저하가 발생
    • 필요한 시점에만 필요한 연관 데이터를 로딩
  2. default_batch_fetch_size 설정 (100~1000 사이)
    • 전역 설정으로 기본적인 N+1 문제 해결
    • 세부 조정이 필요하다면 추가로 @BatchSize 활용
    • 여기까지만 해도 성능 저하는 거의 발생하지 않을 것이다.
  3. ManyToOne / OneToOne 관계
    • 페이징 문제가 없으므로 Fetch Join(EntityGraph) 우선 적용
  4. OneToMany / ManyToMany 관계
    • 단일 컬렉션이면서 페이징을 사용하지 않는 경우 : Fetch Join(EntityGraph)
    • 다중 컬렉션이거나 페이징이 필요한 경우 : Batch Fetching 유지
6.5. 결론

최적화 전략 선택 가이드

  1. 기본 접근 방식
    • 연관 관계는 기본적으로 LAZY 로딩으로 설정
    • default_batch_fetch_size 전역 설정(100~1000 사이)으로 N+1 문제 기본 대응
    • 필요에 따라 추가 최적화 전략 적용
  2. 관계 유형별 최적화 전략
    • ManyToOne/OneToOne : Fetch Join(EntityGraph) 우선 적용
    • OneToMany/ManyToMany + 단일 컬렉션 + 페이징 없음 : Fetch Join(EntityGraph)
    • OneToMany/ManyToMany + 다중 컬렉션 또는 페이징 필요 : Batch Fetching
  3. 메모리 효율성 최우선 환경
    • Entity Graph 적용 : 메모리 효율성 종합 순위 1위(0.9509)
    • 필요한 엔티티 그래프만 선택적으로 로딩하여 메모리 사용 최적화
  4. 응답 시간 최우선 환경
    • 대규모 데이터 + Batch Fetching: Pure 버전이 가장 좋은 응답 시간 (176.89ms)
    • 대규모 데이터 환경에서는 Fetch Join이 견고한 성능 (종합 영향도 153.88%)
  5. 복잡한 객체 그래프 환경
    • Entity Graph + Batch Fetching 조합 권장
    • 유연한 데이터 로딩 전략 제공

개발 및 운영 시 권장 사항

  1. 개발 초기부터 쿼리 모니터링 체계 구축
    • 개발 초기부터 쿼리 로그 확인하기
    • 테스트 환경에서 N+1 문제 조기 발견 및 해결
  2. 다양한 데이터 규모에서 성능 테스트
    • 소규모 데이터와 대규모 데이터 모두에서 테스트
    • 실제 운영 환경과 유사한 데이터 규모 테스트 필수
  3. 모든 성능 지표 종합 분석
    • 쿼리 수, 응답 시간뿐만 아니라 메모리 사용량, GC 활동 등 종합적 분석

JPA N+1 문제 해결 외 최적화 부분

  • 애플리케이션 수준

    • 비동기 처리
    • 최적화 : 커넥션 풀, 스레드 풀, JVM Heap 사이즈
    • GC 튜닝
    • 로깅 최적화 (비동기 로깅)
    • 2차 캐싱, 애플리케이션 캐싱, Redis 분산 캐싱
    • 분산 시스템
    • Elasticsearch 등
  • 데이터베이스 수준

    • 쿼리 튜닝
    • 인덱스 최적화
    • 쿼리 힌트 사용
    • 파티셔닝/샤딩
    • Master-Slave 등
  • 인프라 수준

    • 로드 밸런싱
    • Kubernetes
    • 서버 수평/수직적 강화 등

최종 인사이트

  1. N+1 문제는 선택이 아닌 필수 해결 과제
    • N+1 문제는 응답 시간, 메모리 사용, GC 부담 등 전반적인 애플리케이션 성능에 심각한 영향
    • 개발 초기 단계부터 반드시 해결 필요
  2. 최적화 전략은 상황에 맞게 선택
    • 모든 상황에 맞는 단일 전략은 없음
    • 데이터 크기, 관계 유형, 우선 순위(응답 시간 vs 메모리)에 따라 전략 선택
  3. 지속적인 모니터링과 최적화
    • 성능 최적화는 일회성 작업이 아닌 지속적인 과정
    • 주기적인 성능 측정 및 모니터링을 통한 개선 필요
  • 이번 실험 결과는 JPA 최적화 전략이 단순히 쿼리 수와 응답 시간뿐만 아니라 메모리 관리와 GC에도 큰 영향을 미침을 명확히 보여주었다.
  • 따라서 데이터 접근 패턴을 이해하고 적절한 전략을 선택하는 것이 JPA 애플리케이션 성능 최적화의 핵심이라고 생각한다.