[스프링 DB 2편] 데이터 접근 기술 - Querydsl
포스트
취소

[스프링 DB 2편] 데이터 접근 기술 - Querydsl

Querydsl 등장 배경

기존 방식의 문제점

SQL을 사용하는 JdbcTemplete이든, JPQL을 사용하는 JPA든
쿼리를 작성할 때 문제가 자주 발생하는 경우가 있다.

바로 쿼리를 직접 작성했을 때 빌드만 성공하는 것을 확인하는 것이다.
평소에 개발할 때는 성실한 개발자라면 당연히 테스트를 돌려볼 것이다.
그런데 그런 성실한 개발자마자도 퇴근 시간이거나 급한 이슈가 있다면
당연히 그러면 안 되지만 빌드만 되는지 확인할 수도 있다.

그러면 무슨 문제가 생길까?
가장 흔한 것은 띄워쓰기가 없는 문제이다.
특히 줄바꿈때문에 발생하게 되는데
만약에 select * from member where age = :age라는 쿼리가 있다고 가정해보자.

실제로는 이보다 쿼리가 긴 경우에 줄을 바꾸겠지만 예시를 들기 위해 이를 2줄로 나눠보자.
그러면 select * from memberwhere age = :age로 나눌 수 있을 것이다.
항상 쿼리 문자열 끝에 공백을 추가하는 습관이 되어 있다면 모르겠지만,
그렇지 않다면 위의 문자열을 다시 합친다면
실행되는 쿼리는 select * from memberwhere age = :age가 될 것이다.

그러면 당연히 잘못된 쿼리이기 때문에 예외가 발생할 것이고,
퇴근도… 못 할 것이다.

쿼리의 문제점

쿼리를 직접 작성하는 것은 2가지 문제가 있다.
하나는 쿼리는 결국 문자의 조합이기 때문에 타입 체크를 할 수 없다는 것이고,
나머지 하나는 실행하기 전까지는 작동 여부를 확인할 수 없다는 것이다.

에러의 종류

흔히 우리가 겪는 에러는 2가지다.
컴파일 시점에 발생하는 컴파일 에러, 런타임 시점에 발생하는 런타임 에러다.
에러가 있다면 우리는 당연히 컴파일 시점에 확인할 수 있는 컴파일 에러가 발생하는 것이 훨씬 좋다.

그래서 등장한 QueryDSL

이렇게 쿼리를 직접 작성했을 때의 문제를 방지하기 위해 QueryDSL이라는 프레임워크가 등장했다.
해당 프레임워크는 쿼리를 자바 코드를 통해 작성할 수 있게 지원한다.
주로 JPQL에 사용한다.

JPA에서 쿼리를 실행하는 방법

크게 3가지 종류가 있다.

  1. JPQL
    • 장점
      • SQL 쿼리와 비슷하기 때문에 금방 익숙해진다.
    • 단점
      • type-safe가 아니다.
      • 동적 쿼리 생성이 어렵다.
  2. CriteriaAPI
    • 장점
      • 동적 쿼리 생성이 가능하긴 하다.
    • 단점
      • type-safe가 아니다.
      • 복잡도가 너무 높아서 러닝 커브가 심하다.
      • 알아야할 정보가 너무 많다.
  3. MetaModelCriteriaAPI
    • CriteriaAPI와 거의 동일한 방식이다.
    • 장점
      • 자바 코드르 작성하는 방식이라서 type-safe이긴 하다.
    • 단점
      • 복잡도가 너무 높아서 러닝 커브가 심하다.

DSL이란 뭘까?

DSL이란 Domain Specific Language의 약자로 도메인 특화 언어라는 뜻이다.
그 이름처럼 특정한 도메인에 초점을 맞춘 제한적인 표현력을 가진 프로그래밍 언어다.
그래서 단순하고 간결한 것이 특징이다.

그렇다면 QueryDSL이란 뭘까? 단순히 앞에 Query를 붙인 것이다.
즉, 쿼리에 특화된 프로그래밍 언어라는 뜻이다.

QueryDSL이란 어떤 기술일까?

JPA, MongoDB, SQL같은 기술들을 위해 type-safe SQL을 만드는 프레임워크다.
클래스를 만들면 APT라는 코드 생성기를 통해서 전용 코드를 만들어 준다.
APTAnnotation Processing Tool의 약자로로 애노테이션을 통해 처리하는 도구를 의미한다.
@Entity 애노테이션이 해당한다.

