[ 김영한 Querydsl #6 ] - 중급 문법 (3) : 동적 쿼리 예제

정동욱·2023년 7월 20일
0
post-thumbnail

이번 글에서는 Querydsl을 이용해 WHERE 절에 다중 파라미터가 오는 상황을 가정해 동적 쿼리를 작성하는 방법에 대해 알아보겠습니다. 이 글을 통해 Querydsl의 강력함을 느끼고, 왜 Querydsl을 알아야 하는 지에 대해서도 알게 될 것입니다. 제가 그랬으니까요.

본격적으로 시작하기 전에 프로젝션BooleanBuilder, BooleanExpression 객체의 사용법에 주의를 기울이면 이해에 도움이 될 겁니다. 먼저 MemberRepository를 만들어줄텐데요, JpaRepository를 구현하지 않고 직접 EntityManager를 주입받아 사용할 겁니다.

@Repository
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public MemberJpaRepository(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }
}

이제 테스트 코드를 작성해 회원을 여러 조건으로 조회해보겠습니다.

@Test
void searchTest2() {
MemberSearchCondition condition = new MemberSearchCondition();
//        condition.setUsername("memberD");
    condition.setTeamName("teamB");
    condition.setAgeGoe(35);
    condition.setAgeLoe(40);

	List<MemberTeamDto> result1 = memberJpaRepository.searchByBuilder(condition);
    List<MemberTeamDto> result2 = memberJpaRepository.search2(condition);
    List<Member> members = memberJpaRepository.search3(condition);

	assertThat(result1).extracting("username").containsExactly("memberD");
    assertThat(result2).extracting("username").containsExactly("memberD");
    assertThat(members).extracting("username").containsExactly("memberD");
    }

그리고 쿼리문을 짜볼 텐데요, 회원을 검색하는 쿼리문을 작성해보겠습니다. 조건은 회원의 이름과 팀의 이름, 그리고 나이를 줄텐데요, 이전 글에서도 배운 두 방식 모두 다 사용해보겠습니다. 먼저 BooleanBuilder를 사용한 방식입니다.

public List<MemberTeamDto> searchByBuilder(MemberSearchCondition memberSearchCondition) {
    BooleanBuilder builder = new BooleanBuilder();
    if (hasText(memberSearchCondition.getUsername())) {
        builder.and(member.username.eq(memberSearchCondition.getUsername()));
    }
    if (hasText(memberSearchCondition.getTeamName())) {
        builder.and(team.name.eq(memberSearchCondition.getTeamName()));
    }
    if (memberSearchCondition.getAgeGoe() != null) {
        builder.and(member.age.goe(memberSearchCondition.getAgeGoe()));
    }
    if (memberSearchCondition.getAgeLoe() != null) {
        builder.and(member.age.loe(memberSearchCondition.getAgeLoe()));
    }

    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name
            ))
            .from(member)
            .leftJoin(member.team, team)
            .where(builder)
            .fetch();
}

조회문 내부에 BooleanBuilder 객체를 생성해 null값을 체크한 다음 BooleanBuilder 객체를 조립해 WHERE 절에 넘기는 방식입니다. 이 방식도 전혀 나쁜 방식은 아닙니다만, 매번 null값을 확인해줘야 하고 그로 인해 가독성에 약간의 문제가 생긴다는 점이 있습니다.

이제 WHERE 절에 매서드를 이용해 파라미터를 한번에 받는 방식을 사용해보겠습니다.

public List<MemberTeamDto> search1(MemberSearchCondition memberSearchCondition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name
            ))
            .from(member)
            .leftJoin(member.team, team)
            .where(memberTeamDtoEq(memberSearchCondition))
            .fetch();
}

private BooleanExpression memberTeamDtoEq(MemberSearchCondition memberSearchCondition) {
    return usernameEq(memberSearchCondition.getUsername())
            .and(teamNameEq(memberSearchCondition.getTeamName()))
            .and(ageGoe(memberSearchCondition.getAgeGoe()))
            .and(ageLoe(memberSearchCondition.getAgeLoe()));
}

