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

---
개발

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

들어가며

웹 애플리케이션에서의 라우팅은 보통 페이지를 전환하는 행위이다. 라우팅을 또 다른 관점에서 보면, 단순히 URL을 바꾸는 것을 넘어 상태를 전이시키고 특정 프로세스를 진행시키는 역할을 하기도 한다.

WMS를 개발하면서 물류는 여러 프로세스가 연속적으로 이어지며 흐르고 있고 각 단계 안에서도 사용자 액션이나 데이터 상태에 따라 다음 단계로 넘어가거나, 중간에 다른 화면으로 빠졌다가 복귀하는 흐름이 반복된다. 결국 이건 단순한 “페이지 이동”이라기보다 각 페이지가 가지고 있는 상태에 따라서 다음 라우트를 결정하는 상태 전이에 더 가깝다고 생각했다.

이번 글에서는 FSM(Finite State Machine)에서 아이디어를 얻어 State(상태)와 Event(이벤트)를 정의하고 특정 이벤트가 발생했을 때 Transition(전이)를 통해 다음 상태로 이동할지 규칙으로 표현하여 복잡한 라우팅 프로세스를 단순하게 해결한 경험을 공유해보자.


문제점: 산재된 라우팅 조건

초기에는 각 페이지에서 if/else 문의 복잡한 조건으로 다음 경로를 하드코딩하고 있었고 페이지, 훅, 컴포넌트 등 코드베이스 전반에 각각의 조건과 라우팅 경로가 매우 복잡하게 산재되어있었다. “이 상태에서 이 이벤트가 발생하면 어디로 이동해야 하는가?”라는 핵심 규칙이 여러 파일에 흩어져있었다.

물류에서 상품이 출고가 될때 진행되는 패킹프로세스의 흐름은 대략 아래와 같다.

문제는 각 단계 안에서도 사용자 액션, 데이터 상태등에 따라 다음 라우트가 계속 달라진다. 그런데 이 조건과 규칙이 코드베이스 전체에 흩어져 있었기 때문에 정확한 비즈니스 로직을 파악하기가 매우 곤란했다.

// 컴포넌트에 박혀있는 라우팅 로직 (버튼 클릭 시점)
const 박스변경완료버튼 = () => {
  const 클릭 = () => {
    if (박스없음체크()) 이동("예외처리페이지");
    else 이동("포장재선택완료페이지");
  };

  return <button onClick={클릭}>박스변경완료</button>;
};

// 커스텀 훅에 산재되어있는 라우팅 로직 (비동기 성공 콜백 시점)
const use카트스캔 = () => {
  return 요청("카트스캔", 카트스캔하기, {
    성공: () => {
      if (취소주문존재체크()) 이동("취소스캔페이지");
      else if (개별피킹체크()) 이동("개별피킹페이지");
      else if (송장발행완료체크()) 이동("패킹완료페이지");
    },
  });
};

// 페이지에 산재되어있는 라우팅 로직 (핸들러 / 마운트 시점)
const 스캔페이지 = () => {
  const 스캔완료 = () => {
    if (알수없는정책조건체크()) 이동("송장발행페이지"); // 왜 여기로?
    else if (단일피킹체크()) 이동("단일피킹페이지");
  };

  useEffect(() => {
    if (출고완료상태체크()) 이동("스캔페이지");
  }, []);

  return <button onClick={스캔완료}>스캔완료</button>;
};

이 상태에서 가장 큰 문제는 코드를 봐도 한 번에 알 수 없다는 점이었다. 다음 프로세스로 이동하는 정책을 이해하려면 핸들러 함수를 보고, 훅 내부 콜백을 보고, 페이지의 라이프사이클까지 따라가야 한다. 이렇게 관리되는 파일이 무수히 많았기 때문에 라우팅 조건을 한 번에 알기란 쉽지 않았다.

그래서 질문에 답을 찾으려먼 파일을 여러 개 뒤져야 했고, 결국에는 백엔드 개발자나 PM에게 정책을 물어보며 흐름을 재확인하는 일이 반복되었다.


해결 방안: 전이 기반 라우팅

이 문제를 해결하기 위해 FSM(Finite State Machine)에서 아이디어를 얻었다.

FSM은 시스템이 가질 수 있는 한정된 수의 상태를 정의하고, 특정 조건(이벤트)에 따라 한 상태에서 다른 상태로 전이(Transition)하며 동작을 제어하는 모델이다

