본문 바로가기
spring/jpa

toMany 관계에서 필터링 걸기

by 쭈꾸마뇽 2021. 5. 13.

새로운 회사 프로젝트를 하며 JPA를 도입하며 생겼던 이슈를 해결한 방법을 소개하려 한다.

 

프로젝트 개발 초기 기본적인 CRUD api들을 만들며 생겼던 일인데 당시 난 아래 코드처럼 조회 api에 여러 필터를 걸며 테스트하고 있었다.  

Team과 Member는 OneToMany 관계이다.
@Test
void test2() {
    QTeam team = QTeam.team;
    QMember member = QMember.member;
    JPAQueryFactory query = new JPAQueryFactory(em);
    List<Team> result = query.selectFrom(team)
            .join(team.members, member)
            .where(member.age.eq(10))
            .fetch();

    result.forEach(t -> {
        t.getMembers().forEach(m -> {
            Assertions.assertThat(m.getAge()).isEqualTo(10);
        });
    });
}

Team 조회시 가져오는 Member에 age == 10인 필터링을 걸어서 조회를 한다.  이 쿼리를 작성했을 때 난 Team을 조회하고 연관된 Member중 age가 10인 Member만 가져올것을 기대했지만 결과는 그렇지 않다.  즉 이 테스트 코드는 실패한다.  처음에 난 이해가 안되서 쿼리가 어떻게 찍혔는지 확인해봤다.

일단 쿼리만 봤을때는 문제가 없어 보인다.  그래서 한참을 고민했는데 이 쿼리를 Query Console에 찍어보고 알게되었다.  이 쿼리를 실행하게 되면 Team에 대한 정보만 있고 Member에 대한 정보는 없다.

왜냐하면 Member는 Lazy Loading으로 가져오기 때문에 별도의 쿼리가 생성된다.  그리고 그 쿼리를 확인해 보면 where 절이 없다.

왜냐하면 Lazy Loading으로 생성되는 쿼리는 내가 작성한 쿼리에 영향을 받는게 아닌 조회된 결과에서 필요하지만 조회되지 않은 연관된 엔티티를 조회하기 위해 Jpa가 생성하는 쿼리이기 때문이다.  내가 작성한 쿼리는 내가 조회하고자 하는 엔티티 Team에만 영향을 준다.  만약 Team안에 Member중에서 age가 10인 Member가 없는 경우 해당 Team부터가 조회되지 않을것이다.

 

그렇다면 Lazy Loading을 사용하면서 toMany관계의 엔티티에 어떻게 필터를 걸 수 있을까?  결론은 Lazy Loading으로 생성되는 쿼리를 직접 작성해 주면 된다.

@Test
void test3() {
    QTeam team = QTeam.team;
    QMember member = QMember.member;
    JPAQueryFactory query = new JPAQueryFactory(em);

    List<Team> result = query.selectFrom(team)
            .join(team.members, member)
            .where(member.age.eq(10))
            .fetch();

    List<Long> teamIds = result.stream().map(Team::getId).collect(Collectors.toList());

    List<Member> members = query.selectFrom(member)
            .where(
                    member.team.id.in(teamIds),
                    member.age.eq(10)
            )
            .fetch();

    Map<Long, List<Member>> memberMap = members.stream().collect(Collectors.groupingBy(m -> m.getTeam().getId()));
    result.forEach(t -> t.setMembers(memberMap.get(t.getId())));

    result.forEach(t -> {
        t.getMembers().forEach(m -> {
            Assertions.assertThat(m.getAge()).isEqualTo(10);
        });
    });
}

이런식으로 Lazy Loading으로 생성되는 쿼리를 직접 작성해 주면 다음과 같이 내가 원하는데로 쿼리가 생성되어 원하는 결과값을 얻을 수 있다.


주의점

위같은 방법을 쿼리의 경우 Team을 조회하는 쿼리에도 Member에 대한 필터를 적용했다.  이 경우 만약 필터에 적합한 Member가 없을 경우 Team또한 조회가 되지 않는다.  가령 Team2에 Member의 age가 모두 20인 경우 result에 Team2는 존재하지 않는다는 뜻이다.

Inner Join이기 때문

만약 필터에 적합한 Member가 없더라고 Team이 조회를 하고싶어서 Team을 조회하는 쿼리에 Member에 대한 조건을 제거하면 어떻게 될까?

@Test
void test4() {
    QTeam team = QTeam.team;
    QMember member = QMember.member;
    JPAQueryFactory query = new JPAQueryFactory(em);

    List<Team> result = query.selectFrom(team)
            .fetch();

    List<Long> teamIds = result.stream().map(Team::getId).collect(Collectors.toList());

    List<Member> members = query.selectFrom(member)
            .where(
                    member.team.id.in(teamIds),
                    member.age.eq(10)
            )
            .fetch();

    Map<Long, List<Member>> memberMap = members.stream().collect(Collectors.groupingBy(m -> m.getTeam().getId()));
    result.forEach(t -> t.setMembers(memberMap.get(t.getId())));

    result.forEach(t -> {
        t.getMembers().forEach(m -> {
            Assertions.assertThat(m.getAge()).isEqualTo(10);
        });
    });
}

이 테스트 코드는 Team 조회시 Member에 대한 필터는 걸지 않았다.  그리고 코드를 실행하면 NullPointerException이 발생하면서 실패한다.  원인은 result.forEach(t -> t.setMembers(memberMap.get(t.getId()))); 이 코드에 있다. 

 

이 코드를 해석하면 map에 있는 데이터 중 TeamId를 key값으로 데이터를 가져와 members에 할당해주는 코드이다.  하지만 Team조회시 Member에 대한 필터를 걸지 않고 Member를 조회하는 쿼리에만 필터를 걸게 될 경우 Team은 있지만 Member 조회 결과에는 해당 Team이 없는 경우가 생긴다.  즉 key값이 없기 때문에 null값을 리턴하고 members에 null값을 할당하려 하기 때문에 에러가 발생한다.

 

이를 해결하기 위해선 별도의 setter 함수 오버라이딩이 필요하다.

public void setMembers(List<Member> members) {
    if (members != null) this.members = members;
    else this.members = new ArrayList<>();
}

이처럼 setter에 null값에 대한 예외 처리를 해주게 되면 에러는 없어지고 최종 결과로 Team에는 Member에 대한 필터가 걸리지 않은 결과를 얻을 수 있게 된다.

만약 저 setter에서 else에 구문을 작성하지 않게 되면 members에 프록시 객체가 그대로 들어있게 되어 추가적인 Lazy Loading이 발생하니 또한 주의하자.

 

Lazy Loading까지 고려하기 귀찮고 헷갈린다 -> DTO 조회하면 깔끔하다.

댓글