JPA
를 공부하며 JPA
와 거의 함께 움직이는 QueryDSL
의 간단한 세팅과 사용 방법에 대해서 알아보았다. 그 때는 QueryDSL
의 필요성을 알아보는 것을 중심으로 글을 작성하며 문법에 대해서는 다루지 않았는데, 이번엔 QueryDSL
의 간단한 문법과 BooleanBuilder
를 통한 조건절 만들기 등 실전 예제에 대해서 간단하게 다뤄보도록 하겠다.
이번 글 역시 그레이들 기준으로 세팅을 진행하는데, 메이븐의 경우는 공식문서에서 설정 방법을 찾을 수 있다.
먼저 build.gradle
파일을 아래와 같이 작성해준다.
plugins {
id 'org.springframework.boot' version '2.3.5.RELEASE'
id 'io.spring.dependency-management' version '1.0.10.RELEASE'
id 'java'
id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'mysql:mysql-connector-java'
implementation 'com.querydsl:querydsl-jpa'
implementation 'com.querydsl:querydsl-apt'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
}
test {
useJUnitPlatform()
}
test {
useJUnitPlatform()
}
def querydslSrcDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslSrcDir
}
sourceSets {
main.java.srcDirs querydslSrcDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
compileQuerydsl{
options.annotationProcessorPath = configurations.querydsl
}
설정 관련 자세한 내용은 저번 글을 참조하면 된다.
이번 글 작성에서는 저번 세팅을 그대로 가져오지 않고 공부를 위해
QueryDSL
세팅을 처음부터 다시 해보았는데, 역시나 생각보다 오래 걸렸다. 구글링하며 찾은 글에서도 원래QueryDSL
의 세팅은 복잡하고 오래 걸릴 수 있다고 한다.
JPAQueryFactory
를 빈으로 등록해주고 @Configuration
으로 클래스를 등록해준다. 참고로 모든 코드는 여기에서 볼 수 있다.
@Configuration
public class DatabaseConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory(){
return new JPAQueryFactory(entityManager);
}
}
이번에 사용할 엔티티는 아래와 같다.
@Entity(name = "USER_TABLE")
@Getter @Setter
@ToString(of = {"id", "username", "level"})
@NoArgsConstructor
public class User {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String username;
private Integer level;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "battle_class_id")
private BattleClass battleClass;
public User(String username, Integer level, BattleClass battleClass) {
this.username = username;
this.level = level;
changeBattleClass(battleClass);
}
private void changeBattleClass(BattleClass battleClass){
this.battleClass = battleClass;
battleClass.getUserList().add(this);
}
}
@Entity(name = "BATTLE_CLASS_TABLE")
@Getter @Setter
@ToString(of = {"id", "className"})
@NoArgsConstructor
public class BattleClass {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "battle_class_id")
private Long id;
private String className;
@OneToMany(mappedBy = "battleClass", fetch = FetchType.LAZY)
private List<User> userList = new ArrayList<>();
public BattleClass(String className) {
this.className = className;
}
}
간단히 설명하자면 User
가 있고 이름, 레벨, 직업을 갖는다. 그리고 생성자에서는 직접 영속성 컨텍스트에 직업을 설정하고 직업 군 유저들을 업데이트 할 수 있도록 해주었다.
그리고 모든 fetchType
은 LAZY
형태로 등록하여 연관 매핑이 된 엔티티들은 가져오지 않도록 했다.
각각 리포지토리 역시 JPARepository
를 상속받아 만들어주고 테스트 파일로 넘어온다.
엔티티를 만들어준 다음에는 그레이들 플러그인에서 complieQuerydsl
를 실행하여 Q클래스
를 만들어준다.
@SpringBootTest(classes = Application.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class ApplicationTests {
@Autowired private JPAQueryFactory query;
@Autowired private BattleClassRepository battleClassRepository;
@Autowired private UserRepository userRepository;
@BeforeAll
public void setUp(){
BattleClass warrior = new BattleClass("전사");
BattleClass archer = new BattleClass("궁수");
BattleClass mage = new BattleClass("마법사");
battleClassRepository.saveAll(List.of(warrior, archer, mage));
User pepper = new User("pepper", 5, warrior);
User flame = new User("flame", 10, warrior);
User iksuo = new User("iksuo", 2, archer);
User scott = new User("scott", 15, mage);
User bob = new User("bob", 25, archer);
User jane = new User("jane", 11, mage);
User gorden = new User("gorden", 3, warrior);
User kelly = new User("kelly", 9, archer);
User mickey = new User("mickey", 1, mage);
User ken = new User("ken", 13, warrior);
userRepository.saveAll(List.of(pepper, flame, iksuo, scott, bob, jane, gorden, kelly, mickey, ken));
}
}
이제 위와 같이 테스트할 객체들을 DB에 넣어주고 @BeforeAll
로 테스트 이전에 데이터베이스에 넣어주면 기본적인 세팅은 끝이다.
@Test
public void is_there_10_characters(){
List<User> allCharacters = query
.selectFrom(user)
.fetch();
Assertions.assertEquals(10, allCharacters.size());
}
@Test
public void users_level_under_10(){
List<User> usersLevelUnder10 = query
.selectFrom(user)
.where(user.level.loe(10))
.fetch();
Assertions.assertEquals(6, usersLevelUnder10.size());
}
@Test
public void highest_level_user(){
List<User> userOrderedByLevel = query
.selectFrom(user)
.orderBy(user.level.desc())
.fetch();
Assertions.assertEquals(25, userOrderedByLevel.get(0).getLevel());
}
위 세 코드는 기본적인 쿼리문이다. loe
는 Less or Equal
즉 작거나 같다를 뜻하고 크거나 같다는 goe
를 사용해주면 된다. 또 eq
, between
등 다양한 조건을 거는 것이 가능하며 굉장히 직관적이다. 기본적인 쿼리들은 크게 어려운 부분이 없다.
@Test
public void inner_join(){
List<User> joinedWithBattleClass = query
.selectFrom(user)
.join(user.battleClass, battleClass)
.fetch();
joinedWithBattleClass.forEach(System.out::println);
}
@Test
public void left_join_on(){
List<User> allArchers = query
.select(user)
.from(user)
.leftJoin(battleClass)
.on(battleClass.className.eq("궁수"), user.level.loe(10), user.username.eq("somerandomname"))
.fetch();
Assertions.assertEquals(10, allArchers.size());
}
@Test
public void sub_query(){
//query.select(user.level.max()).from(user).fetch();
List<User> maxLevelUser = query
.selectFrom(user)
.where(
user.level.in(
JPAExpressions
.select(user.level.max())
.from(user)
)
)
.fetch();
Assertions.assertEquals(25, maxLevelUser.get(0).getLevel());
}
위와 같이 join
역시 지원한다. join
이후에 on
과 같이 조건절에서는 and()
또는 or()
를 통해서 조건을 더 추가할 수도 있고
.on(battleClass.className.eq("궁수"), user.level.loe(10), user.username.eq("somerandomname"))
이렇게 ,
를 통해 나열하면 자동으로 and
처럼 동작하게 된다.
서브쿼리 역시 JPAExpressions
을 통해 작성할 수 있다.
Tuple
은 QueryDSL
에서 제공하는 타입이다. 일반적으로 selectFrom
또는 select
를 통해 Q클래스
를 선택하는데, 그룹 함수를 사용할 경우에는 쿼리에서 가져오는 애트리뷰트를 다르게 선택해야하는 경우가 있을 수 있다.
이런 경우에 QueryDSL
은 Tuple
의 형태로 데이터를 반환한다. 일반적인 Collections
객체 처럼 작용하지만 우리가 정확히 원하는 결과를 가져오기 위해서는 Dto
를 사용할 필요가 있다. 특히 Entity
에 변화가 있을 때 Dto
를 사용하지 않으면 API
설계에 큰 영향을 미칠 수 있다.
@Data
@NoArgsConstructor
public class UserDto {
private String username;
private Integer level;
private String className;
@QueryProjection
public UserDto(String username, Integer level, String className) {
this.username = username;
this.level = level;
this.className = className;
}
}
위 처럼 UserDto
를 생성하고 생성자 위에 @QueryProjection
이라는 어노테이션을 붙여준다. 이렇게 하고 다시 그레이들 플러그인의 compileQuerydsl
을 실행해주면 QUserDto
라는 자바 클래스가 생성된다.
@Test
public void custom_dto_tuple(){
List<Tuple> tupleBob = query
.select(
user.username,
user.level,
user.battleClass
)
.from(user)
.where(user.username.eq("bob"))
.fetch();
List<UserDto> bob = query
.select(new QUserDto(
user.username,
user.level,
user.battleClass.className
))
.from(user)
.where(user.username.eq("bob"))
.fetch();
Assertions.assertEquals(1, bob.size());
Assertions.assertEquals("bob", bob.get(0).getUsername());
}
위 코드를 보면 Tuple
형태로 가져올 수도 있고 그 아래 select
안에 QUserDto
를 생성하여 원하는 형태로 데이터를 가져올 수도 있다.
단
Dto
클래스의 각 변수의 이름을 원래 엔티티인User
와 맞춰주어야 하는 점을 유의한다.
일반적으로 조건절을 붙일 때는 where
를 통해 조건을 나열할 수 있다. 하지만 조건이 한 두개가 아닌 경우에는 코드를 작성하기도 알아보기도 어렵고 유지 보수하기 힘들 수 있으며 코드를 확장성 있게 작성하고 싶다면 BooleanBuilder
를 이용할 수 있다.
private List<User> findByClassNameAndLevelUnder(String className, Integer level){
BooleanBuilder builder = new BooleanBuilder();
if(!Objects.isNull(className)){
builder.and(user.battleClass.className.eq(className));
}
if(level > 0){
builder.and(user.level.lt(level));
}
List<User> results = query
.selectFrom(user)
.where(builder)
.fetch();
return results;
}
위 처럼 메소드를 하나 만들어준다. 특정 클래스의 특정 레벨 이하의 유저만 가져오게 하는 코드이다. BooleanBuilder
인스턴스를 하나 만들어주고 and()
를 통해 안에 where()
처럼 조건 제약을 걸어주고 실제 쿼리에는 builder
만 넣어주면 된다.
private BooleanExpression findByClassName(String className){
return Objects.isNull(className) ? null : user.battleClass.className.eq(className);
}
private BooleanExpression findByLevelUnder(Integer level){
return level < 0 ? null : user.level.lt(level);
}
아니면 이렇게 BooleanExpression
을 통해서 특정 조건을 메소드화 할 수도 있다. 이렇게 하면 이 조건을 다양한 쿼리에서 사용할 수 있으므로 확장성에 있어서 유리하다.
private List<User> findByClassNameAndLevelUnderWithBooleanExpression(String className, Integer level){
return query
.selectFrom(user)
.where(findByClassName(className), findByLevelUnder(level))
.fetch();
}
BooleanExpression
을 통해 만든 함수를 where
절에 넣어주면 역시 똑같이 작동한다.
@Test
public void dynamic_query(){
List<User> results1 = findByClassNameAndLevelUnder("마법사", 10);
List<User> results2 = findByClassNameAndLevelUnderWithBooleanExpression("마법사", 10);
Assertions.assertEquals(results1.get(0).getUsername(), results2.get(0).getUsername());
}
위 테스트 코드를 통해 두 코드가 동일하게 작동하는지 확인할 수 있다.