세 개의 머리를 가진 Status Field (그리고 우리가 해결한 방법)
Source: Dev.to
Source:
원죄
제가 처음 import/export 설정을 만들었을 때, 상태는 명확했습니다:
type ImportConfig struct {
Status string `json:"status"` // "draft" | "active" | "paused"
}
깨끗하고. 간단하고. 끝났습니다.
그런 다음 사용자가 import를 실행하기 시작했습니다. 마지막 실행이 성공했는지 실패했는지 알아야 했습니다:
type ImportConfig struct {
Status string `json:"status"` // "draft" | "active" | "paused"
LastRunStatus string `json:"lastRunStatus"` // "success" | "failed" | "running"
}
여전히 관리 가능했습니다. 두 개의 필드, 두 개의 고민.
그 후 마법사를 만들었습니다. 사용자는 흐름 중간에 진행 상황을 저장해야 했지만, 깨진 설정을 만들고 싶지는 않았습니다. 간단한 해결책:
type ImportConfig struct {
Status string `json:"status"`
LastRunStatus string `json:"lastRunStatus"`
IsDraft bool `json:"isDraft"` // wizard in progress
}
세 개의 머리. 하나의 괴물.
Source: …
조합론 악몽

세 개의 필드가 만들고 있는 문제: 상태 매트릭스를 생성한다.
Status: draft | active | paused | disabled | completed
LastRunStatus: success | failed | running | (empty)
IsDraft: true | false
이는 5 × 4 × 2 = 40개의 가능한 상태를 의미한다. 대부분은 의미가 없었다. status=active, lastRunStatus=running, isDraft=true는 무슨 뜻일까?
프론트엔드에 있던 이 보석:
function getDisplayStatus(config: ImportConfig): string {
if (config.isDraft) return 'draft';
if (config.lastRunStatus === 'running') return 'running';
if (config.status === 'paused') return 'paused';
if (config.lastRunStatus === 'failed') return 'failed';
if (config.status === 'active') return 'ready';
return 'draft'; // ¯\_(ツ)_/¯
}
그 함수는 잘못되었다. 어느 경우가 잘못됐는지 나는 몰랐다.
인식의 순간
새로운 기능을 추가하고 있었는데, 기본적인 질문에 답할 수 없다는 것을 깨달았다:
“이 가져오기가 실행 준비가 되었나요?”
답을 찾으려면 세 개의 필드를 확인하고, 그 우선순위를 이해하며, 논리가 맞는지 확인해야 했다.
그때 나는 상태를 모델링하지 못했다는 것을 알았다. 나는 증상만 쌓아두고 있었다.
해결책: 모든 것을 통제하는 하나의 필드
리팩터는 개념적으로는 간단했습니다:
Before: 겹치는 관심사를 추적하는 3개의 필드
After: 5개의 상호 배타적 상태를 갖는 1개의 필드
const (
StatusDraft = "draft" // Wizard incomplete, missing required fields
StatusReady = "ready" // Complete config, can be run
StatusRunning = "running" // Currently executing
StatusPaused = "paused" // User paused execution
StatusFailed = "failed" // Last run failed, needs attention
)
type ImportConfig struct {
Status string `json:"status"` // One of the above. That's it.
}
매트릭스도, 우선순위도, 추측도 없습니다.
“이 가져오기가 실행 준비가 되었나요?” → config.Status == StatusReady
But Wait, What About the Wizard?
isDraft 필드는 마법사가 진행 중인 부분을 저장해야 하기 때문에 존재했습니다. 이를 제거한다는 것은 다른 문제를 해결한다는 의미였습니다: 마법사 상태는 어디에 저장되는가?
Answer: Status == StatusDraft 일 때만 존재하는 DraftData 필드에 있습니다:
type ImportConfig struct {
Status string `json:"status"`
DraftData *DraftData `json:"draftData,omitempty"` // nil unless Status=draft
}
type DraftData struct {
CurrentStep string `json:"currentStep"`
PendingSchema *PendingSchema `json:"pendingSchema,omitempty"`
PendingDataType *PendingDataType `json:"pendingDataType,omitempty"`
}
핵심 인사이트: 대기 중인 리소스는 실제 테이블이 아니라 초안 데이터에 존재합니다. 마법사 진행 중에 스키마를 만들고 있다면, 그 스키마는 아직 존재하지 않습니다. 이는 DraftData에 저장된 대기 중인 스키마입니다. 최종 확정할 때 비로소 실제 스키마가 됩니다. 이제 포기된 마법사 때문에 남는 고아 스키마가 없습니다.
피해 보고서
수정으로 영향을 받은:
- 193개의 파일
- 11,786개의 추가
- 4,179개의 삭제
- 백엔드 모델, 서비스, 핸들러
- 프론트엔드 타입, 훅, 마법사
- 양쪽 모두에 대한 테스트
그럴 가치가 있었을까? 이제 getDisplayStatus 함수는 다음과 같다:
function getDisplayStatus(config: ImportConfig): string {
return config.status;
}
네. 그럴 가치가 있었다.
Lessons
-
State fields multiply. One becomes two becomes three. Each addition feels small. The complexity is combinatorial.
상태 필드가 늘어난다. 하나가 두 개가 되고, 세 개가 된다. 각 추가는 작게 느껴진다. 복잡성은 조합적이다. -
“What state is this?” should be trivial to answer. If you need a flowchart, you have a modeling problem.
”이 상태가 뭐지?” 라는 질문은 쉽게 답할 수 있어야 한다. 흐름도를 만들어야 한다면 모델링 문제가 있다. -
Pending resources aren’t real resources. Wizard state is draft data, not partially‑created entities.
대기 중인 리소스는 실제 리소스가 아니다. 마법사 상태는 초안 데이터이며, 부분적으로 생성된 엔터티가 아니다. -
Big refactors are just many small changes. 193 files sounds scary. It was really “update Status constant” × 193.
대규모 리팩터링은 많은 작은 변경들의 집합일 뿐이다. 193개의 파일은 무섭게 들리지만, 실제로는 “Status 상수 업데이트” × 193이었다.
Building Flywheel – data pipelines for startups. If you’ve ever watched a “simple” field grow three heads, I’d love to hear your war stories.
Flywheel을 구축하고 있습니다 – 스타트업을 위한 데이터 파이프라인. 만약 당신이 “간단한” 필드가 세 갈래로 성장하는 모습을 본 적이 있다면, 전쟁 이야기를 듣고 싶습니다.