ORM - Hibernate - JPA - QueryDSL을 알아보자
QueryDSL에 대해서 어떤 것인지, 어떠한 구조를 가지고 ORM이 진행되는지를 알아보았다.
작은 서비스를 가정하고, 구현한 상태에서 QueryDSL을 적용하여 간단히 사용해보자.
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에 주석으로 달아놓았다.
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;
// .. 생략
}
@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를 매개변수로 받아서 해당 저자가 쓴 책, 그리고 그 책을 따라 대여자 && 대여 정보(대여 시각)을 찾아서 매핑하는 것이 좋아보인다.