개발/스프링

QueryDSL을 사용해보자

brobro332 2025. 2. 15. 14:20
반응형

QueryDSL이 뭐야? 

  • 자바 및 코틀린에서 객체지향적으로 SQL / JPQL 쿼리를 작성할 수 있도록 돕는 쿼리 빌더 라이브러리다.
  • 작성자는 동적 쿼리를 처리하기 위해 QueryDSL을 도입했다.

 

  • 가령 공지사항에서 게시물을 조회할 때 사용자는 제목 조건을 포함하여 조회할 수도 있고, 포함하지 않을 수도 있다.
  • 이러한 상황을 처리하기 위한 것이 동적 쿼리이다.
  • JPA에서도 Criteria API를 제공하여 동적 쿼리를 처리할 수 있게 하지만 문법이 굉장히 복잡하다.
  • 코드 유지보수성도 개발에 있어 굉장히 중요하기 때문에 다른 방안이 필요했다.

 

  • SQL Mapper Framework MyBatis를 통해 동적 쿼리를 처리할 수도 있다.
  • 현업에서는 아마 MyBatis를 더 많이 쓸 것이다.
  • 그 이유로는 직접 SQL을 작성하는 방식이 직관적이고 러닝 커브가 낮아 유지보수하기 좋기 때문이다.
  • 그런데 본인은 MyBatis를 쓰면서 쿼리가 잘 작성되었는지 확인하기 위해 서버를 재시작하고, 사이트에 접속하고, 해당 기능을 눌러보는 것에 불편함을 느꼈다.

 

  • 반면 QueryDSL은 컴파일 레벨에서 필드명이나 타입 작성 오류 등을 잡을 수 있다.
  • 개발자가 컴파일하면 QueryDSL은 반환 DTO나 엔티티 따위에 해당하는 Q클래스를 만들고, 해당 클래스를 통해 컴파일 타임에서 오류를 검출한다.

 

동작 흐름을 알아보자

/* QueryDSL */
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
  • 먼저 의존성을 주입하자. 작성자는 빌드 도구로 gradle을 사용한다.

 

@QueryProjection
public MemberResponseDto(String email, String name, String description, LocalDateTime createdAt, LocalDateTime modifiedAt) {
	this.email = email;
	this.name = name;
	this.description = description;
	this.createdAt = createdAt;
	this.modifiedAt = modifiedAt;
}
  • DB 값을 조회하고서 클라이언트로 엔티티를 반환하는 건 보안 측면에서 위험하다고 알고 있다.
  • 물론 기능에 따라 요구사항도 다르기 때문에 유지보수 문제도 있다.
  • 그렇기 때문에 반환 DTO를 작성할 텐데, 이러한 반환 DTO에 @QueryProjection 애노테이션을 명시하여 QueryDSL 라이브러리로 하여금 Q클래스를 생성할 수 있도록 유도해야 한다.

 

import static kr.co.co_working.invitation.QInvitation.invitation;
import static kr.co.co_working.member.QMember.member;
import static kr.co.co_working.memberWorkspace.QMemberWorkspace.memberWorkspace;
import static kr.co.co_working.workspace.QWorkspace.workspace;

@Repository
@RequiredArgsConstructor
public class MemberDslRepositoryImpl implements MemberDslRepository {
    private final JPAQueryFactory factory;

    @Override
    public List<MemberResponseDto> readMemberListNotInWorkspace(WorkspaceRequestDto.READ dto) {
        return factory
            .select(
                new QMemberResponseDto(
                    member.email,
                    member.name,
                    member.description,
                    member.createdAt,
                    member.modifiedAt,
                    invitation.status
                )
            ).from(member)
            .leftJoin(invitation).on(invitation.member.eq(member))
            .where(
                member.email.notIn(
                    JPAExpressions
                        .select(memberWorkspace.member.email)
                        .from(memberWorkspace)
                        .where(memberWorkspace.workspace.id.eq(dto.getId()))
                    )
                ).where(
                    emailContains(dto.getEmail()),
                    nameContains(dto.getName())
                )
            .fetch();
    }
    
    private BooleanExpression emailContains(String emailCond) {
        return emailCond != null && !emailCond.trim().isEmpty() ? member.email.contains(emailCond) : null;
    }

    private BooleanExpression nameContains(String nameCond) {
    	return nameCond != null && !nameCond.trim().isEmpty() ? member.name.contains(nameCond) : null;
    }
}
  • 요런 식으로 메서드 체이닝 방식을 사용한다.
  • 메인 쿼리에는 JPAQueryFactory를, 서브쿼리에는 JPAExpressions를 사용하면 된다.

 

  • 조심해야 할 부분은 memberWorkspace.workspace.id 이런 식으로 조건에 넣게 되면 내부적으로 조인을 수행한다고 한다.
  • 내부적으로 조인을 수행한다면 내가 의도한 대로 쿼리가 생성되지 않을 수도 있기 때문에 명시적 조인을 사용하는 게 좋을 것 같은데, 본인도 아직 서버 코드 최적화는 신경 쓸 겨를이 없어서 추후 성능 튜닝을 해보고자 한다.
  • 참고로, queryDSL은 엔티티를 감지하여 Q클래스를 생성하는데, 이를 레포지토리에서 변수로 사용하기 위해 static import문을 써야 한다.  

 

아직 복잡한 쿼리는 작성해보지 않았는데 익숙해지기에 시간이 좀 걸릴 것 같다. 성능 테스트도 아직 못 해보았다.

 

사용하면서 알게 된 특징들

  1. 조회 컬럼에 null을 반환할 수 없다.
  2. 조인 시 다른 타입의 엔티티를 분기칠 수 없다.

 

  • 조인 시 다른 타입의 엔티티를 분기칠 수 없다는 것은 다음을 의미한다.
// ...

// 컴파일 오류
.leftjoin(flag ? member : workspace)

// ...
  • 특정 조건에 따라 조인 엔티티를 분기치고 싶다면 아예 쿼리를 나눠야 한다.
  • 이는 컴파일 시점에서 leftJoin() 메서드에 전달되는 인자가 동일한 타입이어야 하기 때문이라고 한다.

 

마치며

아직은 직접 SQL을 작성하는 MyBatis가 더 편하다.

그럼에도 컴파일 단계에서 오류 검출이 가능한 건 꽤 큰 이점이라고 생각하며, 토이 프로젝트에는 계속해서 사용하고 튜닝해 나갈 예정이다. MyBatis랑 QueryDSL 둘 다 쓸 수도 있다는데 그렇게 하면 유지보수하기 힘들 것 같다. 정말 복잡한 쿼리가 필요한 그때에도 QueryDSL을 유지할 수 있을지는 아직 경험이 부족해서 모르겠다.

요즘 2025년에는 많은 것이 급변할 것 같다는 생각이 든다. 당장 3개월 뒤에도 나는 웃고 있을까, 인상 쓰고 있을까 🤤

 

이미지 출처

 

Querydsl

Querydsl has 9 repositories available. Follow their code on GitHub.

github.com