Database

QueryDSL로 원하는 데이터만 추출하기 - Projection

hwlee9905 2024. 12. 8. 19:20

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 데이터 관계

  1. 책(Book):
    • ISBN(국제 표준 도서 번호)으로 구별되며, 동일 ISBN을 가진 책은 모든 도서관에서 동일한 데이터로 간주됨.
    • 각 도서관 내에서는 **책 ID(Book ID)**를 사용하여 동일 ISBN의 책을 여러 권 관리 가능.
    • 예) ISBN: "978-3-16-148410-0", 책 ID: 101, 102.
  2. 회원(User):
    • 각 도서관마다 독립적인 회원 정보를 관리하며, 동일 사용자가 여러 도서관에 등록될 수 있음.
    • 회원 정보는 대출 정보와 1:1로 연결됨.
  3. 대출 정보(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"
                    }
                ]
            }
        ]
    }
]

 

데이터 구조 분석

  1. 도서관 정보 (LibraryProjectionDTO):
    • 각 도서관은 고유의 libraryId와 libraryName을 가진다.
  2. 책 정보:
    • isbn, bookTitle, author, publisher는 ISBN으로 조회된 특정 책의 정보이다.
    • 모든 도서관에서 동일한 ISBN으로 책 데이터를 공유한다.
  3. 대출 그룹 (BookLoanGroupDTO):
    • bookId: 각 도서관 내에서 특정 책을 구별하기 위한 고유 ID이다. 같은 ISBN이라도 여러 개의 책을 가질 수 있으므로 bookId로 구분한다.
    • loans: 같은 책 ID에 해당하는 대출 정보를 그룹화하여 표시한다.
  4. 대출 정보 및 유저 정보 (LoanWithUserDTO):
    • loanId: 대출의 고유 ID이다.
    • loanDate와 dueDate: 대출 날짜와 반납 기한을 나타낸다
    • userId, userName, userEmail: 대출한 사용자의 정보를 포함한다.