용로그
article thumbnail

서론


JPA와 Querydsl을 사용하면서 DTO에 관해 느낀 것이 많다. 그래서 이번 글에는 JPA와 Querydsl으로 개발을 진행할 때 고려해 보면 좋을 몇 가지에 대해 소개하겠다.

 

Repository에서 DTO를 반환해야 하는 이유


여러분들은 Querydsl에서 DTO를 반환해야 한다는 이야기를 자주 들어보았을 것이다. 다만, Querydsl에서 DTO를 아무 의미 없이 기계적으로 반환한다면 그건 옳지 않다고 생각한다. 필자가 Querydsl에서 DTO를 반환하는 이유는 다음과 같다.

 

엔티티 보호

엔티티를 사용자에게 노출하면 원하지 않는 상황에서 자원의 속성이 변경될 가능성이 있다. 그리고 엔티티를 프레젠테이션 계층에 노출하는 것은 테이블 설계와 화면을 공개하는 것이나 다름없기 때문에 보안상으로도 바람직한 구조가 되지 못한다.

 

물론 프레젠테이션 계층에서 엔티티를 받아 DTO로 만들어 반환하는 방법도 있겠지만, 처음부터 레파지토리 계층에서 DTO를 반환한다면 해당 문제를 해결하기 위한 프레젠테이션 DTO 로직을 제거할 수 있다.

 

N+1 문제 해결

DTO에서 엔티티를 필드로 가지고 있지 않는 이상 N+1 문제가 깔끔하게 해결된다. Querydsl을 사용하면서 DTO로 반환할 때 fetch join 자체를 지원하지 않는다.

 

기존 엔티티를 반환할 때는 해당 엔티티와 연관된 엔티티들에 대해서 fetch join을 사용하고 체이닝 된 xToMany 관계에서는 default_batch_fetch_size 옵션까지 고려해줬어야 했는데, DTO를 사용하게 된다면 그럴 필요가 전혀 없어진다.

 

잠재적 OSIV 비활성화

잠재적으로 open-session-in-view(이하 osiv)를 사용하지 않을 수 있다. 보통 yml 또는 properties에서 osiv 옵션 비활성을 채택하겠지만, repository 계층에서 DTO를 반환하기 때문에 osiv 문제를 바로 해결할 수 있다.

 

같은 반환 값, 다른 N+1 해결 방법

이 문제는 반환되는 엔티티가 여러개의 xToMany 관계를 가졌을 때 발생할 수 있는 문제다. 예를 들어 PetFood라는 엔티티가 Functionalities라는 엔티티 그리고 PrimaryIngredients라는 2개의 엔티티를 OneToMany(Lazy Loading) 관계로 가지고 있는다고 가정하자. Functionalities는 데이터 110만 개, PrimaryIngredients는 데이터 100만 개를 가지고 있다.

 

 

이 상황에서 PetFoodQueryService의 Functionalities만 필요한 A 메서드가 PetFoodQueryRepository의 getAllPetFood라는 메서드를 사용한다. 이때 Functionalities에 대한 N+1 문제는 fetch_join으로 해결하기 때문에 조회 쿼리가 단 1번 일어난다.

 

그런데 PetFoodQueryService의 PrimaryIngredients만 필요한 B 메서드가 getAllPetFood 메서드를 사용하는데, 이때 B 메서드는 PrimaryIngredientsdefault_batch_fech_size를 사용해 최적화하기 때문에 default_batch_fetch_size1000으로 설정해도 조회 쿼리가 1000(1,000,000 = 1,000^2)번 발생한다.

 

이런 상황일 때 Repository 계층에서 DTO로 반환한다면 문제를 깔끔하게 해결할 수 있다.

 

Querydsl에서 DTO를 반환하는 여러가지 방법


Querydsl에서는 DTO를 반환하기 위해 아래와 같은 4가지 기능을 제공한다.

  • Projections.bean - setter를 이용한 조회 방법
  • Projections.fields - Reflection을 이용한 조회 방법
  • Projections.constructor - 생성자를 이용한 조회 방법
  • @QueryProjection - 어노테이션을 이용한 조회 방법

Projections.bean

