개발 공부/Spring

[플랭고] JPQL fetch join + where절 사용 방법과 조건

gmelon 2023. 8. 29. 19:58

프로젝트 개발 중에 fetch join 과 where 절을 함께 사용하고 싶은 상황이 생겼다. 아무생각 없이 코드를 치다가 순간 섬뜩해서 영한님 JPA 강의 자료를 뒤져보니 역시.. fetch join의 대상에 별칭을 주고 where 절에서 필터링하는건 불가능하다고 되어있었다.

그래서 고민을 좀 해봤는데 고민을 할수록 다음과 같은 고민들이 꼬리에 꼬리를 물고 생겨났다.

  • fetch join은 별칭을 아예 줄 수 없나?
  • where 절에 XToOne 쪽 필드를 조건으로 주는 경우는 어떻게 동작하나?
  • fetch join 말고 join은 사용 해도 되는건가 그럼?
    • 이 경우에는 어떻게 동작하지?
  • 애초에 fetch join을 왜 where 절과 함께 사용하면 안 되는거지..?

JPA의 어려움에 멘붕이 올뻔했지만 마음을 추스리고 몇 가지로 경우의 수를 추려서 JPA가 어떤 식으로 동작하는지 직접 테스트해보기로 했다. 💪

테스트용 엔티티

테스트는 일대다 관계를 갖는 Parent와 Child 엔티티를 사용해 진행했다.

다이어그램

Parent (One)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Parent {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent")
    private List<Child> childList = new ArrayList<>();

}

Child (Many)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Child {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    private Parent parent;

}

아래와 같이 테스트 데이터를 넣어준 상태이다.

테스트 데이터

테스트 결과

테스트를 해보니, fetch join + where 절 조합이라고 해서 무조건 사용이 불가능한 것은 아니었고 사용이 가능한 상황도 있었다. 그리고 fetch join과 where 절을 같이 사용하면 안 되는 이유에 대해서도 오해를 하고 있었다. 아래에서 각 경우 별로 어떻게 동작하는지, 왜 사용하면 안 되는지 등 테스트를 통해 알아낸 것들을 공유해보려고 한다.

fetch join + XToOne 필드에 where 조건

먼저, XToOne의 필드를 where 절의 조건으로 사용하는 fetch join 는 사용해도 괜찮다. 아래 코드와 같은 경우이다. (where 절은 각 경우마다 동일하게 Parent 나 Child의 이름에 A 가 들어간 row만 조회하도록 조건을 걸어주었다.)

@RequiredArgsConstructor
@Repository
public class ChildRepository {
    private final EntityManager em;

    public List<Child> fetchJoinToOne() {
        return em.createQuery(
                "select c from Child c " +
                        "join fetch c.parent p " +
                        // Child(M)을 조회할 때 Parent(1)에 조건을 줌
                        "where p.name like '%A%'", Child.class
        ).getResultList();
    }
}

테스트를 돌려보면 다음과 같이 기대한대로 Parent A를 가진 Child A와 Child B만 포함된 리스트가 조회되고, 각 Child은 Parent A를 제대로 가지고 있는 것을 확인할 수 있다.

 

그림으로 보면 다음과 같다.

쿼리도 fetch join을 사용했으므로 다음과 같은 하나의 쿼리로 모든 데이터를 가져오게 된다.

일반 join + XToOne 필드에 where 조건

XToOne의 필드를 where 절의 조건으로 사용하는 일반 join 또한 (성능 문제를 제외하면) 사용해도 괜찮다. 다음과 같이 코드를 작성하고 테스트를 돌려보면 잘 통과하게 된다.

public List<Child> joinToOne() {
    return em.createQuery(
            "select c from Child c " +
                    "join c.parent p " +
                    "where p.name like '%A%'", Child.class
    ).getResultList();
}

(LAZY 로딩으로 인해 Parent에서 Child를 꺼내오는 시점이 트랙잭션 내부여야 한다)

하지만, 이 방식의 문제는 당연하게도 쿼리가 N + 1 번 나간다는 것이다.

Child select 쿼리

Child가 가진 Parent select 쿼리

따라서, 먼저 XToOne의 경우 일반 join 대신, fetch join을 (where 조건과 함께) 사용해도 괜찮다는 결론을 내릴 수 있었다.