물류 프로세스에서도 단순히 URL을 바꾸는 행위가 아니라 현재 어떤 단계에 있는가(상태)와 무슨 일이 일어났는가(이벤트)에 따라 다음 단계(다음 프로세스)가 결정되는 구조였다.

예를 들어 같은 ‘스캔 완료’ 이벤트더라도 피킹 타입이나 취소 주문 여부 같은 조건에 따라 다음 결정되는 페이지는 다르다.

FSM에서 말하는 State,Event,Transition은 현재 나의 상황에서도 이렇게 정의해볼 수 있다.

  • State: 한정된 수의 상태 = 한정된 수의 페이지
  • Event: 특정 조건 = 패킹 프로세스 내에서 분기 조건
  • Transition: 다른 상태로 전이 = 다른 페이지로 이동

그래서 FSM의 구조를 그대로 이 문제에 대입해보기로 했다. 상태는 현재 패킹 프로세스의 단계(페이지)로 두고, 이벤트(Event)는 사용자의 액션으로 두었다.

그리고 이 두가지가 결합되었을때 어떠한 전이(Transition)이 선택되는지 정의하면, 결국 다음 라우트(Next Route)가 결정되는 형태로 모델링을 했다.

State + Evnet → Transition → Next Route/State

이 모델의 장점은 라우팅 규칙을 한곳에 선언적으로 모아놓을 수 있다. 이 선언적 전이는 곧 프로세스의 정책이 되고, 정책은 코드내에서 읽히는 문서처럼 활용할 수 있었다.

이제 자세히 어떻게 구현했는지 살펴보자.


아키텍처 설계

  • 전이 규칙을 SSOT(Single Source of Truth)로 두기
  • 조건은 재사용 가능한 형태로 레지스트리로 분리하기
  • 라우팅 엔진(머신)은 순수 함수로 결정을 내리기만 하도록 구현하기

State + Event (+ Context)가 들어오면 다음 상태/라우트를 반환하고, 그 외의 부수효과는 만들지 않는 구조로 잡았다.

그리고 엔진은 반환한 결과를 받아서 다음 경로로 이동하는 액션만 수행한다. 이 방식으로 복잡한 라우팅 처리는 UI에서 분리되고, 정책이 변경되었을 땐 화면 코드를 수정하는 일이 아니라 Transition 정의를 수정하는 일로 바꿀 수 있다.


1. 상태(프로세스)와 이벤트를 먼저 고정하자.

이전에는 페이지마다 문자열로 상태를 비교하고 이벤트를 해석했기 때문에,

  • 오타 하나로도 오류가 발생할 수 있었고
  • 어디까지가 상태이고 어디까지가 조건인지 경계가 모호했고
  • 무엇보다 전체 흐름을 한눈에 보기 어려웠다.

그래서 상태와 이벤트를 타입과 상수로 먼저 고정했다. 상태는 현재 프로세스 단계(페이지)로 두고, 이벤트는 사용자 액션/시스템 액션으로 둔다.

export type State =
  | "카트스캔"
  | "컨테이너스캔"
  | "검수"
  | "포장재선택"
  | "패킹확정"
  | "송장"
  | "완료"
  | "종료"
  | "취소예외"; // ...등등

export type Event =
  | "카트스캔완료"
  | "검수완료"
  | "포장재선택완료"
  | "패킹완료"
  | "박스변경"
  | "박스변경완료"
  | "송장추가"
  | "종료"; // ...등등

export interface Transition {
  id: string;
  to: State;
  when?: string;     // 조건 이름
  priority?: number; // 우선순위
  because: string;   // 정책 설명
}

정책 문서가 없는 환경에서는 결국 “왜 이 흐름이 이렇게 되어있지?”라는 질문이 반복되는데, 이 질문에 대해 코드가 스스로 답을 할 수 있어야 했다. 그래서 전이는 단순히 to만 가지는 게 아니라, 도메인 정책(결정 이유)을 설명하는 문장 because을 함께 들고 있도록 만들었다.

  • 디버깅에서 “왜 이 전이가 선택됐는지”를 즉시 알 수 있고
  • 팀 커뮤니케이션에서 “누구의 기억”이 아니라 “코드에 적힌 정책”을 기준으로 합의할 수 있고
  • 운영 중에도 트레이스 로그에 because를 남기면 케이스 분석이 훨씬 빨라진다.

