Project/배달 주문 관리 플랫폼

[Spring Boot] 배달 플랫폼 주문 도메인 설계: DTO (계층형 데이터 검증과 N+1 문제 방어)

Kr1 2026. 5. 17. 18:14
  • 주문(Order)은 내부에 여러 개의 주문 아이템(OrderItem)을 가지는 계층형(1:N) 구조입니다. 이러한 복잡한 데이터를 클라이언트와 주고받을 때 DTO를 어떻게 설계했는지, 그리고 N+1 문제에 대해 어떤 고민을 했는지 회고해 봅니다.

 

 

💻 실제 코드 (Request & Response DTO)

요청 DTO: ReqCreateOrderDtoV1.java
@Getter
@Schema(description = "주문 생성 요청 객체")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class ReqCreateOrderDtoV1 {

    @Schema(description = "가게 정보")
    @NotNull(message = "가게 정보는 필수입니다.")
    private UUID storeId;

    @Schema(description = "배송지 정보")
    @NotNull(message = "배송지 정보는 필수입니다.")
    private UUID addressId;

    @Schema(description = "요청사항", example = "문앞에 두고 가주세요.")
    private String request;

    @Schema(description = "주문 상품")
    @Valid // List 내부의 OrderItemRequest 객체들까지 유효성 검사 수행
    @NotEmpty(message = "주문 상품은 최소 하나 이상이어야 합니다.")
    private List<OrderItemRequest> orderItems;

    // 내부 클래스 (Inner Class)로 설계
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @AllArgsConstructor
    @Builder
    public static class OrderItemRequest {
        @Schema(description = "메뉴 정보")
        @NotNull(message = "메뉴 정보는 필수입니다.")
        private UUID menuId;

        @Schema(description = "메뉴 수량")
        @NotNull(message = "수량은 필수입니다.")
        @Min(value = 1, message = "수량은 최소 1개 이상이어야 합니다.")
        private Integer quantity;

        @Schema(description = "총 주문액")
        @NotNull(message = "가격 정보는 필수입니다.")
        private Integer priceAtOrder;
    }
}
상세 조회 응답 DTO: ResGetOrderDtoV1.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class ResGetOrderDtoV1 {

    private UUID orderId;
    private UUID orderId;
    private UUID userId;
    private UUID storeId;
    private UUID addressId;
    private String request;
    private Integer totalPrice;
    private OrderStatus status;
    private Integer deliveryFee;
    private LocalDateTime createdAt;
    private String message; // 메시지 필드 추가
    
    // 상세 조회에서는 아이템 목록을 포함함
    private List<OrderItemResponse> orderItems;

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @AllArgsConstructor
    @Builder
    public static class OrderItemResponse {
        private UUID orderItemId;
        private UUID menuId;
        private Integer quantity;
        private Integer priceAtOrder;
    }

    // 서비스에서 메시지를 함께 보낼 수 있도록 수정
    public static ResGetOrderDtoV1 from(OrderEntity order, String message) {
        return ResGetOrderDtoV1.builder()
                .orderId(order.getOrderId())
                // ... 생략
                // Entity 리스트를 DTO 리스트로 변환 
                .orderItems(order.getOrderItems().stream()
                        .map(item -> OrderItemResponse.builder()
                                .orderItemId(item.getOrderItemId())
                                .menuId(item.getMenuId())
                                .quantity(item.getQuantity())
                                .priceAtOrder(item.getPriceAtOrder())
                                .build())
                        .collect(Collectors.toList()))
                .build();
    }

    public static ResGetOrderDtoV1 from(OrderEntity order) {
        return from(order, null);
    }
}
목록 조회 응답 DTO: ResGetOrderListDtoV1.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class ResGetOrderListDtoV1 {
    private UUID orderId;
    // ... 생략

    public static ResGetOrderListDtoV1 from(OrderEntity order) {
        return ResGetOrderListDtoV1.builder()
                .orderId(order.getOrderId())
                // ... 생략
                .build();
    }
}

 

🤔 왜 이렇게 짰는가? (설계 의도와 이유)

