본문으로 바로가기
검색 (Ctrl+K)

복잡한 WMS 라우팅을 상태전이 기반으로 단순화 하기

·
---
·
개발

WMS에서 흩어진 if/else 라우팅 분기를 FSM 관점의 상태·이벤트·전이로 재구성해 복잡한 라우팅 관리를 해결하고 코드를 정책 문서처럼 관리해보자

들어가며

WMS(창고관리시스템)의 주요 프로세스는 작업자가 바코드를 스캔하면서 진행된다. 카트(WRO)를 스캔하고, 상품을 검수하고, 포장재를 확인하고, 송장을 발행하고, 상차 완료 처리를 한다. 화면으로 보면 13개의 단계(상태)가 있고, 작업자는 이 단계들을 순서대로 통과해야한다.

문제는 이 흐름이 직선이 아니라는 점이다. 같은 "카트 스캔 완료"라도 피킹 타입이 총량피킹(TOTL)이면 WRO 선택 화면으로, 싱글오더피킹(SNGL)이면 패킹 슬립 스캔 화면으로 가야 한다. 취소 대기 주문이 끼어 있으면 정상 흐름을 멈추고 취소 처리 화면으로 빠져야 한다. 검수가 끝났을 때도 특정 조건이 모두 맞으면 중간 단계를 통째로 건너뛰고 완료 화면으로 직행한다.

즉, 여기서의 라우팅은 단순한 페이지 이동이 아니라, 현재 어떤 단계에 있고(상태), 방금 무슨 일이 일어났는지(이벤트)에 따라 다음 단계가 결정되는 상태 전이다. 이 글은 그 관점으로 라우팅을 다시 설계해서, 코드 전체에 흩어져 있던 분기 조건을 테스트 가능한 정책 테이블 한 곳으로 모은 경험을 정리한 것이다.

상태 전이 흐름
상태 전이 흐름

문제: 코드 어디에도 전체 흐름이 없다

초기 구현에서 라우팅 조건은 이벤트가 "실행되는 시점"에 붙어 있었다. 버튼 핸들러에, 뮤테이션 성공 콜백에, 페이지 마운트 시점의 useEffect에. 당시 코드를 단순화하면 이런 모양이었다.

// pages/outbound/process/inspection/index.js (당시 구조를 단순화한 예시)
const handleCompleteInspection = async () => {
  if (hasCancelOrder) {
    router.push(ROUTES.CANCEL_WORK);
    return;
  }
  if (isTotalPicking && isInvoiced && skipProcessOnPostInvoice) {
    router.push(ROUTES.COMPLETE);
    return;
  }
  router.push(boxFlag ? ROUTES.PROCESSING : ROUTES.PROCESSING2);
};

한 파일만 보면 별문제 없어 보인다. 하지만 이런 분기가 13개 단계 각각의 페이지, 훅, 콜백에 나뉘어 있었고, 판단 재료(피킹 타입, 송장 발행 여부, 계정 타입 등)는 MobX 스토어와 sessionStorage 곳곳에서 꺼내 쓰고 있었다. 그 결과

  • "검수가 끝나면 어디로 가는가?"에 답하려면 파일을 서너 개 열어야 했다. 핸들러를 보고, 훅의 성공 콜백을 보고, 페이지의 useEffect까지 따라가야 전체 그림이 나왔다.
  • 정책의 근거가 코드에 없었다. skipProcessOnPostInvoice가 왜 존재하는지, 어떤 계정에만 적용되는지는 코드만 봐서는 알 수 없어서 결국 PM이나 백엔드 개발자에게 물어보는 일이 반복됐다.
  • 분기가 어긋나면 작업자가 잘못된 화면에 진입했다. 실제로 특정 조건에서 폐기 예정인 processing2 화면으로 잘못 진입하는 버그가 있었는데, 원인 분기가 어디 숨어 있는지 찾는 것부터가 일이었다.

핵심 규칙인 "이 상태에서 이 이벤트가 발생하면 어디로 가는가"가 코드베이스 전체에 암묵적으로만 존재했던 문제가 있었다.


모델: State + Event + Context → Transition

해결의 아이디어는 FSM(Finite State Machine)에서 가져왔다. FSM은 시스템이 가질 수 있는 한정된 상태를 정의하고, 이벤트에 따라 상태 간 전이(Transition)를 규칙으로 표현하는 모델이다. 패킹 프로세스에 그대로 대입하면:

  • State — 프로세스의 단계. 화면과 1:1로 대응한다 (WRO_SCAN, INSPECTION, PROCESSING, INVOICE, COMPLETE, …)
  • Event — 그 단계에서 일어나는 사용자/시스템 액션 (SCAN_WRO, COMPLETE_INSPECTION, CHANGE_BOX, …)
  • Context — 전이를 판단할 때 필요한 데이터 (피킹 타입, 취소 주문 여부, 송장 발행 여부, …)
  • Transition — "이 상태에서 이 이벤트가 발생했고 이 조건이면, 저 상태로 간다"는 규칙

