Project/배달 주문 관리 플랫폼

[Spring Boot] 배달 플랫폼 주문 도메인 설계: Service (시간 제약 비즈니스 룰과 상태 전이 제어)

Kr1 2026. 5. 17. 20:20
  • 배달 플랫폼에서 한 번 접수된 주문을 취소하거나 상태를 변경하는 것은 결제 및 배달 기사 배차와 직결되는 매우 민감한 작업입니다. 이번 글에서는 객체 지향적인 상태 방어와 '5분 이내 취소'라는 시간 제약 비즈니스 룰을 서비스 계층에서 어떻게 안전하게 풀어냈는지 회고해 봅니다.

 

 

💻 실제 코드 (OrderServiceV1.java - 취소 및 상태 변경)

    /**
     * 주문 취소 (CUSTOMER(본인/5분), MASTER 전용)
     */
    @Transactional
    public ResGetOrderDtoV1 cancelOrder(UUID orderId, UUID userId, UserRole role) {
        // 실시간 권한 재검증 및 차단 유저 접근 방어
        validateUserRoleFromDB(userId, role); 
        
        // 식별자로 주문을 찾되, 이미 삭제된(Soft Delete) 주문은 제외
        OrderEntity order = orderRepository.findById(orderId)
                .filter(o -> !o.getIsDeleted()) // findByOrderIdAndIsDeletedFalse로 리팩토링 필요 
                .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));

        // 시스템 최고 관리자(MASTER)는 비즈니스 룰을 무시하고 조건 없이 강제 취소 처리함
        if (role == UserRole.MASTER) {
            order.cancelOrder();
            order.markUpdatedBy(userId);
            return ResGetOrderDtoV1.from(order, "[MASTER] 주문이 강제 취소되었습니다.");
        }
        
        // 고객(CUSTOMER)의 취소 요청일 경우 엄격한 비즈니스 룰을 적용함
        if (role == UserRole.CUSTOMER) {
            // [인가 로직] 본인의 주문이 아니면 취소를 차단함
            if (!order.getUserId().equals(userId)) { throw new CustomException(ErrorCode.ORDER_NOT_OWNER); }
            // [선제적 상태 방어] 이미 사장님이 수락(ACCEPTED)했거나 조리 중이면 취소를 막음
            if (order.getStatus() != OrderStatus.PENDING) { throw new CustomException(ErrorCode.ORDER_CANCEL_NOT_PENDING); }
            
            // [시간 제약 로직] Duration 클래스를 활용하여 주문 생성 시간(createdAt)과 현재 시간(now)의 격차를 분
      단위로 계산함
            LocalDateTime now = LocalDateTime.now();
            Duration duration = Duration.between(order.getCreatedAt(), now);
            // 5분이 경과했을 경우 커스텀 예외를 발생시켜 취소를 원천 차단함
            if (duration.toMinutes() >= 5) { throw new CustomException(ErrorCode.ORDER_CANCEL_TIME_EXCEEDED); }
            
            // 모든 검증 통과 시 엔티티 내부 메서드를 호출하여 상태를 변경함
            order.cancelOrder();
            order.markUpdatedBy(userId); // Audit 갱신
            return ResGetOrderDtoV1.from(order, "주문이 성공적으로 취소되었습니다.");
        }

        throw new CustomException(ErrorCode.ACCESS_DENIED);
    }	
    
    /**
     * 주문 상태 변경 (사장님 순차전이, 관리자 슈퍼변경)
     */
    @Transactional
    public ResGetOrderDtoV1 updateOrderStatus(UUID orderId, OrderStatus nextStatus, UUID userId, UserRole role) {
        validateUserRoleFromDB(userId, role); // DB 권한 재검증 실행

        OrderEntity order = orderRepository.findById(orderId)
                .filter(o -> !o.getIsDeleted())
                .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
        
        // 사장님(OWNER)일 경우, 해당 주문이 들어온 가게의 진짜 주인이 맞는지 한 번 더 꼼꼼히 검증함
        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)) {
                throw new CustomException(ErrorCode.ORDER_FORBIDDEN_FOR_OWNER);
            }
        }

        try {
            // 관리자는 순서를 무시하고 강제 변경 가능, 사장님은 Entity에 정의된 엄격한 상태 머신(순서)에 따라
      변경함
            if (role == UserRole.MASTER || role == UserRole.MANAGER) {
                order.forceUpdateStatus(nextStatus); 
            } else {
                order.updateStatus(nextStatus); // Entity 내부의 switch 문(상태 머신) 작동
            }
            order.markUpdatedBy(userId);
        } catch (IllegalStateException e) {
             // 순수 도메인 객체(Entity)가 발생시킨 자바 기본 예외(IllegalStateException)를 캐치하여, 웹 계층이 이해할 수 있는 규격화된 HTTP 커스텀 예외(CustomException)로 변환해 던짐
            throw new CustomException(ErrorCode.ORDER_STATUS_UPDATE_FAILED);
        }
        
        return ResGetOrderDtoV1.from(order, "주문 상태가 변경되었습니다.");
    }

 

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

