용로그
article thumbnail

집사의고민 프로젝트에서 JPA를 사용하면서 알아본 Fetch Join시 유의해야 하는 부분들에 대해서 기술해보려고 합니다. 특히나 자주 사용하는 fetch join + on절에 대한 이슈를 모르거나 잘 기억이 나지 않으신다면 이해하고 넘어가는게 좋습니다.

 

우선 쿼리가 정상적으로 동작하는지와 어떤 쿼리가 발생하는지를 알아보기 위해 간단한 테스트 코드를 작성해보았습니다.

PetFoodRepositoryImplTest

@DataJpaTest
@Import(QueryDslTestConfig.class)
@Sql(scripts = {"classpath:truncate.sql", "classpath:data.sql"})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class PetFoodRepositoryImplTest {

    @Autowired
    private PetFoodQueryRepository petFoodQueryRepository;

    @Test
    void 키워드와_브랜드_이름으로_동적_조회한다() {
        // given
        final String keyword = "diet";
        final String brand = "베베";

        // when
        List<PetFood> petFoods = petFoodQueryRepository.searchPetFoodByDynamicValues(keyword, brand);

        // then
        assertAll(
                () -> assertThat(petFoods).extracting(petFood -> petFood.getKeyword().getName()).contains("diet"),
                () -> assertThat(petFoods).extracting(petFood -> petFood.getBrand().getName()).contains("퓨리나"),
                () -> assertThat(petFoods).hasSize(1)
        );
    }

}

쿼리 확인

Hibernate: 
// 1번
    select
        p1_0.id,
        p1_0.brand_id,
        p1_0.image_url,
        p1_0.keyword_id,
        p1_0.name,
        p1_0.purchase_link 
    from
        pet_food p1_0 
    join
        brand b1_0 
            on b1_0.id=p1_0.brand_id 
    join
        keyword k1_0 
            on k1_0.id=p1_0.keyword_id 
    where
        k1_0.name=? 
        and b1_0.name=?
Hibernate: 
// 2번
    select
        k1_0.id,
        k1_0.name 
    from
        keyword k1_0 
    where
        k1_0.id=?
petFood.getKeyword().getName() = 다이어트
Hibernate: 
// 3번
    select
        b1_0.id,
        b1_0.founded_year,
        b1_0.has_research_center,
        b1_0.has_resident_vet,
        b1_0.name,
        b1_0.nation 
    from
        brand b1_0 
    where
        b1_0.id=?
petFood.getBrand().getName() = 베베
=================================

 

총 1 + N(2)가 발생하여 3번의 쿼리가 발생했습니다. fetch join 처리를 하지 않았으니 당연하게도 N + 1이 발생했습니다. 이제 해당 쿼리를 개선해 보겠습니다.

 

Fetch Join으로 개선하기


위 문제를 해결하기 위해 PetFoodQueryRepository의 searchPetFoodByDynamicValues 메서드를 수정해 보겠습니다.

 

@Repository
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PetFoodQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<PetFood> searchPetFoodByDynamicValues(String keywordName, String brandName) {
        return queryFactory
                .selectFrom(petFood)
                .join(petFood.keyword, keyword).on(equalsKeyword(keywordName))
                .join(petFood.brand, brand).on(equalsBrand(brandName))
                .fetchJoin()
                .fetch();
    }

    private BooleanExpression equalsKeyword(String keywordName) {
        if (hasText(keywordName)) {
            return keyword.name.eq(keywordName);
        }
        return null;
    }

    private BooleanExpression equalsBrand(String brandName) {
        if (hasText(brandName)) {
            return brand.name.eq(brandName);
        }
        return null;
    }

}

 

위와 같이 on절로 필터링을 진행하고 fetch join을 사용했습니다. 조인 조건은 다음과 같습니다.

  • 키워드가 일치한 데이터만 Join
  • 브랜드가 일치한 데이터만 Join

하지만 테스트 코드를 실행시키면 에러가 발생합니다.(위와 같은 테스트)

 

 

에러를 자세히 읽어보면 not allowed on fetched associations 즉, fetch 구문에서 on절을 지원하지 않는다고 합니다. 이 이유는 꽤나 간단하면서도 많은 분들이 간과하고 있을 것 같습니다.

 

fetch join 문법은 N+1 문제를 방지하기 위해, 연관관계가 맺어진 테이블의 모든 값들을 한 번에 가져옵니다. 즉, 모든 엔티티의 모든 rows에 대해서 값이 항상 보장되어야 한다는 뜻입니다.

 

Fetch Join의 한계

