주문 상태별 권한을 Builder 패턴으로 관리하기

2026년 01월 29일

주문 관리 시스템을 만들면서 가장 복잡했던 건 주문 상태 관리였습니다.
주문 상태만 해도 8개(미결제, 결제완료, 상품준비중, 배송중 등)인데, 상태마다 보여줘야 하는 정보와 가능한 동작이 모두 달랐습니다.

"결제완료" 상태에서는 취소가 가능하지만, "배송중" 이후에는 취소가 불가능하고 반품이나 교환만 가능합니다.
이처럼 상태에 따라 비즈니스 로직이 계속 달라지면서 복잡도가 빠르게 높아졌습니다.

이 부분을 어떻게 다뤘는지 정리해봤습니다.


문제: 상태별 규칙이 코드에 흩어진다

"결제완료 상태에서 어떤 화면을 보여주고, 어떤 액션을 허용하지?"라는 질문에 아래 코드를 보고 바로 답할 수 있을까요?

// 주문 상세 페이지
{(status === 'SHIPPING' || status === 'CONFIRMED') && <DeliveryInfo />}
{(status === 'SHIPPING' || status === 'CONFIRMED') && <RecipientInfo />}
{(status === 'SHIPPING' || status === 'CONFIRMED') && <InvoiceInfo />}
{status === 'PAID' && <CancelButton />}
{status === 'PAID' && <ConfirmButton />}
{(status === 'PAID' || status === 'PREPARING') && <EditClearanceCodeButton />}
{status === 'SHIPPING' && <RequestReturnButton />}
{status === 'SHIPPING' && <RequestExchangeButton />}
// ...상태가 추가될 때마다 조건이 늘어난다

상태가 3~4개일 때는 크게 문제되지 않습니다. 하지만 상태가 늘어나면 보여줄 화면과 허용할 액션이 상태마다 달라지고, 그 규칙이 컴포넌트 조건문 곳곳에 흩어지면서 전체 흐름을 한눈에 파악하기 어려워집니다.


해결: Builder 패턴으로 규칙을 선언하다

정리해보면, 각 상태마다 관리해야 할 건 두 가지입니다

  • 뷰(View): 이 상태에서 어떤 화면(섹션)을 보여줄 것인가
  • 액션(Action): 이 상태에서 어떤 동작을 허용할 것인가

예를 들어, PAID(결제완료) 상태에서는 주문정보·상품정보·결제정보·타임라인·액션 섹션이 보여야 하고, 발주확인·취소·개인통관부호 수정·수량분할이 가능해야 합니다.
SHIPPING(배송중) 에서는 배송정보·수취인정보·송장정보 섹션이 추가되고, 취소는 불가능하지만 반품·교환 접수가 가능해야 합니다. 이런 비즈니스 로직이 상태마다 전부 다릅니다.

이걸 한 곳에서 선언적으로 관리하기 위해 Builder 패턴을 적용했습니다

class RulesBuilder {
  private rules = new Map<
    ProductOrderStatus,
    { views: Set<OrderViewKey>; actions: Set<OrderItemAction> }
  >();
 
  forStatus(status: ProductOrderStatus) {
    if (!this.rules.has(status)) {
      this.rules.set(status, { views: new Set(), actions: new Set() });
    }
    const entry = this.rules.get(status)!;
    return {
      withViews: (views: OrderViewKey[]) => {
        entry.views = new Set(views);
        return this.forStatus(status);
      },
      withActions: (actions: OrderItemAction[]) => {
        entry.actions = new Set(actions);
        return this.forStatus(status);
      },
    };
  }
 
  build() {
    return this.rules as ReadonlyMap<
      ProductOrderStatus,
      { views: Set<OrderViewKey>; actions: Set<OrderItemAction> }
    >;
  }
}

사용하는 쪽이 핵심입니다. 모든 상태의 권한을 한 곳에서 선언합니다

const builder = new RulesBuilder();
const commonViews: OrderViewKey[] = ['orderInfo', 'productInfo', 'paymentInfo', 'timeline'];
const deliveryViews: OrderViewKey[] = ['deliveryInfo', 'recipientInfo', 'invoiceInfo'];
 
builder.forStatus('PENDING')
  .withViews(commonViews)
  .withActions(['cancelOrder']);
 
builder.forStatus('PAID')
  .withViews([...commonViews, 'actions'])
  .withActions(['orderConfirm', 'cancelOrder', 'editPersonalClearanceCode', 'splitQuantity']);
 
builder.forStatus('PREPARING')
  .withViews([...commonViews, 'deliveryInfo', 'actions'])
  .withActions(['processShipment', 'holdDelivery', 'editPersonalClearanceCode', 'cancelOrder', 'splitQuantity']);
 
builder.forStatus('SHIPPING')
  .withViews([...commonViews, ...deliveryViews, 'actions'])
  .withActions(['editPersonalClearanceCode', 'requestReturn', 'requestExchange', 'splitQuantity']);
 
