development/spring

QueryDSL - Introduction / Join / Dynamic Query

bokshiri 2023. 2. 15. 15:57

안녕하세요. 복실복실 개발자입니다.

업무와 자격증시험 준비를 병행하다보니 포스팅이 많이 늦어졌습니다.. 올해에는 SQLD와 SAA를 취득하려고 합니다. 공부하면서 필요한 내용은 추후 포스팅해보도록 하겠습니다.

이번 글에서는 JPA의 QueryDSL을 실제로 활용해보도록 하겠습니다.  QueryDSL과 JPQL에 대해 사전 지식이 필요하신 분은 이 글 을 참고해주세요.

  • 1. QueryDSL을 활용한 기본 Select
  • 2. 일대 다 관계의 엔티티 Join - @QueryProjection을 활용한 DTO 바인딩
  • 3. BooleanBuilder / BooleanExpression을 활용한 Dynamic Query

1. QueryDSL을 활용한 기본 Select

우선 QueryDSL을 사용하기 위해 JPAQueryFactory를 스프링 빈으로 등록해줍니다.

JPAQueryFactory 객체를 빈으로 등록한다

 

다음으로, QuerydslRepositorySupport 클래스를 상속받아 커스텀 RepositorySupport 클래스를 하나 생성합니다. 그 후 앞서 스프링에 등록하였던 JPAQueryFactory를 생성자 방식으로 DI하여 QueryDSL을 사용하도록 합니다.

커스텀 repositorySupport 클래스 생성

 

주입받은 jpaQueryFactory를 활용하여 queryDSL을 다음과 같이 작성할 수 있습니다.

member 테이블에 대한 list조회

파라미터를 입력받아 조건을 추가하거나, 조건을 체이닝하는 것또한 가능합니다.

파라미터를 받아 조건을 추가할 수 있다

 

다음으로, list count 조회를 살펴보겠습니다.

select count(*) from table

일반적으로 sql에서는 다음과 같은 형태로 사용하지만 QueryDSL에서는 사용방법이 조금 다릅니다.

list의 count를 구하기 위해 fetch().size()를 사용한다

과거에는 fetchCount() 혹은 fetchResults() 메서드를 사용하였으나, 현재 라이브러리를 확인해보니 @Deprecated되어 있었습니다. 복잡한 쿼리를 실행할 경우 해당 메서드들이 문제가 생기는 경우가 있어 공식적으로 사용이 중단된 것 같습니다. 이를 대체하기 위해 .fetch()에서 반환된 List의 count를 .size() 자바 메서드로 구현하였습니다. 이 주제는 다음 포스팅에서 페이징 처리를 위한 Pageable 인터페이스를 다루면서 상세하게 논의하도록 하겠습니다.


2. 일대 다 관계의 엔티티 Join - @QueryProjection을 활용한 DTO 바인딩

다음으로 가장 기본적인 Inner Join을 JPA QueryDSL에서 사용하는 방법을 살펴보도록 하겠습니다. 우선 member 테이블과 연결하기 위한 용도로 board(게시판) 테이블을 하나 생성하도록 하겠습니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity(name = "board")
@SequenceGenerator(
        name = "board_seq_generator",
        sequenceName = "board_seq",
        initialValue = 1,
        allocationSize = 1
)
public class BoardVO {

