[5] 성능 최적화(3): N+1

2026. 1. 25. 15:14·Project/Secondhand Market

1. 상품 목록 조회 API (GET /items) 최적화

 상품 목록 조회 API는 한 페이지당 20개의 상품 데이터를 반환하도록 구현되어 있다.

 API 호출 시 그라파나(Grafana)를 통해 쿼리 실행 내역을 모니터링한 결과, 단 한 번의 요청에 무려 21개의 쿼리가 발생하는 것을 확인했다.

1.1. 원인 분석

이러한 현상이 발생하는 원인은 ItemService의 getItemList() 메서드 로직에 있다.

  1. ItemRepository.searchItems(...)가 호출되면서 Item 엔티티 목록을 조회하는 쿼리 1회가 실행된다.
  2. 조회된 엔티티들을 DTO로 변환하기 위해 .map(item -> ItemListResponse.fromEntity(...))가 실행된다.
  3. 문제는 ItemListResponse.fromEntity 내부에서 item.getItemImages()를 호출하는 순간 발생한다.
  4. Item과 ItemImage는 지연 로딩(Lazy Loading) 관계이므로, 각 상품(Item)마다 이미지를 가져오기 위한 추가 쿼리가 20회 발생한다.

결과적으로 1 (목록 조회) + 20 (단건 상세 조회) = 21번의 쿼리가 나가는 전형적인 N+1 문제가 발생한 것이다.

1.2. 해결: default_batch_fetch_size 설정

 Item과 ItemImage 같은 OneToMany(컬렉션) 관계에서 페이징 처리를 할 때 Fetch Join을 사용하면, 모든 데이터를 메모리에 올린 후 페이징을 처리하는 문제가 발생할 수 있다. (Out Of Memory 위험) 따라서 이 경우에는 Fetch Join 대신 Batch Size 설정을 통해 IN 쿼리로 묶어서 조회하는 방식을 선택했다.

`application.yml`

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100 # ✅ 주요 설정

1.3. 결과 확인

설정 적용 후 다시 테스트한 결과, 기존 21방이었던 쿼리가 단 2방으로 획기적으로 줄어든 것을 확인할 수 있다.

  • 1번: Item 목록 조회
  • 2번: 조회된 Item들의 ID를 IN 절로 묶어 Image 목록 조회


2. 채팅방 목록 조회 API 최적화

상품 목록뿐만 아니라, 채팅방 목록을 조회할 때도 불필요한 쿼리가 다수 발생하는 것을 확인했다. (기존 약 6회 이상의 쿼리 발생)

2.1. 원인 분석

기존 ChatRoomRepository의 조회 메서드는 단순히 ChatRoom 엔티티만 조회하고 있었다.

@Query("""
    SELECT cr
    FROM ChatRoom cr
    WHERE cr.buyer.id = :memberId OR cr.seller.id = :memberId
""")
List<ChatRoom> findMyChatRooms(@Param("memberId") Long memberId);

 ChatRoom을 조회한 후, 연관된 Item(상품), Seller(판매자), Buyer(구매자) 정보를 참조할 때마다 지연 로딩으로 인해 추가 쿼리가 발생하고 있었다.

2.2. 해결: JOIN FETCH 적용

ChatRoom 입장에서 Item, Seller, Buyer는 모두 ManyToOne(N:1) 관계이다. 이 경우 데이터 뻥튀기(Cartesian Product) 문제가 발생하지 않으므로, JOIN FETCH를 사용하여 한 번의 쿼리로 연관 데이터를 모두 가져오는 것이 가장 효율적이다.

@Query("""
    SELECT cr
    FROM ChatRoom cr
    JOIN FETCH cr.item i     
    JOIN FETCH cr.seller s   
    JOIN FETCH cr.buyer b    
    WHERE b.id = :memberId OR s.id = :memberId
""")
List<ChatRoom> findMyChatRooms(@Param("memberId") Long memberId);

2.3. 결과 확인

JOIN FETCH 적용 후, 채팅방 목록과 관련된 모든 데이터를 단 1개의 쿼리로 조회할 수 있게 되었다.


💡 요약

  • OneToMany 컬렉션 페이징 조회 (Item → Images): default_batch_fetch_size 설정을 통해 IN 쿼리로 최적화 (21회 → 2회)
  • ManyToOne 단일 관계 조회 (ChatRoom → Item/Member): JOIN FETCH를 사용하여 즉시 로딩으로 최적화 (N회 → 1회)

'Project > Secondhand Market' 카테고리의 다른 글

[7] 성능 최적화 - Redis(캐싱)  (0) 2026.01.27
[6] 성능 최적화(4): Async 적용  (0) 2026.01.25
[4] 성능 최적화(2): Lock  (0) 2026.01.25
[3] 성능 최적화(1): 복합 Index & FullText Index  (1) 2026.01.21
[2] 프로젝트 모니터링 설정과 테스트 환경 구축  (1) 2026.01.10
'Project/Secondhand Market' 카테고리의 다른 글
  • [7] 성능 최적화 - Redis(캐싱)
  • [6] 성능 최적화(4): Async 적용
  • [4] 성능 최적화(2): Lock
  • [3] 성능 최적화(1): 복합 Index & FullText Index
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[5] 성능 최적화(3): N+1
상단으로

티스토리툴바