Project/배달 주문 관리 플랫폼

[Spring Boot] 배달 플랫폼 주문 도메인 설계: Service (Role 기반 쿼리 분리와 Soft Delete 전략)

Kr1 2026. 5. 17. 21:06
  •  주문 도메인의 마지막 서비스 로직입니다. 이번 글에서는 사용자의 역할(Role)에 따라 완전히 다른 데이터를 안전하게 내려주는 조회(Read) 로직과, 무결성을 지키기 위해 조건부로 작동하는 삭제(Soft Delete) 로직을 회고해 봅니다.

 

 

💻 실제 코드 (OrderServiceV1.java - 조회, 수정, 삭제)

    /**
     * 주문 상세 조회
     */
    public ResGetOrderDtoV1 getOrder(UUID orderId, UUID userId, UserRole role) {
        // 실시간 권한 재검증 및 차단 유저 접근 방어
        validateUserRoleFromDB(userId, role); 

        OrderEntity order = orderRepository.findById(orderId)
                .filter(o -> !o.getIsDeleted())
                .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
                
        // 역할(Role)별 상세 조회 인가(Authorization) 처리 로직
        boolean isAuthorized = false;
        if (role == UserRole.MASTER || role == UserRole.MANAGER) {
            isAuthorized = true; // 관리자는 조건 없이 통과 처리
        } else if (role == UserRole.CUSTOMER && order.getUserId().equals(userId)) {
            isAuthorized = true; // 고객은 본인 주문일 경우에만 통과 처리
        } else if (role == UserRole.OWNER) {
            StoreEntity store = storeRepository.findById(order.getStoreId())
                    .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND));
            if (store.getUser() != null && store.getUser().getId().equals(userId)) {
                isAuthorized = true; // 사장님은 본인 소유의 가게 주문일 경우에만 통과 처리
            }
        }
        
        // 인가에 실패한 제3자의 접근은 원천 차단
        if (!isAuthorized) {
            throw new CustomException(ErrorCode.ORDER_NOT_OWNER);
        }

        return ResGetOrderDtoV1.from(order);
    }

    /**
     * 주문 목록 조회
     */
    public Page<ResGetOrderListDtoV1> getOrders(UUID storeId, Boolean isHidden, Pageable pageable, UUID userId, UserRole role) {
        validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행
        
        // 역할(Role)에 따라 완전히 분리된 Repository 쿼리 메서드를 호출하여 데이터 격리함
        if (role == UserRole.CUSTOMER) {
             // 고객은 본인의 주문 데이터만 가져오는 전용 쿼리 호출
            return orderRepository.findAllByUserIdAndIsDeletedFalse(userId, pageable).map(ResGetOrderListDtoV1::from);
        }

        if (role == UserRole.OWNER) {
            if (storeId == null) { throw new CustomException(ErrorCode.VALIDATION_ERROR); }
            StoreEntity store = storeRepository.findById(storeId)
                    .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND));
                    
            // 가게 소유권 검증 후, 해당 가게의 주문 데이터만 가져오는 전용 쿼리 호출
            if (store.getUser() == null || !store.getUser().getId().equals(userId)) {
                throw new CustomException(ErrorCode.ORDER_FORBIDDEN_FOR_OWNER);
            }
            return orderRepository.findAllByStoreIdAndIsDeletedFalse(storeId, pageable).map(ResGetOrderListDtoV1::from);
        }
        
        // MASTER/MANAGER용 필터링 다형성 처리 (숨김 조건에 따른 동적 쿼리 대용 로직)
        if (storeId != null && isHidden != null) {
            return orderRepository.findAllByStoreIdAndIsHiddenAndIsDeletedFalse(storeId, isHidden, pageable).map(ResGetOrderListDtoV1::from);
        } else if (storeId != null) {
            return orderRepository.findAllByStoreIdAndIsDeletedFalse(storeId, pageable).map(ResGetOrderListDtoV1::from);
        } else if (isHidden != null) {
            return orderRepository.findAllByIsHiddenAndIsDeletedFalse(isHidden, pageable).map(ResGetOrderListDtoV1::from);
        }
        return orderRepository.findAllByIsDeletedFalse(pageable).map(ResGetOrderListDtoV1::from);
    }

    /**
     * 주문 요청사항 수정
     */
    @Transactional
    public ResGetOrderDtoV1 updateOrderRequest(UUID orderId, String newRequest, UUID userId, UserRole role) {
        validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행

        OrderEntity order = orderRepository.findById(orderId)
                .filter(o -> !o.getIsDeleted())
                .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
                
        // 타인이 요청사항을 수정하지 못하도록 소유권을 검증
        if (!order.getUserId().equals(userId)) {
            throw new CustomException(ErrorCode.ORDER_NOT_OWNER);
        }

        try {
            // 서비스에서 PENDING 상태를 직접 체크하지 않고, Entity 내부 비즈니스 로직을 호출하여 상태 검증
       책임을 도메인 객체에 위임
            order.updateRequest(newRequest);
            order.markUpdatedBy(userId);
        } catch (IllegalStateException e) {
            // Entity가 발생시킨 순수 자바 예외를 캐치하여 HTTP API 규격에 맞는 CustomException으로
       변환(Exception Translation)함
            throw new CustomException(ErrorCode.ORDER_REQUEST_UPDATE_FAILED); 
        }
        
        return ResGetOrderDtoV1.from(order, "요청사항이 성공적으로 수정되었습니다.");
    }

    /**
     * 주문 삭제
     */
    @Transactional
    public void deleteOrder(UUID orderId, UUID userId, UserRole role) {
        validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행

        OrderEntity order = orderRepository.findById(orderId)
                .filter(o -> !o.getIsDeleted())
                .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
                
        // 이미 배달 중이거나 완료된 주문은 데이터 무결성을 위해 삭제를 차단
        if (order.getStatus() == OrderStatus.DELIVERED || order.getStatus() == OrderStatus.COMPLETED) {
            throw new CustomException(ErrorCode.ORDER_CANNOT_DELETE_DELIVERED);
        }

        order.softDelete(userId); // 물리적 삭제(Hard Delete) 대신 상태 플래그 변경 처리
    }
}

 

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

