JPA JOIN 시리즈 (5/5)

이전 편: [JPA] 4. RIGHT OUTER JOIN

이 문서가 시리즈의 마지막 편입니다.

JPA에서 JOIN을 쓰면 연관 엔티티를 메모리에 올리지 않는다. 조건 필터링만 한다. 연관 엔티티를 함께 로딩하려면 fetch join을 써야 한다. LAZY로 설정된 연관관계를 N+1 없이 한 번에 가져오는 핵심 도구다.

시리즈 안내

- JOIN 유형 개요

- INNER JOIN

- LEFT OUTER JOIN

- RIGHT OUTER JOIN

- JPA fetch join ← 현재 문서

일반 JOIN vs fetch join

JPA에서 JOIN의 목적은 두 가지다.

  • 일반 JOIN : 조건 필터링. 연관 엔티티는 로딩하지 않는다.
  • fetch JOIN : 연관 엔티티를 함께 로딩. LAZY 로딩을 한 방에 처리한다.
// 일반 JOIN — author 조건 필터링용, 메모리에는 Feed만 올라옴
@Query("SELECT f FROM Feed f JOIN f.author a WHERE a.name = :name")
List<Feed> findByAuthorName(@Param("name") String name);

위 쿼리는 author.name으로 필터링하지만, 결과에 올라오는 건 Feed뿐이다. feed.getAuthor()를 호출하면 LAZY 로딩이 별도로 일어난다.

// fetch JOIN — author까지 한 번에 로딩
@Query("SELECT f FROM Feed f JOIN FETCH f.author")
List<Feed> findAllWithAuthor();

JOIN FETCH를 쓰면 author까지 함께 로딩된다. feed.getAuthor()를 호출해도 추가 쿼리가 없다.

실행되는 SQL은 다음과 같다.

SELECT f.*, u.*
FROM feed f
INNER JOIN user u ON f.author_id = u.id

JOIN FETCH는 기본적으로 INNER JOIN으로 변환된다.

fetch join으로 N+1 해결

LAZY로 설정된 상태에서 목록 조회를 하면 N+1이 발생한다.

List<Feed> feeds = feedRepository.findAll();

for (Feed feed : feeds) {
    System.out.println(feed.getAuthor().getName()); // Feed마다 User 쿼리 1번씩
}

Feed가 100개면 100번의 추가 쿼리가 발생한다. fetch join으로 해결한다.

@Query("SELECT f FROM Feed f JOIN FETCH f.author")
List<Feed> findAllWithAuthor();

이렇게 하면 전체 Feed와 author를 한 번의 JOIN 쿼리로 가져온다. N+1이 사라진다.

자세한 N+1 해결 전략은 N+1 문제와 해결 전략 참고.

LEFT JOIN FETCH

fetch join도 LEFT JOIN 방식으로 쓸 수 있다.

@Query("SELECT f FROM Feed f LEFT JOIN FETCH f.ootds")
List<Feed> findAllWithOotds();

ootds가 없는 Feed도 결과에 포함하면서 ootds를 함께 로딩한다. 일반 JOIN FETCH는 INNER JOIN이라 ootds가 없는 Feed가 제외될 수 있다. 모든 Feed를 포함시키려면 LEFT JOIN FETCH를 써야 한다.

컬렉션 fetch join 주의점

@OneToMany 같은 컬렉션 연관관계에 fetch join을 쓸 때는 두 가지를 주의해야 한다.

중복 행 제거

Feed 1개에 Ootd 3개가 연결되면 JOIN 결과는 3행이 된다. Feed가 3개로 중복된다. DISTINCT로 제거한다.

@Query("SELECT DISTINCT f FROM Feed f LEFT JOIN FETCH f.ootds")
List<Feed> findAllWithOotds();

페이징과 함께 쓰지 않기

컬렉션 fetch join에 Pageable을 같이 쓰면 JPA가 경고를 낸다.

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

DB에서 전체 데이터를 가져온 뒤 메모리에서 페이징 처리한다는 뜻이다. 데이터가 많으면 OOM으로 이어진다.

컬렉션 fetch join과 페이징을 함께 써야 한다면 @BatchSize를 사용한다.

@BatchSize(size = 100)
@OneToMany(mappedBy = "feed")
private List<Ootd> ootds;

@BatchSize는 LAZY 로딩을 IN 절 쿼리로 묶어 처리한다. N+1 대신 N/100 + 1번의 쿼리로 줄어든다.

컬렉션 fetch join은 하나만

컬렉션 fetch join을 두 개 이상 쓰면 MultipleBagFetchException이 발생한다. 하나의 쿼리에서 컬렉션 fetch join은 1개만 가능하다. 나머지 컬렉션은 @BatchSize로 처리한다.

자주 하는 실수

일반 JOIN으로 LAZY 로딩 해결하려는 실수

JOIN f.author를 썼는데 왜 getAuthor() 호출 시 쿼리가 나가냐는 질문을 자주 한다. 일반 JOIN은 필터링만 할 뿐 로딩은 하지 않는다. 연관 엔티티를 로딩하려면 반드시 JOIN FETCH를 써야 한다.

[!BUG] fetch join에 별칭(alias) 남용

JOIN FETCH f.author a WHERE a.name = :name 처럼 fetch join에 별칭을 붙이고 WHERE로 필터링하면 컬렉션의 경우 불완전한 데이터가 로딩될 수 있다. JPA 스펙상 fetch join의 별칭 사용은 단순 참조 외에는 권장하지 않는다.

[!BUG] 페이징 + 컬렉션 fetch join 조합

@OneToMany 연관관계에 fetch join을 쓰면서 Pageable을 함께 넘기면 메모리 OOM 위험이 생긴다. 이 조합은 사용하면 안 된다. 페이징이 필요한 컬렉션 로딩은 @BatchSize나 별도 쿼리로 처리한다.

면접 Q&A

일반 JOIN과 fetch join의 차이는?

일반 JOIN은 SQL JOIN처럼 행 필터링용이다. 연관 엔티티를 메모리에 올리지 않는다. fetch join은 연관 엔티티까지 한 번에 로딩하는 JPA 전용 개념이다. SQL로 변환하면 동일하게 JOIN이지만, JPA가 결과를 처리하는 방식이 다르다.

[!QUESTION] LAZY로 설정하면 N+1 문제가 자동으로 해결되나?

아니다. LAZY 자체는 N+1을 막지 않는다. LAZY로 설정하면 목록 조회 시 각 엔티티의 연관 필드에 접근할 때마다 쿼리가 날아간다. N+1 해결은 fetch join이나 @BatchSize로 별도 처리해야 한다.

[!QUESTION] 컬렉션 fetch join에서 페이징이 왜 안 되는가?

컬렉션 JOIN은 한 행이 여러 행으로 늘어난다. DB에서 LIMIT을 걸면 늘어난 행 기준으로 잘리기 때문에 원하는 엔티티 개수와 다른 결과가 나온다. JPA는 이 문제를 피하기 위해 전체를 가져온 뒤 메모리에서 페이징한다. 데이터가 많으면 OOM 위험이 있다.

[!QUESTION] LEFT JOIN FETCHJOIN FETCH의 차이는?

JOIN FETCH는 기본이 INNER JOIN이라 연관 데이터가 없는 엔티티가 결과에서 빠진다. LEFT JOIN FETCH는 연관 데이터가 없어도 기준 엔티티를 결과에 포함시킨다. @ManyToOne이 nullable인 경우나 컬렉션이 빈 경우를 포함시키려면 LEFT JOIN FETCH를 써야 한다.