본문 바로가기

Spring

[Spring DB] 데이터 접근 기술 - Querydsl

목차

  • Querydsl 소개
  • Querydsl 설정
  • Querydsl 적용

 

Querydsl 소개

Query의 문제점

String sql = "select * from member" +
"where name like ?" +
"and age between ? and ?"

[문자를 합치면]
"select * from memberwhere name like ?and age between ? and ?"
  • Query는 문자, Type-check 불가능 
  • 실행하기 전까지 작동여부 확인 불가
  • SQL 문법 오류 발생

 

에러는 크게 2가지로 분류

  • 컴파일 에러(좋은 에러)
  • 런타임 에러(나쁜 에러)

 

Querydsl

  • 만약 SQL이 클래스 처럼 타입이 있고 자바 코드로 작성할 수 있다면?
  • 쿼리를 자바로 type-safe 하게 개발할 수 있게 지원하는 프레임워크
  • 주로 JPA 쿼리(JPQL)에 사용
  • Type-safe - 컴파일 시 에러 체크 가능, Code assistant

 

JPA에서 QUERY 방법은 크게 3가지

  • JPQL(HQL)
  • Criteria API
  • MetaModel Criteria API(type-safe)

 

1️⃣ JPQL(HQL)

  • 장점
    • SQL QUERY와 비슷해서 금방 익숙해짐
  • 단점
    • type-safe 아님
    • 동적쿼리 생성이 어려움

 

2️⃣ Criteria API

  • 장점
    • 동적 쿼리 생성이 쉬움 ?? (아님)
  • 단점
    • type-safe가 아님
    • 너무 너무 복잡함
    • 알아야 할게 너무 많음

 

3️⃣ MetaModel Criteria API(type-safe)

  • root.get("age") ➔ root.get(Member_.age)
  • Criteria API + MetaMdoel
  • Criteria API와 거의 동일
  • type-safe
  • 복잡하긴 마찬가지

 

이런 이유 때문에 이후에 Timowest 라는 사람이 QueryDSL을 만듬

 

QueryDSL 분석

  • Domain(도메인)
  • Specific(특화)
  • Language(언어)
  • 특정한 도메인에 초점을 맞춘 제한적인 표현력을 가진 컴퓨터 프로그래밍 언어

 

DSL 이란?

  • 쿼리 + 도메인 + 특화 + 언어
  • 쿼리에 특화된 프로그래밍 언어
  • 단순, 간결, 유창
  • 다양한 저장소 쿼리 기능 통합 

 

QueryDSL?

  • JPA, MongoDB, SQL 같은 기술들을 위해 type-safe SQL을 만드는 프레임 워크
  • Type-safe한 Query Type 생성 (코드가 좀 필요하다.)

 

코드 생성기

  • APT: Annotation Processing Tool
  • JPA 같은 경우 ➔ @Entity 가 필요

 

 

QueryDSL - JPA

  • Querydsl은 JPA 쿼리(jpql)을 type-safe 하게 작성하는데 많이 사용됨
  • Querydsl - JPA 버전은 Querydsl을 가지고 jpql을 만들어주는 빌더임
  • 장점
    • type-safe
    • 단순하고 쉬운 동적 쿼리 제공
    • 감동의 컴파일 에러 제공
  • 단점
    • Q코드 생성을 위한 APT(코드 생성기)를 설정해야함.

 

구성

  • Query
    • ex) from, where, join
  • Path
    • ex) QMember, QMember.name
  • Expression
    • ex)name.eq, name.gt

 

SpringDataJPA + Querydsl

  • SpringData 프로젝트의 약점은 조회
  • Querydsl로 복잡한 조회 기능 보안
  • 단순한 경우: SpringDataJpa
  • 복잡한 경우: Querydsl 직접 사용

Querydsl 설정

스프링 부트 2.x와 스프링 부트 3.x의 설정이 다르다.

 

build.gradle

스프링 부트 2.x 설정

//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')
}

 

 

build.gradle

스프링 부트 3.x 설정

dependencies {
//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"
}
  • 2.x 와 비교하면 다음 부분이 jpa ➔ jakarta 로 변경되었다.
//Querydsl 추가, 자동 생성된 Q클래스 gradle clean으로 제거
clean {
delete file('src/main/generated')
}

 

 

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

 

1️⃣ 옵션 선택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 이 생성되어 있어야 한다.

 

빌드 파일이 보이지 않는 문제

  • 점박이 3개 ➔ Tree Appearance ➔ Show Excluded Files 체크

 

참고

  • Q타입은 컴파일 시점에 자동 생성되므로 버전관리(GIT)에 포함하지 않는 것이 좋다.
  • gradle 옵션을 선택하면 Q타입은 gradle build 폴더 아래에 생성되기 때문에 여기를 포함하지 않아야 한다. 
  • 대부분 gradle build 폴더를 git에 포함하지 않기 때문에 이 부분은 자연스럽게 해결된다. 


Q타입 삭제

  • gradle clean 을 수행하면 build 폴더 자체가 삭제된다.
  • 따라서 별도의 설정은 없어도 된다.

 

2️⃣ 옵션 선택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 로 검색하면 본인 환경에 맞는 대안을 금방 찾을 수 있을 것이다.

Querydsl 적용

JpaItemRepositoryV3

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

@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 = em.find(Item.class, itemId);
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(findItem.getPrice());
        findItem.setQuantity(findItem.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) {
        // Querydsl 사용
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

       //  QItem item = new QItem("i");
        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

@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 {}

 

 

테스트 실행

@Slf4j
@SpringBootTest
@Transactional
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    /*@Autowired
    PlatformTransactionManager transactionManager;
    TransactionStatus status;

    @BeforeEach
    void beforeEach() {
        // 트랜잭션 시작
        status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    }*/

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }
        // 트랜잭션 롤백
        // transactionManager.rollback(status);
    }

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId()).get();
        log.info("itemId: {}", item.getId());
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void updateItem() {
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId).get();
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        log.info("repository={}", itemRepository.getClass()); // 예외 변환을 해주는 Proxy
        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items);
    }
}

 

 

정리


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

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

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

 

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