Project/배달 주문 관리 플랫폼

[Spring Boot] 배달 플랫폼 주문 도메인 설계: Entity 스스로 상태를 방어하는 도메인 주도 설계(DDD)

Kr1 2026. 5. 4. 02:51
  • 주문 시스템에서 가장 치명적인 문제는 상태 불일치입니다. 잘못된 상태 전이는 결제, 배송, 환불 등 전체 비즈니스 흐름을 무너뜨릴 수 있습니다. 이를 방지하기 위해 OrderEntity 내부에 상태 전이 제어 로직을 구현하여, 허용된 흐름 내에서만 상태가 변경되도록 설계했습니다. 

 

 

💻 실제 코드 (OrderEntity.java & OrderStatus.java)

OrderStatus.java (주문 상태 정의)
@Getter
public enum OrderStatus {
    PENDING("주문요청"),      // CUSTOMER
    ACCEPTED("주문수락"),     // OWNER
    COOKING("조리완료"),      // OWNER
    DELIVERING("배송수령"),   // OWNER
    DELIVERED("배송완료"),    // OWNER
    COMPLETED("주문완료"),    // OWNER
    CANCELLED("주문취소");    // CUSTOMER (5분 이내) or MASTER

    private final String description;

    OrderStatus(String description) {
        this.description = description;
    }
}
OrderEntity.java (상태 전이 방어 로직)
@Slf4j 
@Entity
@Table(name = "p_orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderEntity extends BaseAuditEntity {

    // ... 필드 생략 (상태, 총금액 등)
    
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItemEntity> orderItems = new ArrayList<>();

    @Builder
    public OrderEntity(UUID userId, UUID storeId, UUID addressId, String request, Integer totalPrice, OrderStatus status, Integer deliveryFee, Boolean isHidden) {
        // ... 생략
    }

    @PrePersist // DB에 최초 저장되기 직전에 호출
    protected void onCreate() {
        if (status == null) {
            status = OrderStatus.PENDING; // 기본값 강제 할당
        }
    }
    
    // [방어 1] 취소 요청
    public void cancelOrder() {
        this.status = OrderStatus.CANCELLED;
    }
    
    // [방어 2] 요청사항 수정
    public void updateRequest(String newRequest) {
        if (this.getStatus() != OrderStatus.PENDING) {
            throw new IllegalStateException("주문이 이미 수락되어 요청사항을 수정할 수 없습니다.");
        }
        this.request = newRequest;
    }
    
    // [방어 3] 핵심 상태 전이(State Machine) 로직
    public void updateStatus(OrderStatus nextStatus) {
        // 이미 종료된 상태면 변경 불가
        if (this.status == OrderStatus.CANCELLED || this.status == OrderStatus.COMPLETED) {
            throw new IllegalStateException("이미 최종 상태에 도달한 주문은 상태를 변경할 수 없습니다.");
        }
        
        // 정해진 순서대로만 상태 변경 허용
        boolean isValid = false;
        switch (this.status) {
            case PENDING: isValid = (nextStatus == OrderStatus.ACCEPTED); break;
            case ACCEPTED: isValid = (nextStatus == OrderStatus.COOKING); break;
            case COOKING: isValid = (nextStatus == OrderStatus.DELIVERING); break;
            case DELIVERING: isValid = (nextStatus == OrderStatus.DELIVERED); break;
            case DELIVERED: isValid = (nextStatus == OrderStatus.COMPLETED); break;
        }

        if (!isValid) {
            throw new IllegalStateException(this.status + " 상태에서 " + nextStatus + " 상태로의 변경은 허용되지 않습니다.");
        }

        this.status = nextStatus;
    }

    /**
     * [관리자 전용] 상태 강제 변경 (슈퍼 권한)
     */
    public void forceUpdateStatus(OrderStatus nextStatus) {
        this.status = nextStatus;
    }
}
연관관계의 주인은 DB에서 외래키(FK)를 가진 쪽이며, 이 구조에서는 order_id를 가진 OrderItem이 주인이 된다. 따라서 실제 연관관계 설정은 OrderItem의 order 필드를 통해 이루어지고, Order의 orderItems는 이 관계를 기반으로 조회할 때 사용되는 컬렉션이다. mappedBy는 이 컬렉션이 직접 관계를 관리하는 것이 아니라, OrderItem의 order 필드에 의해 매핑된다는 의미이다.

 

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

