본문 바로가기
JPA/Spring Data Jpa

[SpringDataJpa] QueryDsl 정리

by 옹알이옹 2023. 8. 10.

 

 


 

 1. QueryDsl 정의

JPA를 사용하다보면 method query나 jpql로 해결하기 애매한 문제들이 발생한다.
또한 jpql, mybatis처럼 문자열을 통해 쿼리를 작성하다 보면 문법에 의한 에러를 한번쯤은 겪게 되는데
QueryDsl을 사용하면 쿼리를 메소드를 통해 작성할 수 있고 자동완성, 컴파일 단계에서의 타입 체크등을 할 수 있어 더욱 편하게 쿼리를 작성할 수 있게된다.

 

 2. QueryDsl 사용 이유

위에서 'JPA를 사용하다 보면 method query나 jpql로 해결하기 애매한 문제들' 이 발생한다고 하였다. 내가 겪은 문제는
동적 쿼리에 대한 처리였다.  자세한 상황은 아래와 같다.

 

@GetMapping("/board")
public List<BoardDto> selectBoardList(@Nullable @RequestBody SearchDto searchDto){

    List<BoardDto> boardList = boardService.selectBoardAll(searchDto);

    return boardList;
}
public class SearchDto {

    private String searchKeyword;
    private String searchValue;

}
  • 게시판 목록을 조회하는 API에서 검색 기능을 추가하기 위해 SeachDto라는 객체를 만들어 검색 키워드와 검색 값 
    필드를 만들어 값을 받아와 where절에 추가하고 싶었다.
  • searchKeyword필드에는 게시판의 컬럼들이 들어갈텐데 JPQL이나, queryMethod를 사용하면 동적으로 searchKeyword를 매핑할 수 없어 키워드 종류마다 메소드를 각각 만들어주어야 하는 상황이 돼버렸다.
  • 그러하여 결국 동적 쿼리를 생성하기 위해 QueryDsl을 적용할 수밖에 없었다.

 3.  Q 클래스란

JPQL을 작성할 때 우리는 Entity를 사용한다. 가령 select b from Board 같이 작성을 한다.
하지만 QueryDsl을 사용할 때는 Entity를 직접적으로 사용하여 쿼리를 만들지 않는다.
gradle 설정을 통해 Entity정보를 바탕으로 QClass를 별도로 생성한 뒤 해당 그것을 통해 쿼리를 작성하게 된다.

QueryDsl => JPQL => SQL 로 변환이 되어 쿼리가 날아가게 된다.
여기서 의문점이 하나 생겼다. Entity도 테이블에 대한 정보, 컬럼등을 가지고 있는데 (Entity를 통해 테이블을 생성했으니까!) 왜 굳이 QClass라는 것을 만들어서 사용해야 하지?

위에 대한 질문에 대한 GPT 형님의 답변을 듣고 고개를 끄덕였다. GTP 왈

=> 엔티티 클래스의 변경이 쿼리 작성에 영향을 미치지 않도록 보장한다.

만약 Entity로 쿼리를 작성하는 도중 Entity 내용이 바뀌었다면, 그로 작성한 쿼리도 영향을 받게 된다.
QClass는 build.gradle에서 빌드를 돌려 생성하고 지울 수 있는 기능이 제공되어 그 행위를 하기 전까지 불변성이 보장.

 

 4. QueryDsl  Config, gradle 세팅

세팅 파일 작성은 해당 블로그의 글을 참조하여 작성했다.
https://velog.io/@soyeon207/QueryDSL-Spring-Boot-%EC%97%90%EC%84%9C-QueryDSL-JPA-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

 

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory(){
        return new JPAQueryFactory(entityManager);
    }

}
QueryDsl을 사용하려면 JPAQueryFactory에 EntityManager를 등록해주어야 한다.

 

plugins {
    id 'java'
    id 'org.springframework.boot' version "2.7.14"
    id 'io.spring.dependency-management' version "1.0.15.RELEASE"
}

