본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 05. 25. 01:12

여러 프로젝트의 템플릿을 하나로 묶는 kata

요약

여러 Rust CLI 프로젝트의 공통 보일러플레이트와 AI 에이전트용 설정(AGENTS.md 등)을 효율적으로 관리하기 위한 메타 템플릿 도구 'kata'를 소개합니다. 기존 도구와 달리 레이어드 템플릿 구조와 AI 판단 기능을 결합하여 프로젝트 고유 섹션과 공통 섹션을 동시에 유지보수할 수 있습니다.

핵심 포인트

  • 다수 프로젝트의 보일러플레이트 유지보수 문제 해결
  • 레이어드 템플릿 구조로 언어 독립적/의존적 설정 분리
  • AI를 활용한 파일 내 특정 섹션의 지능적 병합 지원
  • how(방법)와 when(시기)의 독립적 이축 구조 설계

Claude Code와 함께 Rust로 CLI를 만드는 것이 즐거워서, 최근 shun / rvpm / todoke / yui / renri와 같은 CLI가 계속해서 늘어났습니다. 이들에 완전히 동일한 보일러플레이트 (Boilerplate)를 계속 적용하기 위한 메타 템플릿 CLI, **kata (型, 형태)**를 만들었습니다.

형 (kata) —

판화의 목판 패턴. 각 프로젝트에 동일한 형태를 찍어내는 목판의 이미지입니다.

왜 만들었는가

비슷한 CLI가 늘어나면 공통 보일러플레이트의 유지보수가 점점 힘들어집니다. 구체적으로 힘들었던 부분은 다음과 같습니다.

  • Makefile.tomlcheck / clippy / test 태스크 순서
  • .github/workflows/ci.yml의 OS 매트릭스(Matrix)와 action의 버전 고정 (pin)
  • .github/workflows/release.yml의 cross-compile + cargo publish 템플릿
  • rustfmt.toml / clippy.toml / rust-toolchain.toml의 방침
  • apm.yml에서 APM을 통해 AI 에이전트용 스킬 (renri 등)을 넣는 정형화된 방식
  • renovate.json의 auto-merge 규칙
  • 그리고 결정적으로 AGENTS.md / CLAUDE.md / GEMINI.md

마지막 AGENTS.md가 가장 까다로웠습니다. Claude / Gemini / Codex에 전달하고 있는 "PR 리뷰는 이렇게 진행한다", "worktree workflow는 이렇게 한다", "Rust의 lint/format 정책은 이렇다"와 같은 대화로 축적된 노하우를, 새로운 프로젝트를 생성할 때마다 혹은 규약을 한 줄 수정할 때마다, N개의 리포지토리에 전부 수동으로 복사하여 붙여넣어야 했습니다.

"Gemini Code Assist와 CodeRabbit 양쪽의 리뷰를 기다리도록 하자"라고 깨달았을 때, 7개의 리포지토리 AGENTS.md를 전부 열어서 편집한 적이 몇 번 있었습니다. 한 번이라면 괜찮지만, 규약은 한 번 정하고 끝나는 것이 아니라 운영하면서 몇 번이고 미세 조정하고 싶어집니다.

copier / cookiecutter / cruft와 같은 기존 도구들은 "최초의 프로젝트 생성"과 "기계적인 재적용"까지는 관리해 주지만, AGENTS.md처럼

  • 리포지토리 공통 섹션 (PR 리뷰 규약, worktree workflow)
  • 프로젝트 고유 섹션 (이 프로젝트의 아키텍처 개요, 특수한 사정)

하나의 파일에 공존하고 있는 것을 계속 업데이트해 주는 기능은 없었습니다. AI에게 판단을 위임하는 모드도 없었습니다.

그래서 만든 것이 kata입니다. **"형 (템플릿)을 목판처럼 찍어내고, 다 찍어내지 못하는 부분만 AI에게 판단하게 한다"**라는 발상으로 설계했습니다.

