주문 상태별 권한을 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 선언부만 고치면 되고, 컴포넌트는 건드릴 필요가 없습니다.

처음에는 주문 상태만 적용 후, 확장하면서 제네릭으로 설계했기 때문에 새로운 도메인이 추가되더라도 같은 구조를 그대로 쓸 수 있게 됐습니다.