나는 다시 손으로 코드를 작성하려 한다

3 hours ago 1
  • 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를 하나씩 더하는 비용을 만듦
    • 대안은 CLAUDE.md에 scope boundary를 명시해 k10s가 GPU cluster operator용이며, supported views는 fleet, node-detail, gpu-detail, workload로 제한하고, generic resource views나 k9s 중복 기능은 추가하지 않는다고 못박는 것임
    • AI는 무한한 line budget을 제공할 수 있지만, complexity budget은 여전히 유한하므로 scope를 미리 거절해야 함
  • 원칙 4: 위치 기반 데이터는 시한폭탄임

    • k10s는 Kubernetes API에서 받은 resource를 곧바로 type OrderedResourceFields []string 형태로 flatten함
    • fleet view의 sort function은 ra[3]을 Alloc, ra[2]를 Compute, ra[0]을 Name으로 다뤘고, column identity는 comment와 resource.views.json의 column order에만 의존함
    • 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여야 함
  • 상태 소유권 규칙

    • view-specific state를 App/Model struct에 field로 추가하지 않아야 함
    • 각 view는 별도 struct로 존재해야 하고 자체 key binding을 선언해야 함
    • app은 key를 active view에 dispatch해야 하며, 새 keybinding은 global handler가 아니라 해당 view의 keymap에 추가해야 함
    • view 추가가 기존 view 수정을 요구하면 멈추고 확인해야 함
  • 범위

    • k10s는 모든 Kubernetes 사용자가 아니라 GPU cluster operator를 위한 도구여야 함
    • 지원 view는 fleet, node-detail, gpu-detail, workload로 제한해야 함
    • pods, deployments, services 같은 generic resource view를 추가하지 않아야 함
    • k9s 기능을 복제하는 feature를 추가하지 않아야 함
    • GPU training jobs 운영자에게 도움이 되지 않는 feature request는 거절해야 함
  • 데이터 표현

    • structured data를 []string, Vec<String>, positional array로 flatten하지 않아야 함
    • data는 render call 직전까지 typed struct로 흘러야 함
    • column identity는 array index가 아니라 struct field name에서 나와야 함
    • sort function은 row[3] 같은 positional access가 아니라 typed field에서 동작해야 함
    • display용 string 생성은 render()/view() 함수 안에서만 일어나야 함
  • 동시성 규칙

    • watcher, scraper, API call 같은 background task는 UI state를 직접 mutate하지 않아야 함
    • background task는 결과를 typed message로 channel에 보내야 함
    • main event loop만 received message에서 state mutation을 적용해야 함
    • render()/view()는 side effect, I/O, channel operation이 없는 pure function이어야 함
    • async work 결과로 state를 바꿔야 하면 새 AppMsg variant를 정의해야 함

다시 만드는 방식

  • k10s는 Rust로 다시 작성될 예정이며, 이유는 Rust가 더 낫기 때문이 아니라 직접 steer할 수 있는 언어라고 느끼기 때문임
  • 충분히 써본 언어에서는 무엇이 잘못됐는지 말로 설명하기 전에 감지할 수 있고, 이 감각은 vibe-coding이 대체하지 못함
  • AI가 그럴듯한 코드를 내놓을 때, 그것이 쓰레기인지 감지하는 능력이 필요함
  • 새 버전에서는 코드 작성 전에 concrete interface, message type, ownership rule 같은 design work를 사람이 손으로 먼저 수행함
  • 이전에는 AI가 잘못 결정하던 architecture decision을 첫 prompt 이전에 문서로 정해두는 방식으로 바뀜
  • 기존 TUI와 프로젝트 링크는 k10s GithubK10S.DEV에 있음

덧붙임

  • Bubble Tea는 The Elm Architecture 기반의 Go TUI framework이며, k10s의 architecture 문제는 Bubble Tea가 아니라 k10s 쪽 구현에서 생김
  • “Making impossible states impossible”은 invalid state를 runtime에서 검사하는 대신 type 설계로 invalid state가 구성되지 않게 하는 Elm/Rust 커뮤니티의 표현임
  • AI 글쓰기의 “em-dash”처럼 AI coding에는 “god-object”가 냄새로 남을 수 있고, vibe-coding은 구현을 싸게 느끼게 만들어 focus 상실과 bloat로 이어질 수 있음
Read Entire Article