fetch join + XToMany 필드에 where 조건

다음부터는 XToMany의 경우이다. 먼저 fetch join인데 where 절 조건을 XToMany 측 필드에 거는 경우이다. 즉 아래 코드와 같은 상황이다.

@RequiredArgsConstructor
@Repository
public class ParentRepository {
    private final EntityManager em;

    public List<Parent> fetchJoinToMany() {
        return em.createQuery(
                "select p from Parent p " +
                        "join fetch p.childList c " +
                        // Parent(1)을 조회할 때 Child(M)에 조건을 줌
                        "where c.name like '%A%'", Parent.class
        ).getResultList();
    }
}

DB에 직접 위와 같은 쿼리를 날리면 아래와 같은 모양의 결과를 기대할 것이다.

그리고 실제로도 그러한 결과가 나오게 된다.

 

여기서 엥? fetch join 랑 where 조건을 같이 사용하면 안 된다고 하지 않았던가? 하는 혼란이 왔다. 확인해보니 위의 테스트 결과처럼 원래 fetch join + where 절 조건을 주면 DB에 쿼리를 직접 날렸을 때 기대한 것과 동일한 결과가 나오는게 맞다. 그런데 문제는 Repository에서 반환하는게 엔티티라는 점이다.

 

현재 ParentA와 연관된 Child들의 DB에서의 상태는 아래와 같은데,

조회한 엔티티의 상태는 다음과 같다.

JPA는 DB의 상태와 객체의 상태를 항상 동일하게 유지해준다. 따라서 개발자도 이 점을 고려하며 코드를 작성하게 된다. 하지만 위와 같이 fetch join을 사용하게 되면, DB와 객체의 상태가 불일치하게 되고 아래와 같이 개발자가 현 객체의 상태가 DB와 같다는 것을 가정하고 코드를 작성했을 때 문제가 발생할 수 있다.

이미 영속성 컨텍스트에 Child B가 존재하지 않는 Parent A가 올라와 있기 때문에, findAll()로 모든 Parent를 조건 없이 조회한다고 해도 Child B는 Parent A의 childList에 포함되지 않게 되고, 테스트가 통과하지 못하게 된다.

 

Parent A에 당연히 Child B가 존재할 것이라고 생각하고 코드를 작성한다면 의도하지 않은 결과가 나오게 될 것이다. 즉, fetch join + where 조건을 사용하면 안 되는 이유는 원하는 데이터가 조회되지 않아서 라기 보다는 DB와 객체의 일관성이 깨짐으로 인해 DB의 데이터가 우리가 의도하지 않게 수정될 수 있기 때문이었던 것이다.

물론 이 경우(fetch join 사용)에는 아래와 같이 쿼리 하나로 데이터를 모두 가져올 수 있긴 하다. (하지만 사용하면 위험하다!)

일반 join + XToMany 필드에 where 조건

그렇다면 XToMany 필드에 where 조건을 건 일반 join은 어떻게 동작할까?

public List<Parent> joinToMany() {
    return em.createQuery(
            "select p from Parent p " +
                    "join p.childList c " +
                    "where c.name like '%A%'", Parent.class
    ).getResultList();
}

테스트를 돌려보면, 실패하게 된다.

 

기대한 결과는 다음과 같은데,

실제로는 아래와 같이 Child A, Child B가 모두 조회 된다.

일반 join 시 발생한 쿼리를 보면 그 이유를 알 수 있다.

쿼리가 총 2번 (1 + N번) 나가게 되는데 쿼리를 살펴보면 처음에 Parent를 조회할 때 Child 이름에 조건을 걸어서 조회하기 원하는 Child의 Parent만을 선택하긴 하지만, Child가 LAZY 로딩이기 때문에 이후에 Child를 별도의 쿼리로 다시 조회하고 되고 이때는 Child.name 조건이 걸리지 않고 단순히 앞서 조회된 Parent의 id만을 조건으로 모든 Child를 조회하게 된다. 따라서, 위와 같은 (의도하지 않게 모든 Child가 Parent의 childList에 포함된) 결과가 나오게 된다.

 

즉, 일반 join을 사용하는 경우 DB와의 일관성 문제는 없으나 만약 ChildList에 Child A만이 존재하는 결과를 원했던 경우 의도치 않게 Child B까지 조회되는 상황이 발생한다.