Duration을 활용한 시간 기반 비즈니스 룰 구현

  • 고객은 주문 후 5분 이내에만 취소할 수 있다"는 요구사항을 처리하기 위해 Java의 Duration 클래스를 활용했습니다. Duration두 시간 사이의 간격을 표현하는 객체로, 주문 생성 시간(createdAt)과 현재 시간(now) 사이에 얼마나 시간이 지났는지를 계산할 때 적합합니다.
  • 서비스 계층에서는 Duration.between(order.getCreatedAt(), now)를 통해 주문 생성 시점부터 현재까지의 경과 시간을 구하고, toMinutes()를 사용해 분 단위로 변환했습니다. 그 결과 5분이 지난 주문에 대해서는 ORDER_CANCEL_TIME_EXCEEDED 예외를 발생시켜, 시간 제한이라는 비즈니스 규칙을 코드상에서 명확하게 표현할 수 있었습니다.

try-catch를 활용한 예외 변환(Exception Translation)

  • updateOrderStatus 로직에서는 엔티티가 발생시킨 예외를 서비스 계층에서 try-catch로 잡아 CustomException으로 변환했습니다. 이 방식은 도메인 객체와 API 응답 규격을 분리하기 위한 목적이 있습니다.
  • OrderEntity는 주문 상태 변경 가능 여부만 판단하는 순수한 도메인 객체이므로, 특정 API의 ErrorCode나 응답 형식을 알 필요가 없습니다. 따라서 상태 전이가 불가능한 경우 엔티티에서는 IllegalStateException 같은 순수 자바 예외를 발생시키고, 서비스 계층에서 이를 잡아 API 규격에 맞는 CustomException으로 변환합니다. 이를 통해 엔티티가 프레임워크나 외부 응답 구조에 의존하지 않도록 계층 분리를 유지할 수 있었습니다.

역할별 주문 취소 정책 분리

  • 주문 취소 로직에서는 MASTER와 CUSTOMER의 취소 조건을 명확히 분리했습니다. MASTER는 관리자 권한이므로 주문 상태나 시간 제한과 관계없이 강제 취소가 가능하도록 처리했고, CUSTOMER는 본인 주문인지, 주문 상태가 PENDING인지, 생성 후 5분 이내인지 순서대로 검증하도록 구성했습니다.
  • 이를 통해 같은 "주문 취소" 기능 안에서도 역할에 따라 다른 비즈니스 규칙을 적용할 수 있었고, 권한별 책임과 예외 상황을 명확하게 구분할 수 있었습니다.

 

💡 정리

  • 예외 변환(Exception Translation)을 통한 객체 지향적 계층 분리: 도메인 엔티티가 프레임워크나 외부 API 응답 규격에 의존하지 않도록 설계했습니다. 엔티티에서는 비즈니스 룰 위반 시 순수 자바 예외를 발생시키고, 서비스 계층에서 이를 캐치하여 API 규격에 맞는 커스텀 예외로 변환함으로써 도메인 계층의 순수성을 지켜냈습니다.
  • 시간 기반 비즈니스 룰 및 역할별 정책 분리: Duration 객체를 활용해 "5분 이내 취소"라는 시간 의존적 비즈니스 룰dmf 직관적으로 구현했습니다. 또한, 관리자(강제 취소)와 고객(조건부 취소)의 취소 정책을 명확히 분리하여 하나의 기능 안에서도 권한별 책임을 유연하게 다뤘음을 어필할 수 있습니다.