Reviewed technical note
JPA Fetch Join은 기본값이 아니라 수술 도구다
N+1을 줄이기 위해 Fetch Join을 쓸 때 XToOne, 컬렉션, 페이징, OSIV 경계를 구분하는 기준.
Fetch Join은 JPA 성능 문제를 해결하는 강력한 도구지만 기본값이 되어서는 안 된다. 목적은 모든 연관관계를 한 번에 끌고 오는 것이 아니라, 특정 조회에서 필요한 객체 그래프를 트랜잭션 안에서 명시적으로 준비하는 것이다.
먼저 N+1이 실제로 있는지 본다
Fetch Join을 쓰기 전에는 SQL 로그로 문제가 있는지 확인해야 한다. DTO 변환 중 연관 객체에 접근하면서 주문 10건에 회원 조회 10번, 배송 조회 10번이 추가된다면 전형적인 N+1이다. 이때 다대일·일대일 관계는 Fetch Join으로 비교적 안전하게 줄일 수 있다.
@Query("select o from Order o" +
" join fetch o.member" +
" join fetch o.delivery")
List<Order> findAllWithMemberDelivery();
이 코드는 주문, 회원, 배송을 한 번에 조회한다. 하지만 이것은 특정 API 조회에 맞춘 선택이다. 엔티티의 기본 로딩 전략을 모두 즉시 로딩으로 바꾸는 것과는 다르다.
컬렉션 Fetch Join은 페이징과 충돌한다
문제는 일대다 컬렉션이다. 주문과 주문상품을 함께 조인하면 주문 하나가 상품 개수만큼 반복된다. 루트 엔티티 기준으로 10건만 가져오려 해도 데이터베이스는 주문상품 행 기준으로 결과를 만든다. Hibernate가 전체 데이터를 읽고 메모리에서 페이징하려 할 수 있고, 데이터가 많으면 장애로 이어질 수 있다.
선택 흐름은 단순하게 가져간다.
flowchart TD
start[연관 데이터가 필요한가?] --> xtoone{XToOne 관계인가?}
xtoone -->|예| fetch[Fetch Join 검토]
xtoone -->|아니오| collection{컬렉션인가?}
collection -->|예| paging{페이징이 필요한가?}
paging -->|예| batch[Batch Fetch 또는 별도 조회]
paging -->|아니오| limited[결과 규모 검증 후 제한적 사용]
fetch --> log[SQL과 반환 행 수 확인]
batch --> log
limited --> log
컬렉션 조회에서 중요한 것은 쿼리 수만 줄이는 게 아니다. 반환 행 수, 중복 데이터, 메모리 사용량도 함께 줄여야 한다.
OSIV OFF에서는 조회 설계가 더 중요하다
OSIV가 켜져 있으면 트랜잭션이 끝난 뒤에도 컨트롤러나 뷰에서 지연 로딩이 가능하다. 편하지만 데이터베이스 커넥션을 응답 종료까지 오래 잡을 수 있다. 외부 API 호출이나 긴 응답 조립이 섞이면 커넥션 풀이 고갈될 수 있다.
OSIV를 끄면 커넥션 점유 시간은 줄어든다. 대신 트랜잭션 밖에서 지연 로딩을 시도하면 예외가 난다. 이때 Fetch Join, Batch Fetch, 조회 전용 서비스가 필요하다. 핵심은 컨트롤러에서 우연히 연관 객체를 열어보는 구조를 없애고, 응답에 필요한 데이터를 서비스 트랜잭션 안에서 준비하는 것이다.
안전한 적용 순서
- 기본 로딩 전략은 지연 로딩으로 둔다.
- SQL 로그로 실제 N+1을 확인한다.
- 다대일·일대일 관계는 Fetch Join으로 먼저 해결한다.
- 컬렉션은 페이징 여부를 먼저 본다.
- 컬렉션 페이징이 필요하면 Batch Fetch나 별도 DTO 조회를 쓴다.
- 적용 후 쿼리 수뿐 아니라 반환 행 수와 메모리 사용량을 확인한다.
Fetch Join은 성능 버튼이 아니라 조회별 수술 도구다. 필요한 경로에만 쓰고, 컬렉션과 페이징이 만나는 순간에는 다른 전략을 선택해야 한다.