서비스(Service)가 아닌 엔티티(Entity)에 방어 로직을 넣은 이유 (Rich Domain Model)

  • 주문 상태 변경과 관련된 검증 로직을 Service가 아닌 Entity 내부에 배치했습니다. 이렇게 하면 주문 상태 변경은 반드시 Entity가 제공하는 메서드를 통해서만 이루어지며, 허용되지 않은 상태 전이를 사전에 방지할 수 있습니다.
  • 만약 이러한 로직이 Service 계층에만 존재한다면, Entity의 상태를 직접 변경하는 코드가 다른 위치에서 작성될 가능성이 있고, 이 경우 비즈니스 규칙이 일관되게 지켜지지 않을 수 있습니다.
  • 따라서 OrderEntity 스스로 상태 변경의 책임을 가지도록 설계하여, 정해진 흐름에 따라 상태가 변경되도록 강제하고 데이터의 정합성을 유지했습니다. 

 @PrePersist를 활용한 상태 초기화

  • 주문은 생성 시 항상 PENDING(접수 대기) 상태로 시작해야 합니다.
  • 이를 서비스 계층에서 매번 명시하기보다, @PrePersist를 통해 엔티티가 DB에 저장되기 직전에 기본 상태를 자동으로 설정하도록 했습니다.
  • 이 방식은 상태 초기화 로직을 엔티티에 위임함으로써, 상태 누락을 방지하고 일관된 데이터 상태를 유지할 수 있도록 합니다.

영속성 전이 (cascade = CascadeType.ALL, orphanRemoval = true)

  • 하나의 주문(Order)은 여러 개의 주문 항목(OrderItem)을 포함하므로, 두 엔티티는 강한 생명주기 의존 관계를 가집니다. OrderItem은 혼자서 생성되거나 삭제될 수 없고, 오직 Order가 저장될 때 같이 저장되고, Order에서 빠질 때 삭제됩니다.
  • CascadeType.ALL: 주문을 저장할 때 안에 든 아이템들도 자동으로 함께 INSERT 해주고, 주문이 삭제될 때 아이템들도 함께 DELETE 처리해 줍니다.
  • orphanRemoval = true: 주문 컬렉션(List)에서 아이템을 하나 빼기만 해도(고아 객체), DB에서 해당 아이템 로우를 삭제해 줍니다. 부모(Order)가 자식(OrderItem)의 생명주기를 관리하게 됩니다.
 자식(OrderItem)은 부모(Order)를 통제할 권한이 없으므로 Cascade를 가질 수 없습니다. 오직 부모 쪽인 Order 엔티티의 List 컬럼에만 Cascade를 명시하여, 부모가 자식을 통제하도록 방향을 잡아주는 것이 JPA 설계의 정석입니다.

 

💡 정리 

  • 도메인 주도 설계(DDD) 관점의 풍부한 도메인 모델(Rich Domain Model): 비즈니스 검증 로직(상태 전이 순서 제어, 수정 제한 등)을 Service 계층에 절차지향적으로 나열하지 않고, 도메인 객체(OrderEntity) 내부로 응집시켜 객체지향적인 설계를 완성했습니다.
  • 상태 머신 개념 적용: switch 문을 활용해 상태 전이(State Transition)가 가능한 경로(예: PENDING → ACCEPTED)를 제한하여, 잘못된 상태 변경을 방지하고 주문 상태의 일관성을 유지하도록 했습니다. 
  • 생명주기 제어: Cascade와 orphanRemoval 설정을 통해 부모 엔티티(Order)와 자식 엔티티(OrderItem)의 생명주기를 하나로 묶어 연관관계 매핑의 정합성을 지켰습니다.