    @Builder
    public BoardVO(Long boardKey, MemberVO memberVO, String boardTitle, String boardContent, String regId, Timestamp regDt) {
        this.boardKey = boardKey;
        this.memberVO = memberVO;
        this.boardTitle = boardTitle;
        this.boardContent = boardContent;
        this.regId = regId;
        this.regDt = regDt;
    }

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "board_key",
            nullable = false,
            unique = true,
            columnDefinition = "numeric")
    private Long boardKey;

    /**
     * member 테이블과의 관계설정
     * @ManyToOne: member와 board는 일대 다 관계이므로 board 엔티티에 참조값 설정
     * @JoinColumn: option - referencedColumnName 항목으로 참조하는 대상의 컬럼 정보를 입력한다.
     *              default -> 참조하는 테이블의 primary key
     * */
    @ManyToOne
    @JoinColumn(name = "member_key",
                nullable = false,
                referencedColumnName = "member_key")
    private MemberVO memberVO;

    @Column(name = "board_title",
            nullable = false,
            columnDefinition = "varchar")
    private String boardTitle;

    @Column(name = "board_content",
            columnDefinition = "varchar")
    private String boardContent;

    @Column(name = "reg_id",
            columnDefinition = "varchar")
    private String regId;

    @Column(name = "reg_dt",
            columnDefinition = "timestamp")
    @CreationTimestamp
    private Timestamp regDt;

회원(member) : 게시판(board) 은 1 : N 관계이므로 board 테이블에 @ManyToOne 어노테이션을 활용하여 member를 연결해줍니다. 이렇게 작성된 @Entity 어노테이션이 담긴 엔티티들을 기반으로 컴파일 시점에 annotationProcessor에 의해 Q클래스가 생성됩니다. (build.gradle에서 생성 경로를 지정할 수 있습니다.) 빌드 후 생성된 Q클래스를 활용하여 QueryDSL 조인을 수행하도록 하겠습니다.

QMemberVO member = QMemberVO.memberVO;
QBoardVO board = QBoardVO.boardVO;

List<Tuple> res = jpaQueryFactory.select(member, board)
        .from(board)
        .innerJoin(board.memberVO, member)
        .where(board.boardContent.isNotNull())
        .orderBy(board.regDt.desc())
        .fetch();

member와 board 객체를 조인하여 모든 컬럼을 조회하였더니 리턴 형태가 엔티티가 아닌, Tuple 인터페이스가 되었습니다. Tuple을 사용하게 되면 Map과 유사하게 key, value형태로 데이터를 저장하여 사용할 수 있습니다. 

res.get(0).get(member.memberId);

하지만 Tuple을 사용하여 개발을 진행할 경우 해당 쿼리가 무엇을 대상으로 하는지, 어떤 업무에 해당하는지 한 눈에 파악하기가 어려워집니다. 때문에 저는 Tuple방식을 사용하지 않고 QueryDSL에서 제공하는 Projection을 활용하여 별도의 DTO(Data Transfer Object)를 만들어 사용하였습니다.

 

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

    /** constructor
     * @QueryProjection을 사용하면 해당 엔티티도 Q클래스로 생성된다.
     * Builder 패턴은 적용이 안되는 것 같음... 현재는 select 시점에 객체를 새로 생성해서 생성자 아규먼트 순서대로 정의
     * */
    @Builder
    @QueryProjection
    public MemberDTO(Long memberKey, String memberId, String memberPw, String memberName, String regId, Timestamp regDt, Long boardKey, String boardTitle, String boardContent, String boardRegId, Timestamp boardRegDt) {
        this.memberKey = memberKey;
        this.memberId = memberId;
        this.memberPw = memberPw;
        this.memberName = memberName;
        this.regId = regId;
        this.regDt = regDt;
        this.boardKey = boardKey;
        this.boardTitle = boardTitle;
        this.boardContent = boardContent;
        this.boardRegId = boardRegId;
        this.boardRegDt = boardRegDt;
    }

    private Long memberKey;

    private String memberId;

    private String memberPw;

    private String memberName;

    private String regId;

    private Timestamp regDt;

    /** board **/

    private Long boardKey;

    private String boardTitle;

    private String boardContent;

    private String boardRegId;

    private Timestamp boardRegDt;

전체 생성자를 만든 후 @QueryProjection을 붙여주었습니다. 해당 어노테이션을 사용하게 되면 빌드 시점에 엔티티와 마찬가지로 사용자가 지정한 경로에 Q클래스가 생성됩니다.

@QueryProjection을 사용한 생성자를 바탕으로 Q클래스가 만들어진다