export const ROUTING_STATE = {
  WRO_SCAN: "카트스캔",
  CONTAINER_SCAN: "컨테이너스캔",
  INSPECTION: "검수",
  PACKAGING_MATERIAL_SELECT: "포장재선택",
  PACKING_PROCESSING: "패킹확정",
  INVOICE: "송장",
  COMPLETE: "완료",
  END: "종료",
  CANCEL_EXCEPTION: "취소예외",
} as const;

export const ROUTING_EVENT = {
  SCAN_CART: "카트스캔완료",
  COMPLETE_INSPECTION: "검수완료",
  COMPLETE_PACKAGE_SELECT: "포장재선택완료",
  COMPLETE_PACKING: "패킹완료",
  CHANGE_BOX: "박스변경",
  COMPLETE_BOX_CHANGE: "박스변경완료",
  ADD_INVOICE: "송장추가",
  DONE: "종료",
} as const;

2. 라우팅 조건을 등록하는 레지스트리 구현

전이기반 라우팅을 구현하면서 고민했던 부분은 전이가 발생하는 조건을 어떻게 관리하느냐였다. 상태와 이벤트를 고정하는것 까지는 비교적 단순하지만 실제 흐름을 갈라놓는건 대부분 조건이기 때문이다.

초기에 이 조건들이 흩어져 있을 때는 정책이 어디에 숨어있는지를 찾는 것부터 비용이 들었다. 그래서 조건을 전이 정의 내부에 인라인으로 쓰기보다, 재사용 가능한 predicate로 분리해서 중앙 레지스트리로 관리하기로 했다.

export const predicates = {
  // 피킹 유형
  전체피킹인가: (ctx) => ctx.피킹유형 === "전체피킹",
  개별피킹인가: (ctx) => ctx.피킹유형 === "개별피킹",
  로봇피킹인가: (ctx) => ctx.피킹유형 === "로봇피킹",
  개별또는로봇피킹인가: (ctx) => ["개별피킹", "로봇피킹"].includes(ctx.피킹유형),

  // 송장/트래킹 관련
  국내배송: (ctx) => ctx.국내배송 === true
  해외배송: (ctx) => ctx.해외배송 === true

  // 프로세스 스킵 정책
  송장후공정스킵여부: (ctx) =>
    ctx.로봇피킹 === true &&
    ctx.송장발행완료 === true &&
    ctx.송장후공정스킵옵션 === true,

  // 포장재 선택 여부
  포장재선택됨: (ctx) => ctx.포장재선택완료 === true,

  // 취소 주문 여부
  취소주문있음: (ctx) => ctx.취소주문존재 === true,
} as const;

위와 같이 함수의 이름을 통해 조건을 파악할 수 있게 정의하면 송장후공정스킵여부는 “로봇피킹 + 송장발행 완료 + 송장 이후 공정 스킵 설정”이라는 정책을 하나의 이름으로 캡슐화할 수 있다.

덕분에 Transition 객체는 복잡한 boolean 조합을 직접 품지 않는다. ctx.송장발행완료 && ctx.송장후공정스킵옵션 && ... 같은 조건식이 Transition 정의에 길게 작성되어있지 않을 수 있게 된다.

이렇게 하게되면 Transition 정의는 “어떤 상태에서 어떤 이벤트가 발생했을 때, 어떤 조건이면 어디로 간다.”를 선언적으로 보여주는 표가 되고 실제 조건 계산은 predicate 레지스트리에서만 책임지게 된다.

아래는 그 전이 테이블의 예시이다.

// transitions.ts (예시)
{
  id: "insp_skip_to_complete",
  to: ROUTING_STATE.COMPLETE,
  when: "canSkipPostInvoiceSteps",
  priority: 10,
  because: "로봇피킹 + 송장발행 완료 + 스킵 옵션 ON이면 검수 후 바로 완료 처리",
}

다음은 실제로 Transition을 어떻게 구현했는지 보자


3. 라우팅 전이(Transition) 정의

Transition(전이) 정의 파일은 정책문서처럼 보이게 만드는 게 목표였다. 예를들어 이런 질문에 대해 코드를 열어보는 것만으로 답을 낼 수 있는 상태가 된다면 어떨까?

  • 지금 상태에서 이 이벤트가 발생하면 어디로 가야 할까?
  • 그렇다면 어떤 조건일 때만 그쪽으로 가는 거지?
  • 조건이 여러 개면 어떤 게 우선순위일까?
  • 그리고 왜 그렇게 설계된 걸까?

