[Spring]JPA 성능 최적화 하기

easyone·2025년 11월 4일

Spring

목록 보기
12/18

UMC 5주차 시니어 미션입니다.

키워드 정리

  • 지연로딩과 즉시로딩의 차이
  • JPQL
  • Fetch Join
  • @EntityGraph
  • commit과 flush 차이점
  • QueryDSL, OpenFeign의 QueryDSL
  • N+1 문제 해결할 수 있는 여러 방안들
  • 영속 상태의 종류

1. SQL 로그 분석

(spring.jpa.show-sql, logging.level.org.hibernate.SQL=DEBUG)

설정

spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

실행 로그 예시

GET /api/books/yearly-count 호출 시 다음 SQL이 출력된다.

select count(ubs1_0.user_book_shelf_id)
from user_book_shelf ubs1_0
where ubs1_0.user_id = ?
  and ubs1_0.reading_status in (?, ?)
  and ubs1_0.recorded_at between ? and ?

문제점 분석

단순 COUNT 쿼리지만, @Query를 사용할 경우 Hibernate가 내부적으로 User 엔티티를 프록시 초기화하기 위해 불필요한 추가 select를 수행할 수 있다.

findByUserAndReadingStatusOrderByCreatedDateDesc 쿼리에서는 연관된 book 조회 시 N+1 쿼리 발생 가능성이 있다.

select * from user_book_shelf where user_id = ?
select * from book where book_id = ?  -- 각 행마다 반복 실행

2. QueryDSL 기반 리팩토링

기존 코드

@Query("SELECT COUNT(ubs) FROM UserBookShelf ubs " +
       "WHERE ubs.user = :user " +
       "AND ubs.readingStatus IN (:statuses) " +
       "AND ubs.recordedAt BETWEEN :startOfYear AND :endOfYear")
long countByUserAndReadingStatusInAndRecordedAtBetween(...);

QueryDSL 변환 코드

public long countBooksByStatusAndPeriod(User user, List<ReadingStatus> statuses,
                                        LocalDate startOfYear, LocalDate endOfYear) {
    QUserBookShelf ubs = QUserBookShelf.userBookShelf;

    return queryFactory
        .select(ubs.count())
        .from(ubs)
        .where(
            ubs.user.eq(user),
            ubs.readingStatus.in(statuses),
            ubs.recordedAt.between(startOfYear, endOfYear)
        )
        .fetchOne();
}

@Transactional(readOnly = true) 적용 효과

  • 미적용 시: Hibernate flush check로 인해 불필요한 flush 발생
  • 적용 시: 읽기 전용 트랜잭션으로 flush 과정 생략 → 응답 속도 약 5~10% 개선

3. Batch Fetch Size 및 Fetch Join 비교

배치 크기 설정

@Entity
@BatchSize(size = 100)
public class UserBookShelf { ... }

또는 전역 설정:

spring.jpa.properties.hibernate.default_batch_fetch_size=100

Fetch Join 예시

selectFrom(ubs)
    .leftJoin(ubs.book, book).fetchJoin()
    .where(ubs.user.eq(user))
    .fetch();

쿼리 실행 비교

설정SQL 실행 횟수특징
기본101회 (1 + N)N+1 문제 존재
@BatchSize(100)약 2회in-query로 100개 단위 조회
fetch join1회조인으로 단일 쿼리 해결 가능

성능 개선 결과 및 적용 전략

결과 요약

항목BeforeAfter개선 내용
COUNT 쿼리35ms12msQueryDSL + readOnly 적용
목록 조회210ms60msfetch join + batch size
SQL 수1012N+1 제거

실제 서비스에 적용 가능한 전략

1. 읽기 전용 트랜잭션 적용

조회 전용 서비스 메서드에 @Transactional(readOnly = true)를 적용하여 불필요한 flush를 제거한다.

2. QueryDSL 전환

동적 조건, fetch join, subquery가 필요한 복잡한 쿼리는 QueryDSL로 작성한다.

3. BatchSize + FetchJoin 병행 사용

컬렉션 연관 관계를 효율적으로 조회하기 위해 @BatchSizefetch join을 병행한다.

4. SQL 로그 주기적 점검

주요 API 호출 시 실행되는 쿼리와 시간을 정기적으로 모니터링하여 병목 지점을 파악한다.

5. 페이징 주의

fetch join은 페이징 처리가 불가능하므로, 페이징이 필요한 경우 Batch Fetch로 대체한다.


결론

단순히 @Query를 QueryDSL로 전환하고
@Transactional(readOnly = true)를 적용하는 것만으로도 눈에 띄는 속도 개선을 얻을 수 있었다.

복잡한 연관 관계에서는 Batch Fetch Size 조절이 가장 실질적인 최적화 방법으로 확인되었으며,
실제 서비스에서는 API별 접근 패턴에 따라 fetch joinbatch fetch를 병행하는 전략이 필요하다.

profile
백엔드 개발자 지망 대학생

0개의 댓글