[Spring Boot/JPA] Entity > DTO 변환 방법들 및 QueryDSL 프로젝션
이전 포스팅들에서 JPQL, Spring Data JPA Repository, QueryDSL 등을 통해서 데이터를 조회하는 방법들에 대해서 다루어 보았었다.
이번에는 쿼리 결과로 나온 Entity 혹은 Tuple들을 DTO로 매칭하는 방법을 알아보려고 한다.
- 기본적인 DTO 변환 방법 : 스트림 API
- ToOne 관계, ToMany 관계 조회 매핑
- ModelMapper 활용
- ToOne 관계, ToMany 관계 조회 매핑
- 프로젝션
- 프로젝션 사용 방법들
- 서브쿼리, Case, ToOne 관계, ToMany 관계 조회 매핑
- QueryProjection
- ToOne 관계, ToMany 관계 조회 매핑
사용할 데이터
1. Author : Book = 1 : N
Author(저자)는 여러 개의 Book(책)을 가진다.
2. Author : Organization = N : 1
Author(저자)는 한곳의 Organization(조직)에 속한다.
3. Book : Review = 1 : N
Book(책)은 여러 개의 Review(리뷰)를 가진다.
@Entity
@Table(name = "Organization")
@Getter
@Setter
public class Organization {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orgName;
@OneToMany(mappedBy = "organization", cascade = CascadeType.ALL)
private List<Author> authors = new ArrayList<>();
}
@Entity
@Table(name = "Author")
@Getter
@Setter
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
private List<Book> book = new ArrayList<>();
@ManyToOne(fetch=FetchType.LAZY)
@JoinColumn(name = "organization_id")
private Organization organization;
}
@Entity
@Table(name = "Book")
@Getter
@Setter
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch=FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
@OneToMany(mappedBy = "book", cascade = CascadeType.ALL)
private List<Review> reviews = new ArrayList<>();
}
@Entity
@Table(name = "Review")
@Getter
@Setter
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String comment;
@ManyToOne
@JoinColumn(name = "book_id")
private Book book;
}
사용할 DTO 클래스들
특이사항으로는 @QueryProjection이라는 어노테이션이 보일텐데, 이에 대해서는 후술하도록 하겠다.
A. 기본적인 Author Entity를 담을 DTO
밑의 예제를 위해서 일부로 Entity에서는 gender이었던 필드를 sex로 바꾸어 넣었다.
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthorDto {
private Long id;
private String name;
private int age;
private String sex;
private int averageAge;
public AuthorDto(Long id, String name, int age, String sex) {
this.id = id;
this.name = name;
this.age = age;
this.sex = sex;
}
}
B. 집계 결과를 포함하는 Tuple을 담을 DTO
@Data
@NoArgsConstructor
public class AuthorWithAvergeAgeDto {
private String name;
private double averageAge;
@QueryProjection
public AuthorWithAvergeAgeDto(String name, double averageAge) {
this.name = name;
this.averageAge = averageAge;
}
}
C. Author Entity와 ToOne으로 연관된 Organization을 함께 조회할 DTO
@Data
@NoArgsConstructor
public class AuthorWithOrganizationDto {
private Long id;
private String name;
private int age;
private String gender;
private OrganizationDto organization;
@QueryProjection
public AuthorWithOrganizationDto(Long id, String name, int age, String gender, OrganizationDto orgainzation) {
this.id = id;
this.name = name;
this.age = age;
this.gender = gender;
this.organization = orgainzation;
}
}
@Data
@NoArgsConstructor
public class OrganizationDto {
private Long id;
private String orgName;
@QueryProjection
public OrganizationDto(Long id, String orgName) {
this.id = id;
this.orgName = orgName;
}
}
D. Author Entity와 ToMany로 연관된 Books를 함께 조회할 DTO
@Data
@NoArgsConstructor
public class AuthorWithBooksDto {
private Long id;
private String name;
private int age;
private String gender;
private List<BookDto> book = new ArrayList<>();
@QueryProjection
public AuthorWithBooksDto(Long id, String name, int age, String gender, List<BookDto> book) {
this.id = id;
this.name = name;
this.age = age;
this.gender = gender;
this.book = book;
}
}
@Data
@NoArgsConstructor
public class BookDto {
private Long id;
private String title;
@QueryProjection
public BookDto(Long id, String title) {
this.id = id;
this.title = title;
}
}
}
기본적인 DTO 변환 방법 : 스트림 API
아래의 코드는 ToOne 관계의 데이터를 포함시키는 C번 DTO로 변환하는 가장 기본적인 방법이다.
주로 Java Stream API의 stram(), map(), collect() 등의 메서드를 활용하여, 결과 Entity를 순회하여 각각의 Element들을 다른 DTO 객체의 형태로 변환하곤 한다.
@Test
public void matchDtoToOne() throws Exception {
List<Author> results = queryFactory
.select(author)
.from(author)
.join(author.organization, organization)
.fetch();
List<AuthorWithOrganizationDto> dtos = results.stream()
.map(e -> new AuthorWithOrganizationDto(e.getId(), e.getName(), e.getAge(), e.getGender(),
new OrganizationDto(
e.getOrganization().getId(), e.getOrganization().getOrgName()
))
)
.collect(Collectors.toList());
}
ToMany 관계의 D번 DTO로 변환하는 방법도 유사하다.
이 경우, Author에서 Book List를 함께 Join하므로, 조회 결과의 Book List를 한번 더 map으로 순회해주어 DTO로 변환시켜주었다.
@Test
public void matchDtoToMany() throws Exception {
List<Author> results = queryFactory
.select(author)
.from(author)
.join(author.book, book)
.fetch();
//
List<AuthorWithBooksDto> dtos = results.stream()
.map(e -> new AuthorWithBooksDto(e.getId(), e.getName(), e.getAge(), e.getGender(),
e.getBook().stream()
.map(book -> new BookDto(
book.getId(),
book.getTitle()
)).collect(Collectors.toList())
)
)
.collect(Collectors.toList());
}
ModelMapper 사용하기
ModelMapper 라이브러리를 사용하면 Stream API에서 수작업 해주어야 했던 것을 자동으로 할 수 있어 코드 작성하기가 쉬워진다.
dependencies {
//...
//ModelMapper
implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.4.2'
//...
}
@Configuration
public class ModelMapperConfig {
@Bean
public ModelMapper modelMapper() {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STANDARD)
.setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE);
return modelMapper;
}
}
의존성을 추가해주고, ModelMapper에 대한 Config 설정 이후 Bean으로 등록시켜주어 어디에서든지 사용 가능하도록 만들어주자.
@Autowired
private ModelMapper modelMapper;
@Test
public void matchDtoToMany() throws Exception {
List<Author> results = queryFactory
.select(author)
.from(author)
.join(author.book, book)
.fetch();
List<AuthorWithBooksDto> dtos2 = results.stream()
.map(data -> modelMapper.map(data, AuthorWithBooksDto.class))
.collect(Collectors.toList());
}
@Test
public void matchDtoToOne() throws Exception {
List<Author> results = queryFactory
.select(author)
.from(author)
.join(author.organization, organization)
.fetch();
List<AuthorWithOrganizationDto> dtos2 = results.stream()
.map(data -> modelMapper.map(data, AuthorWithOrganizationDto.class))
.collect(Collectors.toList());
}
위의 코드처럼 modelMapper.map(Entity, DTO)를 통해서 클래스 변환을 쉽게 수행할 수 있다.
단 자동으로 매칭되는 기준이 필드 이름이기 때문에, 동일한 DTO와 결과 Entity(Tuple)이 같은 필드 이름을 가질 경우에만 매칭된다.
튜플 매핑
@Test
public void matchDtoTuple(){
QAuthor subAuthor = new QAuthor("subAuthor");
List<Tuple> results = queryFactory
.select(author.name,
select(subAuthor.age.avg().as("averageAge"))
.from(subAuthor)
)
.from(author)
.fetch();
List<AuthorWithAvergeAgeDto> dtos = results.stream()
.map(tuple -> new AuthorWithAvergeAgeDto(tuple.get(author.name), tuple.get(1, Double.class)))
.collect(Collectors.toList());
print("queryProjections", dtos);
}
QueryDSL에서 쿼리결과가 Entity가 아니라 집계연산 등을 통해 나온 추가적인 컬럼이 포함되어 Tuple일 수도 있다.
이 때에는 tuple.get() 연산을 통해서 튜플의 데이터를 추출하여 Stream API로 DTO로 매칭시키는 방법을 사용할 수 있다.
하지만 이런 방식을 조금 더 간편하게 수행할 수 있는 QueryDSL의 프로젝션이 존재한다.
프로젝션
프로젝션은 QueryDSL에서 쿼리 결과를 DTO 등의 객체로 매핑하는 기능을 뜻한다. 이를 통해 쿼리 결과를 필요한 형태로 변환하여 사용할 수 있다.
Author Entity를 담을 DTO
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthorDto {
private Long id;
private String name;
private int age;
private String sex;
private int averageAge;
public AuthorDto(Long id, String name, int age, String sex) {
this.id = id;
this.name = name;
this.age = age;
this.sex = sex;
}
}
Projections.constuctor() : 생성자를 통해 매핑
//생성자로 접근
@Test
public void projectionTestByConstuctor() throws Exception {
List<AuthorDto> results = queryFactory
.select(Projections.constructor(AuthorDto.class,
author.id,
author.name,
author.age,
author.gender
))
.from(author)
.fetch();
}
해당 방법은 DTO 클래스의 생성자를 통해서 쿼리 결과 객체를 DTO 클래스에 매핑하는 방식이다.
따라서 DTO 클래스에 결과 필드들에 대한 생성자가 존재해야 하며, Projections.constuctor() 내부에 프로퍼티의 순서가 생성자의 Input 프로퍼티 순서와 동일해야 한다.
Projections.bean() : Setter를 통해 매핑
//프로퍼티 접근 (Setter)
@Test
public void projectionTestBySetter() throws Exception {
List<AuthorDto> results = queryFactory
.select(Projections.bean(AuthorDto.class,
author.id,
author.name,
author.age,
ExpressionUtils.as(author.gender, "sex")
))
.from(author)
.fetch();
}
해당 방법은 DTO 클래스의 Setter를 통해서 쿼리 결과 객체를 DTO 클래스에 매핑하는 방식이다.
따라서 Select 결과의 필드 이름들과 DTO 클래스의 Setter 존재 및 필드 이름들이 동일한 경우에만 매핑이 가능하다.
이 때 필드 명에 대한 Alias를 변경해주기 위해서 , ExpressionUtil.as()를 통해서 "gender"를 "sex"로 필드명을 바꾸어 반환해주어 매핑이 가능하도록 해 주었다.
(Entity의 gender -> DTO의 sex)
Projections.fields() : 직접 필드에 접근
//필드 직접 접근
@Test
public void projectionTestByFields() throws Exception {
List<AuthorDto> results = queryFactory
.select(Projections.fields(AuthorDto.class,
author.id,
author.name,
author.age,
// ExpressionUtils.as(author.gender, "sex")
author.gender.as("sex")
))
.from(author)
.fetch();
}
해당 방법은 쿼리 결과 객체를 DTO 클래스에 필드 이름을 기반으로 직접 매핑하는 방식이다.
마찬가지로 필드 이름들이 동일한 경우에만 매핑이 가능하다.
이 때, 위에서 설명했던 ExpressionUtil.as() 대신 그냥 뒤에 .as()를 체이닝하여 사용할 수도 있다.
다양한 경우의 프로젝션
Projections : Case 문을 사용한 경우
@Test
public void projectionTestByCase() throws Exception {
List<AuthorDto> results = queryFactory
.select(Projections.fields(AuthorDto.class,
author.id,
author.name,
author.age,
ExpressionUtils.as(author.gender
.when("M").then("Male")
.otherwise("Female"),
"sex"))
)
.from(author)
.fetch();
}
Projections : 서브쿼리를 사용한 경우
@Test
public void projectionTestWithSubquery() throws Exception {
QAuthor subAuthor = new QAuthor("subAuthor");
List<AuthorDto> results = queryFactory
.select(Projections.fields(AuthorDto.class,
author.id,
author.name,
author.age,
author.gender.as("sex"),
ExpressionUtils.as(
select(
Expressions.numberTemplate(Integer.class, "{0}",
MathExpressions.round(subAuthor.age.avg(),0)
)
).from(subAuthor),
"averageAge"
)
))
.from(author)
.fetch();
}
Projections : ToOne 관계의 Join
@Test
public void projectionsToOne() throws Exception {
List<AuthorWithOrganizationDto> results = queryFactory
.select(Projections.fields(AuthorWithOrganizationDto.class,
author.id,
author.name,
author.age,
author.gender,
Projections.fields(
OrganizationDto.class,
ExpressionUtils.as(organization.id, "id"),
ExpressionUtils.as(organization.orgName, "orgName")
).as("organization")
))
.from(author)
.leftJoin(author.organization, organization)
.fetch();
}
AuthorWithOrganizationDto의 내부 필드로 OrganizationDto가 지정된 형태로 매핑하기 위해서는 Projections.field 내부에서 한번 더 프로젝션을 수행하여 매핑할 수 있다.
2024-02-13T15:45:53.789+09:00 DEBUG 19583 --- [ Test worker] org.hibernate.SQL :
select
a1_0.id,
a1_0.name,
a1_0.age,
a1_0.gender,
o1_0.id,
o1_0.org_name
from
author a1_0
left join
organization o1_0
on o1_0.id=a1_0.organization_id
[AuthorWithOrganizationDto(id=1, name=John Doe, age=25, gender=M, organization=OrganizationDto(id=1, orgName=IT Organization)),
AuthorWithOrganizationDto(id=2, name=Jane Smith, age=40, gender=F, organization=OrganizationDto(id=1, orgName=IT Organization)),
AuthorWithOrganizationDto(id=3, name=Kim, age=12, gender=M, organization=OrganizationDto(id=1, orgName=IT Organization))]
Projections : ToMany 관계의 Join
@Test
public void projectionsToMany() throws Exception {
List<AuthorWithBooksDto> results = queryFactory
.from(author)
.join(author.book, book)
.transform(
GroupBy.groupBy(author.id).list(
Projections.fields(
AuthorWithBooksDto.class,
author.id,
author.name,
author.age,
author.gender,
GroupBy.list(
Projections.fields(
BookDto.class,
book.id,
book.title
)
).as("book")
)
));
}
QueryDSL의 transform()은 쿼리 결과를 특정 형태로 변환하는 데 사용된다.
일반적으로 프로젝션을 사용하여 ToMany 관계의 컬럼을 매칭시킬 때, 일반적으로 transform() 메서드를 사용하여 직접 매핑을 수행한다.
하나의 Author에 여러개의 Book이 그룹으로 구조화되어야 하므로, transform() 안에서 집계 함수(GroupBy)를 사용하여 결과를 그룹화하고, 그룹화된 결과에 대한 매핑을 수행한다.
2024-02-13T15:45:53.815+09:00 DEBUG 19583 --- [ Test worker] org.hibernate.SQL :
select
a1_0.id,
a1_0.name,
a1_0.age,
a1_0.gender,
b1_0.id,
b1_0.title
from
author a1_0
join
book b1_0
on a1_0.id=b1_0.author_id
[AuthorWithBooksDto(id=1, name=John Doe, age=25, gender=M, book=[BookDto(id=1, title=Introduction to SQL), BookDto(id=2, title=Java Programming Basics)]),
AuthorWithBooksDto(id=2, name=Jane Smith, age=40, gender=F, book=[BookDto(id=3, title=Web Development with Spring)]),
AuthorWithBooksDto(id=3, name=Kim, age=12, gender=M, book=[BookDto(id=4, title=My Book)])]
QueryProjection
앞에서 사용했던 DTO를 다시 한번 보자. 밑의 코드처럼 @QueryProjection을 생성자에 붙여줬던 것을 기억할 것이다. 이는 QueryDSL에서 Q타입 Entity를 사용했던 것과 유사하게, DTO와 같은 커스텀 클래스 또한 Q타입으로 변환하여 사용할수 있도록 하기 위해서 사용되는 어노테이션이다.
@Data
@NoArgsConstructor
public class AuthorWithOrganizationDto {
private Long id;
private String name;
private int age;
private String gender;
private OrganizationDto organization;
@QueryProjection
public AuthorWithOrganizationDto(Long id, String name, int age, String gender, OrganizationDto orgainzation) {
this.id = id;
this.name = name;
this.age = age;
this.gender = gender;
this.organization = orgainzation;
}
}
QueryDSL 빌드 시, 아래와 같이 DTO 클래스들이 @QueryProjection 어노테이션이 붙은 생성자를 기반으로 하여 Q타입이 생성된 것을 확인할 수 있다.
QueryProjection : ToOne 관계의 Join
@Test
public void queryQtypeProjectionsToOne() throws Exception {
List<AuthorWithOrganizationDto> results = queryFactory
.select(new QAuthorWithOrganizationDto(author.id, author.name, author.age, author.gender,
new QOrganizationDto(author.organization.id, author.organization.orgName)))
.from(author)
.join(author.organization, organization)
.fetch();
print("queryProjections", results);
}
다음은 @QueryProjection을 통해 다온 Q타입 DTO로 바로 매핑하여 ToOne 관계를 Join하는 방식이다.
이전에 사용했던 방식에 비해서 Projections 연산을 길게 작성할 필요가 없고 Q타입으로 바로 매핑시키므로 코드 길이를 줄일 수 있다.
또한 일반적인 Projections은 런타임 시점에 필드 매핑이 이루어지는데에 반해 @QueryProjection은 컴파일 시점에 DTO 클래스의 생성자와 필요한 필드가 일치하는지 확인할 수 있다. 따라서 오타나 실수로 인한 버그를 미리 방지할 수 있다.
다만 아키텍처 측면에서 DTO 클래스는 일반적으로 Repository 계층 뿐만 아니라 Service, Controller 등 여러 Layer에서 사용되는 클래스인데 해당 기능을 사용하면 QueryDSL에 의존하게 된다는 점을 기억하자. (왜냐하면 Q타입 생성을 위해서 @QueryProjection 어노테이션을 사용하기 때문)
-> 그래도 사용을 권장한다. 요즘 Spring Boot 개발에서 QueryDSL이 항상 많이 사용되기 때문. 또한 약한 의존성을 위해 이런 매력적인 기능을 포기하기에는 아깝다.
QueryProjection : ToMany 관계의 Join
@Test
public void queryQtypeProjectionsToMany() throws Exception {
List<AuthorWithBooksDto> results = queryFactory
.from(author)
.join(author.book, book)
.transform(
GroupBy.groupBy(author.id).list(
new QAuthorWithBooksDto(
author.id,
author.name,
author.age,
author.gender,
GroupBy.list(new QBookDto(
book.id,
book.title
)
)
)
));
}
앞에서 언급했던 ToMany 관계에서 사용되는 transform() 연산과 Q타입 DTO를 함께 사용하여 ToMany 관계를 함께 조회하는 것이 가능하다.
함께 보기
1. QueryDSL 설정과 Repository에서의 사용
https://sjh9708.tistory.com/174
2. QueryDSL 기본 문법
https://sjh9708.tistory.com/175
3. QueryDSL Join 문법
https://sjh9708.tistory.com/178
4. QueryDSL 서브쿼리와 상수/문자열 조작
https://sjh9708.tistory.com/180
5. QueryDSL 프로젝션과 Entity > DTO 변환 방법들
https://sjh9708.tistory.com/181
6. QueryDSL 동적 쿼리 작성하기
https://sjh9708.tistory.com/182
'Backend > Spring' 카테고리의 다른 글
[Spring Boot] 테스트 코드 : 시작하기 (JUnit) (0) | 2024.03.05 |
---|---|
[Spring Boot/JPA] QueryDSL : 동적 쿼리 작성하기 (2) | 2024.02.13 |
[Spring Boot/JPA] QueryDSL 문법(3) : 서브쿼리, 상수/문자열 조작 (1) | 2024.02.01 |
[Spring Boot/JPA] QueryDSL 문법(2) : Join 표현식 (1) | 2024.01.29 |
[Spring Boot/JPA] QueryDSL 문법(1) : 기본 검색 (선택, 조건, 정렬, 집계, 그룹화) (1) | 2024.01.25 |