public List<FilteredPetFoodResponse> findPagingPetFoods(
            List<String> brandsName,
            List<String> standards,
            List<String> primaryIngredientList,
            List<String> functionalityList,
            Long lastPetFoodId,
            int size
    ) {
        return queryFactory
                .selectDistinct(Projections.bean(FilteredPetFoodREsponse.class,
                        petFood.id,
                        petFood.imageUrl,
                        brand.name,
                        petFood.name,
                        petFood.purchaseLink
                ))
                .from(petFood)
                .join(petFood.brand, brand)
                .join(petFood.petFoodPrimaryIngredients, petFoodPrimaryIngredient)
                .join(petFood.petFoodFunctionalities, petFoodFunctionality)
                .where(
                        isLessThan(lastPetFoodId),
                        isContainBrand(brandsName),
                        isMeetStandardCondition(standards),
                        isContainPrimaryIngredients(primaryIngredientList),
                        isContainFunctionalities(functionalityList)
                )
                .orderBy(petFood.id.desc())
                .limit(size)
                .fetch();
    }

 

Projections.bean 방법은 setter 메서드 기반으로 동작한다. 그렇기에 DTO의 모든 필드에 setter가 필요하다. 단순 조회 용도로만 사용되는 DTO라면 setter 메서드가 있어도 무관할 수 있지만, 데이터가 변경될 수 있는 상황이 존재한다면 사용하지 않는 것이 바람직하다.

 

Projections.fields

public List<FilteredPetFoodResponse> findPagingPetFoods(
            List<String> brandsName,
            List<String> standards,
            List<String> primaryIngredientList,
            List<String> functionalityList,
            Long lastPetFoodId,
            int size
    ) {
        return queryFactory
                .selectDistinct(Projections.fields(FilteredPetFoodREsponse.class,
                        petFood.id,
                        petFood.imageUrl,
                        brand.name,
                        petFood.name,
                        petFood.purchaseLink
                ))
                .from(petFood)
                .join(petFood.brand, brand)
                .join(petFood.petFoodPrimaryIngredients, petFoodPrimaryIngredient)
                .join(petFood.petFoodFunctionalities, petFoodFunctionality)
                .where(
                        isLessThan(lastPetFoodId),
                        isContainBrand(brandsName),
                        isMeetStandardCondition(standards),
                        isContainPrimaryIngredients(primaryIngredientList),
                        isContainFunctionalities(functionalityList)
                )
                .orderBy(petFood.id.desc())
                .limit(size)
                .fetch();
    }

 

이 방법은 getter, setter 메서드가 필요 없이 DTO의 필드에 값을 직접 주입해 주는 방식이다. type이 다를 경우 매칭되지 않으며, 컴파일 시점에서 예외를 찾을 수 없다. 그래서 런타임 시점에서야 에러를 파악할 수 있다는 것이 단점이다.

 

Projections.constructor

public List<FilteredPetFoodResponse> findPagingPetFoods(
            List<String> brandsName,
            List<String> standards,
            List<String> primaryIngredientList,
            List<String> functionalityList,
            Long lastPetFoodId,
            int size
    ) {
        return queryFactory
                .selectDistinct(Projections.constructor(FilteredPetFoodResponse.class,
                        petFood.id,
                        petFood.imageUrl,
                        brand.name,
                        petFood.name,
                        petFood.purchaseLink
                ))
                .from(petFood)
                .join(petFood.brand, brand)
                .join(petFood.petFoodPrimaryIngredients, petFoodPrimaryIngredient)
                .join(petFood.petFoodFunctionalities, petFoodFunctionality)
                .where(
                        isLessThan(lastPetFoodId),
                        isContainBrand(brandsName),
                        isMeetStandardCondition(standards),
                        isContainPrimaryIngredients(primaryIngredientList),
                        isContainFunctionalities(functionalityList)
                )
                .orderBy(petFood.id.desc())
                .limit(size)
                .fetch();
    }

 

Projections.constructor생성자 기반으로 데이터를 주입한다. 생성자로 동작하기 때문에 객체의 불변성을 가져갈 수 있다는 장점이 있지만, 바인딩 과정에서 문제가 생길 수 있다.

 

값을 넘길 때 생성자와 필드의 순서를 일치시켜야 하며, 필드의 개수가 적을 때는 문제가 되지 않지만, 필드의 개수가 많아진다면 휴먼 에러가 발생할 확률도 낮지 않다. Projections.constructor 역시 파라미터 작성을 잘못했을 경우 런타임 시점에서야 오류가 발생한다.

 

@QueryProjection