builder.forStatus('CANCELED')
  .withViews(commonViews);
  // 액션 없음 — 취소된 주문은 아무것도 못함
 
builder.forStatus('CONFIRMED')
  .withViews(allViews)
  .withActions(['requestReturn', 'requestExchange']);

이 코드를 읽으면 각 상태의 규칙이 선언적으로 드러납니다

  • 미결제 → 취소만 가능
  • 결제완료 → 발주확인, 취소, 통관부호 수정, 수량분할 가능
  • 배송중 → 통관부호 수정, 반품·교환 접수, 수량분할 가능. 취소는 불가
  • 취소됨 → 아무 액션도 없음

Builder로 만든 rules를 Service로 감싸고, 컴포넌트에서 쓰기 편하도록 Hook으로 제공합니다

const rules = builder.build();
 
class OrderStatusPermissionService {
  static of = new OrderStatusPermissionService(rules);
 
  canPerform(status: ProductOrderStatus, action: OrderItemAction): boolean {
    return rules.get(status)?.actions.has(action) ?? false;
  }
 
  canView(status: ProductOrderStatus, key: OrderViewKey): boolean {
    return rules.get(status)?.views.has(key) ?? false;
  }
}
 
export function useOrderPermissions(status: ProductOrderStatus) {
  const svc = OrderStatusPermissionService.of;
  return {
    canView: (key: OrderViewKey) => svc.canView(status, key),
    canPerform: (action: OrderItemAction) => svc.canPerform(status, action),
  };
}

문제 섹션에서 봤던 조건문 코드가 이렇게 바뀝니다

// Before
{status === 'PAID' && <CancelButton />}
{status === 'PAID' && <ConfirmButton />}
{(status === 'PAID' || status === 'PREPARING') && <EditClearanceCodeButton />}
 
// After
const { canView, canPerform } = useOrderPermissions(status);
 
{canPerform('cancelOrder') && <CancelButton />}
{canPerform('orderConfirm') && <ConfirmButton />}
{canPerform('editPersonalClearanceCode') && <EditClearanceCodeButton />}

컴포넌트는 상태별 규칙을 알 필요 없이, Hook에 물어보기만 하면 됩니다.


제네릭을 활용하여 교환·반품에도 똑같이 적용하기

주문에서 잘 작동한 이 패턴을 교환과 반품에도 적용해야 했습니다. 교환,반품도 주문과 마찬가지로 상태별로 보여줄 화면과 허용할 액션이 다르고, 반품도 마찬가지입니다.

그런데 앞에서 만든 RulesBuilder는 ProductOrderStatus, OrderViewKey, OrderItemAction이 하드코딩되어 있어서 주문에서만 쓸 수 있습니다. 교환이나 반품에도 쓰려면 같은 클래스를 타입만 바꿔서 다시 만들어야 합니다.

그래서 타입을 제네릭으로 분리했습니다

class PermissionRulesBuilder<State extends string, View extends string, Action extends string> {
  private rules = new Map<State, { views: Set<View>; actions: Set<Action> }>();
  // forState, withViews, withActions, build — 구조는 RulesBuilder와 동일
}
 
// 도메인마다 타입만 넣으면 같은 패턴을 재사용할 수 있습니다
const orderBuilder = new PermissionRulesBuilder<ProductOrderStatus, OrderViewKey, OrderItemAction>();
const exchangeBuilder = new PermissionRulesBuilder<ExchangeViewStatus, ExchangeViewKey, ExchangeActionKey>();
const returnBuilder = new PermissionRulesBuilder<ReturnViewStatus, ReturnViewKey, ReturnActionKey>();

각 도메인에서 .forState().withViews().withActions() 체이닝으로 규칙을 선언하는 방식은 동일합니다. 그리고 도메인별로 Hook을 만들어서 컴포넌트에서는 주문과 똑같이 canView, canPerform으로 질문만 하면 됩니다.


구조 요약

최종적으로 상태 권한 관리는 이런 계층으로 정리됐습니다

1. 타입 정의        State, View, Action (도메인별 union type)
2. PermissionRules  Builder로 선언 → Map<상태, {뷰Set, 액션Set}>
3. Hook             usePermissions(status) → { canView, canPerform }
4. 컴포넌트         {canView('shippingInfo') && <ShippingInfo />}

각 계층이 하나의 역할만 합니다. PermissionRules는 상태별 뷰와 액션 권한을 선언하고, Hook은 컴포넌트가 쓰기 편하게 감싸주는 역할입니다.


돌아보며

규칙은 한 곳에 모아두고, 컴포넌트는 물어보기만 하면 됩니다. 규칙이 바뀌면 Builder 선언부만 고치면 되고, 컴포넌트는 건드릴 필요가 없습니다.

처음에는 주문 상태에만 적용했지만, 제네릭으로 설계해두니 교환·반품이 추가됐을 때도 같은 구조를 그대로 쓸 수 있었습니다.