[Querydsl] BooleanExpression 를 조합할 때 발생하는 NPE 대처하기

해로(김선호)·2023년 10월 7일
1

트러블 슈팅

목록 보기
5/6
post-thumbnail

들어가며

동적 쿼리를 구현하기 위해 Querydsl을 사용할 때, 조건식 조합의 편리함 등을 근거로 BooleanBuilder 보다 BooleanExpression 을 주로 사용해왔습니다. 그러나 정작 BooleanExpression을 이용하여 여러 조건들을 조합할 때 and 등을 이용 시 NullPointerException을 마주치게 되어 과연 BooleanExpression을 이용한 조건식 조합이 편리한 것인가? 라는 것까지 생각이 미치게 되었습니다. 본 포스팅에서는 NPE를 마주친 경위와 해결 방법, 그리고 같은 문제를 마주쳤을 때 BooleanBuilderBooleanExpression 중 어떤 것을 선택할지에 대해서도 간단히 의견을 남겨보겠습니다.


본문

예제 설명

예제는 영한님의 강의에서 많이 다뤄지는 회원과 팀 예제를 통해 설명하겠습니다.
(예제 코드는 여기를 참고해주세요.)


엔티티

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String username, Team team) {
        this.username = username;
        this.team = team;
    }

}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;

    private String name;

    public Team(String name) {
        this.name = name;
    }

}

먼저 엔티티입니다.

  • 회원과 팀은 각각 id 외에 username 과 name을 필드로 갖습니다.
  • 회원과 팀은 다대일 관계이며, 회원 -> 팀 방향으로 단방향 매핑되어있습니다.

Repository 구현체

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<MemberTeamDto> searchBy(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        team.id.as("teamId"),
                        team.name.as("teamName")))
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        member.username.eq(condition.getUsername()),
                        team.name.eq(condition.getTeamName())
                )
                .fetch();
    }

}

다음은 동적쿼리를 구현하는 Repository 구현체입니다.

  • MemberRepositoryJpaRepositoryMemberRepositoryCustom을 상속합니다.
  • MemberRepositoryCustom의 구현체 MemberRepositoryImpl에 동적 쿼리를 사용하는 searchBy()를 구현했습니다.

조건식 DTO

@Getter
public class MemberSearchCondition {

    private final String username;
    private final String teamName;

    public MemberSearchCondition(String username, String teamName) {
        this.username = username;
        this.teamName = teamName;
    }

}

동적쿼리의 파라미터로 사용되는 DTO입니다. 필드 usernamenull을 명시적으로 나타내며 문제 상황을 시연해보겠습니다. 이것으로 필요한 예제 설명은 마쳤습니다.


그냥 eq 를 사용하기

먼저 아래처럼 where 절에 eq 를 사용할 때 인자로 null 이 주어질 때 어떻게 되는지 보겠습니다.

...
        
.where(
        member.username.eq(condition.getUsername()),
        team.name.eq(condition.getTeamName())
)

...
@Test
@DisplayName("회원 이름 또는 팀 이름과 일치하는 회원-팀 정보를 조회할 수 있다.")
void searchByCondition() {
    // given
    Team teamA = new Team("teamA");
    Team teamB = new Team("teamB");
    teamRepository.saveAll(List.of(teamA, teamB));

    Member member1 = new Member("member1", teamA);
    Member member2 = new Member("member2", teamA);
    Member member3 = new Member("member3", teamB);
    Member member4 = new Member("member4", teamB);
    memberRepository.saveAll(List.of(member1, member2, member3, member4));

    MemberSearchCondition condition = new MemberSearchCondition(null, "teamA");

    // when
    List<MemberTeamDto> memberTeamDtos = memberRepository.searchBy(condition);

    // then
    assertThat(memberTeamDtos).hasSize(2);
}

위는 앞으로 사용하게 될 테스트 코드입니다. 이 테스트를 실행하면 다음과 같이 IllegalArgumentException이 발생합니다.

org.springframework.dao.InvalidDataAccessApiUsageException: 
eq(null) is not allowed. Use isNull() instead; nested exception is java.lang.IllegalArgumentException: eq(null) is not allowed. Use isNull() instead