@Valid를 활용한 계층형 데이터(Cascade Validation) 검증

  • 주문 생성 요청(ReqCreateOrderDtoV1)에는 부모 정보뿐만 아니라 List<OrderItemRequest>라는 자식 데이터가 포함되어 있습니다. 단순히 필드를 선언하는 것에 그치지 않고, 리스트 필드에 @Valid 어노테이션을 명시했습니다. 이를 통해 스프링 컨트롤러가 요청을 받을 때, 리스트 안에 있는 개별 아이템들의 @Min(value = 1) 같은 제약 조건까지 체인처럼 파고들어(Cascade) 연쇄적으로 유효성을 검증하도록 설계했습니다.

DTO 분리를 통한 N+1 문제 구조적 방어

  • 주문 목록 조회(`ResGetOrderListDtoV1`)와 주문 상세 조회(`ResGetOrderDtoV1`) DTO를 명확하게 분리했습니다. 목록 조회 API의 경우 프론트엔드 요구사항상 주문 상품(Item) 상세 정보가 필요하지 않았기 때문에, DTO에서 `List<OrderItemResponse>`와 같은 연관 컬렉션 자체를 매핑하지 않았습니다. 
  • 그 결과, 엔티티에 설정된 지연 로딩(LAZY)이 초기화될 일이 없어 불필요한 추가 조회(N+1 문제)를 구조적으로 방지할 수 있었습니다. 즉, 단순히 Fetch Join으로 문제를 해결하기보다는, 조회 목적에 맞게 필요한 데이터 범위를 DTO 단에서 분리하여 연관 엔티티 접근 자체를 최소화하는 방향으로 설계했습니다.
  • 반대로 상세 조회 API에서는 단건 주문에 대해서만 연관 컬렉션(orderItems)을 조회하도록 구성했습니다. 목록 조회처럼 여러 주문을 반복 조회하면서 각 주문의 연관 컬렉션을 추가로 조회하는 구조가 아니기 때문에, 대량 N+1 문제로 번질 가능성은 상대적으로 낮다고 판단했습니다. 
  • 다만 상세 조회에서도 접근하는 연관 데이터가 늘어나면 추가 쿼리가 발생할 수 있으므로, 필요 시 fetch join이EntityGraph로 조회 범위를 명시하는 방식이 더 안전합니다.

현재의 방어와 미래의 리팩토링 (LAZY와 Fetch Join의 관계)

  •  현재는 DTO 분리를 통해 연관관계 조회를 피해서 N+1을 회피한 상태입니다. 하지만 이 방법은 요구사항에 맞춘 임시방편일 수도 있음을 인지하고 있습니다. 만약 향후 기획이 변경되어 "주문 목록에서도 아이템 정보가 노출되어야 한다"면, DTO 분리만으로는 N+1 문제를 막을 수 없습니다.
  • 따라서 리팩토링 단계에서 Fetch Join을 도입이 필요할 것 같습니다. 모든 연관관계에 기본 방어벽인 LAZY를 걸어두되, 특정 쿼리에서 강제로 우선순위를 가지는 JOIN FETCH를 명시하여, 여러 주문과 아이템을 단 한 번의 쿼리로 끌고 오는 데이터베이스 튜닝을 적용할 수 있을 것 같습니다.

 

💡 정리 

  • 안전한 계층형 입력 검증: 1:N 구조의 JSON 요청을 안전하게 처리하기 위해, 부모 DTO의 컬렉션 필드에 @Valid를 선언하여 자식 DTO 내부의 제약 조건까지 완벽하게 검증(Cascade Validation)되도록 로직을 작성했습니다
  • DTO 분리를 통한 N+1 문제 사전 차단: 주문 목록을 조회할 때 당장 필요 없는 '메뉴 상세 정보'를 DTO에서 아예 제외했습니다. 필드가 없으니 JPA가 추가로 쿼리를 날리는 지연 로딩(LAZY) 자체가 발동하지 않아, N+1 문제를 가장 단순하게 방지했습니다.
  • 요구사항에 맞춘 최적화와 향후 리팩토링 계획: N+1 문제의 무조건적인 해결책으로 '페치 조인(Fetch Join)'을 쓰기보다는, 불필요한 데이터를 무겁게 가져오지 않도록 현재 상황에 맞는 최적화를 택했습니다. 향후 기획 변경으로 목록 화면에서도 메뉴 정보가 필요해진다면, 그때 쿼리에 페치 조인을 도입하여 성능을 개선할 계획입니다.