public List<FilteredPetFoodResponse> findPagingPetFoods(
            List<String> brandsName,
            List<String> standards,
            List<String> primaryIngredientList,
            List<String> functionalityList,
            Long lastPetFoodId,
            int size
    ) {
        return queryFactory
                .selectDistinct(new QFilteredPetFoodResponse(
                        petFood.id,
                        petFood.imageUrl,
                        brand.name,
                        petFood.name,
                        petFood.purchaseLink
                ))
                .from(petFood)
                .join(petFood.brand, brand)
                .join(petFood.petFoodPrimaryIngredients, petFoodPrimaryIngredient)
                .join(petFood.petFoodFunctionalities, petFoodFunctionality)
                .where(
                        isLessThan(lastPetFoodId),
                        isContainBrand(brandsName),
                        isMeetStandardCondition(standards),
                        isContainPrimaryIngredients(primaryIngredientList),
                        isContainFunctionalities(functionalityList)
                )
                .orderBy(petFood.id.desc())
                .limit(size)
                .fetch();
    }

 

@QueryProjection은 불변 객체 선언과 생성자를 그대로 사용할 수 있다는 장점이 있다. 대신 사용하기 위해서는 DTO에 생성자를 만들어 주고, @QueryProjection 어노테이션을 추가해주어야 한다.

 

public record FilteredPetFoodResponse(
        Long id,
        String imageUrl,
        String brandName,
        String foodName,
        String purchaseUrl
) {

    public static FilteredPetFoodResponse of(
            Long id,
            String imageUrl,
            String brandName,
            String foodName,
            String purchaseUrl
    ) {
        return new FilteredPetFoodResponse(id, imageUrl, brandName, foodName, purchaseUrl);
    }

    @QueryProjection
    public FilteredPetFoodResponse {
    }

}

 

해당 방식은 new QDTO 방식으로 동작하기 때문에 컴파일 시점에 예외를 파악할 수 있으며, 파라미터로도 정상적인 동작을 확인할 수 있다는 장점이 있다.

 

다만 DTO에 Querydsl에 대한 의존성이 추가되는 문제가 생기게 된다. DTO는 Repository 레이어뿐만 아니라 모든 레이어에서 사용되기 때문에 @QueryProjection 어노테이션을 적용시키는 순간 모든 레이어에서 Querydsl을 의존하게 된다는 것이다.

 

 

-토비의 스프링 중-
진정한 POJO란 객체지향적인 원리에 충실하면서, 환경과 기술에 종속되지 않고 필요에 따라 재활용될 수 있는 방식으로 설계된 오브젝트를 말한다.

 

다른 레이어들에서 @QueryProjection을 비종속적으로 사용하기


그렇다면 우리는 불변 객체 선언, 생성자 그대로 사용 가능, 컴파일 시점에 오류 색출 가능이라는 장점을 가진 @QueryProjection을 POJO를 위배한다는 이유로 사용하지 않아야 할까?(물론 @QueryProjection이 아닌 프로젝션들도 POJO를 위배한다)

 

필자는 모든 레이어가 외부 환경을 의존한다면 그건 피해야 할 패턴이라고 생각한다. 하지만, 이미 외부 환경을 종속하고 있는 Repository가 가지고 있는 DTO에 대해서 @QueryProjection을 사용한다면 어떨까?

 

위에서 본 그림에서는 DTO가 Controller, Service, Repository를 거쳐 전 레이어적으로 Querydsl이라는 외부 모듈을 의존한다. 그 이유는 모든 레이어에서 하나의 DTO로만 통신하고 있기 때문이다.

 

그렇다면 Querydsl을 의존하고 있는 QueryRepository에서 반환하는 DTO만 @QueryProjection을 사용하고, 그 외의 레이어들에 대해서는 외부 환경과 완전히 격리된 다른 DTO를 사용하면 어떻게 될까? 아래 그림과 같은 구조가 된다.

 

 

심지어 위 구조는 이미 Querydsl을 의존하고 있는 QueryRepository의 DTO만 Querydsl을 의존하기 때문에 이미 종속된 레이어(QueryRepository)에 대해서만 Querydsl을 의존하게 된다는 것이다.

 

패키지 구조

 

마무리 및 결론


결론은 다음과 같다.

  • Querydsl을 사용할 때 자신이 DTO를 사용하는 것과 안 하는 것, 둘의 차이를 고민해 보고 자신에게 맞는 방법을 선택하자.
  • DTO를 반환하기 위해서 Querydsl이 제공해주는 방법은 4가지가 존재한다.
  • @QueryProjection을 사용한다면 모든 레이어에서 하나의 DTO를 사용하지 말자.

최근 들어 Querydsl 관련 글을 많이 쓰는 것 같은데, 일반적으로 사용하는 것들에 대해서 무작정 사용하기보다는 왜 그렇게 사용하는지, 이렇게 사용하면 어떤 일이 발생하는지 등등 많은 것을 생각해 봐서 그런 것 같다. 좋은 습관이라고 생각된다.

profile

용로그

@용로그

벨덩보단 용덩 github.com/wonyongChoi05