spring/스프링 DB 2편

6. 데이터 접근 기술 - Querydsl

sh1mj1 2023. 2. 23. 13:36
이 글은 배민 기술이사 김영한 이사님의 인프런 강의 "스프링 DB 2편 - 데이터 접근 활용 기술" 을 기반으로 작성되었습니다. 문제 시 삭제 조치하겠습니다.

 

아래와 같은 순서로 공부를 진행합니다.

 

 

  • Querydsl 소개1 - 기존 방식의 문제점
  • Querydsl 소개2 - 해결
  • Querydsl 설정
  • Querydsl 적용

 

QueryDSL

 

1. Querydsl 소개 1 - 기존 방식의 문제점

기존 방식의 문제점

Querydsl 을 사용하기 전 JPA 을 사용했을 때의 문제점부터 정리해 봅시다.

 

QUERY의 문제점

QUERY는 문자이므로 Type-check 가 불가능합니다.

실행하기 전까지 작동여부를 확인할 수 없습니다.

 

JPA에서 QUERY 방법은 크게 3가지가 있었습니다.

 

1. JPQL(HQL)

 

https://user-images.githubusercontent.com/52024566/201093333-219026a4-5541-44f3-aad0-cb06d240992a.png

장점

  • SQL QUERY와 비슷해서 금방 익숙해질 수 있습니다.

단점

  • type-safe 가 아닙니다.
  • 동적쿼리 생성이 어렵습니다.

 

2. Criteria API

 

Criteria API 는 자바 ORM 표준 프로그래밍에서 JPQL 을 자바 코드로 작성할 수 있도록 도와주는 빌더 클래스 API 을 의미합니다.

 

https://user-images.githubusercontent.com/52024566/201093339-f1f17480-e8de-4075-b995-9c0099ddaabf.png

getCriteriaBuilder 함수를 이용해서 Criteria Query Builder 을 생성하고 createQuery 함수로 CriteriaQuery 객체를 생성합니다.

Member 엔티티 별칭 root 을 사용해서 20, 40세 사이이며 김으로 시작하는 Member 을 가져옵니다. 이후 정렬 및 최대 결과에 대한 제한을 걸며 결과를 만듭니다.

 

장점

  • 동적쿼리 생성 가능

 

단점

  • type-safe 아님
  • 너무 너무 너무 복잡함
  • 알아야 할 게 너무 많음
  • 3. MetaModel Criteria API(type-safe)
  • root.get("age")root.get(Member_.age)
  • Criteria API + MetaModel
  • Criteria API와 거의 동일
  • type-safe
  • 복잡하긴 마찬가지

 

우리는 이러한 문제들을 Querydsl 로 해결할 수 있습니다.

 

2. Querydsl 소개 2 - 해결

DSL

DSL 은 도메인(Domain) + 특화(Specific) + 언어(Language) 입니다.

특정한 도메인에 초점을 맞춘 제한적인 표현력을 가진 컴퓨터 프로그래밍 언어입니다.

단순하고 간결하며 유창하다는 특징을 가집니다.

 

QueryDSL

그렇다면 QueryDSL 은 쿼리 + 도메인 + 특화 + 언어 입니다.

쿼리에 특화된 프로그래밍 언어이지요. 역시 단순, 간결, 유창합니다.

다양한 저장소 쿼리 기능을 통합하고 있습니다. JPA, MongoDB, SQL 같은 기술들을 위해 type-safe SQL을 만드는 프레임워크입니다.

 

데이터 쿼리 기능 추상화

https://user-images.githubusercontent.com/52024566/201093340-676a0c16-9aa0-470c-9d4d-5c32922861e7.png

 

Type-safe Query Type 생성

https://user-images.githubusercontent.com/52024566/201093342-581cf921-db93-43a7-9c41-3c9a22ea8a5d.png

 

작동 방식

https://user-images.githubusercontent.com/52024566/201093343-b2d7bc8f-7f3c-4ca1-9a7a-eb09f98cbb4f.png

 

 

SpringDataJPA + Querydsl

 

SpringDataJPA 프로젝트의 약점은 조회에 있습니다.

Querydsl 로 복잡한 조회 기능을 보완할 수 있습니다.

또한 복잡한 쿼리와 동적 쿼리에 약점을 가지는 Spring Data JPA 에 QueryDSL 을 합치는 것은 매우 좋은 선택이 됩니다.

  • 단순한 경우 : SpringDataJPA
  • 복잡한 경우 : Querydsl 직접 사용

 

3. Querydsl 설정

build.gradle

Querydsl로 추가된 부분은 다음 두 부분입니다.