그래서 전이 정의에는 최소한 아래 정보가 들어가도록 했다.

  • from(State): 현재 단계(상태)
  • event(Event): 발생한 이벤트
  • to(State): 다음 단계
  • when(predicate): 조건(있다면)
  • priority: 조건이 겹칠 때 우선순위
  • because: “왜 이 전이가 존재하는지”를 남기는 정책 설명

그래서 최종적으로 Transition(전이) 파일은 아래와 같이 선언적으로 표현할 수 있고 이 코드는 결국 정책문서 처럼 읽을 수 있다.

type TransitionMap = {
  [S in RoutingState]?: {
    [E in RoutingEvent]?: Transition[];
  };
};

export const transitions: TransitionMap = {
  /**
   * 검수중 상태
   */
  [ROUTING_STATE.INSPECTION]: {
    [ROUTING_EVENT.COMPLETE_INSPECTION]: [
      {
        id: "검수완료_취소주문_취소예외로",
        to: ROUTING_STATE.CANCEL_EXCEPTION,
        when: "취소주문있음",
        priority: 30,
        because: "취소 주문은 검수 완료 후 예외(취소) 처리로 이동",
      },
      {
        id: "검수완료_스킵정책_완료로직행",
        to: ROUTING_STATE.COMPLETE,
        when: "송장후공정스킵가능",
        priority: 20,
        because: "스킵 정책에 해당하면 검수 후 바로 완료 처리",
      },
      {
        id: "검수완료_기본_포장재선택으로",
        to: ROUTING_STATE.PACKAGING_MATERIAL_SELECT,
        priority: 0,
        because: "기본: 포장재 선택이 필요하므로 포장재 선택 단계로 이동",
      },
    ],
  },

  /**
   * 포장재 선택 상태
   */
  [ROUTING_STATE.PACKAGING_MATERIAL_SELECT]: {
    [ROUTING_EVENT.COMPLETE_PACKAGE_SELECT]: [
      {
        id: "포장재선택완료_패킹확정으로",
        to: ROUTING_STATE.PACKING_PROCESSING,
        priority: 0,
        because: "포장재 선택 완료 후 패킹 확정 단계로 이동",
      },
    ],
    [ROUTING_EVENT.CANCEL]: [
      {
        id: "포장재선택취소_검수로복귀",
        to: ROUTING_STATE.INSPECTION,
        priority: 0,
        because: "포장재 선택 취소 시 검수 단계로 복귀",
      },
    ],
  },
} as const;

이렇게 전이를 선언적으로 정의하게 되면 “이 상태(페이지)에서 이 액션(이벤트)이 일어났을 때 어디로(to) 전이되는지”를 한눈에 파악할 수 있다.

예를 들어 INSPECTION(검수) 상태를 보면, COMPLETE_INSPECTION(검수 완료) 이벤트가 발생했을 때 가능한 전이들이 한 곳에 모여 있다. 취소 주문이면 CANCEL_EXCEPTION으로 빠지고, 스킵 정책에 해당하면 COMPLETE로 직행하고, 그 외에는 기본적으로 PACKAGING_MATERIAL_SELECT로 이동한다.

이게 중요한 건 “검수 완료 후 다음 단계”라는 도메인 정책이 코드베이스 여기저기가 아니라 검수 상태 블록 안에 전부 모여 있다는 점이다. 그래서 검수 단계에서 무슨 일이 일어나는지 확인하려면 이제 다른 파일을 열 필요가 없다.

이 구현으로 인해 처음 목표였던

  • 라우팅 규칙을 단일 진실 공급원(SSOT)로 만들자
  • 코드를 보면 정책문서처럼 읽힐 수 있게 만들자

라는 2가지 목표를 달성했다.


4. 라우팅 엔진 - 최종 라우팅이 결정 및 실행되는 지점

Transition 정의가 정책 문서라고 하면 라우팅 엔진은 이 정책문서를 실제로 실행하는 부분이다. 더이상 다음라우터를 UI, 페이지, 훅들이 판단하지 않고, 지금 상태(from)에서 어떤 이벤트(event)가 발생했는지, 그리고 그 판단에 필요한 컨텍스트(ctx)만 넘긴다.