private BooleanExpression usernameEq(String username) {
    return hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression teamNameEq(String teamName) {
    return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
    return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression ageLoe(Integer ageLoe) {
    return ageLoe != null ? member.age.loe(ageLoe) : null;
}

이 방식은 파라미터 객체 내부의 모든 필드에 대해 매칭하는 매서드들을 다 만든 뒤, 이 매서드들을 조립해 결과적으로 하나의 매서드만 사용하면 되게끔 하는 방식입니다. 보면 매서드들이 BooleanExpression 객체를 반환하는데요, 이 객체는 null을 반환할 수 있다는 장점과 .and()나 .or()과 같은 논리연산자 매서드로 서로 결합하여 사용할 수 있다는 장점이 있습니다.

BooleanBuilder를 사용하는 방식보다 더 선호되지만 이 방식의 큰 단점이 있습니다.

private BooleanExpression usernameEq(String username) {
    return hasText(username) ? member.username.eq(username) : null;
}

위와 같은 개별적인 매서드는 내부적으로 null값을 체크하고, 실제로 null값을 반환해도 아무런 문제가 생기지 않습니다. 하지만 이런 개별 매서드들을 하나로 묶어 사용할 때에는 null값을 다시 한 번 더 체크해줘야 합니다.

다행히도 이 문제를 해결하기 위한 방법이 있는데요, 바로 BooleanExpressionBooleanBuilder를 같이 사용하는 방법입니다. 그러니까 개별 매서드에서는 기존과 똑같이 null값 확인 후 BooleanExpression를 반환하고, 통합으로 묶는 매서드에서는 BooleanBuilder를 사용해 별도의 null값 확인 없이 사용하는 방식입니다. 코드로 보겠습니다.

public List<MemberTeamDto> search2(MemberSearchCondition memberSearchCondition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name
            ))
            .from(member)
            .leftJoin(member.team, team)
            .where(memberTeamDtoEq2(memberSearchCondition))
            .fetch();
}

private BooleanBuilder memberTeamDtoEq2(MemberSearchCondition memberSearchCondition) {
    BooleanBuilder builder = new BooleanBuilder();

    return builder.and(usernameEq(memberSearchCondition.getUsername()))
            .and(teamNameEq(memberSearchCondition.getTeamName()))
            .and(ageGoe(memberSearchCondition.getAgeGoe()))
            .and(ageLoe(memberSearchCondition.getAgeLoe()));
}
    
private BooleanExpression usernameEq(String username) {
    return hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression teamNameEq(String teamName) {
    return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
    return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression ageLoe(Integer ageLoe) {
    return ageLoe != null ? member.age.loe(ageLoe) : null;
}

이 방식은 개별 매서드에서 null값을 확인하고, 통합 매서드에서는 BooleanBuilder를 사용하는 겁니다. 이 방식이 가능한 이유는 BooleanBuilder 객체가 가진 특성 때문입니다. 첫 번째로는 BooleanBuilder 객체가 이름 그대로 BooleanExpression 타입을 조립하는 객체고, 두 번째로는 개별 매서드가 반환한 BooleanExpression 타입이 null값일 경우, null을 인식하고 자동적으로 조건문에서 해당 필드를 제외하기 때문입니다. 덕분에 코드도 간편해지고, 동적 쿼리에서 null이 오더라도 깔끔하게 제외시켜 처리하는 것이죠.

이외에도 WHERE 절에 매서드를 만들어 검증하는 방식이 좋은 이유가 하나 더 있는데요, 바로 재사용성입니다. 만약 QMemberTeamDto 프로젝션을 반환하는 지금의 쿼리에서 Member 엔티티를 반환하는 쿼리로 변경할 경우, WHERE 절에는 아무 것도 수정하지 않아도 됩니다. 코드로 보겠습니다.

// Before
public List<MemberTeamDto> search2(MemberSearchCondition memberSearchCondition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name
            ))
            .from(member)
            .leftJoin(member.team, team)
            .where(memberTeamDtoEq2(memberSearchCondition))
            .fetch();
}

=>

// After
public List<Member> search3(MemberSearchCondition memberSearchCondition) {
    return queryFactory
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(memberTeamDtoEq2(memberSearchCondition))
            .fetch();
}

위 코드를 보면 select()의 대상이 Dto 프로젝션에서 엔티티로 변경되었음에도 불구하고 WHERE 절의 검증 매서드는 아무런 수정을 하지 않았습니다. 조회의 결과물은 다르겠지만 어찌됐든 조회하는 테이블은 같으니까요.

다음 글에서는 벌크 연산과 SQL 함수 사용법에 대해 알아보겠습니다.

profile
거인의 어깨 위에서 탭댄스를

1개의 댓글

comment-user-thumbnail
2023년 7월 20일

소중한 정보 잘 봤습니다!

답글 달기

관련 채용 정보