만약 서비스단에서 복잡한 비즈니스 로직을 구현해야 하고, 그 반환값으로 또한 복잡한 구조의 DTO가 만들어진다고 해보자.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserInterestSingleDto {
private Long creatorId;
private String creator;
private String clan;
private StockDto stockInfo;
private LocalDate capturedAt;
private LocalDate closedAt;
private BigDecimal capturedPrice;
private BigDecimal targetPrice;
private TradingType tradingType;
// 팔로우한 관심종목인 경우
private InterestFollowDto followInfo;
}
유저의 관심종목 정보를 조회할 때 해당 주식에 대한 정보(StockDto)와 팔로우 정보(InterestFollowDto)를 함께 조회할 수 있도록 중첩된 구조의 DTO 클래스를 설계했다고 해보자. 이렇게 여러 DTO가 여러 Entity 클래스에 걸쳐서 중첩되어있는 경우 단순히 JpaRepository를 통해 데이터를 가져오게 되면 여러번의 query가 발생하게 된다.
따라서 Join query를 이용하여 여러 테이블을 Join하여 데이터를 연결하고 알맞은 데이터를 각 DTO class에 전달하여 객체를 생성해주어야 한다.
이번 포스팅은 다양한 방식으로 복잡한 구조의 DTO를 Repository 레이어에서 query로 매핑하는 방법을 다룬다.
QueryDSL with Projections
이전 포스팅에서도 다뤘지만 Querydsl은 Join이 포함된 복잡한 쿼리를 편하게 설계할 수 있기에 중첩된 DTO를 처리하기 편하다.
QueryDSL에서는 Projections를 사용하여 각 필드값들을 DTO Class로 매핑할 수 있다.
List<UserInterestSingleDto> userCreateInterestList = queryFactory.select(
Projections.fields(
UserInterestSingleDto.class,
interest.createdUser.id.as("creatorId"),
Projections.fields(
StockDto.class,
stock.code,
stock.name,
stock.type,
stock.market
).as("stockInfo"),
interest.capturedAt,
interest.closedAt,
interest.capturedPrice,
interest.targetPrice,
interest.type.as("tradingType")
))
.from(interest)
.join(interest.stock, stock)
.where(interest.createdUser.id.eq(userId))
.fetch();
querydsl 포스팅에서 User.name처럼 select로 필요한 엔티티 필드 값들만 선택하여 가져올 수 있다고 하였다.
이와 유사하게 Projections.fields를 사용하여 DTO class를 매핑해주면 선택한 필드 값들을 DTO class 내부에 전달하여 DTO 객체로 반환해준다.
Projections로 가져오는 방법은 총 3가지가 존재한다.
- Projections.constructor: DTO 클래스의 생성자를 이용하여 데이터를 매핑한다. 이름과 순서가 일치해야 한다.
- Projections.fields: 필드 이름을 기반으로 데이터를 매핑한다. 필드 이름이 일치하면 매핑. 다른 경우 .as() 사용
- Projections.bean: setter 방식을 통해 데이터를 매핑한다. 순서 상관 X
constructor 방식을 사용할 때는 DTO 클래스에 @AllArgsConstructor와 같은 모든 필드를 받는 생성자가 존재해야 하고,
bean 방식은 @NoArgsConstructor 기본 생성자와 모든 필드에 @Setter 함수가 필요하다.
따라서 본인은 fields 방식을 선호하고 사용하는 편이다. 필드명이 다르면 .as()로 변환하는것도 편리하다.
Spring Data JPA with JPQL
@Query("SELECT new com.example.dto.InterestDto(" +
"i.id, i.name, " +
"new com.example.dto.InterestDto.StockDto(s.code, s.name, s.currentPrice), " +
"CASE WHEN f IS NOT NULL THEN new com.example.dto.InterestDto.FollowInfo(f.clan.name, f.clan.description) ELSE null END) " +
"FROM Interest i " +
"LEFT JOIN i.stock s " +
"LEFT JOIN Follow f ON f.interest.id = i.id AND f.user.id = :userId")
List<InterestDto> findInterestWithDetails(@Param("userId") Long userId);
QueryDSL을 사용하지 않으려면 Data JPA의 JPQL로 복잡한 쿼리를 작성할 수 있다.
SELECT에서 NEW 키워드로 DTO 클래스를 생성하고, 클래스 내부에 또 new를 중첩하여 중첩된 DTO를 생성하면 된다.
이 때도 DTO 클래스에 생성자가 정의되어 있어야 한다.
JDBC Template
JDBC Template 방식은 SQL 쿼리를 작성하여 데이터를 가져온 뒤 RowMapper를 통해 DTO에 매핑할 수 있다.
SELECT
i.id AS interest_id, i.name AS interest_name
s.code AS stock_code, s.name AS stock_name, s.current_price AS stock_price,
f.clan_name AS clan_name, f.clan_description AS clan_description
FROM interest i
LEFT JOIN stock s ON i.stock_id = s.id
LEFT JOIN follow f ON f.interest_id = i.id AND f.user_id = ?
public class InterestRowMapper implements RowMapper<InterestDto> {
@Override
public InterestDto mapRow(ResultSet rs, int rowNum) throws SQLException {
// StockDto 매핑
InterestDto.StockDto stockDto = new InterestDto.StockDto(
rs.getString("stock_code"),
rs.getString("stock_name"),
rs.getDouble("stock_price")
);
// FollowInfo 매핑 (값이 존재할 경우)
InterestDto.FollowInfo followInfo = null;
if (rs.getString("clan_name") != null) {
followInfo = new InterestDto.FollowInfo(
rs.getString("clan_name"),
rs.getString("clan_description")
);
}
// InterestDto 매핑
return new InterestDto(
rs.getLong("interest_id"),
rs.getString("interest_name"),
stockDto,
followInfo
);
}
}
RowMapper는 ResultSet을 받아 필드 이름으로 데이터에 접근하여 일일히 Inner DTO를 생성한 후,
마지막으로 최종 DTO 객체를 생성하여 return하는 방식으로 작동한다.
작성한 SQL로 데이터를 가져올 때 정의한 RowMapper를 넣어주면 데이터가 매핑된 DTO를 결과값으로 받을 수 있다.
String sql = "SELECT ..."; // 위에 작성한 SQL
List<InterestDto> results = jdbcTemplate.query(sql, new Object[]{userId}, new InterestRowMapper());
개인적으로는 QueryDSL 방식이 가독성과 유지보수가 좋고, Projections를 사용하면 별도의 선언 없이 사용할 수 있어서 선호한다.
'Backend' 카테고리의 다른 글
[Java Spring Boot] Spring AOP를 이용한 Logging System 구축 방법 (0) | 2025.01.13 |
---|---|
[Java Spring Boot] Entity 복합 키 설정하기 - @Embeddable, @EmbeddedId (2) | 2025.01.13 |
[Java Spring Boot] QueryDSL 사용하기. (0) | 2025.01.13 |
[Java Spring Boot] 스프링에서 REST API 구현하기. @Controller @RequestParam, @PathVariable, @RequestBody (0) | 2025.01.13 |
[Java Spring Boot] @Service에 대한 기본상식과 @Transactional, DTO에 대하여 (0) | 2025.01.03 |