Claude를 이용해 6대짜리 임시 클러스터를 띄우고 동시 명령 실행·동기화·SSH를 제공하는 deno 스크립트 `box`를 만든 경험과, 그 과정에서의 점진적(구조 주도) 바이브코딩 워크플로를 정리한다.
URL: https://matklad.github.io/2026/01/20/vibecoding-2.html
Title: Vibecoding #2
2026년 1월 20일
오늘 Claude로부터 꽤 큰 가치를 얻었다고 느껴서 기록해두고 싶다. 나는 AI 도입의 맨 끝자락에 있는 편이라, 특별히 유용하거나 새롭다고 할 만한 이야기를 하게 될 거라 기대하진 않는다. 하지만 나는 지루한 AI 글이 부족하다고 늘 불평해왔으니, 내가 직접 하나 쓰는 게 예의일 것이다.
TigerBeetle에서는 결정적 시뮬레이션 테스트를 중요하게 생각한다. 어느 정도는 성능을 추적하는 데도 사용한다. 그럼에도, 실제 고도 높은 자연 서식지(=리얼 클러스터)에서 성능 수치를 검증하는 것은 매우 중요하다.
그렇게 하려면 클라우드에서 머신 6대를 조달하고, 커스텀 tigerbeetle 바이너리를 올리고, 클러스터의 레플리카들을 서로 연결한 뒤 부하를 걸어야 한다. 3천년대 1/4세기가 지난 지금이라면 “6대의 머신에서 뭔가를 실행한다”는 게 터미널 열고 ls 치는 것보다 한 단계 정도만 더 어려운 문제여야 할 것 같은데, 나는 하루를 낭비하지 않고는 이걸 해결하는 방법을 개인적으로 모르겠다. 그래서 나는 하루를 써서 나만의 사각 바퀴를 바이브코딩으로 만들었다.
이 문제의 일반적인 형태는, 필요할 때 원하는 스펙으로 일시적인(ephemeral) 머신 플릿을 띄우고, 거기에 임시(adhoc) 명령을 SIMD처럼(동일 명령을 여러 대상에) 실행하고 싶다는 것이다. 6분할 터미널에 약간씩 다른 명령을 손으로 타이핑하고 싶지는 않지만, 특정 박스에 SSH로 들어가 이것저것 찔러보는 기능은 원한다.
내 해결책 아이디어는 다음 세 가지에서 왔다:
rsyscall의 큰 아이디어는 분산 시스템을 direct style로 프로그래밍할 수 있다는 것이다. 로컬에서 프로그래밍할 때는 시스템 콜을 호출하며 일을 한다:
const fd = open("/etc/passwd");
이 API는 원격 머신에서도 동작할 수 있는데, 시스템 콜을 어느 머신에서 실행할지 지정해주기만 하면 된다:
const fd_local = open(.host, "/etc/passwd");
const fd_cloud = open(.{.addr = "1.2.3.4"}, "/etc/passwd");
직접 조작(direct manipulation)은 가장 자연스러운 API이며, 이를 네트워크 경계 너머로 확장하는 것은 가치가 있다.
Peter의 글은 비슷한 아이디어를 “Mac에서 개발하고 Linux에서 테스트한다”는 좁고 일상적인 과제에 적용한 것이다. Peter는 두 개의 스크립트를 제안한다:
remote-sync는 로컬과 원격 프로젝트를 동기화한다. ~/p/tb 폴더 안에서 remote-sync를 실행하면 원격 머신에 ~/p/tb가 그대로 물리화(materialize)된다. 무거운 일은 rsync가 하고, 래퍼 스크립트가 DWIM(Do What I Mean) 동작을 구현한다.
보통 그 다음에 remote-run some --command를 실행하는데, 이는 원격 머신에서 대응되는 디렉터리에서 명령을 실행하고, 출력은 로컬로 포워딩한다.
그래서 내가 로컬 변경 사항을 Linux 박스에서 테스트하고 싶을 때, 대략 다음과 같은 셸 세션이 된다:
$ cd ~/p/tb/work
$ code . # 여기서 해킹
$ remote-sync
$ remote-run ./zig/zig build test
킬러 기능은 셸 자동완성이 동작한다는 점이다. 로컬과 원격에서 명령이 경로까지 동일하다는 사실을 활용해 먼저 실행하고 싶은 명령을 타이핑(자동완성 사용)한 뒤, ^A를 누르고 앞에 remote-run을 붙인다(실제로는 sync&run을 합친 rr alias를 쓴다).
여기서 중요한 건 개별 명령 자체가 아니라 사고방식(mental model)의 변화다. 전통적인 ssh & vim 설정에서는 로컬과 원격, 분리된 상태를 가진 두 머신을 저글링해야 한다. remote-sync에서는 머신들 사이의 상태가 동일하며, 명령을 여기서 실행할지 저기서 실행할지만 선택하면 된다.
머신이 2대뿐이면 그 차이가 학술적으로 느껴진다. 하지만 테스트를 _6대_에서 실행하려고 하면 ssh 접근은 실패한다. 소스 파일 변경을 6번 다시 vim으로 반영하고 싶지 않다. 코드 편집 장소와 코드 실행 장소(들)를 분리하고 싶다. 이는 일반적인 패턴이다. 설계의 어떤 측면이 확실치 않다면, 핵심 추상화의 카디널리티를 1에서 2로 늘려보라.
세 번째 구성 요소인 dax 라이브러리는 꽤 평범하다. 셸 스크립팅을 위한 자바스크립트 라이브러리일 뿐이다. 눈에 띄는 점은:
JavaScript의 템플릿 리터럴로, 구조적으로 안전한 방식으로 커맨드 인터폴레이션을 구현할 수 있다. $ls ${paths}``를 처리할 때 문자열이 한 번도 물리화되지 않고, exec 시스템 콜까지 끝까지 배열로 전달된다(더 자세히).
JavaScript의 async/await로 로컬이든 원격이든 동시 프로세스를 자연스럽게 관리할 수 있다:
await Promise.all([
$`sleep 5`,
$`remote-run sleep 5`,
]);
detached로 명시되지 않는 한 스크립트보다 오래 살아남지 않게 보장한다. 이는 UNIX의 골칫거리이자 신맛 나는 지점이다.세 가지 아이디어를 결합해, 나는 box라는 deno 스크립트를 만들었고, 임시 클러스터에서 임시 코드를 실행하기 위한 멀티플렉스 인터페이스를 제공한다.
세션은 이런 모습이다:
# 로컬 수정이 있는 프로젝트로 이동
$ cd ~/p/tb/work
$ git status --short
M src/lsm/forest.zig
# 머신 3대 스핀업, IP 출력
$ box create 3
108.129.172.206,52.214.229.222,3.251.67.25
$ box list
0 108.129.172.206
1 52.214.229.222
2 3.251.67.25
# 내 코드를 원격 머신으로 이동
$ box sync 0,1,2
# 0번 머신에서 pwd&ls 실행; 이제 코드가 그쪽에 있음:
$ box run 0 pwd
/home/alpine/p/tb/work
$ box run 0 ls
CHANGELOG.md LICENSE README.md build.zig
docs/ src/ zig/
# 개발 환경 세팅하고 세 머신 모두에서 빌드 실행.
$ box run 0,1,2 ./zig/download.sh
Downloading Zig 0.14.1 release build...
Extracting zig-x86_64-linux-0.14.1.tar.xz...
Downloading completed (/home/alpine/p/tb/work/zig/zig)!
Enjoy!
# NB: 여기서는 로컬 커밋 해시 사용(저쪽에는 git이 없음).
$ box run 0,1,2 \
./zig/zig build -Drelease -Dgit-commit=$(git rev-parse HEAD)
# ?? 는 머신 id로 치환됨
$ box run 0,1,2 \
./zig-out/bin/tigerbeetle format \
--cluster=0 --replica=?? --replica-count=3 \
0_??.tigerbeetle
2026-01-20 19:30:15.947Z info(io): opening "0_0.tigerbeetle"...
# 머신 정리(8시간 후 자동 종료도 됨)
$ box destroy 0,1,2
이거 마음에 든다! 아직 실전에 제대로 써먹진 않았지만, 오래전부터 원하던 것이고 이제 손에 넣었다.
위의 것을 구현하는 문제는 내가 현대 클라우드에 대한 실무 경험이 전혀 없다는 점이다. AWS 계정을 오늘 처음 만들었고, 콘솔 인터페이스를 보기만 해도 《성(The Castle)》을 다시 읽고 싶어지는 충동이 솟구쳤다. 내 취향의 푸얼차는 아니다. 하지만 AI가 바로크한 클라우드 API를 다루는 데 강할 거라는 가설이 있었고, 대체로 맞았다.
나는 먼저 내가 얻고 싶은 것을 매우 러프하고 초고수준으로 두 단락 정도 설명하는 것으로 시작했다. 명세가 아니라, 그저 알려지지 않은 미지수들(unknown unknowns)을 향한 전반적인 제스처였다. 그리고 ChatGPT에게 그 두 단락을, 에이전트에게 구현을 맡길 수 있을 정도로 비교적 완전한 스펙으로 확장해달라고 했다.
이 단계에서 내게 여러 미지수가 드러났다. 예를 들어, 머신을 어떻게 식별해야 하는지 전혀 생각하지 못했는데, ChatGPT는 랜덤한 16진수를 쓰자고 제안했고, 나는 0,1,2 같은 이름 체계가 여러 대를 간결하게 지정하기 위해 필요하다는 걸 깨달았다. 이를 고민하다가, 순차 번호 체계에는 또 다른 장점이 있다는 것도 알게 됐다. 내 사용 사례에서는 동시에 두 개의 클러스터를 돌릴 수 없게 되는 것이 바람직한 속성이다. 머신을 끄는 걸 잊었다면, 같은 이름으로 재생성할 때 조용히 충돌을 회피하기보다는 에러가 나서 알려주는 게 낫다. 비슷하게 권한과 네트워크 접근 규칙, 어떤 리전과 어떤 이미지를 쓸지도 생각해야 하는 문제라는 걸 알게 됐다.
스펙 문서를 손에 넣은 뒤에는 실제 구현 작업을 Claude에게 넘겼다. 첫 단계는 스펙을 더 다듬는 것으로, Claude에게 무엇이 불명확한지 물었다. 여기서 몇 가지 흥미로운 정리가 있었다.
첫째, 원래 ChatGPT 스펙은 내가 말한 “현재 디렉터리 매핑” 아이디어—로컬의 ~/p/tb/work를 원격의 ~/p/tb/work로 물리화하고 싶다는 것(두 머신의 ~가 달라도)—를 이해하지 못했다. ChatGPT는 잘못된 설명과 잘못된 예시를 만들었다. 나는 예시는 수동으로 고쳤지만, 간결하고 정확한 설명을 쓰는 데는 실패했다. Claude는 예시를 바탕으로 그 설명을 고쳐줬다. 이건 좀 더 내재화해야 할 것 같다. 지금 세대의 AI에서는 규칙보다 예시가 훨씬 더 가치 있어 보인다.
둘째, 스펙에는 내가 더 이상 머신을 쓰지 않을 때 자동 종료해서(방 나갈 때 불을 안 끄는 걸 방지) 실수로 비용을 낭비하지 않게 하고 싶다는 욕구가 포함돼 있었다. Claude는 여기에서 내가 정확히 무엇을 원하는지 집요하게 캐물었고, 나는 “DWIM하게 해줘”라고 했다.
스펙은 영어 산문 6KiB 정도가 됐다. 최종 구현은 TypeScript 14KiB였다. 스펙과 구현을 완벽히 동기화해 유지하진 않았지만, 결국 꽤 비슷해졌다. 즉, 산문 명세가 코드보다 더 압축적이긴 하지만, 그렇게 더 압축적이진 않다.
다음 단계는 그냥 한 방(one-shot)으로 끝내보는 것이었다. 음, 이건 좀 민망한데, 나는 보통 이 블로그에서 욕설을 피하지만 방금 “one-shot”을 오타로 “one-shit”이라고 쳤고, 음, 이 표현보다 더 맛깔나는 설명은 앞으로 못 만들 것 같다. 결과는 별로였다(이유는 뒤에서 더). 그래서 거의 즉시 버리고 더 점진적인 접근으로 다시 시작하기로 했다.
내 이전 바이브 글에서, 나는 LLM이 피드백 루프를 닫는 데(closing the loop) 강하다는 걸 관찰했다. 여기의 변형은, LLM은 결과물을 내는 데는 강하지만, 반드시 좋은 코드를 내는 것은 아니라는 점이다. 만약 에이전트가 초기 스크립트를 반복 개선하며 실제로 AWS에 _실행_까지 해보게 했다면, 작동하는 것을 얻었을 거라고 확신한다. 하지만 나는 그 방법을 택하고 싶지 않았다. 이유는 세 가지다:
그리고 말했듯 코드가 별로라고 느꼈는데, 구체적인 이유는 다음과 같다:
점진적 접근이 훨씬 잘 먹혔다. Claude는 빈칸을 채우는 데 능하다. box-v2에서 내가 제일 먼저 한 일은 다음을 손으로 타이핑하는 것이었다:
type CLI =
| CLICreate
| CLIDestroy
| CLIList
| CLISync
type BoxList = string[];
type CLICreate = { tag: "create"; count: number };
type CLIDestroy = { tag: "destroy"; boxes: BoxList };
type CLIList = { tag: "list" };
type CLISync = { tag: "sync"; boxes: BoxList; };
function fatal(message: string): never {
console.error(message);
Deno.exit(1);
}
function CLIParse(args: string[]): CLI {
}
그리고 Claude에게 CLIParse 함수를 완성해달라고 했고, 결과에 만족했다. "보여줘, 말하지 말고(Show, Don’t Tell)"을 주목하라.
나는 Claude에게 예외를 던지지 말고 대신 즉시 실패(fail fast)하라고 _요구_하지 않는다. 그냥 fatal 함수를 주고, 나머지를 코드 완성하게 한다.
CLIParse 내부 코드가 최고 수준이라고 말하긴 어렵다. 나는 더 스파르탄하게 썼을지도 모른다. 하지만 중요한 건 이 수준에서는 내가 신경 안 쓴다는 점이다. CLI 인자 파싱에 대한 추상화가 내게 맞다고 느껴지고, 세부사항은 나중에 언제든 고칠 수 있다. 전체 바이브코딩 세션은 이렇게 흘렀다. 나는 구조를 제공하고, Claude는 숫자 색칠하기처럼 채워 넣었다.
특히 그 CLI 파싱 구조가 자리 잡자, Claude는 새 서브커맨드와 새 인자를 만족스럽게 추가하는 데 큰 문제를 겪지 않았다. 유일한 걸림돌은 sync에 선택적 경로를 추가해달라고 했을 때
string |
null
을 선택했다는 점인데, 나는 강하게
string |
undefined
를 선호한다. 당연히 JS에서는 null을 어떻게 쓸지 정해서 일관되게 가는 게 낫다. 그리고 undefined는 피할 수 없다는 사실이 승자를 미리 정해버린다. 이 인자는 작은 점진적 변경으로 추가됐기 때문에, 방향 수정은 사소했다.
null vs undefined 문제는 코드에 캐릭터가 없다는 내 불만을 잘 보여주는 예일지도 모른다. | null은 디폴트적인 “비선택”이다.
|
undefined
는 통찰이며, 나는 VS Code LSP 구현에서 그걸 배웠다.
손으로 쓴 스켈레톤 + 바이브코딩된 내부는 CLI에만 통하지 않았다. 나는 이렇게 썼다:
async function main() {
const cli = CLIParse(Deno.args);
if (cli.tag === "create") return await mainCreate(cli.count);
if (cli.tag === "destroy") return await mainDestroy(cli.boxes);
...
}
async function mainDestroy(boxes: string[]) {
for (const box of boxes) {
await instanceDestroy(box);
}
}
async function instanceDestroy(id: string) {
}
그리고 SPEC.md에 따라 특정 함수의 본문을 써달라고 Claude에게 요청했다.
CLI 때와 달리, Claude는 이 패턴을 스스로 따라가지는 못했다. 한 예시만 보면 명확하지 않지만 전체 구조는 instanceXXX가 단일 박스에 대한 AWS 레벨 작업이고, mainXXX가 루프와 병렬성을 다루는 CLI 레벨 제어 흐름이다. Claude에게
box
run
구현을 요청할 때 내가 직접 main / instance 분할을 해두지 않으면, Claude는 그걸 알아차리지 못했고 방향 수정이 필요했다.
하지만, Claude는 실제 로직에서는 엄청나게 성공적이었다. 다음을 작성하는 데 필요한 구체적이고 재사용 불가능한 지식을 나 스스로 습득하려면 몇 시간이 걸렸을 것이다:
js// 스팟 인스턴스 생성 const instanceMarketOptions = JSON.stringify({ MarketType: "spot", SpotOptions: { InstanceInterruptionBehavior: "terminate" }, }); const tagSpecifications = JSON.stringify([ { ResourceType: "instance", Tags: [{ Key: moniker, Value: id }] }, ]); const result = await $`aws ec2 run-instances \ --image-id ${image} \ --instance-type ${instanceType} \ --key-name ${moniker} \ --security-groups ${moniker} \ --instance-market-options ${instanceMarketOptions} \ --user-data ${userDataBase64} \ --tag-specifications ${tagSpecifications} \ --output json`.json(); const instanceId = result.Instances[0].InstanceId; // 인스턴스가 running 상태가 될 때까지 대기 await $`aws ec2 wait instance-status-ok --instance-ids ${instanceId}`;
조심해서 말하자면, 위 스니펫의 _정확성_이나 특히 _완전성_을 보증할 수는 없다. 하지만 이 문제의 성격상 코드를 실행해보고 결과를 보면 되므로, 나는 괜찮다. 내가 직접 썼더라도 시행착오가 내 접근 방식이었을 것이다.
그리고 합성(synthesis)도 있다. 여러 인스턴스 커맨드를 구현하다 보니, 많은 커맨드가 “1” 같은 상징적 머신 이름을 AWS 이름/IP로 해석하기 위해 AWS를 조회하는 것으로 시작했다. 그 시점에서 나는 상징적 이름을 해석하는 것이 문제의 근본적인 부분이며, 단 한 번만 일어나야 한다고 깨달았고, 결과적으로 다음과 같은 리팩터링된 코드 형태가 나왔다:
async function main() {
const cli = CLIParse(Deno.args);
const instances = await instanceMap();
if (cli.tag === "create") return await mainCreate(instances, cli.count);
if (cli.tag === "destroy") return await mainDestroy(instances, cli.boxes);
...
}
Claude는 로직 추출은 괜찮게 했지만 전체 코드 레이아웃을 망쳤고, 마지막 정리는 내 몫이었다. “컨텍스트” 인자는 마지막이 아니라 첫 번째에 와야 한다. 시각적 정렬 측면에서 공통 접두사가 공통 접미사보다 더 가치 있다.
원래 “one-shotted” 구현은 이런 선조회(up-front querying)도 하지 않았다. 이는 코드를 가까이서 다루는 과정에서만 발견하는 문제의 형태다.
물론 스크립트가 처음부터 완벽히 동작한 건 아니고, 실제 머신에서 코딩 버그와 스펙의 빈틈을 고치기 위해 꽤 많은 반복이 필요했다. 초보 실수를 속도런(speed-run)하는 흥미로운 경험이었다. Claude는 순진한 버그를 만들기도 했지만, 고치기도 잘했다.
예를 들어 box create 후 처음으로 box ssh를 해보려 했을 때 에러가 났다. 에러를 Claude에 붙여넣자마자 문제가 드러났다. 원래 코드는
aws ec2 wait
instance-running
을 하고 있었고,
aws ec2 wait
instance-status-ok
를 해야 했다.
전자는 인스턴스가 논리적으로 생성됐는지 확인하고, 후자는 OS 부팅이 끝날 때까지 기다린다. 이 둘이 따로 존재하는 건 그럴듯하고, 차이도 명확하다(그리고 OS 부팅 완료 != SSH 데몬 시작도 분명하다). 여기서 Claude의 가치는 내가 이미 존재한다고 아는 개념에 대해 구체적인 이름을 제공해준다는 점이다.
또 하나 재미있는 건 디스크였다. 인스턴스에 SSD가 있는데 실제로 쓰이지 않는 걸 알아챘다. Claude에게 그것을 홈으로 마운트해달라고 했지만 실패했다. Claude는 즉시
$ box run 0 cat
/var/some/unintuitive/long/path.log
를 실행해보라고 했고, 그 로그에서 바로 문제가 드러났다. 이건 놀랍다! 내 평소 리눅스 디버깅 하루의 50%는 유용한 로그가 존재한다는 사실을 몰라서 낭비되고, 나머지 50%는 존재해야 한다고 아는 로그가 _어딘가_에 있을 텐데 그걸 찾느라 낭비된다.
수정 후에는 SSH가 안 됐다. 에러를 붙여넣자 답이 바로 나왔다. /home 위에 마운트하면서, 그 전에 설정돼 있던 ssh 키를 덮어써버린 것이다.
이런 반복이 몇 번 더 있었다. 초보 실수는 분명 있었지만, 내 개인 지식으로 가능한 속도보다 훨씬 빠르게 디버깅하고 고쳤다(그리고 다시 말하지만, 이건 깊고 재사용 가능한 지식이라기보다 잡학에 가깝다고 느껴서, 기쁘게 위임한다!).
결국 만족스럽게 동작했고, 더 중요한 건 내가(적어도 내가 필요한 범위에서는) 이 코드를 유지보수하는 데 만족한다는 점이다. 생산성 향상을 측정하기는 좀 어렵지만, 이걸 작동시키기 위해 필요한 CLI 플래그의 압도적인 개수를 생각하면, 이 글을 쓰는 시간까지 감안하더라도 시간을 절약했을 거라고 꽤 확신한다!
최근 Hamming(거리와 코드로 유명한)의 《The Art of Doing Science and Engineering》을 읽었는데, 한 이야기가 머리에 남았다:
Bell Telephone Laboratories에 있는 심리학자 친구가 스위치 12개 정도와 빨간 불, 초록 불이 달린 기계를 만든 적이 있다. 스위치를 설정하고 버튼을 누르면 빨간 불 또는 초록 불 중 하나가 켜진다. 첫 번째 사람이 20번 시도한 뒤 초록 불을 켜는 방법에 대한 이론을 썼다. 그 이론을 다음 희생자에게 주고, 그 사람도 20번 시도한 뒤 이론을 쓰고, 이런 식으로 끝없이 반복했다. 시험의 표면적 목적은 이론이 어떻게 진화하는지 연구하는 것이었다.
하지만 내 친구는 그런 사람이었기에, 불을 난수원(random source)에 연결해두었다! 어느 날 그는 나에게, 모든 시험에서 어떤 사람도(그리고 그들은 모두 Bell Telephone Laboratories의 수준 높은 과학자들이었다) “메시지가 없다”고 말한 적이 없다고 관찰했다. 나는 즉시 그에게, 그들 중 누구도 통계학자나 정보이론가가 아니었기 때문이라고 말했다. 난수에 아주 익숙한 두 부류의 사람 말이다. 확인해보니 내가 맞았다!