DTO 사용 (join XToMany의 대안)

원하는 결과만 얻으면서도 DB - 객체 간 데이터 일관성을 깨뜨리지 않는 방법은 DTO를 사용해 데이터를 직접 조인 후 조회하는 것이다. 먼저 아래와 같이 데이터를 담을 DTO를 생성한다.

ParentQueryDto

@Data
public class ParentQueryDto {

    private Long id;
    private String name;
    private List<ChildQueryDto> childQueryDtos;

    public ParentQueryDto(Long id, String name) {
        this.id = id;
        this.name = name;
    }
}

ChildQueryDto

@Data
public class ChildQueryDto {
    private Long parentId;
    private Long id;
    private String name;

    public ChildQueryDto(Long parentId, Long id, String name) {
        this.parentId = parentId;
        this.id = id;
        this.name = name;
    }
}

ParentQueryRepository

@RequiredArgsConstructor
@Repository
public class ParentQueryRepository {

    private final EntityManager em;

    public List<ParentQueryDto> findAllByDtos() {
        List<ParentQueryDto> parents = findParents();

        Map<Long, List<ChildQueryDto>> childMap = findChildMap();
        parents.forEach(parent -> parent.setChildQueryDtos(childMap.get(parent.getId())));

        return parents;
    }

    private List<ParentQueryDto> findParents() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.ParentQueryDto(p.id, p.name) " +
                        "from Parent p " +
                        "join p.childList c " +
                        "where c.name like '%A%'", ParentQueryDto.class)
                .getResultList();
    }

    private Map<Long, List<ChildQueryDto>> findChildMap() {
        List<ChildQueryDto> childs = em.createQuery(
                        "select new jpabook.jpashop.repository.ChildQueryDto(p.id, c.id, c.name) " +
                                "from Child c join c.parent p " +
                                "where c.name like '%A%'", ChildQueryDto.class
                )
                .getResultList();

        return childs.stream()
                .collect(groupingBy(ChildQueryDto::getParentId));
    }

}

코드를 간단히 설명하면, 먼저 findAllByDto() 에서 findParents() 를 호출해 Child의 name에 조건을 걸고 Parent를 조회한 결과를 얻는다. 그리고 findChildMap() 메서드를 호출해 동일한 where 절로 Child를 조회하고, 조회결과를 Parent.ID를 key로, List<Child>를 value로 갖는 Map을 만들어 반환하도록 한다. 마지막으로 이를 forEach로 순회하며 key에 해당하는 Parent의 ChildList에 Map value인 List를 넣어준다.

 

테스트 해보면 아래와 같이 원하는 결과를 얻은 것을 확인할 수 있다. 또한 엔티티가 아닌 DTO로 조회했기 때문에 데이터 일관성 깨짐으로 인한 문제도 발생하지 않게된다.

조회 결과

테스트 결과

마지막으로, 이 경우 쿼리는 다음과 같이 2번(O(1)) 발생하게 된다.

결론

여러 가지 경우를 테스트해보며 다음과 같은 결론을 내릴 수 있었다.

 

먼저, XToOne 측에 조건을 주고자 하는 경우 fetch join을 사용해도 괜찮다. 또 만약 특정 Child 조건을 만족하는 'Parent' 객체만 필요한 경우라면, 일반 join XToMany를 사용하면 될듯하다. 이 경우엔 (Child를 사용하지 않으므로) 추가 쿼리도 발생하지 않고, 데이터 일관성도 깨지지 않는다.

 

만약 그게 아니라면 (Child 에 조건을 주면서 Child의 데이터도 필요한 경우) DTO를 사용해 필요한 필드를 모아서 한번에 join으로 조회해야 한다. 왜냐하면 그렇게 해야 데이터의 일관성이 깨지지 않아 DB에 의도치 않은 값이 들어가거나 다른 값이 조회될 염려가 없으며 정말 조회하기 원했던 Many 측 데이터만 조회하여 사용할 수 있기 때문이다. DTO를 통해 직접 join을 수행하게 되면 N + 1 쿼리도 발생하지 않는다.

직접 테스트를 통해 작성한 내용이라 잘못된 내용이 있을 수 있습니다.
혹시 잘못된 내용이 있다면 댓글로 알려주시면 확인하고 수정하겠습니다.

참고 자료