복잡한 상태 흐름을 Stepper로 시각화하기
선언적 설계와 상태 머신
배달 앱의 Stepper UI 예시
TL;DR
백엔드 API는
status하나와 히스토리 배열만 준다. 하지만 UI에서는 채널별로 다른 단계 수, 각 단계의 완료/진행중/대기 상태, 취소·반품 같은 비정상 분기까지 시각적으로 표현해야 한다. 이 글은 if/else 분기 대신 스키마(데이터)로 단계를 선언하고, 순수 함수로 상태를 계산하고, 컴포넌트는 렌더링만 담당하는 구조를 공유한다.
문제 상황
배송 추적, 결제 프로세스, 채용 파이프라인 — 대부분의 비즈니스 시스템에는 다단계 프로세스가 있습니다. 그리고 대부분의 서버 API는 이를 이렇게 전달합니다:
{
"status": "PREPARING",
"statusHistories": [
{
"status": "ORDER_CONFIRMED",
"changedAt": "2026-03-10T10:00:00Z"
},
{
"status": "PAYMENT_COMPLETED",
"changedAt": "2026-03-10T10:05:00Z"
},
{ "status": "PREPARING", "changedAt": "2026-03-10T11:30:00Z" }
]
}
flat한 데이터입니다. 현재 상태와 히스토리 배열. 그런데 UI에서는 이것을 이렇게 보여줘야 합니다:
- 주문 유형에 따라 다른 단계 수 (일반배송은 5단계, 매장픽업은 2단계)
- 각 단계의 완료/진행중/대기 상태
- 비정상 분기 (취소, 반품 등)를 시각적으로 구분
- 역할에 따라 다른 액션 버튼
이걸 컴포넌트 안에서 if/else로 처리하면 어떻게 될까요?
if/else 접근의 한계
// 컴포넌트 안에서 조건 분기로 처리
function getSteps(orderType: string, status: string) {
if (orderType === "STANDARD") {
if (status === "ORDER_CONFIRMED")
return ["완료", "대기", "대기", "대기", "대기"];
if (status === "PAYMENT_COMPLETED")
return ["완료", "완료", "대기", "대기", "대기"];
if (status === "CANCEL_REQUESTED")
return ["완료", "완료", "취소요청", "대기", "대기"];
// ... 10개 상태 × 5단계 = 50개 분기
}
if (orderType === "EXPRESS") {
// ... 또 다른 분기
}
// 주문 유형이 추가될 때마다 분기 폭발
}
상태 10개 × 주문 유형 4개 = 40개의 조합. 여기에 비정상 분기까지 더하면 유지보수가 불가능한 코드가 됩니다.
해결 아이디어: 데이터로 정의하고, 코드로 계산한다
핵심은 "어떤 주문 유형이면 어떤 스텝을 보여줄 것인가"를 if/else가 아닌 데이터(스키마)로 정의하는 것입니다.
역할 분리:
- 스키마: 주문 유형별로 “어떤 단계들이 있는지” 선언
- 계산 함수: 서버 데이터 + 스키마를 받아 “각 단계가 어떤 상태인지” 산출
- UI 컴포넌트: 산출된 상태 배열을 받아 렌더링만 담당
신규 주문 유형이 추가되면? 스키마에 배열 1개만 추가. 계산 함수와 UI 코드는 변경하지 않습니다.
1. 스텝 스키마 설계
주문 유형별 단계 정의
각 주문 유형이 어떤 마일스톤을 거치는지를 배열로 선언합니다.
// 주문 유형별 스텝 스키마 — 완료 마일스톤만 포함
const STEPS_BY_ORDER_TYPE = {
// 일반배송: 5단계
STANDARD: [
"ORDER_CONFIRMED",
"PAYMENT_COMPLETED",
"PREPARING",
"SHIPPING",
"DELIVERED",
],
// 당일배송: 4단계 (상품준비 생략, 바로 배송)
EXPRESS: [
"ORDER_CONFIRMED",
"PAYMENT_COMPLETED",
"SHIPPING",
"DELIVERED",
],
// 매장픽업: 3단계
PICKUP: ["ORDER_CONFIRMED", "PAYMENT_COMPLETED", "PICKED_UP"],
// 디지털 상품: 2단계 (결제 즉시 완료)
DIGITAL: ["PAYMENT_COMPLETED", "DELIVERED"],
} as const;
핵심 설계 결정: PENDING은 스키마에 포함하지 않는다
서버 상태에는 PAYMENT_PENDING → PAYMENT_COMPLETED 같은 쌍이 있습니다. 하지만 UI에서 사용자에게 보여줄 마일스톤은 COMPLETED만으로 충분합니다. PENDING은 "아직 완료되지 않은 상태"로서 계산 로직에서 처리합니다.
액션용 스키마 (우측 진행상태 카드)
상단 스테퍼와 별도로, 운영자가 실제로 액션을 수행하는 진행상태 카드도 있습니다.
const PROGRESS_STEPS_BY_ORDER_TYPE = {
STANDARD: [
{
key: "confirm",
matchStatus: "ORDER_CONFIRMED",
label: "주문확인",
},
{
key: "payment",
matchStatus: "PAYMENT_COMPLETED",
label: "결제완료",
},
{ key: "prepare", matchStatus: "PREPARING", label: "상품준비" },
{ key: "ship", matchStatus: "SHIPPING", label: "배송" },
{ key: "deliver", matchStatus: "DELIVERED", label: "배달완료" },
],
PICKUP: [
{
key: "confirm",
matchStatus: "ORDER_CONFIRMED",
label: "주문확인",
},
{
key: "payment",
matchStatus: "PAYMENT_COMPLETED",
label: "결제완료",
},
{ key: "pickup", matchStatus: "PICKED_UP", label: "매장수령" },
],
// ...
} as const;
key는 액션 핸들러와 매핑되는 식별자, matchStatus는 상태 계산에 사용됩니다.
2. 상태 계산: 5가지 시각적 상태
스키마로 "어떤 단계가 있는지"를 정의했으니, 이제 “각 단계가 어떤 상태인지” 계산합니다.
| 상태 | 의미 | 시각적 표현 | 예시 |
|---|---|---|---|
pending |
아직 도달하지 않은 단계 | 회색 동그라미 | “배달완료” 아직 멀었을 때 |
current |
현재 진행 중 | 파란 펄스 애니메이션 | “배송중” 상태 |
completed |
완료된 단계 | 파란 체크마크 | “상품준비” 끝났을 때 |
cancelled-current |
비정상 진행 중 | 빨간 펄스 애니메이션 | “반품 처리 중” |
cancelled |
비정상 완료 | 빨간 체크마크 | “주문취소 완료” |
이 5가지면 대부분의 다단계 프로세스를 표현할 수 있습니다. 정상 흐름(pending → current → completed)과 비정상 흐름(cancelled-current → cancelled)을 시각적으로 명확히 구분합니다.
type StepStatus =
| "completed"
| "current"
| "pending"
| "cancelled-current"
| "cancelled";
3. 핵심 알고리즘: resolveStepStatuses
서버 데이터와 스키마를 받아 각 단계의 상태를 산출하는 함수입니다.
function resolveStepStatuses(
steps: string[], // 주문 유형별 스텝 스키마
currentStatus: string, // 현재 상태
history: StatusHistoryItem[], // 상태 변경 히스토리
): StepStatus[] {
const historySet = new Set(history.map((h) => h.status));
let foundCurrent = false;
return steps.map((step) => {
// 히스토리에 있으면 완료
if (historySet.has(step)) return "completed";
// 히스토리에 없는 첫 번째 단계 = 현재 위치
if (!foundCurrent) {
foundCurrent = true;
if (CANCELLED_STATUSES.has(currentStatus)) return "cancelled";
if (CANCELLED_CURRENT_STATUSES.has(currentStatus))
return "cancelled-current";
return "current";
}
// 나머지는 대기
return "pending";
});
}
알고리즘의 핵심: 히스토리 기반 위치 산출
히스토리에 있으면 completed, 없는 첫 번째가 current, 나머지는 pending. 이 단순한 로직이 10개 상태 × 4개 주문 유형의 모든 조합을 커버합니다.
4. 비정상 분기 처리
실제 시스템에서는 “주문접수 → 배달완료” 같은 정상 흐름만 있는 게 아닙니다. 취소, 반품 같은 비정상 분기가 발생합니다.
비정상 상태 구분
비정상 상태는 두 가지로 구분됩니다:
// 종료된 비정상 (더 이상 전이 없음)
const CANCELLED_STATUSES = new Set([
"ORDER_CANCELLED", // 주문취소 완료
"REFUND_COMPLETED", // 환불 완료
"RETURN_COMPLETED", // 반품 완료
]);
// 진행 중인 비정상 (후속 처리 필요)
const CANCELLED_CURRENT_STATUSES = new Set([
"CANCEL_REQUESTED", // 취소 요청됨
"REFUND_REQUESTED", // 환불 요청됨
"RETURN_REQUESTED", // 반품 요청됨
"DELIVERY_FAILED", // 배송 실패
]);
이 구분이 중요한 이유:
RETURN_REQUESTED(반품요청)는 아직 처리가 필요한 상태 → 빨간 펄스 애니메이션으로 주의 환기RETURN_COMPLETED(반품완료)는 더 이상 할 일이 없는 상태 → 빨간 체크로 종료 표시
resolveProgressSteps에서의 비정상 처리
function resolveProgressSteps({
orderType,
currentStatus,
statusHistories,
availableActions,
}: ResolveProgressStepsParams): ResolvedProgressStep[] {
// 주문취소는 특수 처리
if (currentStatus === "ORDER_CANCELLED")
return [
{
key: "order-cancelled",
label: "주문취소",
state: "cancelled",
},
];
const configs = PROGRESS_STEPS_BY_ORDER_TYPE[orderType];
const completedStatuses = new Set(
statusHistories.map((h) => h.status),
);
const isAbnormal =
CANCELLED_STATUSES.has(currentStatus) ||
CANCELLED_CURRENT_STATUSES.has(currentStatus);
// 히스토리 기반으로 마지막 완료 인덱스 찾기
const lastCompletedIdx = findLastCompletedIndex(
configs,
completedStatuses,
);
// 각 스텝: 완료 인덱스 이하 → completed, 그 다음 → current, 나머지 → pending
const steps = configs.map((config, idx) => ({
...config,
state:
idx <= lastCompletedIdx
? "completed"
: !isAbnormal && idx === lastCompletedIdx + 1
? "current"
: "pending",
}));
// 비정상 상태면 마지막에 추가 스텝 삽입
if (isAbnormal)
steps.push({
/* 빨간 스텝 추가 */
});
return steps;
}
핵심: 비정상 상태를 별도 스텝으로 추가한다
비정상 상태를 기존 스텝에 덮어쓰는 게 아니라, 마지막에 빨간 스텝을 추가합니다. 이렇게 하면 “어디까지 정상 진행되었고, 어디서 비정상이 발생했는지” 시각적으로 파악 가능합니다.
5. 라벨 동적 변경: 같은 단계, 다른 텍스트
같은 “결제” 단계라도 상태에 따라 라벨이 달라져야 합니다:
| 단계 | pending | current | completed |
|---|---|---|---|
| 결제 | “결제” | “결제 대기중” | “결제완료” |
| 배송 | “배송” | “배송중” | “배송완료” |
| 수령 | “수령” | “수령 대기” | “수령완료” |
이것도 데이터로 선언합니다:
const STEP_LABELS: Record<string, Record<StepStatus, string>> = {
PAYMENT_COMPLETED: {
pending: "결제",
current: "결제 대기중",
completed: "결제완료",
},
SHIPPING: {
pending: "배송",
current: "배송중",
completed: "배송완료",
},
PICKED_UP: {
pending: "수령",
current: "수령 대기",
completed: "수령완료",
},
// ...
};
function getStepLabel(status: string, stepState: StepStatus): string {
// 취소 상태는 실제 취소 상태의 라벨 사용
// 정상 상태는 pending/current/completed별 라벨 테이블 참조
return STEP_LABELS[status]?.[stepState] ?? STATUS_LABEL[status];
}
if/else로 라벨을 분기하는 대신, 라벨 테이블을 참조하는 방식입니다. 새로운 단계가 추가되면 라벨 항목 하나만 추가하면 됩니다.
6. 역할별 액션 필터링
다단계 프로세스에서는 역할에 따라 수행할 수 있는 액션이 다릅니다. 예를 들어:
- 관리자: “환불 승인”, “주문취소” 실행 가능
- 물류 담당자: “상품준비 완료”, “배송 시작” 실행 가능
이것도 전이 규칙 + 역할 허용 목록의 교집합으로 계산합니다.
// 역할별 허용 상태 전이
const ROLE_ALLOWED_TARGETS = {
ADMIN: ["REFUND_COMPLETED", "ORDER_CANCELLED"],
WAREHOUSE: ["PREPARING", "SHIPPING", "DELIVERED"],
} as const;
// 현재 상태에서 가능한 전이 ∩ 역할 허용 전이
function getAvailableActions(
orderType: string,
currentStatus: string,
role: string,
): string[] {
return getNextStatuses(orderType, currentStatus).filter((s) =>
ROLE_ALLOWED_TARGETS[role].includes(s),
);
}
7. 두 계층 분리: entities → features
여기서 중요한 아키텍처 결정이 있습니다. 상태 계산과 CTA 주입을 두 계층으로 분리했습니다.
왜 분리했는가?
resolveProgressSteps는 순수 함수입니다. UI 의존성 없이 데이터만 받아 결과를 반환. 테스트하기 쉽고, 여러 곳에서 재사용 가능.buildProgressStepsWithActions는 특정 시나리오의 UI 로직입니다. 액션 버튼 라벨, D-Day 배지, 클릭 핸들러 등 UI 요소를 주입.
// features: CTA 주입
function buildProgressStepsWithActions({
resolvedSteps,
currentStatus,
actionHandlers,
estimatedDeliveryAt,
}: BuildParams): BuiltProgressStep[] {
return resolvedSteps.map((step) => ({
...step,
badge: computeDDayBadge(step, estimatedDeliveryAt),
ctaLabel: actionHandlers[step.key]
? ACTION_CTA_CONFIG[step.key]?.label
: undefined,
onAction: actionHandlers[step.key],
}));
}
8. 단일 원천 리팩토링
처음에는 상단 스테퍼와 우측 진행상태 카드가 각각 독립적으로 상태를 계산했습니다.
Before: 이중 계산
문제: 두 계산이 독립적이라 불일치 가능성. 상단에서는 “상품준비” 단계인데, 우측에서는 “결제완료” 단계로 표시되는 버그가 발생할 수 있음.
After: 단일 원천
상태 계산은 resolveProgressSteps 한 곳에서만 수행. 상단 스테퍼와 우측 카드 모두 이 결과를 받아 렌더링합니다.
9. 사용처 코드: 조합만으로 완성
실제 페이지에서는 이렇게 사용합니다.
function OrderDetailPage({ order }) {
const stepperItems = buildMainStepperItems(order)
const actions = getAvailableActions(order.type, order.status, currentRole)
const resolved = resolveProgressSteps({ ...order, availableActions: actions })
const progressSteps = buildProgressStepsWithActions({ resolvedSteps: resolved, ...handlers })
return (
<>
<Stepper items={stepperItems} />
<StatusProgressCard steps={progressSteps} />
</>
)
}
페이지 컴포넌트는 조합만 담당합니다. 상태 계산 로직, 비정상 판정, 역할 필터링 등은 모두 함수 내부에 있습니다.
확장성 검증: 반품 프로세스에도 동일 패턴 적용
이 패턴이 정말 범용적인지 검증하기 위해, 반품 프로세스에도 동일한 구조를 적용했습니다.
// 반품 진행상태: 동일한 패턴, 다른 스키마
function resolveReturnProgressSteps(
isCompleted: boolean,
): ProgressStep[] {
return [
{ key: "requested", label: "반품요청", state: "cancelled" },
{
key: "completed",
label: "반품완료",
state: isCompleted ? "cancelled" : "current",
ctaLabel: isCompleted ? undefined : "반품완료 처리",
},
];
}
스키마만 다르고, 함수 구조와 UI 컴포넌트는 그대로 재사용합니다.
핵심 설계 포인트 정리
| 포인트 | 선택 | 이유 |
|---|---|---|
| 스텝 표현 방식 | if/else 대신 스키마 배열 | 주문 유형 추가 시 스키마만 추가, UI 코드 변경 0 |
| 상태 산출 | 히스토리 기반 위치 계산 | COMPLETED만 추적하면 모든 조합 커버 |
| 비정상 처리 | 별도 스텝 추가 (cancelled) | 정상 흐름 훼손 없이 분기 시각화 |
| 시각적 상태 | 5가지 (pending/current/completed/cancelled-current/cancelled) | 정상+비정상 모든 케이스 표현 가능 |
| 라벨 관리 | 상태별 라벨 테이블 참조 | pending/current/completed별 다른 텍스트 데이터로 관리 |
| 역할별 액션 | 전이 규칙 ∩ 역할 허용 교집합 | 권한 로직을 if/else 없이 선언적으로 처리 |
| 아키텍처 | entities(순수 로직) → features(UI 로직) 분리 | 테스트 용이성 + 재사용성 |
| 단일 원천 | 두 UI가 같은 계산 결과 참조 | 상태 불일치 방지 |
결론
다단계 프로세스 UI를 구현할 때 컴포넌트 안에서 if/else로 처리하면, 상태와 주문 유형이 늘어날수록 코드가 기하급수적으로 복잡해집니다.
스키마 + 계산 함수 + 렌더러 3개로 역할을 분리하면:
- 스키마: “무엇을 보여줄지” 선언 → 주문 유형 추가 시 배열 1개만 추가
- 계산 함수: “각 단계가 어떤 상태인지” 산출 → 순수 함수라 테스트 용이
- 렌더러: “결과를 그리기만” → 상태 계산 로직과 무관
이 패턴은 배송 추적, 결제 프로세스, 채용 파이프라인, 승인 워크플로우 등 다단계 프로세스가 있는 어떤 도메인에든 적용할 수 있습니다.
