JustDoEat

Data JPA, DTO, Projection 방식중 적합 한 방식은 ? 본문

Yajoba/Backend

Data JPA, DTO, Projection 방식중 적합 한 방식은 ?

kingmusung 2024. 10. 28. 22:33

개요

JPA를 이용해서 쿼리의 결과를 가지고 오는 도중 궁금증이 생겼다.

 

쿼리의 결과로, 엔티티 객체 자체를 가지고 와서 서비스 로직에서 DTO로 맵핑하는 방법만 머리에 있었다.

 

그리고 복잡한 데이터가 아니고 단순히 엔티티객체에서 필요한 일부분을 가볍게 가지고 오고 싶다는 생각에서 발단을 하였다.

 

기존 방식은 엔티티 객체를 map을 돌려 DTO로 변환을 하는 과정에서 자원소모가 클 거 같았고,

 

찾아보니 DTO 형태로 바로 맵핑을 받는 방법도 있었는데, 이건 JPQL 쿼리에 DTO의 경로명까지 적어주는 방식으로 하는 거 같았다, 이 부분에서 만약에 내가 쓰려는 DTO의 이름이 바뀌거나, 위치가 바뀐다면 곤란하겠는데?라는 생각도 해봤다.

 

조금 더 맛있는 방법이 없을까?라고 생각을 하던 도중 Projection이라는 방법을 알아버렸다.

 

솔직히 조심스럽다. 각자 스타일이 있는거고, 무엇이 좋다고 단정을 짓기에는 난 쪼꼬미이기 때문이다.

 

이런 방법이 있는데 얘는 이걸 이제 알아서 신기해하는구나.라고 봐주면 좋겠다.

 

https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html

 

Projections :: Spring Data JPA

Another way of defining projections is by using value type DTOs (Data Transfer Objects) that hold properties for the fields that are supposed to be retrieved. These DTO types can be used in exactly the same way projection interfaces are used, except that n

docs.spring.io

 

이 부분은 내 설명에 근거를 뒷받침할 스프링 공식문서에 projection 부분이다.


"기존 엔티티 객체 맵핑에 있어 (내가 생각한) 문제점들"

 

엔티티 객체를 직접 DTO로 맵핑을 하자.

    @Query("SELECT c.id, c.name FROM Category c WHERE c.parent.id = :parentId")
    List<MidCategoryResponse> findMid(@Param("parentId") Long parentId);

 

당연히 위와 같이 반환형에 DTO를 넣으면 오류가 난다.

    @Query("SELECT new com.gulbi.Backend.domain.rental.product.dto.MidCategoryResponse(c.id, c.name) FROM Category c WHERE c.parent.id = :id")
    List<MidCategoryResponse> findMid(@Param("id") Long id);

 

 

위와 같이 DTO의 절대경로를 적어주어야 하는데.

 

이 경우 의존성이 너무 강할 거 같다는 생각을 했다, 추가로 단순히 엔티티 칼럼 중에 아주 일부만 필요한데 DTO를 굳이 만들어야 할까?

 

JPQL 코드가 너무 길어진다..

 

이에 대한 해결책으로 DTO도 인터페이스를 정의해서 그걸 구현체로 쓰면 되려나?라는 생각도 했는데. 어? 여기서 궁금해서 찾아보니

 

인터페이스로 받아오는 방법이 있었다. 그건 바로 인터페이스 기반 Projection을 사용하는 것이다.

 

(아 물론 복잡한 데이터 형식이나, 그 안에서 메서드 정의 및 데이터 가공이 필요하다면 DTO를 사용하는 게 맞다고 생각한다)


"Projection 이 무엇?"

 

JPA를 이용해 데이터베이스에서 결과를 가지고 올 때 DTO방식의 클래스기반의 맵핑이 아닌, 인터페이스 기반의 맵핑 방법이다.

 

예시를 좀 들어보자면, 즉시로딩 전략으로 모든 엔티티를 가지고 오는 경우에는 엔티티에 해당하는 객체가 바로 뽑히지만

지연로딩 전략이 걸린 엔티티를 가지고 오는 경우에는 동적 프락시에 필요한 필드만 담긴다.

 

인터페이스 기반 프로젝션도 동적으로 생성된 프락시에 필요한 부분만 담겨서 내가 원하는 필드값을 가지고 와준다.

 

그럼 이제 간단한 예시로

 

위와 같은 상품 테이블에서 

 

id, mainImage, title, price만 가지고 오고 싶다면.

@Getter
@Builder
public class ProductResponseDto {
    private Long id;
    private String mainImage;
    private String title;
    private String price;
}

 