kata의 특징

  • layered templatepj-base + pj-rust + pj-rust-cli를 순서대로 겹쳐서 나중에 적용된 것이 우선됨. 언어에 의존하지 않는 부분을 pj-base에 집약할 수 있음
  • how × when의 이축 구조how (overwrite / merge-section / merge-toml / merge-yaml / ai / script)와 when (once / always / manual)이 독립적임. how="ai", when="once"how="script", when="always"를 모두 자연스럽게 작성할 수 있음
  • marker-bracketed merge-sectionAGENTS.md<!-- kata:agents:base:begin --> ~ <!-- kata:agents:base:end --> 사이만 kata가 관리. 프로젝트 고유의 절은 바깥쪽에 얼마든지 작성 가능
  • path-based merge-tomlMakefile.tomltasks.check

/tasks.clippy

/tasks.test

만을 kata가 소유하며, tasks.install-local과 같은 독자적인 태스크는 건드리지 않습니다.

  • AI 위임 (AI Delegation)how = "ai"인 파일은, 설치된 claude / gemini / codex CLI에 template의 diff와 현재 내용을 전달하여, chezmoi 방식의 [a]ccept / [e]dit / [s]kip / [d]efer로 확인합니다.
  • 진실(truth)은 프로젝트(PJ) 측에 있음 — 어떤 템플릿을 어떤 rev로 적용했는지는 각 PJ의 .kata/applied.toml에 기록됩니다. 글로벌 설정은 단순한 PJ 경로의 레지스트리입니다.
  • 병렬 실행 (Parallel Execution) — tokio를 통해 PJ를 팬아웃(fan-out)하고, AI 호출은 세마포어(semaphore)로 억제하여 에이전트 CLI의 동시 실행이 폭발적으로 늘어나지 않도록 합니다.
  • CI 동기화 (CI Synchronization)pj-basekata-apply.yml을 배포합니다. 매일 kata update + kata apply를 실행하여, 템플릿 상류(upstream)의 변경 사항을 PR로서 자동으로 가져옵니다.

설치

cargo install kata

kata --version으로 동작 확인이 가능하면 OK입니다.

퀵 스타트 (Quick Start)

# 새로운 Rust CLI 프로젝트에 rust-cli 프리셋을 적용
mkdir my-rust-cli && cd my-rust-cli
kata init github.com/yukimemi/pj-presets:rust-cli --non-interactive
...

이렇게 하면 Makefile.toml / apm.yml / renri.toml / .github/workflows/ci.yml / release.yml / AGENTS.md / CLAUDE.md / GEMINI.md / rustfmt.toml / clippy.toml / rust-toolchain.toml / renovate.json 등이 한꺼번에 프로젝트에 생성됩니다.

preset = 템플릿의 묶음

pj-presets:rust-cli는 단순히 "어떤 템플릿을 어떤 순서로 겹칠 것인가"를 작성한 작은 파일입니다.

# pj-presets/rust-cli.toml
name = "rust-cli"
[[templates]]
...

작성된 순서대로 적용되며, 동일한 파일이 충돌할 경우 **나중에 적용된 것이 승리(last-one-wins)**합니다. 이를 통해 다음과 같은 역할 분담이 가능해집니다.

  • pj-base — 언어 비의존적 (LICENSE, .gitignore, AGENTS.md의 공통 절, apm.yml, renri.toml의 base, kata-apply.yml……)
  • pj-rust — Rust 공통 (Makefile.toml, CI matrix, rust-toolchain.toml, rustfmt.toml, clippy.toml)
  • pj-rust-cli — CLI용 추가 (release.yml의 cross-compile + cargo publish)

예를 들어 "Rust 라이브러리라서 CLI용 release는 필요 없다"는 케이스에는 pj-rust-lib를 조합한 별도의 preset을 준비하는 것만으로 충분합니다.

라이브러리용 rust-lib, Web 프론트용 web-react, Firebase를 포함하는 web-react-firebase도 준비되어 있어, preset 단위로 간편하게 "타입"을 전환할 수 있습니다.

howwhen을 독립적으로 갖는다는 것

kata의 설계에서 가장 공을 들인 부분은 how (적용 방법)와 when (타이밍)을 별개의 축으로 가지는 것입니다.