설계 원칙은 세 가지로 잡았다.

  1. 전이 규칙을 한 곳에 선언적으로 모은다. 이 테이블이 라우팅 정책의 단일 진실 공급원(SSOT)이 된다.
  2. 결정은 순수 함수가 내린다. (state, event, ctx)를 받아 다음 상태를 반환할 뿐, 라우터를 직접 호출하는 등의 부수효과는 만들지 않는다.
  3. 모든 전이는 자신의 존재 이유(because)를 문장으로 들고 다닌다. 정책 문서가 없는 환경에서 코드가 문서 역할을 대신해야 하기 때문이다.

구현

폴더 구조: 상태별로 정책을 나눈다

전이 정의를 파일 하나에 다 넣으면 그 파일이 새로운 늪이 된다. 그래서 상태(단계)별로 폴더를 나누고, 각 폴더가 자기 단계의 전이·조건·컨텍스트 타입만 소유하게 했다.

src/features/outbound/packing/shared/routes-config/
├── engine.ts              # decideNext — 외부에 노출되는 진입점
├── reducer.ts             # 순수 함수: 전이 후보 평가와 선택
├── route-selector.ts      # State → URL 매핑
├── trace.ts               # 전이 로그(트레이스) 생성
├── validate.ts            # 전이 테이블 무결성 검증
├── types.ts               # 상태/이벤트/컨텍스트 타입 정의
├── __tests__/             # engine, reducer 유닛 테스트
└── routes/
    ├── index.ts           # 조합만 담당하는 중앙 레이어
    ├── wro-scan/          # 단계별: transitions / predicates / types
    ├── inspection/
    ├── processing/
    ├── invoice/
    └── complete/

중앙의 routes/index.ts는 정책을 갖지 않고 조합만 한다.

/** 중앙 조합 레이어: 도메인별 전이를 조합만 한다. */
export const transitions: ComposedTransitions = {
  ...wroScanTransitions,
  ...inspectionTransitions,
  ...processingTransitions,
  ...invoiceStateTransitions,
  ...completeTransitions,
  // ...
};

타입으로 "가능한 것"을 먼저 좁힌다

이전에는 상태도 이벤트도 문자열 비교여서, 오타 하나로 분기가 조용히 어긋날 수 있었다. 그래서 상태와 이벤트를 타입으로 고정하되, 한 걸음 더 나가서 상태별로 발생 가능한 이벤트를 타입 레벨에서 제한했다.

export type RoutingState =
  | "WRO_SCAN" | "TOTAL_WRO_SELECT" | "WRO3" | "WRO2"
  | "SCAN_TRACKING" | "CANCEL_WORK" | "INSPECTION" | "PROCESSING"
  | "CHANGE" | "INVOICE" | "INCONSISTENCY" | "COMPLETE" | "END";

export interface RoutingEventByState {
  WRO_SCAN: "SCAN_WRO";
  INSPECTION: "COMPLETE_INSPECTION";
  PROCESSING: "COMPLETE_PROCESSING" | "CHANGE_BOX" | "BOX_SHORTAGE";
  COMPLETE: "COMPLETE_PACKING" | "SCAN_CART" | "DONE";
  END: never; // 종료 상태에서는 어떤 이벤트도 발생할 수 없다
  // ...
}

컨텍스트도 마찬가지다. 전이 판단에 어떤 데이터가 필요한지는 상태마다 다르므로, 상태별 컨텍스트 타입을 따로 선언했다.

export interface RoutingContextByState {
  WRO_SCAN: {
    pickingTypeCode?: PickingTypeCode | null;
    hasCancelOrder?: boolean | null;
  };
  INSPECTION: {
    accountType?: string | null;
    isTotalPicking?: boolean | null;
    isInvoiced?: boolean | null;
    skipProcessOnPostInvoice?: boolean | null;
    boxFlag?: boolean | null;
  };
  // ...
}

이렇게 하면 INSPECTION 상태에서 SCAN_WRO 이벤트를 넘기는 코드, 검수 전이에 필요 없는 데이터를 욱여넣는 코드가 컴파일 단계에서 걸러진다. "어디까지가 상태이고 어디까지가 조건인지"의 경계가 타입 시그니처 자체로 문서화되는 효과도 있다.

조건은 이름 있는 predicate로

실제 흐름을 갈라놓는 것은 대부분 조건이다. 조건식을 전이 정의에 인라인으로 쓰는 대신, 이름 있는 predicate 함수로 분리해서 각 상태 폴더의 predicates.ts에 모았다.