Querydsl 설정

build.gradle에 아래 라이브러리들을 추가해주자.

//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"

그리고 QueryDSL은 APT를 통해 Q타입 클래스라는 클래스를 생성해주기 때문에
변경된 사항이 있으면 기존 Q타입 클래스를 없애줘야 하기 때문에
clean이 실행될 때 기존 Q타입 클래스들 제거하는 코드를 추가해주자.

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

Q타입 클래스 생성하기

  • 인텔리제이 활용 시
    1. Gradle -> Tasks -> build -> clean
    2. Gradle -> Tasks -> other -> compileJava
  • 콘솔 활용 시
    • ./gradlew clean compileJava

버전 관리에서 제외시키기

Q타입은 컴파일 시점에 자동 생성된다.
그래서 Git이나 SVN같은 버전 관리 도구에 포함하지 않는 것이 좋다.
인텔리제이에서 gradle 옵션을 선택했다면 Q타입 클래스들은 gradle build 폴더 아래에 생성된다.
대부분은 gradle build 폴더를 git에 포함하지 않기 때문에 이 부분은 자연스럽게 해결될 것이다.
만약 빌드되는 폴더가 다르거나 다른 이슈로 포함된다면
.gitignore에 빌드되는 폴더명을 추가해서 제외시켜버리자.

Querydsl 적용

생성자

QueryDSL은 EntityManagerJPAQueryFactory를 사용한다.
생성자 단에서 주입시켜주는 코드를 작성하자.

private final EntityManager em;
private final JPAQueryFactory query;

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

데이터 등록

데이터 등록은 EntityManagerpersist 메소드를 사용한다.

 em.persist(item);

데이터 수정

데이터 수정은 JPA를 사용하기 때문에 기존과 동일하다.
영속성 컨텍스트에 포함된 엔티티의 값을 변경하고 트랜잭션이 종료되면
해당 변경 내용을 저장하는 쿼리가 발생한다.

단순 조회

단순 조회는 EntityManagerfind 메소드를 사용한다.

Item item = em.find(Item.class, id);

Q타입 클래스를 사용하는 방법

만약 상품에 대한 리포지토리가 있다고 가정해보자.
그러면 Item이라는 클래스가 있을 것이고, 그것에 대응하는 QItem이라는 Q타입 클래스가 있을 것이다.
우리는 이것을 아래와 같이 static 키워드를 통해서 import할 수 있다.

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

그러면 기본적으로 원본이 되는 클래스를 카멜 케이스를 적용한 이름으로 사용할 수 있다.
QItem 클래스의 원본인 Item 클래스에 카멜 케이스를 적용한 item이라는 이름을
별도의 정의 없이 사용할 수 있다는 뜻이다.

QueryDSL을 통한 조회

QueryDSL은 기본적으로 빌더 패턴을 통해 쿼리를 생성한다.
예시를 확인해보자.

List<Item> result = 
    query
    .select(item)
    .from(item)
    .fetch();

작성된 자바 코드를 살펴보면 기존에 SQL에서 사용하던 키워드들이 메소드명으로 사용된 것을 알 수 있다.
이를 통해 우린 직관적인 이름의 메소드명을 사용해서 JPQL을 만들고,
만들어진 JPQL을 번역해서 실제 SQL을 실행할 수 있는 것이다.

조건절 만들기

조건절을 만들 때는 item.itemName.like("%" + itemName + "%")처럼 조건을 명시해주면 된다.
다만 간단한 조건의 경우에는 상관없지만 하지만 우리가 원하는 동적 쿼리와는 좀 다르다.
그럴 때는 BooleanBuilder 클래스를 사용하면 된다.

BooleanBuilder 클래스를 사용하면 조건을 동적으로 만들어줄 수 있다.

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

이를 where 메소드에서 사용하면 조건 설정 끝이다.

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

반복되는 조건은 따로 메소드로 만들어도 된다.

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;
}

결과값을 보면 null로 반환하는 것을 알 수 있다.
이는 QueryDSL이 조건 활성화 여부를 확인할 때
BooleanExpression의 값이 null이면 해당 조건을 무시시키기 때문이다.

생성된 BooleanExpression에 대한 메소드들은 동시에 사용할 수 있다.

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;
}

동시에 사용하는 경우 별도 설정이 없으면 자동으로 AND로 연결된다.
다만 아까 설명했듯이 둘 다 null이 아니어야지 둘 다 활성화되서
AND로 연결된다.

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.