JPA와 QueryDSL

PEPPERMINT100·2021년 3월 7일
0

서론

지난 번에 JPA의 기본과 내부 구조를 알아보며 QueryDSL에 대해 잠시 언급하였다. QueryDSL은 복잡한 쿼리를 쉽게 바꿔주고 Java와 객체를 이용하여 쿼리문을 작성하게 해주므로 굉장히 유용하다. 지금부터 QueryDSL의 설정과 필요성을 살펴보고 간단히 사용해보도록 하겠다.

QueryDSL 설정

먼저 설정은 국내 Java 교육 블로깅 관련에서 유명한 jojoldu님의 글을 참고하였다. 처음에 간단히 스프링 이니셜라이저 페이지에서 MySQL, JPA' 디펜던시만 가져오고 이 후 세팅은 위 글을 따라서 했다. 원래 Maven으로 관리해왔는데 위 글은 Gradle로 되어있어서 Gradle로 하다가 삽질을 좀 했다.

최종 그레이들 설정 파일은 아래와 같다.

// 스프링 부트 프로젝트 기본 설정 플러그인
plugins {
	id 'org.springframework.boot' version '2.4.3'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'


// 상위 버전의 그레이들은 어노테이션 프로세서를 사용한다고 한다.
configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

// 어느 패키지 관리 사이트에 올라가 있는 패키지를 사용할 것인지 선택한다. 이외에도 Jcenter라는 곳을 사용할
// 수도 있다.
repositories {
	mavenCentral()
}

apply plugin: "io.spring.dependency-management"

dependencies {
	// querydsl 패키지
	compile("com.querydsl:querydsl-core")
	compile("com.querydsl:querydsl-jpa")
	annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    
    	// querydsl JPAAnnotationProcessor을 사용
	annotationProcessor("jakarta.persistence:jakarta.persistence-api")
	annotationProcessor("jakarta.annotation:jakarta.annotation-api")

	// 스프링 이니셜라이저에서 가져온 기본 세팅
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	// JPA, MySQL 패키지
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'mysql:mysql-connector-java'
}

// QueryDSL은 QueryDSL의 사용을 위한 엔티티를 새로 만드는데, 그 엔티티를 저장할 경로
def generated='src/main/generated'
sourceSets {
	main.java.srcDirs += [ generated ]
}

tasks.withType(JavaCompile) {
	options.annotationProcessorGeneratedSourcesDirectory = file(generated)
}

clean.doLast {
	file(generated).deleteDir()
}

test {
	useJUnitPlatform()
}

조금 더 조사를 해보니 계속해서 버전업되는 그레이들을 쫓아가지 못하는 경우가 있어서 인텔리제이의 버전과 그레이들의 버전과 패키지의 버전을 전부 신경써줄 필요가 있다고 한다.

생성된 스프링 부트 프로젝트에 config라는 패키지를 생성해주고 안에 QuerydslConfig라는 클래스를 만들어주자. 참고로 모든 코드는 여기에서 확인할 수 있다.

그리고 아래와 같이 작성해준다.

package com.example.querydsl_prac.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager em;

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

위 코드를 통해 QueryDSL의 쿼리에 사용되는 JPAQueryFactory를 빈으로 등록해준다.

그리고 엔티티와 리포지토리를 각각 생성해준다. 다시 말하지만 코드는 전부 깃허브에 올려두었으니 복사 붙여넣기를 해도 되고 연습용 엔티티를 직접 작성해도 된다.

간단히 구조를 설명하자면 UserItem이 있고 User가 장바구니에 Item을 담으면 중간에 CartItemUser@ManyToOne으로 Item@OneToOne으로 단방향 매핑해준다.

QueryDSL의 필요성

지금부터는 테스트 코드를 작성하며 QueryDSL의 필요성을 한 번 알아보자. 먼저 @BeforeEach를 사용해 데이터베이스에 들어갈 객체들을 직접 생성해주자.


@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class QuerydslPracApplicationTests {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private ItemRepository itemRepository;

    @Autowired
    private CartItemRepository cartItemRepository;

    @BeforeAll
    public void setUp() {
        User user1 = new User("pepper", 27);
        User user2 = new User("hansol", 25);
        User user3 = new User("tan", 1);

        Item item1 = new Item("shoes", 100);
        Item item2 = new Item("jacket", 150);
        Item item3 = new Item("pants", 80);

        CartItem cartItem1 = new CartItem(3, item1, user1);
        CartItem cartItem2 = new CartItem(1, item2, user1);
        CartItem cartItem3 = new CartItem(5, item3, user1);

        userRepository.saveAll(List.of(user1, user2, user3));
        itemRepository.saveAll(List.of(item1, item2, item3));
        cartItemRepository.saveAll(List.of(cartItem1, cartItem2, cartItem3));
    }
}

여기서 만약 간단히 데이터베이스에 몇 명의 유저가 존재하는지 또 몇 개의 아이템이 존재하는지 확인하는 코드를 작성한다고 해보자.

    @Test
    @DisplayName("check there are 3 users")
    public void check_there_are_3_users() {
        List<User> users = userRepository.findAll();

        Assertions.assertEquals(3, users.size());
    }

    @Test
    @DisplayName("check there are 3 items")
    public void check_there_are_3_items() {
        List<Item> items = itemRepository.findAll();

        Assertions.assertEquals(3, items.size());
    }

3명의 유저와 3개의 아이템을 저장했으니 당연히 테스트는 통과한다. 그렇다면 아주 살짝 복잡한 경우는 어떨까?

  	@Test
    @DisplayName("get cartitems by username")
    public void get_cartitems_by_username() {
        String username = "pepper";

        List<CartItem> cartItems = cartItemRepository.findByUserName(username);

        Assertions.assertEquals(3, cartItems.size());
    }

유저의 이름으로 장바구니에 들어있는 아이템을 가져온다. findByUserName이라는 메소드는 존재하지 않으므로 CartItemRepository에 아래와 같이 메소드를 만들어주자.

    List<CartItem> findByUserName(String userName);

JPA는 매우 똑똑해서 위와 같이 메소드를 상세히 적지않고 인터페이스에만 만들어주어도 잘 알아먹는다.

하지만 만약 모든 장바구니 중에서 10세 이상의 유저들의 아이템만 가져온다면 어떻게 될까? 연습할 때는 그러려니 하고 예를 들었는데 굉장히 쓸데 없는 예제인것 같다.

먼저 10세 이상의 유저들을 모아서 user_id를 가져온 다음 Nested Query를 사용하여 CartItem안의 외래 키인 user_id와 비교하여 가져오면 될 것이다.

먼저 테스트 코드를 작성하자면

    @Test
    @DisplayName("cartitems that user's age is over 10 is 3")
    public void cart_items_that_user_s_age_is_over_10_is_3() {
        Integer ageOver = 10;

        List<CartItem> cartItems = cartItemRepository.findByAgeOver(ageOver);

        Assertions.assertEquals(3, cartItems.size());
    }

이렇게 작성이 될 것이다. 총 장바구니 아이템은 3개이고 모두 10세 이상인 "pepper"가 가지고 있으므로 3개의 결과가 나와야 한다. 이제 복잡한 쿼리인 findByAgeOverCartItemRepository에 만들어보자.

    @Query(value = "SELECT * FROM cartitem WHERE cartitem.user_id IN (SELECT user.id FROM user WHERE user.age > 10)", nativeQuery = true)
    List<CartItem> findByAgeOver(@Param("ageOver") Integer ageOver);

위와 같은 코드를 리포지토리에 추가해준다. 참고로 jpql로 작성하려다 문법이 자꾸 안맞았는데 네이티브 쿼리 옵션이 있는것을 알고 그냥 MySQL문법으로 작성하였다.

이 과정에서 왜 QueryDSL을 사용해야 하는지 알 수 있었다.

테스트를 돌리면 당연히 결과는 제대로 나온다. 하지만 이러한 방법에는 문제가 좀 많다.

먼저 쿼리문을 문자열로 직접 작성해야 한다. JPA는 이러한 쿼리문을 간단하도록 만들어 주고 또 컴파일 상태에서 에러를 잡을 수 있도록 하는게 장점인데, 직접 jpql문법을 문자열로 작성하니 JPA의 장점의 80%를 날리는 것과 같았다.

그리고 jpql의 문법 자체도 문제이다. 물론 배우면 되겠지만 일반적인 SQL 문법과 조금 다른 부분들이 있어 헷갈릴 수 있다.

QueryDSL을 사용하여 코드를 개선하기

먼저 gradle을 빌드해주자. 인텔리제이 기준으로 오른쪽 그레이들 설정에서 build를 눌러주면 처음에 그레이들 설정 파일에서 정한 폴더에 Q 클래스들이 생성된다.

이 클래스들은 우리가 작성한 엔티티를 QueryDSL 문법에 사용할 수 있게 컴파일한 결과들이다.

참고로 이 파일들은 전부 그레이들의 빌드과정에서 생성되니 깃허브에 올리는 프로젝트라면 .gitignore를 통해 제외해주면 된다.

그리고 리포지토리 패키지안에 CartItemRepositoryCustomCartItemRepositoryImpl을 생성해주고 아래와 같이 작성한다.

public interface CartItemRepositoryCustom {
    List<CartItem> q_findByAgeOver(Integer ageOver);
}

위 인터페이스를 통해 JPARepository와 커스텀 메소드들을 연결해준다. 구분 및 비교를 위해 QueryDSL을 적용한 메소드 앞에는 임의로 q_를 붙여주었다.

public class CartItemRepositoryImpl implements CartItemRepositoryCustom{
    @Autowired
    private JPAQueryFactory query;

    @Override
    public List<CartItem> q_findByAgeOver(Integer ageOver) {
        //SELECT * FROM cartitem WHERE cartitem.user_id IN (SELECT user.id FROM user WHERE user.age > 10)
        return query.selectFrom(cartItem).where(cartItem.user.age.gt(ageOver)).fetch();
    }
}

참고로 cartItem을 가져올 때

import static com.example.querydsl_prac.entity.QCartItem.cartItem;

위와 같은 경로를 통해 가져와야한다. 그리고 굉장히 직관적이게 만들어진 QueryDSL 코드를 작성해준다. .where 이후 부터 잘 보면 객체를 통하여 계속 체이닝한 쿼리를 작성할 수 있다. 참고로 gtgreater than를 의미한다.

마지막으로 진짜 CartItemRepository에서 CartItemRepositoryCustom을 상속 받는다.

@Repository
public interface CartItemRepository extends JpaRepository<CartItem, Long>, CartItemRepositoryCustom {

    List<CartItem> findByUserName(String userName);

    @Query(value = "SELECT * FROM cartitem WHERE cartitem.user_id IN (SELECT user.id FROM user WHERE user.age > 10)", nativeQuery = true)
    List<CartItem> findByAgeOver(@Param("ageOver") Integer ageOver);

    @Override
    List<CartItem> q_findByAgeOver(Integer ageOver);
}

이제 아래와 같은 테스트 코드를 또 추가해준다.

    @Test
    @DisplayName("querydsl cartitems that user's age is over 10 is 3")
    public void querydsl_cart_items_that_user_s_age_is_over_10_is_3() {
        Integer ageOver = 10;

        List<CartItem> cartItems = cartItemRepository.q_findByAgeOver(ageOver);

        Assertions.assertEquals(3, cartItems.size());
    }

당연히 결과는 같고 모든 테스트 역시 통과하는 것을 확인할 수 있다.

결론

개인적으로 한 프로젝트에서는 쿼리가 조금만 복잡해도 jpql을 통해서 쿼리를 작성하곤 했는데, 코드도 더럽고 jpql의 문법때문에 계속 실수하는 경우가 생겼다. 하지만 QueryDSL을 사용하면 코드도 직관적이고 실수를 하더라도 컴파일 과정에서 바로 잡을 수 있다. 아직 복잡한 쿼리문을 작성할 정도의 프로젝트는 진행해보지 않았지만 현업에서는 충분히 복잡한 쿼리를 계속해서 작성할 일이 생길 것이므로 간단히 배워보았다. 이번 글 역시 QueryDSL의 문법보다는 복잡할 수 있는 설정 방법과 필요성에 대해 작성하였으므로 문법에 대한 내용은 따로 다루지 않았다.

profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.

0개의 댓글