위와 같은 DTO 형식을 받아오곤 했다.

 

인터페이스 기반 DTO인 프로젝션을 사용한다면.

 

public interface ProductResponseProjection {

   Long getId();
   String getMainImage();

   String getTitle();

   String getPrice();

}

 

이런 식으로 정의할 수 있겠다.

 

가지고 오고자 하는 칼럼명 앞에 Getter 맹키로 get을 붙여주면 해당 필드만 기지고 온다.

 

자세한 내용은 글 위에 첨부 한 공식문서를 참고하시면 될 듯하다.

 


장점은 무엇일까?

 

내 생각에는 장점이 또한 단점이라는 생각이 든다.

 

일반적으로 DTO로 받아오는 방식과 같지만 절대경로를 안 써줘도 된다는 장점이 있다!

JPQL쿼리의 최적화...?

    @Query("SELECT new com.gulbi.Backend.domain.rental.product.dto.MidCategoryResponse(c.id, c.name) FROM Category c WHERE c.parent.id = :id")
    List<MidCategoryResponse> findMid(@Param("id") Long id);

 

@Query(value = "SELECT p.id AS id, p.main_image AS mainImage, p.title AS title, p.price AS price FROM products p WHERE p.title LIKE CONCAT('%', :query, '%')", nativeQuery = true)
public Optional<List<ProductResponseProjection>> findProductByQuery(@Param("query")String que

 

위랑 아래랑 비교했을 때 확실히 인터페이스 기반 프로젝션을 사용하는 게 보기에도 너무 좋다.

 

아주 간단한 조회에 부담이 없고 아주 소규모의 데이터 경우 간편하게 할 수 있어 너무 좋을 거 같다.

 

"중첩 프로젝션이 가능하지만, 간결하다."

 

두 가지 이상의 엔티티의 요소를 하나의 DTO로 넣고 싶을 때 위처럼 중첩 프로젝션을 이용하면 짧은 코드로 원하는 정보를 빼 올 수 있다.

 

추가로 쿼리문의 가독성도 높아진다.

 

DTO방식

"SELECT new com.gulbi.Backend.domain.rental.product.dto.ProductDto(p.id, p.name, p.description, " +
       "(SELECT new com.gulbi.Backend.domain.rental.product.dto.ReviewDto(r.id, r.rating, r.comment) " +
       "FROM Review r WHERE r.product.id = p.id)) " +
       "FROM Product p WHERE p.name LIKE CONCAT('%', :productName, '%')")

 

Interface Projection 방식

@Query("SELECT p AS product, r AS review " +
       "FROM Product p LEFT JOIN Review r ON r.product.id = p.id " +
       "WHERE p.name LIKE CONCAT('%', :productName, '%')")

 

 

"필요한 필드만 받아오기 때문에 빠르다?"

이 부분은 경계를 할 필요가 있는 거 같다. 빠르다의 기준은 너무 재각각이기 때문이다.

 


그러면 단점은?

 

"복잡한 파생필드를 만들 때 가독성이 떨어진다."

 

예를 들어 위에서 예시를 든 DTO, Projection에 보면 price라는 필드가 있는데

여기에 기존 price가격에 10%를 더해서 저장하는 필드를 만들려고 한다.

public interface ProductResponseProjection {
    Long getId();
    String getMainImage();
    String getTitle();
    int getPrice();
    @Value("#{target.price * 1.1}")
    double getPriceWithVAT();
}

 

위와 같이 @Value 어노테이션을 적어주면 간단히 해결이 된다.(공식문서 참고)

 

하지만 이건 간단하지만 엄청 복잡한 경우에는 저 한 줄에 담기에는 무리가 있어 보인다.

 

 

"중첩 프로젝션의 기능이 있지만, 복잡한 중첩을 해야 할 때 부적합 할 수 있다."

 

 

두 가지 이상의 엔티티를 하나의 DTO로 넣을 때 아주 간단한 작업이라면 너무 편하겠지만, 복잡한 로직이 들어가야 한다면 부적합할 수도 있다.


결론

간단한 작업 같은 경우에는 Projection 또한 좋은 선택지 이겠지만, 팀원들과 협업하는 과정에서 아무런 룰 없이 DTO와 Projection을 혼합해서 사용한다면 혼란을 야기시킬 거 같다.

 

간단한 작업을 해야 할 때, 코드가 짧아지는 점에서 너무 마음에 든다. 다만 성능적인 측면에서 더 우수한가? 에 대한 답은 나중에 실험을 해보도록 하겠다.