fetch join은 너무나 필수적인 기능이기도 하지만 다음과 같이 명확한 한계가 존재한다는 것을 알 수 있습니다. 

  • fetch join의 대상은 별칭을 줄 수 없다.
    • select, where, 서브쿼리 등에서 fetch join 대상을 사용할 수 없다.
    • 하이버네이트 자체에서는 별칭을 지원하지만, 연관된 데이터 수에 대한 무결성이 깨질 수도 있다.(아래에서 자세하게 설명)
      • jpql과 querydsl에서 fetch join + on 문법을 지원하지 않는 이유도 위와 같다.
  • (참고) 2개 이상의 xToMany 관계에 대해서 모두 fetch join을 적용할 수 없다.
    • 이는 2개 이상의 xToMany관계에는 fetch join을 사용할 수 없다는 특징 때문이기도 하다.
    • 대신 default_batch_fetch_size라는 차선책이 존재한다.
    • 관련 내용은 해당 글에서 자세히 정리해 두었습니다.
  • (참고) 페이지네이션 사용이 불가능하다.
    • 컬렉션(xToMany) 관계에서 fetch join을 사용하면 페이징 처리가 불가능하다.

 

 

On에서 Where로

fetch join에서 on절을 지원하진 않지만, where절은 사용할 수 있습니다. 다만, 잘못 사용하면 꽤나 골치 아픈 일이 일어날 수도 있기 때문에 확실히 짚고 넘어가는 것이 좋습니다.

Fetch Join의 On절을 지원하지 않는 이유는 무엇일까?

위에서 하이버네이트 자체에서는 별칭을 지원하지만, 연관된 데이터 수에 대한 무결성이 깨질 수도 있다고 했습니다. 이는 JPA에서는 엔티티 객체 그래프와 DB의 데이터 일관성을 맞추어 주어야 하기 때문입니다. 예를 들어 아래와 같은 데이터가 있다고 가정해보겠습니다.

  • brand1 - petFood1
  • brand1 - petFood2
  • brand1 - petFood3

만약 조인 대상(petFood)를 필터링의 주체로 사용하여 petFood3을 제외하고 조인한다면, JPA는 다음과 같은 결과를 반환해 줄것입니다. 

  • brand1 - [petFood1, petFood2]

얼핏보면 잘못된게 없습니다. 저희가 바라던대로 데이터가 반환되었으니까요. 하지만 위와 같은 결과가 나오면 fetch join을 한 jpa의 입장에서 상당히 난처해집니다.

 

fetch join은 단 한번의 쿼리로 join 대상 테이블 데이터까지 한 번에 가져오고 영속성 컨텍스트에 반영합니다. 그래서 임의로 데이터를 빼고 조회하면 db에는 해당하는 데이터가 없다고 판단합니다.

 

엔티티 값을 변경하면 db에 쉽게 반영되는 걸 알 수 있듯이, 이렇게 되면 최악의 경우에는 petFood3이 db에서 삭제될 수도 있습니다. 위 상황들을 고려해서 JPA는 이 상황을 방지하기 위해 별칭에 대한 조건절을 제공해주지 않습니다.

 

이해를 돕기 위해 쿼리문을 직접 작성해 보겠습니다. 예를 들어 brand(1)과 연관관계가 맺어진 petFood(N)을 fetch join으로 조회한다고 가정해 보겠습니다.

 

keyword가 없다는 가정하에 쿼리문을 작성합니다.

 

// 1번 쿼리
select b from Brand b join fetch b.petFoods pf on pf.name = '베베'

 

1번 쿼리는 fetch join에 별칭을 사용했기 때문에 런타임에서 에러가 발생합니다. 따라서 N+1을 해결할 수 없습니다.

 

// 2번 쿼리
select b from Brand b join fetch b.petFoods pf where pf.name = '베베'

 

사실 1번 쿼리는 2번 쿼리와 동일하게 동작하는 쿼리입니다. 그리고 조인과 무관하게 Brand 자체 데이터를 필터링하는 것이기 때문에, on절보다 where절을 사용하는 게 더 적절합니다.

 

Fetch Join과 Where절의 함정 그리고 Outer Join

다시 한 번 말하지만, 위 글을 읽고 보면 자칫 "fetch join은 on을 지원하지 않지만, where로 모든 것을 해결할 수 있다."라고 느껴질 수도 있는데, 이건 절대 아닙니다. where절도 잘못 사용하게 된다면 on절과 똑같은 문제가 발생합니다.

 

우선 Brand와 PetFood 테이블에 대한 영속성 컨텍스트의 상황을 먼저 살펴보겠습니다. 우리가 Fetch Join을 사용할 때, 영속성 컨텍스트는 페치 조인의 대상은 모두 존재한다고 가정합니다. 영속성 컨텍스트는 페치 조인 실행 시 아래와 같이 베베 브랜드는 일료, 사료라는 데이터를 가지고 있죠.

 

 

