상태 설계
상태 설계는 업무 객체가 현재 어떤 단계에 있고, 다음에 어떤 행동을 허용할지 정하는 기준이다. 유통 시스템에서는 주문, 출고, 피킹, 검수, 배송, 반품, 정산, 외부 연동이 모두 상태를 가진다. 상태가 모호하면 화면 버튼, API 검증, 재고 반영, 취소 처리, 장애 재처리가 모두 흔들린다.
상태값은 화면 표시용 문구가 아니라 업무 규칙이다. 화면에서 버튼을 숨겼더라도 백엔드 API에서 같은 상태 전이 검증을 반드시 수행해야 한다.
개념 정의
좋은 상태 설계는 다음 세 가지를 분리한다.
| 구분 | 설명 | 예시 |
|---|---|---|
| 업무 상태 | 주문이나 출고가 실제 업무상 어디까지 진행됐는지 | ORDER_CONFIRMED, PICKING, SHIPPED |
| 작업 상태 | 작업자가 수행하는 단위 작업의 진행 상태 | ASSIGNED, IN_PROGRESS, DONE |
| 연동 상태 | 외부 시스템에 데이터가 전송됐는지 | PENDING, SENT, FAILED |
하나의 status에 모든 의미를 넣으면 "출고는 확정됐는데 ERP 전송은 실패" 같은 상황을 표현하기 어렵다. 그래서 업무 상태와 연동 상태는 보통 분리한다.
실제 업무 흐름
주문부터 정산까지의 상태는 하나의 긴 줄처럼 보이지만 실제로는 여러 도메인이 맞물려 움직인다.
이 흐름에서 주문 상태, 출고 상태, 배송 상태, 정산 상태를 모두 하나로 합치면 부분 출고, 부분 배송, 반품 후 재정산을 다루기 어렵다. 도메인별 상태를 따로 두고, 상위 화면에서는 이를 조합해 보여주는 방식이 실무에서 더 안전하다.
화면/기능 관점
상태는 화면에서 가능한 기능을 결정한다.
| 화면 | 상태 기준 | 노출 기능 |
|---|---|---|
| 주문 상세 | 주문 상태 | 취소, 부분 취소, 출고 요청 |
| 출고 상세 | 출고 상태 | 피킹 지시, 검수 완료, 출고 확정, 출고 취소 |
| 배송 상세 | 배송 상태 | 송장 출력, 배송 추적, 배송 완료 확인 |
| 반품 상세 | 반품 상태 | 회수 접수, 검수 완료, 환불 요청 |
| 정산 상세 | 정산 상태 | 금액 확정, 세금계산서 발행, 마감 |
프론트엔드는 상태에 따라 버튼을 숨기거나 비활성화할 수 있지만, 최종 판단은 백엔드가 해야 한다. 여러 사용자가 동시에 같은 주문을 처리할 수 있고, 외부몰이나 택배사 콜백이 화면보다 먼저 들어올 수 있기 때문이다.
백엔드 API 관점
상태 변경 API는 단순히 PATCH /status처럼 만들기보다 업무 행위 중심으로 설계하는 편이 좋다.
| API | 의미 | 허용 전이 |
|---|---|---|
POST /orders/{id}/confirm | 주문 확정 | RECEIVED -> CONFIRMED |
POST /orders/{id}/cancel | 주문 취소 | RECEIVED, CONFIRMED -> CANCELED |
POST /outbounds/{id}/start-picking | 피킹 시작 | ALLOCATED -> PICKING |
POST /outbounds/{id}/confirm | 출고 확정 | PACKED -> CONFIRMED |
POST /returns/{id}/inspect | 반품 검수 | ARRIVED -> INSPECTED |
업무 행위 API는 로그를 읽기 쉽고 권한을 나누기 쉽다. 또한 API 내부에서 수량, 재고, 마감, 중복 요청, 멱등성 키를 함께 검증할 수 있다.
const transitions = {
RECEIVED: ['CONFIRMED', 'CANCELED'],
CONFIRMED: ['OUTBOUND_REQUESTED', 'CANCELED'],
OUTBOUND_REQUESTED: ['PICKING', 'CANCELED'],
PICKING: ['PICKED'],
PICKED: ['INSPECTED'],
INSPECTED: ['PACKED'],
PACKED: ['CONFIRMED'],
CONFIRMED_OUTBOUND: ['DELIVERY_REQUESTED'],
} as const;
데이터베이스 테이블 관점
현재 상태와 상태 이력은 분리한다.
| 테이블 | 역할 |
|---|---|
orders | 주문 현재 상태와 주문 요약 |
outbound_orders | 출고 현재 상태와 창고 작업 요약 |
deliveries | 배송 현재 상태와 송장 정보 |
returns | 반품 현재 상태와 판정 결과 |
status_history | 상태 변경 이력 |
integration_messages | 외부 연동 처리 상태 |
상태 이력 테이블 예시는 다음과 같다.
| 컬럼 | 설명 |
|---|---|
id | 상태 이력 ID |
entity_type | ORDER, OUTBOUND, DELIVERY, RETURN |
entity_id | 대상 업무 객체 ID |
from_status | 변경 전 상태 |
to_status | 변경 후 상태 |
action | 상태를 바꾼 업무 행위 |
reason_code | 취소, 보류, 실패 사유 |
changed_by | 사용자 또는 시스템 |
changed_at | 변경 시각 |
상태값 예시
| 도메인 | 상태 예시 |
|---|---|
| 주문 | RECEIVED, CONFIRMED, PARTIAL_CANCELED, CANCELED |
| 출고 | REQUESTED, ALLOCATED, PICKING, PACKED, CONFIRMED, CANCELED |
| 배송 | READY, INVOICED, SHIPPED, DELIVERING, DELIVERED, FAILED |
| 반품 | REQUESTED, PICKUP_REQUESTED, ARRIVED, INSPECTED, REFUNDED, CLOSED |
| 정산 | WAITING, CONFIRMED, CLOSED, ADJUSTED |
| 연동 | PENDING, PROCESSING, SUCCESS, FAILED, RETRY_WAITING |
예외 상황
| 예외 | 설계 방향 |
|---|---|
| 같은 API가 두 번 호출됨 | 멱등성 키나 unique key로 같은 결과를 반환 |
| 배송 완료 후 주문 취소 요청 | 취소가 아니라 반품 프로세스로 전환 |
| ERP 전송 실패 | 업무 상태는 유지하고 연동 상태만 실패 처리 |
| 부분 출고 | 주문 상태와 출고 상태를 별도로 관리 |
| 마감 후 상태 변경 | 직접 수정 대신 보정 전표나 조정 이벤트 생성 |
실무에서 자주 생기는 문제
DONE,COMPLETE,CONFIRMED가 섞여 의미가 불분명해진다.status와isCanceled,isConfirmed같은 플래그가 서로 다른 값을 가진다.- 화면에서는 버튼이 막혀 있지만 API는 직접 호출하면 상태가 바뀐다.
- 출고 확정 후 ERP 연동 실패를 출고 실패로 되돌려 재고가 꼬인다.
- 상태 이력이 없어 누가 언제 어떤 사유로 취소했는지 추적하지 못한다.
설계 시 주의사항
상태는 업무 행위와 함께 설계해야 한다. "어떤 상태가 있는가"보다 "어떤 상태에서 어떤 행위를 허용하는가"가 더 중요하다. 상태 전이표, 권한, 재고 영향, 정산 영향, 외부 연동 영향을 함께 문서화하면 개발자와 운영자가 같은 기준으로 판단할 수 있다.
상태명을 만들 때는 화면 문구보다 업무 의미를 우선한다. STEP_1, STEP_2보다 PICKING, INSPECTED, CONFIRMED가 운영 추적에 유리하다.
간단한 예시 테이블
| entity_type | entity_id | from_status | action | to_status | changed_by |
|---|---|---|---|---|---|
ORDER | ORD-1001 | RECEIVED | CONFIRM | CONFIRMED | system |
OUTBOUND | OUT-2001 | ALLOCATED | START_PICKING | PICKING | worker-17 |
OUTBOUND | OUT-2001 | PACKED | CONFIRM | CONFIRMED | manager-01 |
DELIVERY | DLV-3001 | SHIPPED | COMPLETE_DELIVERY | DELIVERED | courier-api |