QueryDSL.. 그것이 무엇이고 왜 쓰는 것일까 ❓ 🤔
QueryDSL은 자바 기반의 ORM(Object Relational Mapping) 쿼리 빌더로, type-safety하고 SQL에 가까운 방식으로 쿼리를 작성할 수 있게 해준다. JPA와 함께 사용하면 엔티티 기반의 데이터 조회와 조작이 쉬워진다. 특히 동적쿼리 작성에 용이하다. 이게 대체 무슨 말일까? 코드를 보며 이해해보자.
querydsl을 사용하기 위해서는 몇가지 초기 설정이 필요하다.
spring boot3로 올라오면서 javax -> jakarta 로 변경되었다.
의존성 추가 시 Querydsl JPA의 버전 명시 뒤 :javkarta 를 추가해야한다.
build.gradle
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"
전체 코드
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.0'
id 'io.spring.dependency-management' version '1.1.5'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
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"
}
tasks.named('test') {
useJUnitPlatform()
}
// querydsl 세팅 시작
def querydslDir = "$buildDir/generated/querydsl"
sourceSets {
main.java.srcDir querydslDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
결국 Querydsl도 JPA를 통해서 엔티티를 조회하는 것이기 때문에 EntityManager가 필수로 필요하다.
EntityManager는 JPA(Java Persistence API)에서 엔티티의 생명주기를 관리하고 데이터베이스와 상호작용하는 역할을 한다.
QuerydslConfig
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
User
@Entity
@Getter
@Setter
@RequiredArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "address_id", referencedColumnName = "address_id")
private Address address;
}
Address
@Entity
@Getter
@Setter
@RequiredArgsConstructor
public class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "address_id")
private Long addressId;
@Column
private String city;
}
clean build 후 앞서 만든 엔티티에 대한 QClass가 생성된다.

엔티티 구조와 속성을 설명해주는 메타 데이터이다. QClass를 통해 type-safe하게 쿼리 조건을 설정하여 컴파일 타임에 쿼리 오류를 검출할 수 있다.
QUser
import static com.querydsl.core.types.PathMetadataFactory.*;
import com.querydsl.core.types.dsl.*;
import com.querydsl.core.types.PathMetadata;
import javax.annotation.processing.Generated;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.PathInits;
/**
* QUser is a Querydsl query type for User
*/
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QUser extends EntityPathBase<User> {
private static final long serialVersionUID = 1992977997L;
private static final PathInits INITS = PathInits.DIRECT2;
public static final QUser user = new QUser("user");
public final QAddress address;
public final NumberPath<Long> id = createNumber("id", Long.class);
public final StringPath name = createString("name");
public QUser(String variable) {
this(User.class, forVariable(variable), INITS);
}
public QUser(Path<? extends User> path) {
this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
}
public QUser(PathMetadata metadata) {
this(metadata, PathInits.getFor(metadata, INITS));
}
public QUser(PathMetadata metadata, PathInits inits) {
this(User.class, metadata, inits);
}
public QUser(Class<? extends User> type, PathMetadata metadata, PathInits inits) {
super(type, metadata, inits);
this.address = inits.isInitialized("address") ? new QAddress(forProperty("address")) : null;
}
}

