노션에서 보기
이전 글
ORM - Hibernate - JPA - QueryDSL을 알아보자
QueryDSL에 대해서 어떤 것인지, 어떠한 구조를 가지고 ORM이 진행되는지를 알아보았다.
작은 서비스를 가정하고, 구현한 상태에서 QueryDSL을 적용하여 간단히 사용해보자.
셋업
- Gradle 의존성과 세팅
plugins {
id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10' // Q 클래스 생성 플러그인
// ...
}
def querydslDir = "$buildDir/generated/querydsl" // 생성된 Q 클래스가 저장될 위치를 정의한다.
// queryDSL 자체에 대한 config
querydsl {
jpa = true // jpa = true로 설정하여 JPA 애너테이션을 인식
querydslSourcesDir = querydslDir // querydslSourcesDir를 querydslDir로 설정하여 Q 클래스에 대한 output 디렉토리를 지정
}
sourceSets {
main.java.srcDir querydslDir // Q 클래스 등이 저장되는 querydslDir를 소스 코드가 저장되는 디렉토리로 등록
// 이 덕에 Gradle이 자동으로 QueryDSL 코드를 컴파일할 수 있다.
}
configurations {
querydsl.extendsFrom compileClasspath // querydsl 클래스 경로를 컴파일 클래스 경로에 상속
}
// 내부적으로 Querydsl은 Java의 Annotation Processor를 이용한다.
// 컴파일러는 이 경로에 위치한 모든 JAR 파일들을 검색하여 annotation processor를 찾는다.
// 그리고 이를 실행하여 소스 코드에서 정의한 어노테이션들을 처리한다.
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
dependencies {
//...
implementation "com.querydsl:querydsl-codegen:5.0.0" // QueryDSL 코드 생성
implementation "com.querydsl:querydsl-jpa:5.0.0" // JPA 지원
implementation "com.querydsl:querydsl-apt:5.0.0" // Annotation Process Tool
//...
}
// -proc:only: 이 옵션은 컴파일러의 어노테이션 처리만 수행하고, 클래스를 실제로 컴파일하지 않는 것을 의미한다.
// -processor: 이 옵션은 어노테이션 프로세서를 지정하는데, QueryDSL의 어노테이션 프로세서와 Lombok의 어노테이션 프로세서를 지정하여 명시적으로 설정한다.
project.afterEvaluate {
project.tasks.compileQuerydsl.options.compilerArgs = [
"-proc:only",
"-processor", project.querydsl.processors() +
',lombok.launch.AnnotationProcessorHider$AnnotationProcessor'
]
}
// Java 컴파일 작업이 compileQuerydsl 작업에 의존하도록 설정한다.
// compileQuerydsl 작업이 먼저 수행된 후에 compileJava 작업이 수행되게끔 순서를 정해주는 것인데,
// QueryDSL Q-Type 소스 생성 작업이 Java 컴파일 작업 전에 먼저 이뤄지도록 한다.
// 위 afterEvaluate와 같이 사용하면, Lombok 어노테이션 프로세스와 충돌하지 않는다.
tasks.named('compileJava') {
dependsOn tasks.named('compileQuerydsl')
}
자세한 설명은 gradle에 주석으로 달아놓았다.
Repository
- JPAQueryFactory Bean 등록하기
@Configuration
public class QueryDslConfig {
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
- JpaQueryFactory는 QueryDSL이 제공하는 JPA를 사용하여 데이터를 조회할 때 사용하는 클래스다. JPAQueryFactory를 사용하면 복잡한 쿼리를 type-safe하게 작성할 수 있다. 즉, 쿼리를 작성하는 도중에 발생할 수 있는 오타나 타입 미스매치 등의 문제를 컴파일 타임에 잡아낼 수 있다.
- Bean으로 등록해서 여러 군데에서 사용할 수 있도록 해줄 수 있다.
- JpaRepository의 메서드를 사용하면서 동시에 Custom한 QueryDSL을 사용하는 방법.
public interface BookRepositoryCustom {
List<Book> findAllBooks();
}
public interface BookRepository extends JpaRepository<Book, Long>, BookRepositoryCustom {
}
@Repository
@RequiredArgsConstructor
public class BookRepositoryImpl implements BookRepositoryCustom {
private final JPAQueryFactory queryFactory;
public List<Book> findAllBooks() { // findAll 쓰면 된다. 그냥 예제임
QBook book = QBook.book;
return queryFactory
.selectFrom(book)
.fetch();
}
}
JpaRepository는 인터페이스로서 동작하고, 빌드를 하더라도 그 구현체(implement)가 없기 때문에(대신, 런타임에 프록시를 이용해 동적으로 구현한다), Interface로서 사용하게 되어 있다.
BookRepositoryCustom은 사용자 정의 쿼리 메소드를 선언하는 인터페이스로, 이를 상속받은 BookRepositoryImpl은 실제 QueryDSL 쿼리를 작성하여 실행하는 구현체다.
이를 통해서 우리는 JpaRepository를 상속받은 BookRepository 인터페이스에서 BookRepositoryCustom을 상속받는 것으로 Spring Data JPA의 기능과 QueryDSL(RepositoryImpl)의 기능을 동시에 활용할 수 있다.
스프링은 JpaRepository 인터페이스를 상속받는 Repository 인터페이스를 찾으면, 이를 구현하는 프록시 객체를 생성하여 직접 기본 제공 기능을 구현한다.
그리고 BookRepositoryCustom을 상속받은 BookRepositoryImpl은 스프링 빈으로 등록된다.
따라서 Repository 인터페이스를 사용하는 클라이언트는 JpaRepository의 기능과 RepositoryCustom의 기능을 동시에 사용할 수 있게 된다.
물론 JpaRepository를 사용하지 않고도 사용할 수 있겠지만, 꼭 필요한 경우가 아니라면 이렇게 사용하는 것이 편할 수 있을 것 같다. 예를 들어, 간단한 부분은 JpaRepository를 이용하고, 복잡한 쿼리와 래핑, 동적 쿼리는 QueryDSL을 이용한다든지 말이다.
도메인
@Entity
@Table(name = "RENTAL")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Rental {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "RENTAL_ID")
private Long id;
@Column(name = "MEMBER_ID", nullable = false)
private Long memberId;
@Column(name = "BOOK_ID", nullable = false)
private Long bookId;
@Column(name = "RENTAL_STATUS", nullable = false)
@Enumerated(EnumType.STRING)
private RentalStatus rentalStatus;
@Column(name = "RENTED_AT", nullable = false)
private LocalDateTime rentedAt;
@Column(name = "RETURNED_AT")
private LocalDateTime returnedAt;
@JoinColumn(name = "MEMBER_ID", nullable = false, insertable = false, updatable = false)
@ManyToOne(fetch = LAZY, optional = false)
private Member member;
@JoinColumn(name = "BOOK_ID", nullable = false, insertable = false, updatable = false)
@ManyToOne(fetch = LAZY, optional = false)
private Book book;
// .. 생략
}
/*---------------------------------------------------------*/
@Entity
@Table(name = "BOOK")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Book {
@Id
@GeneratedValue(strategy = javax.persistence.GenerationType.AUTO)
@Column(name = "BOOK_ID")
private Long id;
@Column(name = "TITLE", nullable = false)
private String title;
@Column(name = "AUTHOR", nullable = false)
private String author;
@Column(name = "PUBLISHER", nullable = false)
private String publisher;
@Column(name = "STORED_AT", nullable = false)
private LocalDateTime storedAt;
@Column(name = "RELEASED_AT")
private LocalDateTime releasedAt;
// .. 생략
}
/*---------------------------------------------------------*/
@Entity
@Table(name = "MEMBER")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "NAME", nullable = false)
private String name;
@Column(name = "AGE")
private int age;
@Column(name = "REGISTERED_AT", nullable = false)
private LocalDateTime registeredAt;
@Column(name = "UPDATED_AT", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "DELETED_AT")
private LocalDateTime deletedAt;
// .. 생략
}
- 조금 복잡한 응답 DTO
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class AuthorRentalInfoDto {
private final String author;
private final List<RentalInfoDto> rentalInfoDtos;
}
/*---------------------------------------------------------*/
@Getter
@ToString
public class RentalInfoDto {
private final String title;
private final String publisher;
private final LocalDateTime storedAt; // title, publisher, storedAt은 Book의 정보.
private final String name; // name은 Member의 정보.
private final LocalDateTime rentedAt; // rentedAt은 Rental의 정보다.
}
대여 중인 책과 그 대여자의 이름에 대한 정보를 담은 DTO, 그리고 그것을 저자(author)를 기준으로 가져오는 Dto가 필요하다고 해보자
그렇다면, author를 매개변수로 받아서 해당 저자가 쓴 책, 그리고 그 책을 따라 대여자 && 대여 정보(대여 시각)을 찾아서 매핑하는 것이 좋아보인다.
이를 QueryDSL을 이용해서 구현해보자.
QueryDSL 구현
@Repository
@RequiredArgsConstructor
public class RentalRepositoryImpl {
private final JPAQueryFactory queryFactory;
public List<RentalInfoDto> findAllActiveRentalInfoByAuthor(String author) {
return queryFactory.select(new QRentalInfoDto(
rental.book.title,
rental.book.publisher,
rental.book.storedAt,
rental.member.name,
rental.rentedAt
))
.from(rental)
.where(rental.book.author.eq(author)
.and(rental.returnedAt.isNull()))
.fetch();
}
/*-----------------------필요하다면 이렇게도 가능하다------------------------*/
public AuthorRentalInfoDto findRentalInfoByAuthor(String author) {
List<RentalInfoDto> rentalInfoList = queryFactory.select(new QRentalInfoDto(
rental.book.title,
rental.book.publisher,
**rental.book**.storedAt,
**rental.member**.name,
rental.rentedAt
))
.from(rental)
.where(rental.book.author.eq(author)
.and(rental.returnedAt.isNull()))
.fetch();
return new AuthorRentalInfoDto(author, rentalInfoList);
}
}
함수형으로 이쁘고 직관적으로 작성할 수 있다. 심지어 rental.book에서도 나타나듯, @JoinColumn을 통한 연관관계로 엔티티를 지정해놓으면, InnerJoin을 굳이 사용하지 않아도 편하게 사용할 수 있음을 알 수 있다!
queryFactory를 이용하여 select를 함과 동시에 원하는 객체로 매핑할 수 있다!
이런 매핑 방법은 여러 가지가 있는데 다음과 같은 케이스들이 있다.
- Projections를 이용한 방법
/*
* Projections.bean(): getter와 setter를 이용하여 값을 할당한다.
* DTO에 기본 생성자가 필요하며, 이름 기반으로 매핑되므로 필드 이름이 일치해야 한다.
*/
public List<RentalInfoDto> findAllActiveRentalInfoByAuthor(String author) {
return queryFactory.select(Projections.bean(RentalInfoDto.class,
rental.book.title,
rental.book.publisher,
rental.book.storedAt,
rental.member.name,
rental.rentedAt
))
.from(rental)
.where(rental.book.author.eq(author)
.and(rental.returnedAt.isNull()))
.fetch();
}
/*
* Projections.fields(): Reflection을 이용하여 필드에 직접 접근해서 값을 할당한다.
* DTO에 기본 생성자가 필요 없지만, 필드에 직접 접근가능해야 한다.
*/
public List<RentalInfoDto> findAllActiveRentalInfoByAuthor(String author) {
return queryFactory.select(Projections.fields(RentalInfoDto.class,
rental.book.title,
// ...
}
/*
* Projections.constructor(): 생성자를 이용하여 DTO를 반환한다.
* DTO에 기본 생성자가 필요하며, 생성자의 매개변수 타입과 순서가 맞아야 한다.
*/
public List<RentalInfoDto> findAllActiveRentalInfoByAuthor(String author) {
return queryFactory.select(Projections.constructor(RentalInfoDto.class,
rental.book.title,
// ...
}
괜찮아보이지만, setter를 사용한다든지, 리플렉션을 사용한다든지, 매개변수의 타입과 순서가 맞아야한다든지 등의 이유들로 인해 제약사항이 있다. 매개변수의 순서나 타입에 대한 휴먼에러를 줄이려면 다음과 같은 방법을 사용해볼 수 있다.
- @QueryProjection 사용하기
@Getter
@ToString
public class RentalInfoDto {
private final String title;
private final String publisher;
private final LocalDateTime storedAt;
private final String name;
private final LocalDateTime rentedAt;
@QueryProjection // <- 생성자에 어노테이션을 달아주면, 다른 Q 엔티티와 같이 빌드 시에 생성된다
public RentalInfoDto(String title, String publisher, LocalDateTime storedAt, String name, LocalDateTime rentedAt) {
this.title = title;
this.publisher = publisher;
this.storedAt = storedAt;
this.name = name;
this.rentedAt = rentedAt;
}
}
/*--------------------------------------------------------*/
public List<RentalInfoDto> findAllActiveRentalInfoByAuthor(String author) {
return queryFactory.select(new QRentalInfoDto( // 생성된 Q 객체 - 생성자를 직접 사용한다는 점에서 휴먼 에러를 줄일 수 있다.
rental.book.title,
rental.book.publisher,
rental.book.storedAt,
rental.member.name,
rental.rentedAt
))
.from(rental)
.where(rental.book.author.eq(author)
.and(rental.returnedAt.isNull()))
.fetch();
}
이 방법이 좀 더 QueryDSL을 잘 쓰는 것 같은 느낌(착각)도 있고, 휴먼 에러를 줄일 여지가 더 높다고 생각한다.
이제 이 구현체를 가지고, 테스트를 작성해보자.
테스트
@DisplayName("한 저자의 책들 각각, 현재 대여하고 있는 사용자들을 조회한다.")
@Test
void findAllActiveRentalsByAuthor() {
//given
LocalDateTime now = LocalDateTime.now();
bookRepository.saveAll(List.of(
Book.of("QueryDSL 알아보기", "sanan", "cabi", now),
Book.of("JPA 알아보기", "sanan", "cabi", now),
Book.of("Spring 알아보기", "sanan", "cabi", now),
Book.of("Java 알아보기", "sanan", "cabi", now))
);
memberRepository.saveAll(List.of(
Member.of("정상수", 40, now),
Member.of("김흥국", 60, now),
Member.of("지상렬", 50, now),
Member.of("박재범", 30, now))
);
Member 정상수 = memberRepository.findByName("정상수");
Member 김흥국 = memberRepository.findByName("김흥국");
Member 지상렬 = memberRepository.findByName("지상렬");
Member 박재범 = memberRepository.findByName("박재범");
Book querydsl = bookRepository.findByTitle("QueryDSL 알아보기");
Book jpa = bookRepository.findByTitle("JPA 알아보기");
Book spring = bookRepository.findByTitle("Spring 알아보기");
Book java = bookRepository.findByTitle("Java 알아보기");
// 엄밀히 따지면 대여하는 Book은 각자 다른 엔티티여야 함
rentalService.rentBook(정상수.getId(), querydsl.getId());
rentalService.rentBook(박재범.getId(), querydsl.getId());
rentalService.rentBook(지상렬.getId(), jpa.getId());
rentalService.rentBook(박재범.getId(), jpa.getId());
rentalService.rentBook(지상렬.getId(), spring.getId());
rentalService.rentBook(김흥국.getId(), spring.getId());
rentalService.rentBook(정상수.getId(), java.getId());
rentalService.rentBook(김흥국.getId(), java.getId());
//when
List<RentalInfoDto> result = rentalRepository.findAllActiveRentalInfoByAuthor("sanan");
//then
assertThat(result).hasSize(8);
// 직접 찍어보았을 때 잘 동작하는 것을 확인했다.
}
정리
객체 매핑부터 타입 안전성, 그리고 함수형으로 깔끔하게 작성되는 것까지 추상화가 아름답다고 느껴졌다.
간단히 적용해보는 것까지 해보았는데, 기존의 JpaRepository와 복잡한 경우에 별도로 QueryDSL을 사용할 수 있는 유연성도 대단한 것 같다.
추후에 더 복잡한 문제들을 해결할 때에 요긴히 사용할 수 있을 것 같다.
참고자료
[우아콘2020] 수십억건에서 QUERYDSL 사용하기
'JAVA' 카테고리의 다른 글
데이터 캐싱해보기 - Spring Cache와 Caffeine (0) | 2023.08.14 |
---|---|
ORM - Hibernate - JPA - QueryDSL을 알아보자 (0) | 2023.07.19 |
느린 응답 26배 빠르게 개선해보기 (2) | 2023.07.16 |
테스트를 알아보자 With 단위, 통합, E2E (0) | 2023.07.06 |
동시성 문제와 데드락, 테스트와 해결, 그리고 삽질 (3) | 2023.06.06 |