이제 queryDSL에서 조회해온 값을 DTO에 바인딩하기 위한 준비가 끝났습니다. 방식은 간단합니다. select시에 Q클래스 객체를 아규먼트로 입력하지 않고, DTO 객체를 새로 만들어 매핑을 시켜주면 됩니다.

select 인자로 DTO객체를 생성하여 넘긴다

이 지점에서 한 가지 해결되지 않은 문제가 발생하였습니다. 분명히 MemberDTO 클래스를 생성할 때 전체 생성자에 @QueryProjection과 함께 @Builder 어노테이션을 붙여주었는데, Q클래스엔 빌더 패턴이 적용되지 않았습니다... 지금은 조회하는 컬럼이 10개 내외라 괜찮다지만 만일 대상컬럼이 100건, 1000건이 된다면 생성자에 선언된 순서에 맞게 일일이 파라미터 순서를 맞춰줘야하는 문제가 생기게 됩니다. Projection을 사용하는 DTO의 경우 빌더 패턴이 적용될 수 없는 것인지 조금 더 확인이 필요할 것 같습니다..


3. BooleanBuilder / BooleanExpression을 활용한 Dynamic Query

마지막으로 동적쿼리(Dynamic Query)에 대해 살펴보도록 하겠습니다. sql-mapper mybatis에서는 <if></if>나 <choose> <when> 구문으로 동적쿼리를 처리하였지만 JPA에서는 BooleanBuilder와 BooleanExpression을 활용하여 자바단에서 코드로 구현이 가능합니다.

  • BooleanBuilder
  • BooleanExpression

먼저 BooleanBuilder를 살펴보도록 하겠습니다.