// routes/inspection/predicates.ts
export const canSkipToComplete = (ctx: InspectionContext): boolean => {
  const isDirectAccount = ctx.accountType === ACCOUNT_TYPE.DIRECT;

  return (
    ctx.isTotalPicking === true &&
    ctx.isInvoiced === true &&
    ctx.skipProcessOnPostInvoice === true &&
    isDirectAccount
  );
};

"총량피킹 + 송장 발행 완료 + 스킵 옵션 + DIRECT 계정"이라는 네 조건의 조합이 canSkipToComplete라는 하나의 이름으로 캡슐화된다. 전이 정의는 이 이름만 참조하므로 boolean 조합을 직접 품지 않는다.

전이 정의: 정책이 표로 읽힌다

전이 정의에는 다섯 가지를 담았다. 어디로 가는지(to), 어떤 조건일 때인지(when), 조건이 겹치면 무엇이 이기는지(priority), 추적용 식별자(id), 그리고 왜 이 규칙이 존재하는지(because). 분기가 가장 많은 카트 스캔 단계의 실제 정의는 이렇다.

// routes/wro-scan/transitions.ts
const scanWroTransitions: TransitionDefinition<WroScanContext>[] = [
  {
    id: "wro_cancel",
    to: "CANCEL_WORK",
    when: hasCancelOrder,
    priority: 100,
    because: "취소 대기 주문이 발견되어 취소 처리 페이지로 이동 (정상 패킹 프로세스보다 우선 처리)",
  },
  {
    id: "wro_totl",
    to: "TOTAL_WRO_SELECT",
    when: isPickingTotl,
    priority: 90,
    because: "총량피킹(TOTL) 타입이므로 총량 패킹 WRO 선택 페이지로 이동",
  },
  {
    id: "wro_sngl_robt",
    to: "WRO3",
    when: isPickingSnglOrRobt,
    priority: 80,
    because: "싱글오더피킹(SNGL) 또는 로봇피킹(ROBT) 타입이므로 패킹 슬립 스캔 페이지로 이동",
  },
  // ... EACH, SNTR/EATR 분기 생략
  {
    id: "wro_fallback",
    to: "WRO2",
    priority: -1,
    because: "기타 모든 경우에 대한 기본 라우트",
  },
];

이 파일을 위에서 아래로 읽으면 그대로 정책 문서다. 취소 주문 처리가 무엇보다 우선하고(100), 피킹 타입별 분기가 이어지고(90~60), 아무 조건에도 걸리지 않으면 기본 경로로 간다(-1). "카트 스캔 후 어디로 가는가?"라는 질문에 이제 다른 파일을 열 필요가 없다.

결정은 순수 함수가, 실패는 명시적으로

reducer.tsreduceRouting은 전이 테이블에서 후보를 찾고, predicate를 평가하고, priority 내림차순으로 정렬해 하나를 고르는 순수 함수다. 여기서 신경 쓴 부분은 실패를 침묵시키지 않는 것이다. 결과는 ok: true | false의 Result 타입이고, 실패에는 명시적인 에러 코드가 붙는다.

  • NO_TRANSITION — 이 상태+이벤트 조합에 전이가 정의되지 않음
  • NO_MATCHING_TRANSITION — 전이는 있지만 조건을 통과한 후보가 없음
  • AMBIGUOUS_TRANSITION — 조건을 통과한 후보 중 최상위 priority가 동률

특히 세 번째가 중요하다. 우선순위가 같은 후보가 둘 남았다는 것은 정책 자체가 애매하다는 뜻이므로, 아무거나 골라서 조용히 이동하는 대신 에러를 낸다.

// reducer.ts
if (candidates.length > 1) {
  const topPriority = candidates[0].priority ?? 0;
  const secondPriority = candidates[1].priority ?? 0;

  if (topPriority === secondPriority) {
    return createFail(state, event, "AMBIGUOUS_TRANSITION",
      `Ambiguous transition for ${state} + ${event}: ${candidates[0].id}, ${candidates[1].id}`);
  }
}

예전에는 이런 정책 충돌이 "가끔 이상한 화면으로 가는 버그"로 발현됐다면, 지금은 개발 단계에서 에러 메시지로 먼저 드러난다.

엔진과 트레이스: because가 로그가 된다

외부에 노출되는 진입점은 decideNext 하나다. 결정하고, 상태를 URL로 변환하고(route-selector.ts), 트레이스를 남긴다.

// engine.ts
export function decideNext({ from, event, ctx }): RoutingResult {
  const decision = assertDecision(reduceRouting({ state: from, event, ctx }));
  const route = selectRouteByState(decision.to);
  const result = { to: decision.to, route, transitionId: decision.transitionId, because: decision.because };

  const trace = makeTrace({ from, event, chosen: result, ctx });

  if (process.env.NODE_ENV === "development") {
    console.info("🔀 ROUTING_TRACE", trace);
  }
  if (process.env.NODE_ENV === "production") {
    console.log(`[ROUTING] ${from} + ${event}${result.to} (${result.transitionId})`);
  }

  return result;
}