역할(Role)에 따른 쿼리 분리와 데이터 격리를 통한 인가 처리 및 성능 최적화

  • 주문 목록 조회(getOrders) 로직에서는 데이터를 애플리케이션 단으로 모두 가져온 뒤 if문으로 필터링하는 방식을 지양했습니다. 대신, 요청을 보낸 사용자의 역할(Customer, Owner)을 먼저 판단하고 해당 역할에 맞는 Repository 쿼리를 물리적으로 다르게 호출하도록 구성했습니다.
  •  이를 통해 타인의 데이터가 애플리케이션 메모리로 불필요한 데이터가 올라오는 상황을 줄이고자 하였으며, 대규모 트래픽 환경에서의 조회 성능과 데이터 인가(Authorization) 처리의 안전성을 동시에 확보할 수 있었습니다.

데이터 무결성을 위한 조건부 논리 삭제(Soft Delete)

  •  주문 데이터를 삭제(deleteOrder)할 때 단순히 isDeleted 상태만 변경하여 덮어쓰지 않았습니다. 삭제 로직 수행 직전에 현재 주문의 상태를 확인하여, 이미 정산 프로세스로 넘어간 DELIVERED나 COMPLETED 상태일 경우 예외를 발생시켜 삭제를 강제로 차단했습니다.
  • 이는 단순한 CRUD 구현을 넘어, 서비스 운영 시 발생할 수 있는 주요 결제 및 통계 데이터의 유실을 도메인 관점에서
      선제적으로 방어하기 위한 목적이었습니다.

엔티티 내부 비즈니스 로직 위임

  • 요청사항 수정(updateOrderRequest) 시, 서비스 계층에서 현재 주문 상태가 PENDING인지 검사하는 로직을 작성하지 않았습니다. 대신 엔티티 내부의 updateRequest 메서드를 호출하여, 엔티티 스스로가 자신의 상태를 검증하도록 책임을 위임했습니다.
  • 이를 통해 서비스 계층은 도메인 로직을 알 필요 없이 인가 처리와 트랜잭션 관리에만 집중하도록 역할을 분리했습니다.

 

💡 정리

  • 다형적 쿼리 설계를 통한 안전한 데이터 격리: 사용자의 역할(Role)에 따라 호출하는 DB 쿼리를 분리하여 애플리케이션 메모리 낭비를 막고, 잘못된 객체 접근 권한(BOLA) 취약점을 쿼리 레벨에서 방어했습니다.
  • 도메인 생명주기를 고려한 방어적 삭제 로직: 이미 배달이 완료되어 정산 대상이 된 주문의 삭제를 예외 처리로 차단하는 조건부 Soft Delete를 적용하여, 결제 데이터의 무결성을 지켜냈습니다.
  • 응집도 높은 도메인 객체 설계: 상태 검증 로직을 서비스 계층에 흩뿌리지 않고 엔티티 객체에 책임을 위임함으로써, 객체 지향적인 코드 작성을 통해 유지보수성을 높였습니다.