그러면 엔진은 그 입력을 받아서 어떻게 전이되어야하는지 평가하고, 다음 상태(to)와 라우트(route)를 결정해서 돌려준다. 결국 이 구조에서 엔진이 맡는 역할은 아래와 같다.

그리고 라우팅 엔진은 아래 순서로 동작한다.

  1. transitions[from][event]에서 후보 전이를 가져온다.
  2. 각 전이에 when이 있으면 predicate를 평가해서 통과한 전이만 남긴다.
  3. 남은 전이를 priority로 내림차순 정렬한다.
  4. 가장 높은 우선순위 전이를 선택한다.
export function decideNext({ from, event, ctx = {} }) {
  const 전이목록 = transitions[from]?.[event];
  if (!전이목록?.length) throw new Error("경로없음");

  const 후보 = 전이목록
    .filter((t) => !t.when || (predicates[t.when] && predicates[t.when](ctx)))
    .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));

  const 선택 = 후보[0];
  if (!선택) throw new Error("매칭실패");

  return {
    to: 선택.to,
    route: STATE_TO_ROUTE[선택.to],
    because: 선택.because,
  };
}

실제 사용 예시

예를 들어 검수 페이지에서는 “검수 완료” 버튼을 누르는 순간 다음 라우트를 if/else로 판단하지 않는다. 대신 현재 상태가 INSPECTION이고 이벤트가 COMPLETE_INSPECTION이라는 사실과, 판단에 필요한 컨텍스트만 넘긴다. 그렇게 되면 다음 상태(라우팅)가 결정되게된다.

이제 어떻게 코드에 적용했는지 이전 코드와 함께 살펴보자.

// before
const handleCompleteInspection = async () => {
  if (취소주문존재체크()) {
    router.push(ROUTES.취소예외);
    return;
  }

  if (송장후공정스킵정책해당체크()) {
    router.push(ROUTES.패킹완료);
    return;
  }

  if (포장재선택완료체크()) {
    router.push(ROUTES.패킹확정);
    return;
  }

  router.push(ROUTES.포장재선택);
};

Before: 이전 코드는 위와 같이 페이지가 모든 라우팅 처리를 다 담당하고 있고 비즈니스 로직도 섞여있다.

// after
import { ROUTING_EVENT, ROUTING_STATE } from "./constants";
import { decideNext } from "./engine";

const handleCompleteInspection = () => {
  const { route, transitionId, because } = decideNext({
    from: ROUTING_STATE.검수페이지,
    event: ROUTING_EVENT.검수완료이벤트,
    ctx: {
      취소주문존재: false,
      피킹유형: "단일피킹",
      로봇피킹: false,
      송장발행완료: true,
      송장후공정스킵옵션: true,
      포장재선택완료: false,
    },
  });

  if (route) router.push(route);
  console.info("[라우팅]", transitionId, because);
};

After: 이렇게 페이지는 어떤 이벤트가 발생했다만 엔진에게 전달하고 어디로 이동할지는 관심사 밖이며 엔진이 던져주는 route로 단순히 이동만 하면 된다.


마무리

이 구조를 도입하고난 뒤 복잡한 분기 조건이 한 곳에 모이니까 전체 흐름을 이해하기가 훨씬 쉬워졌고 새로운 예외나 변경이 들어와도 “이게 어디에 영향 가지?”를 감으로 때리는 게 아니라 transitions만 열어서 해당 상태/이벤트를 보면 바로 가늠할 수 있었다. 예전에는 분기들이 페이지 안에 숨어 있어서 영향 범위를 찾는 것부터 일이었는데, 지금은 정책의 정답이 한 곳에 고정되어 있으니 작업 또한 훨씬 단순해졌다.

또 하나 좋았던 건 문서가 부족한 환경에서 코드가 정책문서 역할을 하기 시작했다. “왜 여기서 저기로 가요?” 같은 질문이 오면 이제 누군가의 기억이나 추측으로 설명하는 대신, transitions를 같이 보면서 “여기 정의가 이렇게 되어 있어서 그렇다”라고 답할 수 있다.

그리고 목표는 거창한 상태 머신을 도입하는 게 아니라 매번 팀원들에게 물어보던 그 알 수 없는 정책들에 대해 개발자가 코드를 보며 스스로 답할 수 있게 만드는 것이었다.

결과로 상태전이 기반 라우팅을 도입하면서 복잡한 라우팅 조건이 한 곳에 모였고, 이 다음으로는 팀이 합의한 정책으로 관리를 해보려고 한다.

목차