JOIN FETCH가 데이터베이스 부하를 94% 감소시킨 방법: 실제 사례 연구

발행: (2025년 12월 16일 오전 05:47 GMT+9)
6 min read
원문: Dev.to

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;
}

ExternalExchangeRate 코드 보기 →

다음과 같이 실행할 때:

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
  • currencyFrom 15 SELECT
  • currencyTo 15 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()
        );
    }
}

DebugController 코드 보기 →

최적화 전 측정값

GET /api/external-exchange-rates/latest?date=2024-11-24&currencyFromId=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에 대해서는 실제 문서와 라인 아이템 예시를 들어 다음 글에서 자세히 다룰 예정입니다.

비교 표

FeatureJOIN 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);
}
Back to Blog

관련 글

더 보기 »

Spring Boot를 사용한 RESTful Web API 구현

REST API를 구축하는 것은 모든 백엔드 개발자에게 가장 필수적인 기술 중 하나입니다. Spring Boot는 production‑ready 환경을 제공함으로써 이를 매우 간단하게 만들어 줍니다.