[Spring] N+1문제 발생과 분석
![[Spring] N+1문제 발생과 분석](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1756727965704%2F8c24d83c-1a9e-4b4d-b733-c130243cbc6b.png&w=3840&q=75)
✍️ 작성하게 된 이유
옷을 관리하는 서비스를 개발하면서 Cloth 엔티티와 그에 연관된 ClothWithAttributes, Attribute 데이터를 함께 조회하는 기능이 필요했다.
그런데 연관 데이터를 조회할 때마다 쿼리가 폭발적으로 증가(N+1 문제) 하며, 성능이 급격히 저하되는 상황을 마주하게 되었다.
Spring JPA의 대표적인 문제로 N+1임을 알고있었지만, 해결하는 방법은 Fetch Join밖에 몰랐다. 지연로딩되는 필드를 엔티티가 조회될 때 조인하여 한방 쿼리로 갖고오는 것이다. 하지만, 프로젝트에서 Fetch Join을 해결했더니 에러가 계속 발생했다. Fetch Join을 두 번 써야하는 상황이었기 때문이다.
이번 글에서는 N+1 문제 발생 배경, Fetch Join을 사용하지 못할 때의 해결 방법(EntityGraph), K6를 이용한 성능 비교를 정리했다.
N+1 문제란?
JPA에서 연관관계를 가진 엔티티를 조회할 때,조회된 데이터 갯수만큼(n) 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다. 이를 N+1문제라고 한다.
상황

현재 연관관계 상태이다.
속성 정의는 2개로
test,계절이 저장된 상태이다위와 같은 구조에서 옷(Cloth)을 2개를 등록한 후 조회를 시도했다.
옷 → clothes_attribute → 속성 정의
테이블
clothes테이블:옷 정보 등록users테이블: 사용자 정보 등록clothes_attribute_def테이블: 의상 속성 정의 테이블( 속성이름, 선택 후보값 )clothes_attribute테이블: 의상 속성 테이블(의상, 속성, 사용자가 선택한 속성 선택값)
🧪 결과 (N+1 문제 발생)
배포 서버 | K6
최대 사용자 약 100명(78req/s)
최대 요청 수 84req/s.
상황 : 사용자 6명, 옷 100개, 속성 4개
가정1 : 2분 동안 100명씩 최대 500명까지 단계적 증가 ( Google Workspace Business Plus = 500명 참여 화상회의 지원 내용 참고 )
테스트1 : 의상 조회 API, 동시 접속자가 몇 명까지 버틸 수 있는가
기준1 : 지연시간 1s ( 1s이상 걸린다면 사용자 이탈률 증가하기 때문)



결과 해석
22:19 지연시간 1초 도달, 해당 구간 가상 유저 수 106명, 최대 요청 수 78.1req/s
→ 응답 지연 발생, RDS 병목 현상 발생
→ 최대 사용자 수 약 100명
가정2: 2분동안 100개씩 요청 수 증가, 최대 400개까지 단계적 증가
테스트2 : 의상 조회 API, 가상 유저수가 100까지 늘어날 때 한 유저가 요청할 수 있는 최대 수
기준2 : 지연시간 1s ( 1s이상 걸린다면 사용자 이탈률 증가하기 때문)
안전성 확보 : 시작을 0으로 두면 요청이 갑자기 몰리면서 콜드 스타트 지연 가능성 존재 → 50으로 웜업 → 정상 처리 상태 확인 후 점진적 증가


결과 해석
22:35부터 시점 1초 이상 튀는 구간 발생 → 병목 시작 시점, 동시 사용자 94명, 84.6req/s
→ 사용자 늘었지만 서버 못 따라감 = 응답 지연 발생, RDS 병목 현상 발생
→ 최대 요청 수 : 약 84개
로컬 서버 | 총 쿼리 수 9개, 169m/s
옷 2개를 위한 조회 1번(의상 타입이 검색 조건이다)
어떤 속성을 선택했는지 보여주기 위해 각 옷에 대한 속성 조회 2번
속성은 어떤 후보 값들을 가지고 있는지 보여주기 위해 각 속성의 속성정의 조회 4번
기타: 유저 조회 1번, 조회 페이징 카운트 쿼리 1번

왜 이런 일이 벌어질까?
지연로딩(LAZY)
JPA의 연관관계는 기본적으로 지연로딩(LAZY) 으로 설정함.
즉, 연관된 엔티티는 실제로 접근하는 순간에 쿼리를 실행.
@ManyToOne(fetch = LAZY)관계에서 루프 내에서 .getXXX() 같은 코드가 실행되면,
그 때마다 DB에서 select 발생 → 이게 N번
단기 해결방법
FetchJoin
JPA의 JPQL에서 연관 엔티티를 명시적으로 JOIN하여 한 번의 쿼리로 함께 조회하는 방법이다.
JPQL에서 성능 최적화를 위해 제공하는 기능이다.
JOIN과 달리, 연관 엔티티를 즉시 로딩해서 실제 객체로 채워준다.
기본적으로는 inner join으로 동작하고 left join이라고 명시하면 outer join이 된다.
| 항목 | 설명 |
| 동작 방식 | SQL의 JOIN처럼 동작하며 Article과 Member를 한 번에 조회, 영속성 컨텍스트 1차 캐시에 저장되어 바로 활용될 수 있음 |
| 대상 | @ManyToOne, @OneToMany 모두 가능 |
| 장점 | N+1 완전 차단, 쿼리 한 번에 모든 데이터 조회 |
| 단점 | 컬렉션에서 페이징 불가, 복잡한 조인 시 중복 문제 발생, LAZY 설정 사용 불가능, 이중 컬렉션 페치 불가 |
@EntityGraph
Spring Data JPA에서 연관 엔티티를 fetch join처럼 미리 불러오도록 지시(EAGER로 동작)하는 선언적 방법이다. left outer join이 사용되며 페이징과 함께 사용 가능하다
| 항목 | 설명 |
| 동작 방식 | 내부적으로 join fetch를 자동 생성 |
| 대상 | @ManyToOne, @OneToMany |
| 장점 | 페이징(Pageable) 가능, 코드 간결, 단건 + 컬렉션 가능 |
| 단점 | 복잡한 조인에는 한계, JPQL처럼 유연한 제어 어려움 |
@BatchSize
지연 로딩이 발생할 때 ID를 모아 IN 쿼리로 한 번에 조회 (추가 쿼리 1개)
전역 설정도 가능
Fetch Join을 이용해보려했으나 이중 페치 조인은 불가하고 속성의 모든 정의를 불러와야하니 @EntityGrpah를 사용하였다.