dependencies {
  //Querydsl 추가
  implementation 'com.querydsl:querydsl-jpa'
  annotationProcessor "com.querydsl:querydsl-apt:$
  {dependencyManagement.importedProperties['querydsl.version']}:jpa"
  annotationProcessor "jakarta.annotation:jakarta.annotation-api"
  annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

//Querydsl 추가, 자동 생성된 Q클래스 gradle clean으로 제거
clean {
  delete file('src/main/generated')
}

 

검증 - Q 타입 생성 확인 방법

Preferences → Build, Execution, Deployment → Build Tools → Gradle

 

 

여기에 가면 크게 2가지 옵션을 선택할 수 있는데 옵션은 둘다 같게 맞추어야 합니다.

  1. Gradle: Gradle을 통해서 빌드
  2. IntelliJ IDEA: IntelliJ가 직접 자바를 실행해서 빌드

 

옵션 선택1 - Gradle - Q타입 생성 확인 방법

Gradle IntelliJ 사용법

  • Gradle -> Tasks -> build -> clean

 

  • Gradle -> Tasks -> other -> compileJava

 

Gradle 콘솔 사용법

  • ./gradlew clean compileJava

 

Q 타입 생성 확인

  • build -> generated -> sources -> annotationProcessor -> java/main 하위에 hello.itemservice.domain.QItem이 생성되어 있어야 합니다.

 

 

참고 - Q타입은 컴파일 시점에 자동 생성되므로 버전관리(GIT)에 포함하지 않는 것이 좋습니다.
gradle 옵션을 선택하면 Q타입은 gradle build 폴더 아래에 생성되기 때문에 여기를 포함하지 않아야 합니다.

대부분 gradle build 폴더를 git에 포함하지 않기 때문에 이 부분은 자연스럽게 해결됩니다.

 

 

Q타입 삭제

gradle clean을 수행하면 build 폴더 자체가 삭제되므로 별도의 설정이 필요하지 않습니다.

 

옵션 선택2 - IntelliJ IDEA - Q타입 생성 확인 방법

Build -> Build Project 또는 Build -> Rebuild 또는 main(), 또는 테스트를 실행

 

 

src/main/generated 하위에 hello.itemservice.domain.QItem이 생성되어 있어야 합니다.

 

참고 - Q타입은 컴파일 시점에 자동 생성되므로 버전관리(GIT)에 포함하지 않는 것이 좋습니다. IntelliJ IDEA 옵션을 선택하면 Q타입은 src/main/generated 폴더 아래에 생성되기 때문에 여기를 포함하지 않는 것이 좋습니다.

 

Q타입 삭제

//Querydsl 추가, 자동 생성된 Q클래스 gradle clean으로 제거
clean {
  delete file('src/main/generated')
}

IntelliJ IDEA 옵션을 선택하면 src/main/generated에 파일이 생성되고, 필요한 경우 Q파일을 직접 삭제해야 합니다. gradle에 해당 스크립트를 추가하면 gradle clean 명령어를 실행할 때 src/main/generated의 파일도 함께 삭제됩니다.

 

참고

Querydsl은 이렇게 설정하는 부분이 사용하면서 조금 귀찮은 부분인데, IntelliJ가 버전업 하거나 Querydsl의 Gradle 설정이 버전업 하면서 적용 방법이 조금씩 달라지기도 합니다.

 

그리고 본인의 환경에 따라서 잘 동작하지 않기도 합니다. 공식 메뉴얼에 소개 되어 있는 부분이 아니기 때문에, 설정에 수고로움이 있지만 querydsl gradle 로 검색하면 본인 환경에 맞는 대안을 금방 찾을 수 있을 것입니다.

 

4. Querydsl 적용

JpaItemRepositoryV3

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import hello.itemservice.domain.Item;
import hello.itemservice.domain.QItem;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

import static hello.itemservice.domain.QItem.*;

@Repository
@Transactional
public class JpaItemRepositoryV3 implements ItemRepository {

    private final EntityManager em;
    private final JPAQueryFactory query;

    public JpaItemRepositoryV3(EntityManager em) {
        this.em = em;
        this.query = new JPAQueryFactory(em);
    }

    @Override
    public Item save(Item item) {
        em.persist(item);
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        Item findItem = findById(itemId).orElseThrow();
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }

    @Override
    public Optional<Item> findById(Long id) {
        Item item = em.find(Item.class, id);
        return Optional.ofNullable(item);
    }

    public List<Item> findAllOld(ItemSearchCond itemSearch) {

        String itemName = itemSearch.getItemName();
        Integer maxPrice = itemSearch.getMaxPrice();

        QItem item = QItem.item;
        BooleanBuilder builder = new BooleanBuilder();
        if (StringUtils.hasText(itemName)) {
            builder.and(item.itemName.like("%" + itemName + "%"));
        }
        if (maxPrice != null) {
            builder.and(item.price.loe(maxPrice));
        }

        List<Item> result = query
          .select(item)
          .from(item)
          .where(builder)
          .fetch();
        return result;
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {

        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        List<Item> result = query
          .select(item)
          .from(item)
          .where(likeItemName(itemName), maxPrice(maxPrice))
          .fetch();

        return result;
    }

    private BooleanExpression likeItemName(String itemName) {
        if (StringUtils.hasText(itemName)) {
            return item.itemName.like("%" + itemName + "%");
        }
        return null;
    }

    private BooleanExpression maxPrice(Integer maxPrice) {
        if (maxPrice != null) {
            return item.price.loe(maxPrice);
        }
        return null;
    }
}

 

공통

Querydsl 을 사용하려면 JPAQueryFactory가 필요합니다. JPAQueryFactory는 JPA 쿼리인 JPQL을 만들기 때문에 EntityManager가 필요합니다.

 

설정 방식은 JdbcTemplate 을 설정하는 것과 유사합니다.

참고로 JPAQueryFactory 를 스프링 빈으로 등록해서 사용해도 됩니다.

 

save(), update(), findById()

기본 기능들은 JPA가 제공하는 기본 기능을 사용합니다.

 

findAllOld

Querydsl 을 사용해서 동적 쿼리 문제를 해결하였습니다.

BooleanBuilder를 사용해서 원하는 where 조건들을 넣습니다.

이 모든 것을 자바 코드로 작성하기 때문에 동적 쿼리를 매우 편리하게 작성할 수 있습니다.

 

findAll

앞서 findAllOld에서 작성한 코드를 깔끔하게 리팩토링했습니다.

List<Item> result = query
.select(item)
.from(item)
.where(likeItemName(itemName), maxPrice(maxPrice))
.fetch();

 

Querydsl에서 where(A,B)에 다양한 조건들을 직접 넣을 수 있는데, 이렇게 넣으면 AND 조건으로 처리됩니다. 참고로 where()null을 입력하면 해당 조건은 무시합니다.

 

이 코드의 또 다른 장점은 likeItemName(), maxPrice()를 다른 쿼리를 작성할 때 재사용 할 수 있다는 점입니다.

 

쉽게 이야기해서 쿼리 조건을 부분적으로 모듈화 할 수 있습니다!!!

자바 코드로 개발하기 때문에 얻을 수 있는 큰 장점입니다.

 

QuerydslConfig

package hello.itemservice.config;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jpa.JpaItemRepositoryV3;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;

@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {

    private final EntityManager em;

    @Bean
    public ItemService itemService() {
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        return new JpaItemRepositoryV3(em);
    }
}

 

ItemServiceApplication - 변경

//@Import(SpringDataJpaConfig.class)
@Import(QuerydslConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {}

QuerydslConfig를 사용하도록 변경합니다.

 

예외 변환

Querydsl에서 별도의 스프링 예외 추상화를 지원하지 않는 대신 JPA에서 학습한 것처럼 @Repository에서 스프링 예외 추상화를 처리합니다.

 

정리

Querydsl 장점

Querydsl 덕분에 동적 쿼리를 매우 깔끔하게 사용할 수 있습니다.

List<Item> result = query
    .select(item)
    .from(item)
    .where(likeItemName(itemName), maxPrice(maxPrice))
    .fetch();

쿼리 문장에 오타가 있어도 컴파일 시점에 오류를 막을 수 있습니다.

메서드 추출을 통해서 코드를 재사용할 수 있습니다. 예를 들어서 여기서 만든 likeItemName(itemName), maxPrice(maxPrice) 메서드를 다른 쿼리에서도 함께 사용할 수 있습니다!

 

Querydsl을 사용해서 자바 코드로 쿼리를 작성하는 것이 장점입니다.

그리고 스프링 데이터 JPA 에서의 약점인 동적 쿼리 문제도 깔끔하게 해결할 수 있습니다.

 

Querydsl은 이 외에도 수 많은 편리한 기능을 제공합니다.

예를 들어서 최적의 쿼리 결과를 만들기 위해서 DTO로 편리하게 조회하는 기능은 실무에서 자주 사용하는 기능입니다.

JPA를 사용한다면 스프링 데이터 JPA 와 Querydsl 은 실무의 다양한 문제를 편리하게 해결하기 위해 선택하는 기본 기술입니다!