QueryDSL로 원하는 데이터만 추출하기 - Projection
Projection
데이터베이스 쿼리를 작성할 때, 필요한 데이터만을 선택적으로 가져오는 것은 성능 최적화와 코드의 효율성을 높이는 중요한 방법이다.
QueryDSL은 이러한 요구를 충족시키기 위해 다양한 프로젝션 기능을 제공하는데, 그 중에서도 동적 프로젝션은 매우 유용한 기능으로, DTO(Data Transfer Object)로 데이터를 변환하여 필요한 정보만을 효율적으로 추출할 수 있게 해준다.
동적 프로젝션을 사용하면, 쿼리 결과를 특정 클래스의 인스턴스로 변환할 수 있는데, 이를 통해 데이터베이스에서 가져온 데이터를 전부 사용하지 않고 원하는 필드로 구성한 DTO 클래스에 매핑할 수 있다.
QueryDSL의 Projections 클래스를 활용하면 다양한 방식으로 DTO를 생성할 수 있어, 개발자의 필요에 맞게 유연하게 데이터를 처리할 수 있다.
QueryDSL을 사용한 동적 프로젝션의 기본 개념과 다양한 활용 방법을 살펴보자.
쿼리 결과를 엔티티 객체로 직접 반환
가장 간단한 예재로, QueryDSL을 사용하여 직접 엔티티 객체를 반환받을 수 있다. 이 경우 쿼리 결과로 전체 엔티티를 가져오게 된다. 다만, 별도의 데이터 가공이 필요할 것이다.
QPerson person = QPerson.person;
List<Person> people = queryFactory
.selectFrom(person)
.fetch();
for (Person p : people) {
System.out.println("Name: " + p.getName()); //이름만 출력하기
}
Dynamic Projection
QueryDSL에서 프로젝션을 사용하여 쿼리 결과를 바로 DTO Class로 변환하여 반환해보자.
DTO 클래스 정의
@Getter
@Setter
public class PersonDTO {
private String name;
private Integer age;
public PersonDTO(String name, Integer age) {
this.name = name;
this.age = age;
}
}
Projections.bean 메서드 사용하여 projection
Projections.bean은 지정된 클래스의 인스턴스를 생성하고, setter 메서드를 사용하여 필드에 값을 설정한다. 따라서, DTO 클래스에 setter 메서드가 필요하다.
List<PersonDTO> personDTOs = queryFactory
.select(Projections.bean(PersonDTO.class,
person.name,
person.age))
.from(person)
.fetch();
Projections.fields 메서드 사용하여 projection
Projections.fields는 필드에 직접 접근하여 값을 설정한다.
이 방법은 필드에 직접 접근하기 때문에, DTO 클래스에 public 필드가 필요하거나, 리플렉션을 통해 필드에 접근할 수 있어야 한다. 따라서 getter/setter 메서드가 없어도 동작할 수 있다.
List<PersonDTO> personDTOs = queryFactory
.select(Projections.fields(PersonDTO.class,
person.name,
person.age))
.from(person)
.fetch();
Transform
QueryDSL에서 transform 메서드를 사용하면 쿼리 결과를 다양한 형태로 변환할 수 있다. 주로 GroupBy와 함께 사용하여 결과를 그룹화하거나 집계할 때 유용하다.
Projections은 단일 엔티티의 데이터를 DTO로 매핑하거나, 단순 필드 추출 작업에 사용된다.
따라서, Projections만으로는groupBy에 의해 그룹화된 데이터를 처리할 수 없다.. 그룹화된 데이터에 대해 집계 연산(예: max, sum, avg)을 수행하려면, 반드시 transform과 함께 groupBy를 사용해야 한다.
이 예제에서는 GroupBy를 사용하여 데이터를 그룹화하고, Projections를 사용하여 DTO로 변환한 후 transform으로 데이터를 집계한다.
아래는 사람들의 이름을 기준으로 그룹화하고, 각 그룹의 최대 나이를 DTO로 변환하여 반환하는 예제이다.
DTO 클래스 정의
@Getter
@Setter
public class PersonDTO {
private String name;
private Integer maxAge;
public PersonDTO(String name, Integer maxAge) {
this.name = name;
this.maxAge = maxAge;
}
}
구현
QPerson person = QPerson.person;
List<PersonDTO> personDTOs = queryFactory
.from(person)
.groupBy(person.name) // 첫 번째 groupBy: 이름을 기준으로 그룹화
.transform(groupBy(person.name).list( // 두 번째 groupBy: 그룹화된 결과를 변환
Projections.constructor(PersonDTO.class,
person.name,
person.age.max())
));
첫 번째 groupBy
쿼리 결과를 특정 필드(여기서는 person.name)를 기준으로 그룹화한다.
두 번째 groupBy
그룹화된 결과를 변환한다. 이 단계에서는 transform 메서드와 함께 사용되어, 그룹화된 데이터를 원하는 형태로 변환한다.
복잡한 쿼리 결과를 Projection 을 활용하여 추출해보기
다수의 도서관이 책 데이터를 공유하지만 유저 데이터를 독립적으로 관리하는 상황을 가정해보자.
이 환경에서 특정 ISBN에 해당하는 책의 대출 정보를 각 도서관별로 조회할 수 있는 통합 API를 설계하고 구현해볼 것이다.
API는 도서관 정보, 책 정보, 대출 정보, 유저 정보를 포함하며, 데이터를 효과적으로 그룹화하여 반환해야 한다.
시스템 구성
1.1 도서관(Library)
- 다수의 도서관이 존재하며, 각 도서관은 다음과 같은 데이터를 관리합니다:
- 도서관 정보: 고유 ID, 이름, 주소 등.
- 회원(User): 도서관마다 독립적으로 관리되며, 유저 정보는 도서관 간 공유되지 않음.
- 책(Book): 모든 도서관이 동일한 책 데이터(ISBN 기준)를 공유함.
- 대출(Loan): 특정 책이 대출된 내역.
1.2 데이터 관계
- 책(Book):
- ISBN(국제 표준 도서 번호)으로 구별되며, 동일 ISBN을 가진 책은 모든 도서관에서 동일한 데이터로 간주됨.
- 각 도서관 내에서는 **책 ID(Book ID)**를 사용하여 동일 ISBN의 책을 여러 권 관리 가능.
- 예) ISBN: "978-3-16-148410-0", 책 ID: 101, 102.
- 회원(User):
- 각 도서관마다 독립적인 회원 정보를 관리하며, 동일 사용자가 여러 도서관에 등록될 수 있음.
- 회원 정보는 대출 정보와 1:1로 연결됨.
- 대출 정보(Loan):
- 특정 책(Book ID)에 대한 대출 기록.
- 대출 정보는 책 ID와 회원 ID에 매핑되어 있으며, 같은 책(Book ID)을 여러 회원이 대출할 수 있음.
API 목적
- 특정 책의 ISBN으로 모든 도서관에서 해당 책의 대출 정보를 조회.
- 결과는 도서관별로 그룹화되며, 대출 정보에는 대출을 수행한 회원 정보도 포함.
DTO 클래스 구성
import java.util.List;
public class LibraryProjectionDTO {
// 도서관 정보
private Long libraryId;
private String libraryName;
// 특정 책 정보 (ISBN으로 조회)
private String isbn;
private String bookTitle;
private String author;
private String publisher;
// 대출 정보 그룹화된 리스트
private List<BookLoanGroupDTO> loanGroups;
public LibraryProjectionDTO(Long libraryId, String libraryName, String isbn, String bookTitle, String author, String publisher, List<BookLoanGroupDTO> loanGroups) {
this.libraryId = libraryId;
this.libraryName = libraryName;
this.isbn = isbn;
this.bookTitle = bookTitle;
this.author = author;
this.publisher = publisher;
this.loanGroups = loanGroups;
}
public static class BookLoanGroupDTO {
private Long bookId; // 특정 책 ID
private List<LoanWithUserDTO> loans; // 대출 정보 + 유저 정보 리스트
public BookLoanGroupDTO(Long bookId, List<LoanWithUserDTO> loans) {
this.bookId = bookId;
this.loans = loans;
}
}
public static class LoanWithUserDTO {
private Long loanId; // 대출 정보 ID
private String loanDate;
private String dueDate;
// 유저 정보
private Long userId;
private String userName;
private String userEmail;
public LoanWithUserDTO(Long loanId, String loanDate, String dueDate, Long userId, String userName, String userEmail) {
this.loanId = loanId;
this.loanDate = loanDate;
this.dueDate = dueDate;
this.userId = userId;
this.userName = userName;
this.userEmail = userEmail;
}
}
}
Repository
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class LibraryQueryRepository {
private final JPAQueryFactory queryFactory;
public LibraryQueryRepository(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
public List<LibraryProjectionDTO> findBookLoansByIsbn(String isbn) {
QLibrary library = QLibrary.library;
QBook book = QBook.book;
QLoan loan = QLoan.loan;
QUser user = QUser.user;
return queryFactory
.select(Projections.constructor(
LibraryProjectionDTO.class,
library.id,
library.name,
book.isbn,
book.title,
book.author,
book.publisher,
Projections.list(
Projections.constructor(
LibraryProjectionDTO.BookLoanGroupDTO.class,
loan.book.id, // 책 아이디로 그룹화
Projections.list(
Projections.constructor(
LibraryProjectionDTO.LoanWithUserDTO.class,
loan.id,
loan.loanDate.stringValue(),
loan.dueDate.stringValue(),
user.id,
user.name,
user.email
)
)
)
)
))
.from(library)
.join(library.books, book)
.join(book.loans, loan)
.join(loan.user, user)
.where(
book.isbn.eq(isbn) // 조건: ISBN으로 특정 책 조회
.and(library.isActive.isTrue()) // 예: 활성화된 도서관만 포함
.and(loan.returned.isFalse()) // 예: 반납되지 않은 대출 정보만 포함
)
.orderBy(library.id.asc(), loan.loanDate.desc()) // 정렬 조건: 도서관 ID, 대출 날짜 기준
.fetch();
}
}
반환 데이터 구조
[
{
"libraryId": 1,
"libraryName": "Central Library",
"isbn": "978-3-16-148410-0",
"bookTitle": "Effective Java",
"author": "Joshua Bloch",
"publisher": "Addison-Wesley",
"loanGroups": [
{
"bookId": 101,
"loans": [
{
"loanId": 1001,
"loanDate": "2024-01-01",
"dueDate": "2024-01-15",
"userId": 501,
"userName": "Alice Johnson",
"userEmail": "alice@example.com"
},
{
"loanId": 1002,
"loanDate": "2024-02-01",
"dueDate": "2024-02-15",
"userId": 502,
"userName": "Bob Smith",
"userEmail": "bob@example.com"
}
]
},
{
"bookId": 102,
"loans": [
{
"loanId": 1003,
"loanDate": "2024-03-01",
"dueDate": "2024-03-15",
"userId": 503,
"userName": "Charlie Brown",
"userEmail": "charlie@example.com"
}
]
}
]
},
{
"libraryId": 2,
"libraryName": "Westside Library",
"isbn": "978-3-16-148410-0",
"bookTitle": "Effective Java",
"author": "Joshua Bloch",
"publisher": "Addison-Wesley",
"loanGroups": [
{
"bookId": 201,
"loans": [
{
"loanId": 2001,
"loanDate": "2024-01-10",
"dueDate": "2024-01-25",
"userId": 504,
"userName": "Dave Lee",
"userEmail": "dave@example.com"
}
]
}
]
}
]
데이터 구조 분석
- 도서관 정보 (LibraryProjectionDTO):
- 각 도서관은 고유의 libraryId와 libraryName을 가진다.
- 책 정보:
- isbn, bookTitle, author, publisher는 ISBN으로 조회된 특정 책의 정보이다.
- 모든 도서관에서 동일한 ISBN으로 책 데이터를 공유한다.
- 대출 그룹 (BookLoanGroupDTO):
- bookId: 각 도서관 내에서 특정 책을 구별하기 위한 고유 ID이다. 같은 ISBN이라도 여러 개의 책을 가질 수 있으므로 bookId로 구분한다.
- loans: 같은 책 ID에 해당하는 대출 정보를 그룹화하여 표시한다.
- 대출 정보 및 유저 정보 (LoanWithUserDTO):
- loanId: 대출의 고유 ID이다.
- loanDate와 dueDate: 대출 날짜와 반납 기한을 나타낸다
- userId, userName, userEmail: 대출한 사용자의 정보를 포함한다.