image

eq() 메서드는 파라미터가 null 이면 IllegalArgumentException을 던지도록 구현되어있기 때문에, 이는 당영한 결과입니다. 그럼 이 문제를 어떻게 해결할까요? 인자가 null 이어서 생긴 문제니까, 인자가 null 인지 확인하도록 하면 될 것 같습니다.


BooleanExpression을 리턴하는 null-safe 메서드

// MembmerRepositoryImpl
@Override
public List<MemberTeamDto> searchBy(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEquals(condition.getUsername()),
                    teamNameEquals(condition.getTeamName())
            )
            .fetch();
}

public BooleanExpression usernameEquals(String username) {
    return username != null ? member.username.eq(username) : null;
}

public BooleanExpression teamNameEquals(String teamName) {
    return teamName != null ? team.name.eq(teamName) : null;
}

문제가 되었던 member.username.eq(username)을 호출하기 전에 먼저 username을 체크하는 메서드 usernameEquals() 만들었습니다. 테스트를 다시 실행해보면 이전과 같은 IllegalArgumentException은 발생하지 않습니다. username이 null 이어서, usernameEquals()이 null 을 리턴한다고 해도, where 절에서 null 이 인자로 들어왔을 때 무시하기 때문에 문제될 것이 없습니다. 그리고 이 때문에 동적쿼리가 가능해지는 것이죠. BooleanExpression을 리턴하는 메서드로 조건식을 분리하면 또 다른 장점도 있습니다. 바로 조합이 가능하다는거죠.


BooleanExpression 조합하기

where 절에서 아래와 같이 모든 조건 메서드를 호출하는 것은 아무래도 조금 번거롭습니다. 추후에 조건이 더 추가될 수도 있는 것이고, 아예 모든 조건을 동적으로 처리하는 메서드로 새롭게 분리하고 싶어질 수도 있죠.

.where(
        usernameEquals(condition.getUsername()),
        teamNameEquals(condition.getTeamName())
)

이 두 개의 메서드를,

@Override
public List<MemberTeamDto> searchBy(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    searchConditionEquals(condition)
            )
            .fetch();
}

public BooleanExpression searchConditionEquals(MemberSearchCondition condition) {
    return usernameEquals(condition.getUsername())
            .and(teamNameEquals(condition.getTeamName()));
}

public BooleanExpression usernameEquals(String username) {
    return username != null ? member.username.eq(username) : null;
}

public BooleanExpression teamNameEquals(String teamName) {
    return teamName != null ? team.name.eq(teamName) : null;
}

searchConditionEquals() 라는 메서드에서 호출하도록 리팩토링 했습니다. 이제 조건이 더 추가되어도 해당 메서드만 조금씩 수정하면 되겠죠.

그런데 문제는, null 체크를 해주는 메서드들을 위처럼 조합할 때 생깁니다. 바로 조합된 메서드(searchConditionEquals())에서 NPE가 발생할 위험이 있기 때문입니다.


BooleanExpression 조합 시 발생하는 NPE

먼저 NPE가 어느 상황에서 발생하는지 알아보겠습니다. 기존에 사용하던 테스트를 실행시켜봅시다.

@Test
@DisplayName("회원 이름 또는 팀 이름과 일치하는 회원-팀 정보를 조회할 수 있다.")
void searchByCondition() {
    // given
    Team teamA = new Team("teamA");
    Team teamB = new Team("teamB");
    teamRepository.saveAll(List.of(teamA, teamB));

    Member member1 = new Member("member1", teamA);
    Member member2 = new Member("member2", teamA);
    Member member3 = new Member("member3", teamB);
    Member member4 = new Member("member4", teamB);
    memberRepository.saveAll(List.of(member1, member2, member3, member4));

    MemberSearchCondition condition = new MemberSearchCondition(null, "teamA");

    // when
    List<MemberTeamDto> memberTeamDtos = memberRepository.searchBy(condition);

    // then
    assertThat(memberTeamDtos).hasSize(2);
}

image

그럼 이렇게 NPE가 발생합니다. 이상합니다. 분명 where 절에서는 null 을 인자로 넘겨도 무시된다고 했는데 말이죠. 원인을 찾기 위해 디버깅 합니다.


