나의 개발일지

[트러블 슈팅] Projections 를 활용하여 Enum 변수를 정제한 DTO 생성하기 본문

Language/SpringBoot

[트러블 슈팅] Projections 를 활용하여 Enum 변수를 정제한 DTO 생성하기

사각분무기 2024. 4. 25. 15:36

기존 코드의 리팩토링을 진행하던 과정에서 좋은 방법을 찾아냈습니다.

Querydsl 의 활용법 중 Projections을 활용할 경우

JPAQueryFactory에서 엔티티나 Tuple을 가져온 후 따로 DTO로 정제하는 과정을 거치는 방식이 아니라

JPAQueryFactory에서 바로 임의의 DTO를 반환하도록 할 수 있다고 합니다.


초보 개발자로서, 혹은 개발의 초기 단계에 Querydsl 내에서 Tuple을 사용할 일은 잘 없을 것이라고 생각합니다.

Tuple의 경우 보통 JPAQueryFactory에서 엔티티나 한 변수 형태의 리스트 값을 받는 경우가 아니라 원하는 컬럼만을 지정해서 가져오는 경우에 사용됩니다.

가령 회원이라는 엔티티가 있을 경우, 아이디, 비밀번호, 전화번호 중 아이디와 전화번호만 필요할 경우와 같은 상황을 떠올릴 수 있습니다.

이는 사실 불필요한 정보를 제외하고 가져오는 최적화의 개념이고 개발 초기 단계이거나 초보 개발자에게는 크게 중요한 부분이 아닙니다.

하지만 저의 경우엔 상속 관계 매핑 덕분에 마주하게 됬습니다.

JPA 에서 지원하는 상속 관계 매핑은 Lazy Initialization 을 지원하지 않더라구요.

작은 부분들은 무시할만하다고 생각하지만 Join에 불필요한 정보들까지 잔뜩 가져오는 상황이 신경 쓰였습니다.

어쩔 수 없이 울며 겨자 먹기로 최적화를 진행했습니다.

Querydsl 코드

List<Tuple> content = jpaQueryFactory  
  .select(  
                cashLog.id,  
                cashLog.eventType,  
                cashLog.amount,  
                cashLog.member.id,  
                cashLog.orderId,  
                cashLog.createdAt  
  )  
        .from(cashLog)  
        .where(conditions)  
        .limit(pageable.getPageSize())  
        .offset(pageable.getOffset())  
        .orderBy(order)  
        .fetch();  

List<CashLogConfirmResponse> res = content.stream()  
        .map(tuple -> CashLogConfirmResponse.of(  
                tuple.get(cashLog.id),  
                tuple.get(cashLog.eventType),  
                tuple.get(cashLog.amount),  
                tuple.get(cashLog.member.id),  
                tuple.get(cashLog.orderId),  
                tuple.get(cashLog.createdAt)  
        )).toList();

DTO 코드

@Schema(description = "CashLog 엔티티 내용 응답")  
@Getter  
@RequiredArgsConstructor(access = PRIVATE)  
public class CashLogConfirmResponse {  

    @Schema(description = "CashLog 아이디", example = "1")  
    private final Long cashLogId;  

    @Schema(description = "입출금 유형", example = "결제")  
    private final String eventType;  

    @Schema(description = "상품 가격", example = "100000")  
    private final Long price;  

    @Schema(description = "해당 CashLog 를 가진 유저 아이디", example = "1")  
    private final Long memberId;  

    @Schema(description = "주문 고유 식별 코드", example = "o8sJILLP1EP6V1nLksCBL")  
    private final String orderId;  

    @Schema(description = "CashLog 생성 일시", example = "2024-02-06 17:26:48.772390")  
    private final LocalDateTime createdAt;  

    public static CashLogConfirmResponse of(final CashLog cashLog) {  
        Member member = cashLog.getMember();  

        Long memberId = (member != null) ? member.getId() : null;  

        return CashLogConfirmResponse.of(  
                cashLog.getId(),  
                cashLog.getEventType(),  
                cashLog.getAmount(),  
                memberId,  
                cashLog.getOrderId(),  
                cashLog.getCreatedAt()  
        );  
    }  

    public static CashLogConfirmResponse of(  
            final Long cashLogId,  
            final EventType eventType,  
            final Long price,  
            final Long memberId,  
            final String orderId,  
            final LocalDateTime createdAt  
    ) {  
        return new CashLogConfirmResponse(  
                cashLogId,  
                eventType.getStatus(),  
                price,  
                memberId,  
                orderId,  
                createdAt  
        );  
    }

위는 저의 기존 코드입니다.

이를 Projections 을 활용하여 더 짧고 간결하게 만들어보겠습니다.


Projections을 활용할 경우 총 세가지의 방법을 통해 DTO 반환이 가능하다고 합니다.