group = "com.study"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_11
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
    maven { url 'https://repo.spring.io/milestone' }
    maven { url 'https://repo.spring.io/snapshot' }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly'org.projectlombok:lombok'
    runtimeOnly'com.mysql:mysql-connector-j'
    annotationProcessor'org.projectlombok:lombok'
    testImplementation'org.springframework.boot:spring-boot-starter-test'
    testImplementation'org.springframework.security:spring-security-test'

    // validator 추가
    implementation'org.springframework.boot:spring-boot-starter-validation'

    //  spring-boot-starter-mail 추가
    implementation'org.springframework.boot:spring-boot-starter-mail'

    // queryDsl
    implementation 'com.querydsl:querydsl-jpa'
    implementation 'com.querydsl:querydsl-apt'

    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}

def querydslSrcDir = 'src/main/generated'
sourceSets {
    main {
        java {
            srcDirs += [ querydslSrcDir ]
        }
    }
}

compileJava {
    options.compilerArgs << '-Aquerydsl.generatedAnnotationClass=javax.annotation.Generated'
}

tasks.withType(JavaCompile) {
    options.generatedSourceOutputDirectory = file(querydslSrcDir)
}

clean {
    delete file(querydslSrcDir)
}

tasks.named('test'){
    useJUnitPlatform()
}

먼저 간략한 설명은 다음과 같다.

  • queryDsl 관련 의존성을 추가한다.
  • Q클래스를 생성할 프로젝트의 경로를 지정한다.
compileJava {
    options.compilerArgs << '-Aquerydsl.generatedAnnotationClass=javax.annotation.Generated'
}
  • 해당 부분을 실행시켜 실제 Q클래스를 위의 경로에 생성한다.

 

 5. 사용 예제

 

5-1 설계 및 사용법

구글링을 하다 보니 다양한 사용 방법이 있었고, 내가 생각한 방법과 동일한 예제도 존재하여 해당 방법으로 진행하였다.

 

상황
기존의 JpaRepository의 기능도 사용하고 싶어 그대로 유지하며, 추가로 QueryDsl Repository도 사용하고 싶다.

 

설계 방법

  • QueryDslRepository 인터페이스를 생성한다.
  • 그에 대한 구현체인 구현체를 생성하여 실제 쿼리를 작성한다.
  • 서비스 레이어에선 기존의 JpaRepository와 QueryDslRepositor, 두 개를 의존주입하여 사용한다.

적용한 설계 방식의 장단점     

 

장점

  • Repository 간의 의존성이 약해진다. => 결합도가 낮아진다.
  • Service 레이어에서 Repository 사용이 유연해진다.

단점

  • Repository 파일 이름을 직관적으로 만들지 않으면 용도가 헷갈릴 수 있다.
  • 서비스에서 어떤 Repository를 사용할지에 대한 분기처리가 추가될 수 있어, 코드가 지저분해진다.

BaordService

public class BoardService {

    private final BoardRepository boardRepository;
    private final MemberRepository memberRepository;
    private final BoardDslRepository boardDslRepository;

    @Autowired
    public BoardService(
            BoardRepository boardRepository,
            MemberRepository memberRepository,
            BoardDslRepository boardDslRepository) {
        this.boardRepository = boardRepository;
        this.memberRepository = memberRepository;
        this.boardDslRepository = boardDslRepository;
    }

    /**
     * 게시판 목록 조회
     * @return
     */
    public List<BoardDto> selectBoardAll(SearchDto searchDto) {

        System.err.println("searchDto : "+searchDto);
        List<Board> boardEntityList = boardDslRepository.findSearchAll(searchDto);

        Stream<BoardDto> boardListDtoStream = boardEntityList.stream().map(board -> board.toDto());
        return boardListDtoStream.collect(Collectors.toList());
    }

}

 

BoardDslRepository(QueryDsl 인터페이스)

public interface BoardDslRepository {

    List<Board> findSearchAll(SearchDto searchDto);
}

 

BoardDslRepositoryImpl(QueryDsl 인터페이스 구현체)

@Repository
public class BoardDslRepositoryImpl implements BoardDslRepository{

    private final JPAQueryFactory jpaQueryFactory;

