JOIN FETCH가 데이터베이스 부하를 94% 감소시킨 방법: 실제 사례 연구
Source: Dev.to
소개
N+1 문제는 Spring Boot 애플리케이션에서 데이터베이스 부하가 높아지는 가장 흔한 원인 중 하나입니다. 이 글에서는 실제 금융 시스템 프로젝트를 예시로 들어 이 문제를 체계적으로 해결하는 방법을 보여드리겠습니다.
N+1 문제란?
N+1 문제는 ORM이 연관된 엔티티를 로드하기 위해 추가적인 SELECT 쿼리를 생성할 때 발생합니다.
문제 예시
@Entity
public class ExternalExchangeRate {
@ManyToOne(fetch = FetchType.LAZY)
private Currency currencyFrom;
@ManyToOne(fetch = FetchType.LAZY)
private Currency currencyTo;
}
다음과 같이 실행할 때:
List rates = repository.findByExchangeDateAndCurrencyFromId(date, currencyFromId);
for (ExternalExchangeRate rate : rates) {
System.out.println(rate.getCurrencyFrom().getCode()); // N개의 쿼리!
System.out.println(rate.getCurrencyTo().getCode()); // N개의 추가 쿼리!
}
결과: 데이터베이스에 1 + N*2 개의 쿼리가 실행됩니다.
예를 들어 하루에 15개의 통화 환율이 있다면:
- 환율 1 SELECT
currencyFrom15 SELECTcurrencyTo15 SELECT
총 31개의 쿼리! 😱
N+1 문제 감지하기
개발 모드 설정
# application-dev.properties
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.generate_statistics=true
spring.jpa.properties.hibernate.use_sql_comments=true
logging.level.org.hibernate.SQL=DEBUG
application-dev.properties 보기 →
모니터링용 디버그 엔드포인트
@RestController
@RequestMapping("/api/debug")
public class DebugController {
@Autowired
private EntityManagerFactory emf;
@GetMapping("/hibernate-stats")
public Map getHibernateStats() {
Statistics stats = emf.unwrap(SessionFactory.class)
.getStatistics();
return Map.of(
"queriesExecuted", stats.getQueryExecutionCount(),
"prepareStatementCount", stats.getPrepareStatementCount(),
"entitiesLoaded", stats.getEntityLoadCount(),
"entitiesFetched", stats.getEntityFetchCount()
);
}
}
최적화 전 측정값
GET /api/external-exchange-rates/latest?date=2024-11-24¤cyFromId=2
Result:
- Time: 48ms
- prepareStatementCount: 33 ← 실제 실행된 SQL 문
- queriesExecuted: 2 ← HQL/JPQL 쿼리 종류
- entitiesLoaded: 131
- entitiesFetched: 31 ← 추가된 lazy fetch!
지표 이해하기
queriesExecuted= 구분된 HQL/JPQL 쿼리 수prepareStatementCount= 실제 JDBC 문(33)entitiesFetched= lazy 로드된 엔티티 수 (31개의Currency엔티티)
SQL 로그 발췌:
-- 1 query for exchange rates
SELECT * FROM external_exchange_rates
WHERE exchange_date = '2024-11-24'
AND currency_from_id = 2;
-- 31 additional queries for currencies
SELECT * FROM currencies WHERE id = 1;
SELECT * FROM currencies WHERE id = 3;
SELECT * FROM currencies WHERE id = 4;
-- ... 28 more queries
JOIN FETCH가 완벽히 작동할 때
평탄한 데이터 구조 (@ManyToOne)
JOIN FETCH는 엔티티가 다른 테이블의 하나의 엔티티만 참조하는 평탄한 구조에 이상적입니다.
@Entity
public class ExternalExchangeRate {
@Id
private Long id;
// 각 환율은 FROM 통화 1개를 참조
@ManyToOne(fetch = FetchType.LAZY)
private Currency currencyFrom;
// 각 환율은 TO 통화 1개를 참조
@ManyToOne(fetch = FetchType.LAZY)
private Currency currencyTo;
private BigDecimal rate;
}
왜 완벽히 동작하는가
- 1 ExternalExchangeRate → 1 Currency (from)
- 1 ExternalExchangeRate → 1 Currency (to)
@OneToMany같은 다중 관계가 없음- 페이징이 정상 작동 ✅
- COUNT 쿼리가 정확 ✅
JOIN FETCH가 적합하지 않을 때
테이블 파트와 컬렉션 (@OneToMany)
JOIN FETCH는 엔티티가 컬렉션을 참조할 때(예: 문서 라인 아이템) 최적이 아닙니다.
@Entity
public class Invoice {
@Id
private Long id;
@ManyToOne
private Customer customer;
// ⚠️ 문제: 아이템 컬렉션!
@OneToMany(mappedBy = "invoice")
private List items; // 1개, 10개, 100개 이상 될 수 있음
}
페이징 문제
잘못된 접근
@Query("SELECT DISTINCT i FROM Invoice i " +
"LEFT JOIN FETCH i.items") // ⚠️ 문제!
Page<Invoice> findAll(Pageable pageable);
실패 이유
- 카르테시안 곱: 아이템이 10개인 Invoice 1건 → 10행이 생성
- 페이징 깨짐: 페이지 크기 20이면 실제로는 2개의 Invoice만 반환될 수 있음
- COUNT 부정확: 조인 후 행 수를 셈, Invoice 수가 아님
Hibernate 경고
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Hibernate가 전체 데이터를 로드하고 메모리에서 페이징을 적용하므로 성능 이점이 사라집니다.
컬렉션 대안
@EntityGraph– 연관 엔티티를 효율적으로 가져올 수 있음- 두 개의 별도 쿼리 – 각 fetch를 완전히 제어
- DTO 프로젝션 – 필요한 필드만 조회
- 배치 페치 – 컬렉션에 대한 쿼리 수 감소
※ @EntityGraph에 대해서는 실제 문서와 라인 아이템 예시를 들어 다음 글에서 자세히 다룰 예정입니다.
비교 표
| Feature | JOIN FETCH (@ManyToOne) | JOIN FETCH (@OneToMany) |
|---|---|---|
| Pagination | ✅ 완벽히 작동 | ❌ 깨짐 |
| COUNT accuracy | ✅ 정확함 | ❌ 행 수를 셈 |
| Number of queries | ✅ 1–2 개 정도 | ⚠️ 모두 메모리에서 처리 |
| Predictability | ✅ 안정적 | ❌ 데이터에 의존 |
| Recommendation | ✅ 사용 권장 | ❌ 사용 금지 |
해결책: 평탄 구조에 대한 JOIN FETCH
1단계: 최적화된 Repository 메서드 만들기
@Repository
public interface ExternalExchangeRateRepository
extends JpaRepository<ExternalExchangeRate, Long> {
/**
* 날짜와 currencyFrom 로 최신 환율을 찾으며, 통화 정보를 함께 로드합니다.
* N+1 쿼리를 방지하기 위해 JOIN FETCH를 사용합니다.
*/
@Query("SELECT e FROM ExternalExchangeRate e " +
"LEFT JOIN FETCH e.currencyFrom " +
"LEFT JOIN FETCH e.currencyTo " +
"WHERE e.exchangeDate = :date " +
"AND e.currencyFrom.id = :currencyFromId")
List<ExternalExchangeRate> findLatestRatesByCurrencyFromWithCurrencies(
@Param("date") LocalDate date,
@Param("currencyFromId") Long currencyFromId);
/**
* 모든 환율을 단일 쿼리로 통화와 함께 조회합니다.
*/
@Query(value = "SELECT DISTINCT e FROM ExternalExchangeRate e " +
"LEFT JOIN FETCH e.currencyFrom " +
"LEFT JOIN FETCH e.currencyTo " +
"ORDER BY e.exchangeDate DESC, e.id DESC",
countQuery = "SELECT COUNT(e) FROM ExternalExchangeRate e")
Page<ExternalExchangeRate> findAllWithCurrencies(Pageable pageable);
}