그럼 여기에서 Left Join Fetch 문법을 사용하게 되면 어떻게 될까요? 페치 조인의 대상에 대한 조건을 걸었을 때 영속성 컨텍스트가 기대하는 객체 그래프의 상태와는 달라진 것을 볼 수 있습니다.

 

 

이제 영속성 컨텍스트가 기대하는 객체 그래프와 실제 영속성 컨텍스트의 상태가 달라지게 되었습니다. 그로인해 영속성 컨텍스트는 이런 판단을 내리게 됩니다. "원래는 베베 브랜드에는 일료, 사료라는 객체가 존재했는데, 일료 객체가 제거되었다." 우리는 실제로 일료 객체를 삭제한적이 없는데 말이죠.

 

 

위 그림처럼 베베 브랜드의 일료 데이터가 사라진 상태이기 때문에 저 상태에서 db에 flush가 된다면 실제 데이터베이스의 데이터가 삭제됩니다. 이렇게 잘못된 where절로 해결하려고 하다가 런타임에 막아둔 것 마저 무시해버리고 db 데이터를 삭제하게 될 수도 있으니 꼭 주의해서 사용해야합니다.

 

검증 - Outer Fetch Join과 Where절 필터링

 

 

위에서 봤던 그림처럼 brand는 데이터베이스 상에선 실제로 일료와 사료를 가지고 있지만, 조회해보면 사료라는 데이터 단 하나만 가지고 있습니다. 그럼 여기서 좀 더 나가서 해당 영속성 컨텍스트를 데이터베이스에 flush하게되면 어떨까요?

 

영속성 컨텍스트 flush

 

 

결과

 

저희는 분명 값을 직접적으로 update하거나 delete 하지 않았음에도 불구하고 데이터베이스에서 다시 조회했을 때 일료 데이터가 사라졌습니다. 실제 서비스에서 쿼리가 이렇게 나간다면, 유저들이 조회 쿼리 날릴 때 마다 데이터가 사라집니다. 진짜 말도 안되는 상황이죠. 이렇게 직접 outer fetch join과 where절의 위험성을 검증해보았습니다.

 

이런 문제를 방지하기 위한 몇 가지 방안이 있습니다.

  1. outer fetch join에서 where절을 사용하지 않는다.
  2. 페치 조인 대상에 where절을 사용하지 않는다.
  3. 하나의 트랜잭션이 무조건 조회만 수행하도록 한다.(DB에 Flush 될 일이 절대로 없도록)

만약 JPA를 사용하는 중 이런 쿼리가 발생한다면 위 3가지 중 하나를 잘 고민해보고 적용해야 할 것 같습니다.

정리


이렇게 xToMany 연관관계가 2개 이상이고, N+1이 발생하며 동적 조회까지 해야 하는 상황에서 단 한 번의 쿼리로 모든 연관관계가 맺어진 엔티티에 대해서 조회하는 법을 알아보았습니다.

 

사실 이 글의 핵심은 N+1 문제를 해결하는 것이 아닙니다. JPQL과 Querydsl을 사용하면서 만날 수 있는 fetch join + on절 이슈를 알아보고 어떤 식으로 설계를 해야 하는지를 이해하면 좋을 것 같습니다.

요약

  • JPQL과 Querydsl에서는 객체의 상태와 DB의 일관성 문제 때문에 다음과 같은 기능들을 제공하지 않는다.
    • fetch join의 on절을 지원하지 않는다. 
    • fetch join 대상에 대해서는 별칭을 지원하지 않는다.
  • 해당 문제를 해결하기 위해 where절을 사용하여 비즈니스를 풀어낼 수 있다.
    • 단, Fetch Join의 컬렉션 결과 다(N)쪽을 필터링 기준으로 작성하면 안된다.
    • 특히 Outer Join에 대해서는 더욱 조심해야한다.

글을 작성하고 피드백을 반영하며 검증하는 과정까지 많은 고민을 해봤습니다. on절을 지원하지 않는 대신 where절을 지원해주지만, 그마저도 where절이라서 더 조심해야 하는 부분들이요.

 

참고로 fetch join에서 이러한 페인 포인트를 해결할 수 있는 방법들이 존재하기도 합니다.

  • Entity 타입이 아닌 DTO로 반환
  • Stateless Session(무상태 세션) 사용

위 방법들로 해결할 수 있다고 해도, 해당 문제들을 완벽하게 학습하고 이해하지 못했다면 join에 조건절을 거는 것 보다는, 일단 fetch join만 하고 그 이후에 조건을 따로 처리하던가 그렇게 하는게 좋을 것 같다는 생각도 드네요.

profile

용로그

@용로그

벨덩보단 용덩 github.com/wonyongChoi05