how무엇을 할 것인가
overwrite템플릿대로 파일을 덮어쓰기
merge-section<!-- kata:*:begin -->end 사이만 교체
merge-tomltoml_edit를 사용하여 지정된 경로만 병합
merge-yamlserde_yaml을 사용하여 YAML의 지정된 경로만 병합
aiClaude / Gemini / Codex에 판단 위임
script임의의 쉘(shell) 명령 실행
when언제 적용할 것인가
once최초 1회만. 이후에는 .kata/applied.tomlonce_applied = true로 스킵
always매번 적용 (kata apply를 실행할 때마다 동기화)
manual명시적으로 --file을 지정했을 때만

이 두 축이 독립적이므로, 예를 들어:

  • release.ymloverwrite, when=once
    — 최초 1회만 배포하고, 이후에는 프로젝트 측에서 자유롭게 편집
  • ci.ymloverwrite, when=always
    — CI는 항상 상류(upstream)를 추종
  • Makefile.tomlmerge-toml, when=always
    — kata가 소유한 태스크만 추종
  • AGENTS.mdmerge-section, when=always
    — 마커(marker) 내부만 추종
  • apm.ymloverwrite, when=once
    — 최초 템플릿 적용 후, 이후에는 프로젝트 측에서 관리
  • LICENSEoverwrite, when=once
    — 최초 1회만 적용

과 같은 세밀한 정책을 하나의 매니페스트(manifest)로 표현할 수 있습니다. 이를 하나의 mode라는 열거형(enum)으로 묶지 않은 이유는, how="ai", when="once" (ROADMAP.md의 초기 생성만 AI에게 맡김)와 같은 조합을 제한하고 싶지 않았기 때문입니다.

AGENTS.md의 merge-section이 유용한 점

kata를 도입하며 가장 큰 혜택을 느끼고 있는 부분이 AGENTS.md를 다루는 방식이기에, 이 부분은 조금 자세히 작성하겠습니다.

pj-base 측의 AGENTS.md.base (템플릿)에는 공통 규약만 적혀 있습니다.