    public BoardDslRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
        this.jpaQueryFactory = jpaQueryFactory;
    }

    @Override
    public List<Board> findSearchAll(SearchDto searchDto) {


        return jpaQueryFactory
                .selectFrom(board)
                .join(board.member)
                .fetchJoin()
                .fetch();
    }

}
  • board라는 변수는 QBoard의 안에 들어있는 값으로 아래의 import문을 통해 바로 사용하였다.
import static com.study.demo.entity.QBoard.board;
  • 필자는 Jooq와 문법의 90프로 동일하다고 느껴졌다.(입사 후 첫 프로젝트에서 사용해 봄)
  • fetchJoin()등 JPA 관련 함수들도 전부 지원하여 사용법만 좀 익히면 'JPA의 성능이슈' 태클은 피할 수 있을 거 같다.
  • 위의 코드는 아직 미완성이다. 왜냐하면 검색값을 동적으로 넣기 위해 만들었지만 동적 쿼리 기능이 빠져있다

5-2 동적 쿼리 생성 방법

이제 QueryDsl을 고생하며 적용한 이유인 동적 쿼리를 통한 유연한 검색 기능을 구현부이다.

현재 Dto는 다음과 같이 검색 필드와, 실제 검색 값 모두를 매핑하여 가져온다.
이제 QueryDsl에서 두 변수에 담긴 값을 동적으로 처리해야 한다.
public class SearchDto {

    private String searchKeyword;
    private String searchValue;

}

 

BooleanBuilder


잘못된 방법

return jpaQueryFactory
        .selectFrom(board)
        .join(board.member)
        .fetchJoin()
        .where(searchDto.getSearchKeyword.eq(searchDto.getSearchValue()))
        .fetch();

처음에는 위처럼 검색 키워드 = 검색 값을 where절에 넣으면 될 줄 알았다.
하지만 QueryDsl 안에서는 모든 필드값을 QClass의 값들을 사용해야 했다.

그래서 고민에 빠지던 중 BooleanBuild 라는 것을 발견하여 적용시켜 해결하였다.

 

BooleanBuild 는 동적으로 QClass 쿼리를 작성할 수 있게 해준다. 가령 예로 where절에 동적값이 아닌 고정 값이 들어간다고 생각해 보면

    return jpaQueryFactory
            .selectFrom(board)
            .join(board.member)
            .fetchJoin()
            .where(board.boardNm.eq(searchDto.getSearchKeyword()))
            .fetch();

이런 식으로 들어가게 될 것이다. 하지만 원하는 것은 저 board.boardNm이 변수처리되어 들어가야 한다.


BooleanBuilder 적용 후

@Override
public List<Board> findSearchAll(SearchDto searchDto) {


    // 검색 값이 없는 경우
    BooleanBuilder builder = new BooleanBuilder();
    if(searchDto == null){
        return jpaQueryFactory
                .selectFrom(board)
                .join(board.member)
                .fetchJoin()
                .fetch();
    }
    // boardNm으로 검색한 경우
    if(searchDto.getSearchKeyword().equals("boardNm")){
        builder.and(board.boardNm.eq(searchDto.getSearchValue()));


    // memberNo으로 검색한 경우
    }else if(searchDto.getSearchKeyword().equals("memberNo")){
        builder.and(board.member.memberNo.eq(Long.parseLong(searchDto.getSearchValue())));
    }

    System.err.println("builder : "+builder);

    return jpaQueryFactory
            .selectFrom(board)
            .join(board.member)
            .fetchJoin()
            .where(builder)
            .fetch();
}
  • searchKeyword값으로 분기 처리를 한 뒤 builder를 이용해 원하는 쿼리를 생성한다.
  • 결과적으로 where절에 builder를 넣게 되면 위에서 searchKeyword에 따라 생성된 쿼리문이 들어가게 된다.
  • builder의 값을 출력하면 다음과 같다. builder : board.boardNm = 보드2

반응형

'JPA > Spring Data Jpa' 카테고리의 다른 글

JPA 지연 로딩 Json파싱 에러  (2) 2023.08.10
[SpringDataJpa] findAll() n+1 문제 해결  (0) 2023.08.09