  1. Setter 주입 Projections.bean
  2. 필드 주입 Projections.fields
  3. 생성자 주입 Projections.constructor

이 중 Setter 주입의 경우 DTO의 일관성 측면에서 좋은 선택이 아니라고 생각했기 때문에 필드 주입을 사용해보았습니다.

Querydsl 코드 (필드 주입)

List<CashLogConfirmResponse> res = jpaQueryFactory  
  .select(Projections.fields(CashLogConfirmResponse.class,  
                        cashLog.id.as("cashLogId"),  
                        cashLog.eventType.as("eventType"),  
                        cashLog.amount.as("price"),  
                        cashLog.member.id.as("memberId"),  
                        cashLog.orderId.as("orderId"),  
                        cashLog.createdAt.as("createdAt")  
                )  
        )  
        .from(cashLog)  
        .where(conditions)  
        .limit(pageable.getPageSize())  
        .offset(pageable.getOffset())  
        .orderBy(order)  
        .fetch();

문제

에러가 발생합니다.

개발자의 일상이네요.

에러를 확인해봅니다.

EventType 은 String으로 변환될 수 없다고 합니다.

원인

여기서 제가 잊고 있던 부분이 부각되었습니다.

기존의 방식에선 DTO의 정적 생성자를 통해 내부적으로 Enum 객체를 toString()을 통해 String으로 변환해주는 과정을 걸칩니다.

하지만 필드 주입의 경우 DB에서 가져온 값을 바로 필드에 주입해줍니다.

필드 주입의 경우 타입이 같아야 한다는 조건이 있지만 MySQL에는 Enum 속성으로 저장되어 있기 때문에 오류가 발생한 겁니다.

해결

이를 해결하기 위해선 여러가지 방법이 있습니다.

서브쿼리를 사용하거나 Querydsl 의 StringExpression 과 같은 방법을 활용할 수 있다고 합니다.

저의 경우 생성자 주입을 활용했습니다.

Querydsl 코드 (생성자 주입)

List<CashLogConfirmResponse> res = jpaQueryFactory  
  .select(Projections.constructor(CashLogConfirmResponse.class,  
                cashLog.id,  
                cashLog.eventType,  
                cashLog.amount,  
                cashLog.member.id,  
                cashLog.orderId,  
                cashLog.createdAt  
  )  
        )  
        .from(cashLog)  
        .where(conditions)  
        .limit(pageable.getPageSize())  
        .offset(pageable.getOffset())  
        .orderBy(order)  
        .fetch();

좀 더 깔끔해보이는 코드가 완성되었습니다.

생성자 주입의 경우 필드와 컬럼의 이름을 맞춰줄 필요는 없습니다.

대신 생성자를 만들고 변수의 타입과 순서를 맞춰주면 됩니다.

이 방식의 장점은 우선 특정 타입을 받았을 경우 제가 지정한 생성자를 통해 저가 원하는대로 데이터를 정제할 수 있다는 점입니다.

DTO 코드

@Schema(description = "CashLog 엔티티 내용 응답")  
@Getter  
public class CashLogConfirmResponse {  

    @Schema(description = "CashLog 아이디", example = "1")  
    private final Long cashLogId;  

    @Schema(description = "입출금 유형", example = "결제")  
    private final String eventType;  

    @Schema(description = "상품 가격", example = "100000")  
    private final Long price;  

    @Schema(description = "해당 CashLog 를 가진 유저 아이디", example = "1")  
    private final Long memberId;  

    @Schema(description = "주문 고유 식별 코드", example = "o8sJILLP1EP6V1nLksCBL")  
    private final String orderId;  

    @Schema(description = "CashLog 생성 일시", example = "2024-02-06 17:26:48.772390")  
    private final LocalDateTime createdAt;  

    public CashLogConfirmResponse(  
            final Long cashLogId,  
            final EventType eventType,  
            final Long price,  
            final Long memberId,  
            final String orderId,  
            final LocalDateTime createdAt  
    ) {  
        this.cashLogId = cashLogId;  
        this.eventType = eventType.toString();  
        this.price = price;  
        this.memberId = memberId;  
        this.orderId = orderId;  
        this.createdAt = createdAt;  
    }  
}

덕분에 @RequiredArgsConstructor와 이를 기반으로 만든 정적 생성자들을 없애고 public 생성자 하나만 남겨둘 수 있게 됬습니다.