Cloth→ClothWithAttributes→Attribute까지 한번에 조회
결과 (@EntityGraph적용 후)
배포 서버 | K6
최대 사용자 약 200명(197req/s)
최대 요청 수 100req/s.
테스트 1 결과
20:13 즈음부터 최대 요청 수 병목 발생 : 약 200명(197req/s), 지연시간 1s 이내 , RDS 병목 현상 발생

테스트 2 결과
20:00:30 즈음부터 최대 요청 수 100req/s 병목 발생, 지연시간 1s이내, RDS병목 현상 발생
N+1해결 전 요청 수가 가상 유저 수 증가를 따라가지 못하는 응답 지연이 생겼었지만 현 시점 해결

로컬 서버 | 총 쿼리 수 6개, 34m/s

✅ 성능 개선 수치
| 구분 | 최대 사용자 수 | 최대 요청 수 | 쿼리 수 | 조회 시간 |
| 기존(N+1 문제 발생) | 100명 | 84req/s | 9개 | 169ms |
| EntityGraph 적용 | 200명 | 100req/s | 6개 | 34ms |
최대 사용자 수 : 2배 개선, 100% 증가
최대 요청 수 : 1.2배 개선, 약 19% 증가
쿼리 수 : 약 1.5배 개선, 33% 감소
조회 시간 : 약 5배, 80%개선
같은 데이터를 조회하면서도, 필요한 연관 관계를 미리 가져와 성능 최적화
회고
이전에는 연관관계의 대표적인 문제가 N+1이기 때문에 무조건 리팩토링 해야한다는 의식이 있었다. 하지만 N+1문제가 특별히 성능에 문제가 없다면 한 방 쿼리로 성능이 안좋아질 수 있다. N+1을 해결하는 것이 꼭 필요한 과정인가? 해당 물음을 가지고 부하테스트를 진행했다. 해결하기 전에는 테스트 1에서 사용자가 증가함에 따라 요청 수가 따라가지 못했다. 응답 지연시간이 발생한 것이다. 반면에, @EntityGraph 로 해결한 결과, 사용자 수에 비례하게 요청수가 증가하지 않지만 테스트1만큼의 차이는 나지 않는다. (전:20, 후: 3) 따라서, N+1은 해결해야하는 문제였으며 성능이 나아졌다고 볼 수 있다.
하지만, RDS 병목을 해결하지 못했다. @EntityGraph는 카디널리티 곱으로 조인이 되어 중복 데이터가 발생할 수 있다.
이 문제로 RDS에서 병목이 생긴 것으로 확인된다. 내가 구조를 잘 짰는 먼저 검토했다. 중간테이블을 만들었는데 나는 왜 cloth에 양방향 관계를 만든거지? 중간테이블에서 조회하면 되지 않나? 문득 생각이 들었다. 바로 행동으로 옮겼다. 양방향 관계를 제거하고 옷을 조회할 때, cltohWithAttributeRepository에서 옷의 ID에 따른 속성테이블을 JOIN하여 갖고온다. 그 후, 서비스 단에서 clothId에 따른 속성들을 그룹화 해주고 DTO로 변환한다. 최종적으로 옷과 속성정보를 같이 조회할 수 있게된다. 따라서, 단방향 구조로 변환한 것이다.
이로서 양방향 관계의 단점을 알게되었다. ORM이 자동으로 관리해주지만 쿼리 수가 예측 불가능하고 성능이 떨어진다.
성능테스트를 해보려했으나 날씨 키 등 팀원들이 정했던 API키들이 유효하지 않다고 뜬다. (아마 프로젝트 기간이 끝나서 삭제한게 아닐까 싶다) 그렇다 보니 배포가 어렵게 되었다. 어떻게 API키를 발급받는지 공부하는 중이다.
참고자료
개발 N년차JPA N+1 문제 해결 방법 및 실무 적용 팁 - 삽질중인 개발자
평양냉면7JPA N+1 문제 해결하기 (fetch join, entityGraph, batch size)

![[Project] 날씨에 맞는 옷 추천 서비스 : 지그재그 크롤링 여정 기록 (1) ChromeDriver를 EC2에 설치하기](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1753843082352%2Fc2452b33-97a4-4148-8ba4-750e5eee6aff.png&w=3840&q=75)
![[Project] 날씨에 맞는 옷 추천 프로젝트: Selenium은 정말 필요한 선택이었을까? - 크롤링 삽질 기록](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1753343114273%2Fb32cc35e-a2e6-4085-a132-26c72f8792d9.png&w=3840&q=75)
![[Spring] @Cacheable, @CachePut, @CacheEvict](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1749265456525%2F51b97bad-f86e-4f0f-9b33-77eaa733176f.png&w=3840&q=75)