public List<MemberDTO> dynamicExample(String type, String nowYn, String id, String title) throws Exception {
    QMemberVO member = QMemberVO.memberVO;
    QBoardVO board = QBoardVO.boardVO;

    List<MemberDTO> res = new ArrayList<>();

    if(StringUtils.hasText(type)) {

        /**
         * 1. BooleanBuilder를 사용하는 경우
         * BooleanBuilder 객체 생성 후 조건에 따라 쿼리 추가
         * */
        if(type.equals(Builder)) {
            BooleanBuilder booleanBuilder = new BooleanBuilder();

            if(StringUtils.hasText(nowYn) && nowYn.equals("Y")) {
                booleanBuilder.and(member.regDt.eq(Expressions.currentTimestamp()));
            }

            if(StringUtils.hasText(id)) {
                booleanBuilder.and(member.memberId.eq(id));
            }

             res = jpaQueryFactory.select(
                            new QMemberDTO(
                                    member.memberKey,
                                    member.memberId,
                                    member.memberPw,
                                    member.memberName,
                                    member.regId,
                                    member.regDt,
                                    board.boardKey,
                                    board.boardTitle,
                                    board.boardContent,
                                    board.regId,
                                    board.regDt
                            )
                    )
                    .from(board)
                    .innerJoin(board.memberVO, member)
                     /** booleanBuilder 선언 후에도 조건은 이어서 체이닝 가능 */
                    .where(booleanBuilder.and(member.memberName.isNotNull()))
                    .orderBy(board.regDt.desc())
                    .fetch();

파라미터 중 'type'의 용도는 하나의 메서드에서 BooleanBuilder와 BooleanExpression을 분기처리하여 사용하기 위한 목적입니다. 그리고 나머지 nowYn, id, title 항목은 dynamic query를 구현하기 위한 인자라고 생각하시면 될 것  같습니다.

우선 BooleanBuilder 객체를 생성한 후에 Java 단에서 if문을 사용하여 파라미터의 null여부를 검증합니다. 만일 null이 아니라면 앞서 생성한 BooleanBuilder 객체에 사용하고자하는 sql 예약어(and / or 등)메서드를 추가해줍니다. 이후 select절에서 where문에 BooleanBuilder 객체를 입력하여 쿼리를 실행시키면 됩니다.

BooleanBuilder가 하나의 객체에 표현식을 추가한 후 where조건문에 최종 객체를 담는 방식이라면, BooleanExpression은 메서드를 분리한 후 해당 메서드에서 표현식 자체를 반환하는 형태로 사용합니다. 

BooleanExpression은 메서드 재활용도 가능하고, BooleanBuilder에 비해 가독성도 좋다는 평가가 있긴 하지만, 어느 것을 사용할지는 각자의 프로젝트 환경에 맞춰서 결정하는 것이 좋다고 생각합니다.

BooleanExpression 코드를 살펴보겠습니다.

/**
 * 2. BooleanExpression을 사용하는 경우 - 권장
 * 1에 비해 훨씬 더 깔끔한 코딩이 가능하고, 가독성이 좋아진다.
 * */
}else if(type.equals(Expression)){
    res = jpaQueryFactory.select(
                    new QMemberDTO(
                            member.memberKey,
                            member.memberId,
                            member.memberPw,
                            member.memberName,
                            member.regId,
                            member.regDt,
                            board.boardKey,
                            board.boardTitle,
                            board.boardContent,
                            board.regId,
                            board.regDt
                    )
            )
            .from(board)
            .innerJoin(board.memberVO, member)
            .where((member.memberName.isNotNull()),
                    checkMemberId(id),
                    checkBoardTitle(title))
            .orderBy(board.regDt.desc())
            .fetch();
}

/**
 * 해당 메서드를 사용하여 Expression을 조건에 등록할 경우
 * -> checkMemberId()에서 null이 return되면 nullPointerException이 발생하는 문제가 있어, 주석처리함
 * 개별 조건은 where조건에서 ','을 활용하여 나열형태로 사용
 * */
/*private BooleanExpression addAllCond(String id, String title) throws Exception {
    return checkMemberId(id).and(checkBoardTitle(title));
}*/

private BooleanExpression checkMemberId(String id) throws Exception {
    QMemberVO member = QMemberVO.memberVO;
    return StringUtils.hasText(id) ? member.memberId.eq(id) : null;
}

private BooleanExpression checkBoardTitle(String title) throws Exception {
    QBoardVO board = QBoardVO.boardVO;
    return StringUtils.hasText(title) ? board.boardTitle.eq(title) : null;
}

위와 같이 별도의 메서드에서 검증을 진행한 후 표현식 자체를 리턴하는 형태로 사용합니다. queryDSL 쿼리를 보시면 where조건문 안에 메서드 항목이 ',' 구분자로 구분되어 나열되는 것을 보실 수 있습니다. BooleanBuilder를 사용할 때보다 확실히 조건을 파악하는 데에는 유리한 점이 있는 것 같습니다.

처음에 개발을 진행할 땐 addAllCond() 를 사용하여 반환된 표현식을 체이닝 한 후 where조건에 해당 메서드만을 인자로 추가하였었습니다. 하지만 이 방식은 만일 checkMemberId()에서 null이 반환될 경우 nullPointerException이 발생하는 문제가 있습니다. dynamic query는 파라미터가 없을 경우 조건에서 자동 제외하는 것이 핵심이기 때문에, 예외를 잡기 위해 addAllCond() 를 주석처리하고 where조건에 직접 메서드를 추가하는 방식으로 개발 방식을 변경하였습니다.

다음 포스팅에서는 JPA의 페이징처리와 Pageable 인터페이스를 사용하는 방법에 대해 논의해보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다.

'development > spring' 카테고리의 다른 글

테스트 주도 개발 - TDD란?  (0) 2023.08.01
Spring Boot 와 Spring Legacy  (2) 2023.07.18
자주 사용하는 Spring annotation 정리  (2) 2023.06.05
JPA - JPQL과 QueryDSL 활용  (5) 2023.01.31
JPA를 활용한 기본 CRUD 구현  (7) 2023.01.28