반복적으로 실행해야 하는 실험/벤치마크 명령을 셸 히스토리에 의존하지 않고, gitignored된 make.ts 파일에 적어 실행하는 인터랙티브 스크립팅 패턴을 소개한다.
2026년 1월 27일
Up Enter Up Up Enter Up Up Up Enter
익숙한가? 이게 내가 역사적으로 벤치마크나, 동일한 명령을 반복 실행해야 하는 각종 실험을 돌리던 방식이다. — 한 번은 터미널에 수동으로 타이핑하고, 그 다음부터는 셸 히스토리(그리고 가끔은 터미널 분할)를 믿고 재현하는 식이다. 지난 몇 년 동안 나는 훨씬 더 나은 워크플로 패턴에 도달했다. — make.ts. 멀티프로세스 애플리케이션을 다루기 시작하면서 억지로 적응하게 되었는데, 그때는 명령을 수동으로 입력하는 게 사실상 불가능에 가까웠다. 돌이켜보면, 이 워크플로를 몇 년 더 일찍 받아들였어야 했다.
인터랙티브 스크립팅을 위한 (gitignored된) 파일을 사용하라. 터미널에 명령을 직접 입력하는 대신, 먼저 파일에 써 두고 그 파일을 실행한다. 나는 make.ts에 이것저것 타이핑하고, 터미널에서 ./make.ts를 실행한다(좋아, 여기에는 Up Enter가 한 번 필요하다).
분명히 하고 싶은데, 나는 “제대로 된” 스크립트를 작성하라고 주장하는 게 아니다. 그저 인터랙티브하게, 즉흥적으로 쓴 명령을 지속적인 파일에 캡처하자는 것이다. 물론 반복적으로 실행하고 싶은 명령은 빌드 시스템에 넣는 게 맞다. 놀라운 점은, 더 복잡한 일회성 명령도 파일을 통해 실행하면 이득이 있다는 것이다. 왜냐하면 그런 명령은 제대로 되기까지 여러 번 시행착오를 겪기 마련이기 때문이다!
Up Up Up 방식 대비 장점은 많다:
make.ts 이전에는 이 때문에 끔찍한 && 연쇄를 만드는 일이 잦았다).for와 if 몇 개로 감싸기만 하면 된다.스크립트 파일명을 일관되게 사용하라. 나는 make.ts를 쓴다. 그래서 내가 작업하는 대부분의 프로젝트 루트에는 make.ts가 있다. 그리고 프로젝트의 .git/info/exclude(공유되지 않는 .gitignore)에는 make.ts 한 줄을 넣어 둔다. 고정된 이름은 고정 비용을 줄여 준다. 복잡한 인터랙션이 필요할 때마다 새 파일 이름을 고민할 필요 없이, 기존 make.ts를 열고 내용을 싹 지운 다음 해킹을 시작하면 된다. 마찬가지로 셸 히스토리에는 ./make.ts가 남아 있으니, fish 자동 제안도 잘 작동한다. 한때는 make.ts를 실행하는 VS Code 태스크를 쓰기도 했지만, 지금은 terminal editor를 사용한다.
스크립트는 해시뱅(hash bang)으로 시작하자.
#!/usr/bin/env -S deno run
--allow-all
내 경우엔 이렇게 하고, 실행이 편하도록 chmod a+x make.ts로 파일에 실행 권한을 준다.
스크립트는 다음 조건을 만족하는 언어로 작성하라:
나에게는 TypeScript가 그렇다. 현대 JavaScript는 충분히 인체공학적(ergonomic)이고 구조적이며, 점진적(gradual) 타입 시스템은 적당한 코드 완성도를 제공하면서도 문자열 딕셔너리(stringly dict)만 충분히 던져서 어떤 문제든 무식하게 해결할 수 있는 달콤한 지점이다.
JavaScript의 태그드 템플릿(tagged template) 문법은 스크립팅 용도에서 훌륭하다:
function $(literal, ...interpolated) {
console.log({ literal, interpolated });
}
const dir = "hello, world";
$`ls ${dir}`;
출력:
{
literal: [ "ls ", "" ],
interpolated: [ "hello, world" ]
}
여기서 일어나는 일은, $가 백틱 안의 리터럴 문자열 조각들의 목록을 받고, 그리고 별도로 그 사이에 끼워 넣을 값들의 목록을 받는다는 것이다. 모든 것을 이어 붙여 단일 문자열로 만들 수도 있지만, 그럴 필요는 없다. 이것이 바로 프로세스 스폰에 필요한 방식이다. 즉 exec 시스템 콜에 문자열 배열을 넘기고 싶기 때문이다.
구체적으로 나는 Deno와 함께 dax 라이브러리를 쓰는데, 단일 바이너리에 배터리 포함(batteries-included) 스크립팅 환경으로서 훌륭하다(<3 Deno 참고). Bun에는 dax 비슷한 라이브러리가 기본으로 들어 있어 좋은 대안이 된다(다만 개인적으로는 deno fmt과 deno lsp 때문에 Deno를 고수한다). 유명한 zx를 써도 되지만, 이것이 셸을 중간자로 사용한다는 점은 주의하자. 나는 이걸 대충(sloppy)하다고 생각한다(설명).
dax는 단일 프로그램을 스폰하기 편하게 해 주지만, 여러 프로세스를 몰아넣는 데에는 async/await가 훌륭하다:
await Promise.all([
$`sleep 5`,
$`sleep 10`,
]);
오늘 아침에 내가 이 패턴을 어떻게 적용했는지 이야기해 보자. TigerBeetle 클러스터가 primary 크래시에서 어떻게 복구하는지 측정하고 싶었다. 수동으로 하려면 여러 대의 클라우드 머신에 대해 ssh 세션을 여러 개 띄우고, 데이터 파일을 포맷하고, 레플리카를 시작한 다음, 부하(load)를 만들어야 한다. 터미널을 분할하려고 거의 했지만, 곧 더 똑똑하게 할 수 있다는 걸 깨달았다.
첫 단계는 바이너리를 크로스 컴파일하고, 클라우드 머신들에 업로드한 다음, 클러스터를 실행하는 것이었다(지난주에 소개한 내 box를 사용):
#!/usr/bin/env -S deno run --allow-all
import $ from "jsr:@david/dax@0.44.2";
await $`./zig/zig build -Drelease -Dtarget=x86_64-linux`;
await $`box sync 0-5 ./tigerbeetle`;
await $`box run 0-5
./tigerbeetle format --cluster=0 --replica-count=6 --replica=?? 0_??.tigerbeetle`;
await $`box run 0-5
./tigerbeetle start --addresses=?0-5? 0_??.tigerbeetle`;
위 내용을 두 번째로 실행해 보니, 먼저 기존 클러스터를 죽여야 한다는 걸 알게 되었고, 그래서 두 개의 새 명령을 “인터랙티브하게” 끼워 넣었다:
await $`./zig/zig build -Drelease -Dtarget=x86_64-linux`;
await $`box sync 0-5 ./tigerbeetle`;
await $`box run 0-5 rm 0_??.tigerbeetle`.noThrow();
await $`box run 0-5 pkill tigerbeetle`.noThrow();
await $`box run 0-5
./tigerbeetle format --cluster=0 --replica-count=6 --replica=?? 0_??.tigerbeetle`;
await $`box run 0-5
./tigerbeetle start --addresses=?0-5? 0_??.tigerbeetle`;
이 시점에서, 명령을 하나씩 입력하는 대신 파일로 작성해 둔 것만으로도 이미 투자한 값어치를 했다!
다음 단계는 벤치마크 부하를 클러스터와 병렬로 실행하는 것이다:
await Promise.all([
$`box run 0-5 ./tigerbeetle start --addresses=?0-5? 0_??.tigerbeetle`,
$`box run 6 ./tigerbeetle benchmark --addresses=?0-5?`,
])
프로세스 두 개를 위해 터미널 두 개가 필요하지 않고, 거의 동일한 명령을 복사-붙여넣기-편집할 수 있다.
그 다음 단계에서는 레플리카 중 하나를 죽이고 싶고, 동시에 실시간 로그도 캡처해서 클러스터가 어떻게 반응하는지 즉시 보고 싶다. 여기서 box의 0-5 멀티플렉싱 문법은 한계가 있지만, JavaScript이므로 그냥 for 루프를 쓰면 된다:
const replicas = range(6).map((it) =>
$`box run ${it}
./tigerbeetle start --addresses=?0-5? 0_??.tigerbeetle
&> logs/${it}.log`
.noThrow()
.spawn()
);
await Promise.all([
$`box run 6 ./tigerbeetle benchmark --addresses=?0-5?`,
(async () => {
await $.sleep("20s");
console.log("REDRUM");
await $`box run 1 pkill tigerbeetle`;
})(),
]);
replicas.forEach((it) => it.kill());
await Promise.all(replicas);
이 지점에서는 터미널이 두 개 필요하다. 하나는 ./make.ts를 실행해서 벤치마크 자체의 로그를 보여 주고, 다른 하나는 tail -f logs/2.log로 다음 레플리카가 primary가 되는 과정을 지켜본다.
확실히 이제는 스크립트를 작성하는 게 말이 되는 선을 넘어섰다. 하지만 멋진 점은 여기까지의 점진적인 진화다. 다섯 개 터미널에서 이것저것 즉흥 명령을 실행한 뒤, 이를 하나의 일관된 스크립트로 만들기 위해 15분을 쓰는 불연속적인 순간이 없다. 애초에 파일에 있었기 때문이다.
그리고 스크립트는 계속 발전시키기 쉽다. 예를 들어 다른, 베이스라인 버전의 TigerBeetle에 대해서도 같은 벤치마크를 돌리는 게 좋겠다고 깨닫는 순간, ./tigerbeetle를 ./${tigerbeetle}로 바꾸고 전체를 다음으로 감싸면 된다:
async function benchmark(tigerbeetle: string) {
// ...
}
const tigerbeetle = Deno.args[0]
await benchmark(tigerbeetle);
$ ./make.ts tigerbeetle-baseline
$ ./make.ts tigerbeetle
조금 더 손보면, 파라미터 매트릭스에 대해 반복 가능한 벤치마크 스케줄을 얻게 된다:
for (const attempt of [0, 1])
for (const tigerbeetle of ["baseline", "tigerbeetle"])
for (const mode of ["normal", "viewchange"]) {
const results = $.path(
`./results/${tigerbeetle}-${mode}-${attempt}`,
);
await benchmark(tigerbeetle, mode, results);
}
요지는 이거다. 셸 히스토리를 소스로 삼지 말고, 먼저 파일에 캡처하라!