k10s는 Claude와의 vibe-coding으로 빠르게 만든 GPU-aware Kubernetes TUI였지만, fleet view 추가 뒤 여러 화면 상태가 깨짐
model.go는 1690줄 단일 Model과 500줄 Update()로 커졌고, UI·클라이언트·캐시·navigation·view 상태를 모두 떠안게 됨
AI는 기능을 빠르게 붙였지만 god object와 전역 key handler를 키웠고, 새 view마다 기존 handler에 branch가 늘어나는 구조가 됨
위치 기반 []string 데이터와 background tea.Cmd의 직접 mutation은 column 오류와 명백한 data race를 만들 수 있었음
새 k10s는 Rust로 다시 쓰며, 첫 prompt 전에 interface·message type·ownership rule·scope를 CLAUDE.md에 고정하기로 함
k10s를 다시 쓰게 된 배경
k10s는 GPU-aware Kubernetes 대시보드로 시작했으며, NVIDIA 클러스터 운영자가 GPU 사용률, DCGM 메트릭, 유휴 노드, 시간당 $32/hr 비용 같은 정보를 바로 확인하도록 만든 TUI 도구였음
Go와 Bubble Tea로 작성됐고, 약 7개월, 234개 커밋, 약 30번의 주말 동안 Claude와의 vibe-coding 세션으로 만들어짐
초기에는 pods, nodes, deployments, services, command palette, watch 기반 live updates, Vim keybindings 같은 기본 k9s 클론 기능이 약 3주말 만에 동작함
핵심 기능인 GPU fleet view는 각 노드의 GPU 할당, 사용률, DCGM 기반 지표, 온도, 전력, 메모리, 색상 기반 상태를 보여주는 화면이었고, Claude는 한 번에 FleetView 구조체, GPU/CPU/All 탭 필터링, allocation bars 렌더링까지 생성함
fleet view 추가 뒤 :rs pods로 pods view에 돌아가자 테이블이 비고, live updates가 멈추고, nodes view에는 fleet view 필터의 stale data가 보였으며, fleet tab count도 틀어짐
문제를 추적하면서 Claude가 만든 model.go 전체 1690줄을 처음으로 읽게 됐고, 하나의 Model 구조체가 UI 위젯, Kubernetes client, logs/describe/fleet 상태, navigation history, cache, mouse handling을 모두 들고 있었음
Update() 메서드는 500줄 규모의 msg.(type) dispatch 함수였고, 110개 switch/case branch가 들어간 구조였음
AI는 기능을 빠르게 만들 수 있지만, 제약 없이 계속 맡기면 아키텍처가 무너지며, 속도감은 전체가 동시에 붕괴되기 전까지 성공처럼 보이게 만듦
잔해에서 나온 다섯 가지 원칙
원칙 1: AI는 기능을 만들지만 아키텍처를 만들지 않음
Claude는 fleet view, log streaming, mouse support 같은 개별 기능을 잘 만들었지만, 각 기능은 “지금 동작하게 만들기” 맥락에서 구현됐고 같은 상태를 공유하는 다른 기능들과의 관계를 고려하지 못함
resourcesLoadedMsg handler에는 msg.gvr.Resource == k8s.ResourceNodes && m.fleetView != nil 같은 조건이 들어갔고, generic resource loading path 안에 fleet view 전용 로직이 섞임
새 view마다 custom behavior가 필요하면 같은 handler에 branch가 추가됐고, 이전 view의 데이터가 새 view에 새지 않도록 여러 필드를 수동으로 지워야 했음
model.go에는 m.logLines = nil, m.allResources = nil, m.resources = nil 같은 수동 cleanup이 9개 흩어져 있었고, 하나라도 빠지면 이전 view의 ghost data가 남음
대안은 코드 작성 전에 구체적인 interface, message type, ownership rule을 직접 쓰고 CLAUDE.md에 architecture invariant로 넣는 것임
예시 규칙은 각 view가 View trait/interface를 구현하고, view가 다른 view의 state에 접근하지 않으며, async data는 AppMsg variants로만 들어오고, App struct는 navigation과 message dispatch만 담당한다는 식임
원칙 2: god object는 AI가 기본으로 만드는 산출물임
AI는 immediate prompt를 가장 적은 ceremony로 만족시키기 위해 single struct가 모든 것을 들고 있는 구조로 기울었음
key handling도 view별로 분리되지 않았고, s key 하나가 logs view에서는 autoscroll, pods view에서는 shell, containers view에서는 container shell로 동작함
“pods에 shell support 추가”라는 요청은 기존 global key handler 근처에 branch를 끼워 넣는 방식으로 구현됨
Enter key도 contexts view, namespaces view, logs view, generic drill-down 로직이 하나의 flat dispatch 안에서 m.currentGVR.Resource string 비교로 분기됨
model.go 한 파일 안에서 m.currentGVR.Resource ==가 20회 이상 type discriminator처럼 사용됐고, 새 view를 추가할 때마다 여러 handler를 건드려야 했음
대안은 App/Model에 view-specific state field를 추가하지 않고, 각 view를 별도 struct로 만들며, key binding도 active view의 keymap에 두는 규칙을 CLAUDE.md에 넣는 것임
“view 추가는 파일 추가여야 하며 기존 view 수정이 필요하면 멈추고 묻는다” 같은 guardrail이 있어야 AI가 가장 짧은 경로로 branch를 추가하지 않게 됨
원칙 3: 속도감의 착시는 scope를 넓힘
k10s는 원래 GPU training cluster를 운영하는 좁은 audience를 위한 도구였지만, vibe-coding은 pods, deployments, services, command palette, mouse support, contexts, namespaces 같은 기능이 “공짜”처럼 느껴지게 만듦
결과적으로 GPU-focused tool이 아니라 모든 Kubernetes 사용자를 위한 general-purpose TUI, 사실상 k9s를 다시 만드는 방향으로 넓어짐
flat keyMap에는 Fullscreen, Autoscroll, ToggleTime, WrapText, CopyLogs, ToggleLineNums, Describe, YamlView, Edit, Shell, FilterLogs, FleetTabNext, FleetTabPrev 같은 다양한 view 전용 binding이 한 구조체에 섞임
Autoscroll과 Shell은 모두 s였고, dispatch가 현재 resource를 확인하기 때문에 “동작”은 했지만 keybinding을 지역적으로 이해할 수 없게 됨
코드 작성 속도는 “shipping”처럼 보였지만, 각 feature는 god object 안에 branch를 하나씩 더하는 비용을 만듦
resource.views.json에서 Instance와 Compute 사이에 column을 하나 추가하면 ra[2], ra[3]을 참조하는 sort, conditional render, drill target이 조용히 틀어질 수 있었음
compiler는 []string의 의미를 알 수 없고, JSON config도 sort behavior, conditional rendering, custom drill target을 표현하지 못해 Go code가 positional assumption을 hardcode함
AI는 table widget에 바로 넣기 쉬운 []string 또는 Vec<String>을 선택하기 쉽고, typed struct는 upfront ceremony가 더 크기 때문에 빠른 경로에서 밀림
대안은 structured data를 render 직전까지 FleetNode, PodInfo 같은 typed struct로 유지하고, sort는 row[3] 같은 positional access가 아니라 named field에서 수행하도록 하는 것임
예시 구조는 FleetNode { name, instance_type, compute_class, alloc }처럼 column identity를 type으로 표현해 잘못된 column sort 같은 불가능한 상태를 만들 수 없게 함
“Making impossible states impossible”은 Elm/Rust 커뮤니티에서 쓰이는 표현으로, runtime check 대신 invalid state가 구성되지 않도록 type을 설계한다는 뜻임
원칙 5: AI는 state transition을 소유하지 않음
Bubble Tea의 구조는 message로 구동되는 Update()에서만 state가 변하는 것이 핵심이지만, k10s는 이를 어김
updateTableMsg handler는 tea.Cmd closure를 반환했고, 이 closure 안에서 m.updateColumns(m.viewWidth), m.updateTableData(), m.table.SetCursor(savedCursor) 같은 호출로 Model field를 변경함
Bubble Tea는 tea.Cmd를 별도 goroutine에서 실행하므로, closure가 m.resources, m.table, m.viewWidth를 읽고 쓰는 동안 main goroutine의 View()가 같은 field를 읽을 수 있었음
lock이나 mutex가 없었고, <-m.updateTableChan은 update signal을 기다릴 뿐 View()가 half-written state를 읽는 것을 막지 못함
이 구조는 명백한 data race였고, 대부분은 동작하지만 가끔 display가 깨지는 식으로 나타남
대안은 background worker가 UI state를 직접 mutate하지 않고, typed message를 channel로 보내며, main event loop가 message를 받아 state mutation을 적용하는 것임
concurrency rule은 background task가 UI state를 직접 변경하지 않고, 결과를 typed message로 보내며, render()/view()는 side effect, I/O, channel operation이 없는 pure function이어야 한다는 것임
CLAUDE.md와 agents.md에 넣을 보호 규칙
아키텍처 불변 조건
각 view는 View trait/interface를 구현해야 하고, 다른 view의 state에 접근하지 않아야 함
모든 async data는 AppMsg variants로 들어와야 하며, background task가 field를 직접 mutate하면 안 됨
새 view 추가가 기존 view 수정을 요구하지 않아야 함
App struct는 navigation과 message dispatch를 담당하는 thin router여야 함