Skip to main content

Command Palette

Search for a command to run...

[Spring] N+1문제 발생과 분석

Updated
6 min read
[Spring] N+1문제 발생과 분석
S

Nice to meet u :) Im Backend Developer

✍️ 작성하게 된 이유

옷을 관리하는 서비스를 개발하면서 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

  1. 최대 사용자 약 100명(78req/s)

  2. 최대 요청 수 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를 사용하였다.

  • ClothClothWithAttributesAttribute까지 한번에 조회

결과 (@EntityGraph적용 후)

배포 서버 | K6

  1. 최대 사용자 약 200명(197req/s)

  2. 최대 요청 수 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/s9개169ms
EntityGraph 적용200명100req/s6개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+1 문제 | Incheol's TECH BLOG

개발 N년차JPA N+1 문제 해결 방법 및 실무 적용 팁 - 삽질중인 개발자

평양냉면7JPA N+1 문제 해결하기 (fetch join, entityGraph, batch size)

뱅어돔JPA N+1 문제에 대하여 (BatchSize, EntityGraph)​​

https://tudamoa.tistory.com/13

More from this blog

데이터베이스 기본 개념 정리

1️⃣ 데이터베이스(DB) & DBMS DB (Database): 일정한 규칙(스키마)에 따라 구조화되어 저장된 데이터의 집합. DBMS (Database Management System): DB를 제어/관리하는 시스템 소프트웨어. 특징: 실시간 접근 가능, 동시 공유 가능. 구조: 데이터베이스 → DBMS → 응용 프로그램 → 사용자 2️⃣ 엔티티(Entity) & 릴레이션(Relation) 엔티티: 여러 속성을 가진 "개체"...

Aug 5, 20252 min read
데이터베이스 기본 개념 정리

[Project] 날씨에 맞는 옷 추천 서비스 : 지그재그 크롤링 여정 기록 (1) ChromeDriver를 EC2에 설치하기

✍️ 작성하게 된 이유 무신사, 29cm는 Jsoup으로 충분히 크롤링이 가능했기 때문에, ZigZag도 당연히 Jsoup으로 처리될 것이라 생각했다. 무신사, 29cm와 마찬가지로 필요한 데이터는 모두 <script> 태그 안에 들어있었다. 하지만… 예상은 보기 좋게 빗나갔다. 🧪 현상 ✅ 로컬 크롤링 → 정상 작동 Jsoup으로 script 태그 내에서 대표 이미지와 상품명을 잘 추출 로컬 환경에서는 아무 문제 없이 작동 ❌ A...

Jul 30, 20253 min read
[Project] 날씨에 맞는 옷 추천 서비스 : 지그재그 크롤링 여정 기록 (1) ChromeDriver를 EC2에 설치하기

[Project] 날씨에 맞는 옷 추천 프로젝트: Selenium은 정말 필요한 선택이었을까? - 크롤링 삽질 기록

✍️ 작성하게 된 이유 날씨에 따라 옷을 추천해주는 서비스를 만들면서, 사용자가 입력한 구매 링크에서 옷 정보( 대표이미지, 상품명 )를 불러오는 기능이 필요했다. 처음에 해당 페이지를 동적 페이지로 판단했고, 자연스럽게 Selenium을 도입했다. 하지만 이 결정이 과연 최선이었는지는 수많은 시행착오 끝에야 알 수 있었다. 🕸️ Selenium을 선택한 이유 동적 페이지는 Jsoup으로 크롤링이 어렵다는 인식으로 처음부터 Selenium을 ...

Jul 28, 20254 min read
[Project] 날씨에 맞는 옷 추천 프로젝트: Selenium은 정말 필요한 선택이었을까? - 크롤링 삽질 기록

Soyulia's Blog

49 posts