[LG U+ 유레카 3기] JPA N+1 · Fetch Join · JPQL Join

2025. 11. 20. 16:33Java/JPA

오늘은 JPA를 사용할 때 반드시 알아야 하는 N+1 문제와 이를 해결하는 fetch join, 그리고 JPQL Join 문법을 실습 중심으로 정리한 날이다.
실제 Hibernate 로그가 어떻게 출력되는지 직접 보면서 이해하는 “핵심 실습”이었기 때문에 내용을 정확히 정리해두면 향후 프로젝트/JPA 최적화에 큰 도움이 된다.


❶ N+1 문제란?

기본 엔티티 목록을 조회하는 SELECT(1) 후, 각 엔티티가 가지고 있는 연관 엔티티를 N번 추가로 조회하며 발생하는 비효율이다.

String jpql = "select o from Orders o";
List<Orders> list = em.createQuery(jpql, Orders.class)
                       .getResultList();

Hibernate 로그는 아래처럼 찍힌다.

# 1) Orders 목록 조회
select * from orders;

# 2) Orders 각각에 대해 Product/Customer 즉시 로딩(EAGER)
select * from product where id=?
select * from customer where id=?
select * from product where id=?
select * from customer where id=?
...

1번 + N번 = 총 N+1개의 SQL이 실행되어 성능이 치명적으로 떨어진다.


❷ 왜 발생하는가? (핵심 원리)

JPA의 연관 관계 기본 로딩 전략 때문.

  • LAZY → “필요한 순간 조회” → 목록 조회 시 거의 100% N+1
  • EAGER → “즉시 조회”이지만 JOIN을 강제하는 게 아니라 엔티티 로딩 순간 추가 SELECT를 실행

그래서 EAGER도 목록 조회에서는 N+1이 발생한다.


❸ 해결 방법: Fetch Join

핵심 해결책은 join fetch 한 줄이다.

String jpql = 
"select o from Orders o 
 join fetch o.customer 
 join fetch o.product";

List<Orders> list = em.createQuery(jpql, Orders.class).getResultList();

Hibernate 출력:

select o.*, c.*, p.*
from orders o
join customer c on o.customer_id = c.id
join product p on o.product_id = p.id;

→ 단 1번의 SELECT로 Orders + Product + Customer를 모두 로딩
→ N+1 완벽 제거


❹ JPQL Join 문법 2가지 방식

1) 현대적인 join 문법 (추천)

select o, p
from Orders o
join o.product p

관계 필드를 기준으로 JOIN하는 정석 JPQL 문법이다.

2) 전통 스타일 (SQL 스타일)

select o, p 
from Orders o, Product p 
where o.product = p

이는 SQL의 고전 조인 방식이며 내부적으로는 아래와 동일한 의미이다.

from orders o
cross join product p
where o.product_id = p.id

실제로는 INNER JOIN처럼 동작한다.
하지만 실무/강의에서는 관계 필드 기반 JOIN을 훨씬 더 많이 사용한다.


❺ 엔티티 두 개를 동시에 Select하는 경우

select o, p 
from Orders o 
join o.product p

결과 타입은 무조건 List<Object[]> 형태.

for(Object[] row : result){
    Orders o = (Orders) row[0];
    Product p = (Product) row[1];
}

❻ UnknownEntityException 원인 정리

JPA/Hibernate 수동 환경에서는 엔티티를 직접 등록해야 한다.
MyPersistenceUnitInfo 안에 아래가 반드시 있어야 한다.

@Override
public List<String> getManagedClassNames() {
    return List.of(
        "entity.Orders",
        "entity.Customer",
        "entity.Product"
    );
}

등록을 빠뜨리면 Hibernate는 엔티티를 모른다 → UnknownEntityException: Could not resolve root entity ‘Orders’


❼ 실무적 결론

JPA 사용할 때 반드시 기억해야 하는 3줄 결론:

  • 1) 목록 조회 시는 무조건 LAZY + fetch join 조합
  • 2) EAGER는 예상보다 더 쉽게 N+1을 유발
  • 3) JPQL join은 join o.product 형태가 가장 안전하고 정석

오늘 내용은 JPA 성능 최적화의 핵심이기 때문에 나중에 스프링 부트 프로젝트에서도 그대로 적용된다.


마무리

Hibernate 로그를 직접 확인하며 N+1 → fetch join → join 문법을 모두 체득한 하루였다.
이제부터 JPQL 목록 조회가 보이면 자동으로 “여기 fetch join 필요하겠는데?” 하고 감이 올 것이다.