트레이스에는 어떤 전이가 왜 선택됐는지(transitionId, because)와 함께, 그 판단에 쓰인 컨텍스트가 담긴다. 이때 상태별 projector가 해당 상태와 관련된 필드만 추려낸다. 덕분에 운영 중 "이 작업자가 왜 이 화면으로 갔지?"라는 문의가 오면, 로그에서 전이 ID와 판단 근거를 바로 확인할 수 있다.

라우팅을 유닛 테스트한다

결정이 순수 함수가 되면서 생긴 가장 큰 보너스는 테스트다. 예전에는 라우팅 분기를 검증하려면 화면을 띄우고 시나리오를 태워야 했지만, 지금은 vitest로 표를 검증하면 된다.

// __tests__/engine.test.ts
it("event -> state transition -> route resolution", () => {
  const result = decideNext({
    from: ROUTING_STATE.WRO_SCAN,
    event: ROUTING_EVENT.SCAN_WRO,
    ctx: { pickingTypeCode: "TOTL" },
  });

  expect(result.to).toBe(ROUTING_STATE.TOTAL_WRO_SELECT);
  expect(result.route).toBe("/outbound/process/totalPacking/wroSelect");
  expect(result.transitionId).toBe("wro_totl");
});

여기에 더해 validate.ts가 전이 테이블 전체를 순회하며 전이 ID 중복과 빈 전이 목록을 잡아낸다. 정책 테이블이 커져도 무결성은 기계가 지킨다.


적용: 페이지는 이벤트만 던진다

이제 검수 페이지의 완료 핸들러는 다음 라우트를 스스로 판단하지 않는다. "내 상태는 INSPECTION이고, 검수 완료 이벤트가 발생했으며, 판단 재료는 이것"이라고 엔진에 알릴 뿐이다.

// b2c-inspection/hooks/useInspectionComplete.tsx (실제 코드)
const { route } = decideNext({
  from: ROUTING_STATE.INSPECTION,
  event: ROUTING_EVENT.COMPLETE_INSPECTION,
  ctx: {
    isTotalPicking,
    isInvoiced: !!selectionData.outboundIdToComplete,
    skipProcessOnPostInvoice,
    boxFlag,
    accountType,
  },
});

router.push(route);

글머리의 before 코드와 비교하면, 페이지에서 정책이 사라졌다. 정책이 바뀌어도 이 파일은 그대로고, routes/inspection/transitions.ts의 표만 바뀐다.


트레이드오프

물론 공짜는 아니다. 도입하면서 감수한 것들도 적어보자.

  • 컨텍스트 수집 책임은 여전히 호출부에 있다. 엔진은 순수 함수라서, 판단에 필요한 데이터를 모아 넘기는 일은 각 페이지/훅의 몫이다. ctx에 잘못된 값을 넣으면 잘못된 전이가 선택된다. 상태별 컨텍스트 타입이 실수를 줄여주지만, 값의 정합성까지 보장하지는 못한다.
  • 모든 라우팅에 쓸 물건은 아니다. 목록에서 상세로 이동하는 단순한 내비게이션까지 전이 테이블로 만들면 오히려 과설계다. 이 구조는 "상태와 조건에 따라 다음 단계가 달라지는 프로세스성 흐름"에만 적용했다.
  • XState 같은 라이브러리를 쓰지 않았다. 필요한 것은 상태 머신 런타임 전체가 아니라 "다음 라우트를 결정하는 순수 함수 하나와 읽기 좋은 정책 테이블"이었다. 직접 만든 코드는 reducer와 engine을 합쳐도 이백 줄이 안 되고, 팀원 누구나 전체를 읽고 이해할 수 있는 크기다. 계층 상태나 병렬 상태가 필요해지는 시점이 오면 그때 라이브러리 도입을 다시 검토해도 늦지 않다고 판단했다.

마무리

목표는 거창한 상태 머신 도입이 아니라, "왜 여기서 저 화면으로 가요?"라는 질문에 사람의 기억이 아니라 코드가 답하게 만드는 것이었다.

지금은 그 질문에 대한 답이 routes/ 아래의 전이 테이블에 있다. 전이마다 붙은 because가 근거를 설명하고, 같은 문장이 운영 로그에 남고, vitest가 표의 무결성을 지킨다. 정책이 바뀌면 표를 고치고 테스트를 고치면 된다. 라우팅 분기를 찾아 코드베이스를 수색하던 시간이, 표 한 곳을 읽는 시간으로 바뀌었다.