QueryDSL을 사용하기 위해서는 3개의 Repository가 필요하다.
public interface UserRepositoryCustom {
List<User> findUsersByAddressId(Long address_id);
}
JPA가 제공하는 CRUD 외에 사용하고자 하는 메서드를 커스텀하여 명세한다. 특정 address_id를 기반으로 user를 찾도록 커스텀했다.
@Repository
@RequiredArgsConstructor
@Transactional
public class UserRepositoryImpl implements UserRepositoryCustom{
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<User> findUsersByAddressId(Long address_id) {
QUser qUser = QUser.user;
QAddress qAddress = QAddress.address;
return jpaQueryFactory
.selectFrom(qUser)
.innerJoin(qUser.address, qAddress)
.on(qAddress.addressId.eq(address_id))
.fetch();
}
}
앞서 만든 커스텀 인터페이스의 구현체이다. QueryDSL의 JPAQueryFactory를 사용하여 커스텀 쿼리를 작성하고 실행한다. 커스텀한대로 입력받은 address_id를 기반으로 user를 조회할 것이다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> ,UserRepositoryCustom{
}
JPA와 Custom을 하나의 레파지토리에 구현하여 기본적인 CRUD + Custom Query 사용이 가능해졌다. 즉, 모든 데이터의 조작이 가능해졌다 🤩 🤩 🤩
UserController
@RestController
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping("/user")
public List<UserDto> getUser(
@RequestParam Long address_id
){
List<UserDto> users = userService.findUserByAddressId(address_id);
return users;
}
}
UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public List<UserDto> findUserByAddressId(Long address_id){
List<User> users = userRepository.findUsersByAddressId(address_id);
List<UserDto> userDtoList = users.stream()
.map(UserDto::of)
.collect(Collectors.toList());
return userDtoList;
}
}

위와 같이 테이블을 생성한 후 인텔리제이와 연동했다. 데이터베이스는 mysql를 사용했다.

address_id가 1인 user만 조회되는 것이 확인된다.
중간정리
QueryDSL을 사용하여 주소 ID를 기반으로 사용자 정보를 조회하는 기능을 구현했다. builder 패턴을 사용해 쿼리 작성을 용이하게 했다. 지금부터는 동적 쿼리 작성을 위한 구체적인 레포지토리를 구현해보겠다.
동적쿼리란 실행 시점에 조건에 따라 쿼리를 동적으로 생성하고 실행하는 쿼리 방식이다. 이를 통해 고정된 쿼리가 아닌 다양한 조건에 맞춰 쿼리를 유연하게 작성할 수 있다. 동적 쿼리는 주로 검색 필터링, 조건부 데이터 조회 등 다양한 상황에서 유용하게 사용된다.
다음 코드들을 추가한다.
UserRepositoryCustom
List<User> findUsersByDynamic(Long address_id, String name);
address_id와 name을 기반으로 동적으로 조건을 설정하여 user를 조회하는 메서드를 정의한다.
UserRepositorylmpl
public List<User> findUsersByDynamic(Long address_id, String name){
QUser qUser = QUser.user;
QAddress qAddress = QAddress.address;
BooleanBuilder builder = new BooleanBuilder();
if(address_id!=null){
builder.and(qAddress.addressId.eq(address_id));
}
if (name != null && !name.isEmpty()) {
builder.and(qUser.name.containsIgnoreCase(name));
}
return jpaQueryFactory
.selectFrom(qUser)
.innerJoin(qUser.address, qAddress)
.where(builder)
.fetch();
}
jpaQueryFactory를 사용하여 QUser를 선택하고, QAddress와 조인하여 조건에 맞는 결과를 조회한다.
UserController
@GetMapping("/user/dynamic")
public List<UserDto> getUserByDynamic(
@RequestParam(required = false) Long address_id,
@RequestParam(required = false) String name
){
List<UserDto> users = userService.findUsersByDynamic(address_id, name);
return users;
}
UserService
public List<UserDto> findUsersByDynamic(Long address_id, String name){
List<User> users = userRepository.findUsersByDynamic(address_id, name);
List<UserDto> userDtoList = users.stream()
.map(UserDto::of)
.collect(Collectors.toList());
return userDtoList;
}

원래라면 RequestParam으로 지정한 address_id과 name을 둘다 받아야 하지만 동적 쿼리를 사용해 address_id를 주지 않아도 name이 Jay인 user만 조회가 가능하다.

마찬가지로 name을 받지 않아도 address_id가 1인 user만 조회가 가능하다.
QueryDSL을 이용하여 타입 안전하게 SQL과 유사한 쿼리를 작성해보았다. 쿼리문이 길어지면 가독성이 떨어질 위험이 있는 JPQL과 달리 QueryDSL을 사용하여 메소드 체이닝 방식으로 가독성은 높이고 오류 발생 가능성은 낮출 수 있게 되었다. 또한 동적 쿼리를 작성할 수 있게 되었다.