image

MemberSearchCondition의 필드 usernamenull이므로, userNameEquals()의 결과로 null이 리턴 됩니다. 결국 searchConditionEquals() 에서 null.and(teamNameEquals())가 호출되고, and()를 호출하는 시점에서 NPE가 발생한 거죠.

그럼 NPE를 방지하면서, 조건식 조합 역시 편리하게 이용하려면 어떻게 해야할까요?

일단 지금 방식인 BooleanExpression을 사용해서는 어려울 것 같습니다. and() 등을 이용해 조건을 조합할 때 NPE가 발생하니까요. 그렇습니다. 다시 BooleanBuilder를 살펴볼 때가 된거죠.


NPE를 해결하는 null-safe BooleanBuilder

영한님의 답변에서 위 문제에대한 해결법을 찾을 수 있었습니다. 바로 코드로 살펴보겠습니다.

@Override
public List<MemberTeamDto> searchBy(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    searchConditionEquals(condition)
            )
            .fetch();
}

public BooleanBuilder searchConditionEquals(MemberSearchCondition condition) {
    return usernameEquals(condition.getUsername())
            .and(teamNameEquals(condition.getTeamName()));
}

public BooleanBuilder usernameEquals(String username) {
    return nullSafeBooleanBuilder(() -> member.username.eq(username));
}

public BooleanBuilder teamNameEquals(String teamName) {
    return nullSafeBooleanBuilder(() -> team.name.eq(teamName));
}

private BooleanBuilder nullSafeBooleanBuilder(Supplier<BooleanExpression> supplier) {
    try {
        return new BooleanBuilder(supplier.get());
    } catch (IllegalArgumentException e) {
        return new BooleanBuilder();
    }
}

중요한 내용부터 하나씩 살펴보겠습니다.


private BooleanBuilder nullSafeBooleanBuilder(Supplier<BooleanExpression> supplier) {
    try {
        return new BooleanBuilder(supplier.get());
    } catch (IllegalArgumentException e) {
        return new BooleanBuilder();
    }
}

BooleanExpression의 Supplier를 파라미터로 받아서, 정의된 람다식을 실행하고, IllegalArgumentException이 발생하면 빈 BooleanBuilder 객체를 만들어 리턴합니다.

파라미터로 () -> member.username.eq(username) 같은 람다식이 주어지는 경우를 예로 들어보죠.

image

supplier.get() 을 할 때, member.username.eq(username) 이 실행되고, eq() 에서는 위 사진에서 보다시피 rightnull 이니까 IllegalArgumentException 을 발생시킵니다. try-catch 문에서 예외를 잡고 있으므로 빈 BooleanBuilder() 객체를 리턴해주게 됩니다.

public BooleanBuilder searchConditionEquals(MemberSearchCondition condition) {
    return usernameEquals(condition.getUsername())
            .and(teamNameEquals(condition.getTeamName()));
}

결국 usernameEquals() 의 결과로 null 이 아닌 BooleanBuilder 객체가 리턴되므로, BooleanBuilder.and() 처럼 동작하여 변경 전에 발생했던 NPE를 방지할 수 있게 됩니다.

마치며

위에 소개해드린 nullSafeBooleanBuilder() 메서드의 경우, 여러 Repository 구현체에서 재활용하기 좋은 메서드입니다. 따라서 별도의 유틸 클래스 등을 만들어 적절하게 재사용해보면 좋을 것 같습니다.

사실 영한님의 Querydsl 강의를 들을 때만 해도 BooleanBuilder 보다 BooleanExpression 이 낫구나 정도로만 이해했는데, 질답 게시판과 스스로 여러 테스트 케이스를 짜보며 위같은 NPE 문제 때문에 꼭 그런 것만도 아닌 것을 알게 되었습니다. 항상 뭐가 더 낫다라고 미리 판단하기 보다는 모두 사용해보며 장단점을 확실히 파악해둬야겠다는 생각이 드는 경험이었습니다. 이상으로 본 포스팅을 마칩니다.

마침.


Reference

profile
Every Run, Learn Counts.

0개의 댓글