## Shared conventions
This file is the agent-agnostic source of truth (per the
[agents.md](https://agents.md) convention)...
...

이를 pj-base/template.toml에서 다음과 같이 선언합니다.

[[file]]
src = "AGENTS.md.base"
dst = "AGENTS.md"
...

kata apply를 실행하면, 각 프로젝트의 AGENTS.md 내 대응하는 마커 내부만 템플릿 내용으로 교체됩니다. 마커 외부에는 해당 프로젝트의 고유한 상황 (아키텍처, 설계 판단, 도메인 용어, Phase n의 상황 등...)을 얼마든지 적어둘 수 있으며, 이는 kata apply 시 전혀 건드려지지 않습니다.

게다가 레이어(layered) 구조이므로, pj-rust<!-- kata:agents:rust:* -->, pj-rust-cli<!-- kata:agents:rust-cli:* -->와 같이 각자 자신만의 마커 블록을 소유합니다. 이를 통해 하나의 AGENTS.md 안에 「언어 비의존 / Rust 공통 / Rust CLI 전용 / 프로젝트 고유」라는 4개의 계층이 깔끔하게 공존하는 구조를 만들 수 있습니다.

Makefile.toml의 merge-toml로 「kata 소유 태스크」만 추종시키기

AGENTS.md의 마커와 더불어 자주 사용하는 것이 Makefile.tomlmerge-toml입니다. pj-rust/template.toml에서는 다음과 같이 선언하고 있습니다.

[[file]]
src = "Makefile.toml"
how = "merge-toml"
...

paths를 통해 "kata가 소유한 TOML 경로"를 명시합니다. tasks.check / tasks.clippy / tasks.test와 같은 kata-managed task는 매번 상위(upstream)로부터 덮어씌워지지만, consumer 측에서 임의로 추가한 tasks.install-local이나 tasks.deploy와 같은 독자적인 task는 paths에 포함되지 않으므로 전혀 건드리지 않는다는 동작을 수행합니다.

merge-tomltoml_edit를 사용하여 paths에서 지정한 key만 교체(replace)해 나가기 때문에 다음과 같은 특징을 가집니다.

  • 동일한 key의 값은 업데이트됨 (예: tasks.check.script의 내용이 변경됨)
  • paths에 나타나지 않는 key는 consumer가 직접 작성한 내용이 완전히 유지됨
  • 들여쓰기(indent) / 주석 / 순서도 toml_edit 수준에서 유지됨

즉, overwrite 방식에서는 파괴될 수 있는 수동 작성 내용을 존중하며 동기화할 수 있습니다.

.kata/vars.toml으로 분리하여 Renovate에 맡기기

GHA(GitHub Actions) action 버전의 경우, merge-toml × when = "once"를 사용하는 또 다른 실용적인 사례는 GitHub Actions의 version pin을 .kata/vars.toml (Tera 변수 파일)로 분리하는 패턴입니다.

고민해야 할 문제는 다음과 같습니다.

  • ci.yml / release.yml / kata-apply.yml의 version pin (actions/checkout@v6.0.2 등)을 Renovate가 자동으로 bump하게 하고 싶다.
  • 하지만 workflow 본체는 overwrite, when=always 설정으로 kata-managed 상태이므로, Renovate가 직접 ci.yml을 편집하더라도 다음 kata apply 시점에 덮어씌워져 버린다.

해결책은 pin 값만 .kata/vars.toml로 분리하고, workflow 본체는 Tera 템플릿을 통해 이를 참조하는 것입니다.

pj-base/vars.toml (universal pin):

[actions]
checkout = "actions/checkout@v6.0.2"
create_pull_request = "peter-evans/create-pull-request@v8.1.1"

pj-rust/vars.rust.toml에서 Rust 전용 pin을 추가 머지(additional merge):

[actions]
swatinem_rust_cache = "Swatinem/rust-cache@v2"

template.toml 측에서 .kata/vars.toml의 소유 관계를 선언:

# pj-base — universal pin을 최초 1회만 seed
[[file]]
src = "vars.toml"
...

둘 다 when = "once"이므로, 최초 apply 시 seed한 이후에는 consumer의 .kata/vars.toml을 kata가 전혀 건드리지 않는다는 소유 관계가 형성됩니다.

ci.yml.tera 내부에서는 다음과 같이 참조됩니다:

- uses: {{ vars.actions.checkout }}
- uses: {{ vars.actions.swatinem_rust_cache }}

따라서 .kata/vars.toml의 pin이 변경되면, 다음 kata applyci.yml 전체가 새로운 버전으로 다시 렌더링되는 흐름이 됩니다.

이 구조 위에서 consumer 측의 .kata/vars.toml을 Renovate의 customManager가 스캔하고 있으며, 새로운 action 릴리스가 나오면 PR을 생성해 줍니다. Renovate가 수정하는 대상은 .kata/vars.toml의 pin 값을 bump하는 것입니다.

단 한 줄만 수정하면, workflow 본체는 kata-apply의 재렌더링(re-rendering)을 통해 반영되는 분업 구조가 됩니다. 요약하자면 다음과 같습니다.

  • workflow의 구조 변경 (새로운 스텝 추가, jobs 정리 등) → 상류 템플릿(upstream template)에 push → 매일 kata-apply를 통해 내려옴
  • action의 version bump → consumer의 .kata/vars.toml을 Renovate가 자율적으로 수정 → 다음 kata apply에서 ci.yml이 재렌더링

이처럼 merge-tomlwhen = "once"의 조합만으로 역할 분담이 명확한 병렬 동기화를 구축할 수 있습니다.

덧붙여, 위의 ci.yml.tera에서 {{ vars.actions.checkout }}와 같이 작성할 수 있는 것은 Rust로 제작된 템플릿 엔진 Tera 덕분입니다. .tera 접미사가 붙은 파일이 apply 시점에 Tera로 렌더링(render)되고, 접미사를 제거한 이름으로 consumer에게 쓰여지는 단순한 메커니즘입니다. {% if is_windows() %}와 같은 분기 처리나 {{ env.HOME }}와 같은 환경 변수 참조 모두 Tera의 기능이며, kata 측에서는 거의 새로운 것을 재발명하지 않았습니다.

사실 이러한 "설정을 Tera로 작성할 수 있는" 경험은 rvpm이나 todoke와 같은 다른 Rust 기반 CLI에서도 동일한 스택으로 제공되고 있으며, 내부적으로는 teravars (Tera + vars + include + system context를 통합한 얇은 래퍼(wrapper))를 공유합니다. rvpm에서 익숙하게 보았던 {% if is_windows() %}가 kata의 템플릿에서도 그대로 작동한다는 점은 은근히 강력한 장점이며, Rust로 "설정을 선언적으로 작성할 수 있는 작은 CLI"를 만들 때의 표준 스택으로서 teravars를 적극 추천합니다.

핵심 내용: pj-base를 수정하면 모든 프로젝트에 반영된다

이것이 kata를 만들면서 가장 기뻤던 부분입니다.

"아, AGENTS.md의 이 구절, 표현이 좀 별로네. Claude가 오해하기 쉬우니까 고치고 싶다."
"PR review cycle 섹션에 CodeRabbit의 rate-limit notice 처리에 관한 내용을 추가하고 싶다."
"Worktree workflow 섹션에 새로운 renri prune 설명을 넣고 싶다."

규칙(convention)을 운영하다 보면 이런 깨달음이 매우 빈번하게 찾아옵니다. kata를 도입하기 전에는 N개의 AGENTS.md를 전부 열어서 동일한 편집을 N번 반복해야 했습니다.

하지만 kata를 도입한 지금은 다음과 같이 작동합니다.

# pj-base 쪽에서만 수정
cd ~/src/github.com/yukimemi/pj-base
$EDITOR AGENTS.md.base
...

구체적으로는, 각 프로젝트의 .github/workflows/kata-apply.yml (pj-base를 통해 배포됨)이 매일 03:17 UTC에 실행되어:

  • 상류 템플릿 (pj-base / pj-rust / pj-rust-cli)의 최신 rev를 가져옴
  • kata updateapplied.toml의 rev를 업데이트
  • kata apply --non-interactive --no-ai로 재렌더링
  • 차이(diff)가 발생하면 kata-apply/auto 브랜치로 PR 생성
  • CI가 통과(green)하면 모든 리포지토리에서 auto-merge를 자동으로 수행

즉, 규칙이나 설정의 업데이트가 해당 템플릿 레이어에 대한 단 한 번의 push만으로 N개의 프로젝트에 전파되는 것입니다. AGENTS.md의 공통 섹션이라면 pj-base, Makefile.toml / rustfmt.toml / clippy.toml / ci.yml과 같은 Rust 공통 규칙이라면 pj-rust, release.yml의 cross-compile + cargo publish 관련 내용이라면 pj-rust-cli를 수정하면 됩니다.

이러한 레이어 분담 구조를 그대로 유지하면서, 각각의 상류(upstream)에 1 push만 하면 모든 소비자(consumer) 프로젝트가 다음 날 바로 따라오게 됩니다.

워크플로우 본체도 템플릿으로 관리되므로, 워크플로우 자체의 개선(새로운 action 버전, 스텝 추가)도 동일한 메커니즘을 통해 각 프로젝트로 자동으로 내려갑니다. 템플릿이 자기 자신의 유지보수까지 담당하는 형태입니다.

CI에서 kata-apply 실행하기

이 플로우의 핵심은 CI에서 kata apply를 실행할 수 있다는 점입니다. 설계 단계부터 이 부분을 최우선으로 고려했으며,

  • --non-interactive 옵션으로 프롬프트를 완전히 건너뛸 수 있음
  • --no-ai 옵션으로 AI 파일을 통째로 건너뜀 (= 기계적인 템플릿 동기화만 자동으로 실행 가능)
  • --non-interactive --yes 옵션으로 「전부 수락(accept)」 모드 지원 (= 신뢰할 수 있는 템플릿이라면 AI 결과도 자동으로 수락)

이라는 3가지 플래그를 통해 CI 적합성을 보장합니다.

pj-base가 배포하는 kata-apply.yml.tera의 내용은 본질적으로 다음과 같습니다 (일부 발췌).

name: kata-apply
on: 
schedule:
...

요점만 하이라이트해 두겠습니다.

  • KATA_APPLY_TOKENGITHUB_TOKEN으로는 안 됩니다. GITHUB_TOKEN을 통해 열린 PR은 GitHub의 루프 방지 사양으로 인해 하류 워크플로우 (CI)를 트리거(trigger)하지 않습니다. auto-merge의 전제 조건이 CI의 통과(green) 판정이므로, CI가 실행되지 않으면 영원히 머지(merge)되지 않습니다. 적절한 권한 (contents: write + pull-requests: write)을 가진 PAT (fine-grained 또는 classic 모두 가능)를 KATA_APPLY_TOKEN 리포지토리 시크릿(repository secret)으로 설정하는 것이 소비자 측의 유일한 셋업 작업입니다.
  • 브랜치는 kata-apply/auto로 고정합니다. create-pull-requestdelete-branch: true와 조합하면, 매일 새로운 차이분을 동일한 브랜치에 rolling 방식으로 덮어쓰게 되므로 PR이 대량으로 쌓이지 않습니다.
  • CI가 실패하면 PR은 open 상태로 남습니다. auto-merge는 CI가 통과되었을 때만 발화하므로, 무언가 망가졌을 때 사람이 확인해야 할 타이밍이 자연스럽게 생깁니다.
  • cron: "17 3 * * *"는 의도적으로 피크 타임(off-peak)이 아니며 정각을 피한 설정입니다. 0 0 * * *0 9 * * *처럼 :00 정각에 스케줄을 잡는 사람이 지구상에 너무 많아서, GitHub Actions의 러너(runner) 풀이 :00 / :30에 고갈되는 현상(cron-storm)이 있습니다. :17과 같이 애매한 분으로 옮기면 러너 확보가 원활합니다. 또한 3 UTC는 일본 시간으로 12:00 / 미국 서해안의 밤 / 유럽의 이른 아침이며, 러너 자체도 비교적 한가한 시간대이므로 데일리(daily)한 기계적 동기화에는 딱 적당한 시간대입니다.

"모든 프로젝트에 동일한 워크플로우가 들어있다"는 사실이, 모든 프로젝트에 동일한 자동 동기화가 적용되고 있다는 안심감으로 이어졌으며, 이는 도입할 가치가 매우 컸습니다.

AI 위임 모드

how = "ai"를 지정한 파일은 설치된 AI CLI에 판단을 맡깁니다.

template.toml에 다음과 같이 작성하면:

[[file]]
src = "ROADMAP.md.tera"
dst = "ROADMAP.md"
...

kata는 템플릿의 diff, 현재 dst의 내용, 프롬프트를 한데 모아 지정된 에이전트의 CLI (claude -p, gemini -p, codex exec 중 하나)로 전달합니다. 반환된 full body 또는 patch에 대해 chezmoi 스타일의 대화형 프롬프트가 나타납니다.

ROADMAP.md에 대한 제안된 변경 사항:
+ Phase 5 — opencode adapter
+ ## Crate structure (재생성된 섹션)
...

a

= 그대로 채택

e

=$EDITOR로 열어서 직접 수정한 후 채택

s

= 이번 회차는 건너뜀 (다음 kata apply 시 다시 제안됨)

d

=defer (이번에는 보류하되, "다음 회차에 반드시 재제안"하도록 applied.toml에 기록)

--non-interactive만 사용하면 안전을 위해 AI 파일은 건너뛰며, --non-interactive --yes를 사용하면 모두 수락 (accept) 하는 CI 완전 자동 모드가 됩니다.

backend는 trait로 추상화되어 있으며, claude / gemini / codex 3가지를 구현 완료했습니다. agent = "auto"는 PATH를 확인하여 위에서부터 순서대로 폴백 (fallback) 하는 동작을 수행합니다. MockAiAgent도 내장되어 있어, 테스트 시 결정적인 응답을 받을 수 있습니다.

병렬도 제어도 포함되어 있어, AI 호출은 글로벌 세마포어 (semaphore, 기본값 4)로 제한됩니다. 이는 kata apply --all로 10개의 프로젝트를 병렬로 실행할 때, 에이전트 CLI의 동시 실행이 폭발적으로 늘어나지 않도록 하기 위함입니다.

.kata/applied.toml이 신뢰할 수 있는 단일 원천 (source of truth)

kata의 상태는 전부 프로젝트(PJ) 측의 .kata/applied.toml에 기록됩니다. 글로벌 설정 (~/.config/kata/config.toml

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0