UMC 5주차 시니어 미션입니다.
(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 = ? -- 각 행마다 반복 실행
@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(...);
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% 개선
배치 크기 설정
@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 join | 1회 | 조인으로 단일 쿼리 해결 가능 |
| 항목 | Before | After | 개선 내용 |
|---|---|---|---|
| COUNT 쿼리 | 35ms | 12ms | QueryDSL + readOnly 적용 |
| 목록 조회 | 210ms | 60ms | fetch join + batch size |
| SQL 수 | 101 | 2 | N+1 제거 |
조회 전용 서비스 메서드에 @Transactional(readOnly = true)를 적용하여 불필요한 flush를 제거한다.
동적 조건, fetch join, subquery가 필요한 복잡한 쿼리는 QueryDSL로 작성한다.
컬렉션 연관 관계를 효율적으로 조회하기 위해 @BatchSize와 fetch join을 병행한다.
주요 API 호출 시 실행되는 쿼리와 시간을 정기적으로 모니터링하여 병목 지점을 파악한다.
fetch join은 페이징 처리가 불가능하므로, 페이징이 필요한 경우 Batch Fetch로 대체한다.
단순히 @Query를 QueryDSL로 전환하고
@Transactional(readOnly = true)를 적용하는 것만으로도 눈에 띄는 속도 개선을 얻을 수 있었다.
복잡한 연관 관계에서는 Batch Fetch Size 조절이 가장 실질적인 최적화 방법으로 확인되었으며,
실제 서비스에서는 API별 접근 패턴에 따라 fetch join과 batch fetch를 병행하는 전략이 필요하다.