Git 커밋을 사용자 관점의 릴리즈 노트로 정리하는 오픈소스 Swift CLI, relnote를 만들며 정리한 설계 노트다.
brew install agiletalk/tap/relnote
사내 자동화에서 핵심만 떼어내기
회사에서는 iOS 릴리즈 노트 작성을 자동화하는 내부 시스템을 운영하고 있다. 배포 브랜치의 변경 내역을 LLM이 사용자 관점에서 정리하고, Slack에서 승인과 피드백을 받은 뒤 App Store Connect까지 동기화하는 파이프라인이다.
시스템 전체를 밖으로 가져올 수는 없었지만, 핵심 아이디어는 단순했다. 개발자가 남긴 커밋에서 사용자가 알아야 할 변화만 골라내고, 이해하기 쉬운 릴리즈 노트로 다시 쓰는 것.
relnote는 이 아이디어를 CLI로 옮긴 도구다. 마지막 태그 이후의 커밋을 읽고, 제목뿐 아니라 본문까지 살펴 사용자가 체감할 변화만 남긴다.
결과에는 가장 중요한 변화 하나인 hero, 몇 개의 하이라이트, 그리고 제외한 커밋과 그 이유가 함께 담긴다.
여기서 의외로 중요했던 것은 제외 목록이었다. 무엇을 넣었는지만 보여주면 LLM이 중요한 변경을 빠뜨렸는지 알기 어렵다. 무엇을 왜 뺐는지까지 보여줘야 사람이 그 판단을 검토할 수 있다.
설계 결정 1: API 키를 다루지 않는다
LLM을 사용하는 오픈소스 도구는 대개 환경변수에 API 키를 넣으라고 안내한다. 하지만 relnote의 사용자는 터미널에서 개발 도구를 사용하는 사람들이다. 이들 중에는 별도 API 키를 발급받기보다 이미 Claude Code를 구독하고 로그인해 사용하는 경우가 많다.
그래서 relnote는 LLM API를 직접 호출하지 않는다. 대신 Claude Code의 headless 모드에 작업을 맡긴다.
claude -p --output-format json --json-schema '<스키마>' # 프롬프트는 stdin
이 방식에는 몇 가지 장점이 있다.
- 인증과 과금을 따로 구현하지 않아도 된다. 사용자는 기존 Claude Code 로그인을 그대로 사용하고, relnote 코드에는 API 키가 들어가지 않는다.
- 정해진 형식의 결과를 받을 수 있다.
--json-schema를 전달하면 응답의structured_output에 스키마 검증을 거친 JSON 객체가 담긴다. 출력 형식을 교정하거나 파싱에 실패해 재시도하는 로직을 크게 줄일 수 있다. - 커밋이 많아져도 안전하다. 프롬프트를 표준 입력으로 넘기기 때문에 명령행 길이 제한을 걱정하지 않아도 된다.
스키마에서는 version과 date를 제외했다. 둘 다 Git이 이미 알고 있는 값이므로 relnote가 직접 채운다. 프로그램이 확실히 아는 사실까지 LLM에 추론시키지 않는 것이 환각을 줄이는 가장 간단한 방법이다.
설계 결정 2: Claude Code에 묶이지 않게 만든다
처음에는 Claude Code만 지원했지만, Git에서 커밋을 읽고 결과를 렌더링하는 로직까지 Claude에 종속시킬 이유는 없었다. 실행 방식에 따라 달라지는 부분은 프롬프트를 전달하고 결과를 받는 과정뿐이었다.
그래서 이 부분을 LLMExecutor라는 공통 인터페이스로 분리했다.
public protocol LLMExecutor: Sendable {
var promptStyle: PromptStyle { get }
func execute(
prompt: String,
schema: String,
model: String?
) async throws -> Data
}
규칙은 단순하다. 어떤 실행기든 프롬프트와 스키마를 받아 정해진 형태의 JSON을 돌려주면 된다. 이 규칙만 지키면 Git 파싱이나 결과 렌더링 코드는 실행 방식이 바뀌어도 영향을 받지 않는다.
현재 LLM을 사용하는 실행기는 두 가지다.
claude는 설치된 Claude Code에 작업을 맡긴다.apple은 macOS 26의 Foundation Models를 통해 Apple Intelligence 온디바이스 모델을 호출한다.
어느 쪽이든 relnote가 모델을 내장하거나 직접 서빙하지는 않는다. Claude 모델은 Claude Code가, 온디바이스 모델은 macOS가 제공한다.
LLM을 전혀 사용하지 않는 방법도 있다. --no-llm은 Conventional Commits 규칙에 따라 커밋을 분류하는 오프라인 모드다. Git을 읽고 결과를 만드는 전체 흐름은 그대로 두고, 커밋을 선별하는 방식만 바뀐다.
설계 결정 3: 온디바이스 모델도 같은 구조에 넣는다
macOS 26의 Foundation Models를 이용해 Apple Intelligence 온디바이스 모델을 실행기로 추가했다.
흥미로웠던 점은 Foundation Models의 guided generation이 Claude Code의 --json-schema와 비슷한 역할을 한다는 것이었다.
@Generable
struct GeneratedCuration {
@Guide(description: "The single most impactful user-facing change")
var hero: Entry
// …
}
let response = try await session.respond(
to: prompt,
generating: GeneratedCuration.self
)
Foundation Models는 디코딩 단계부터 결과가 지정한 구조를 따르도록 제한한다. relnote 입장에서는 스키마를 CLI 옵션으로 전달하느냐, Swift 타입과 매크로로 선언하느냐의 차이일 뿐이다.
앞에서 실행기를 분리해둔 덕분에 통합 자체는 반나절이면 끝났다. 더 오래 걸린 것은 작은 모델에 맞춰 프롬프트를 다시 만드는 일이었다.
3B 모델의 현실
온디바이스 모델은 약 3B 규모이고 컨텍스트는 4,096토큰이다. Claude용으로 만든 20여 줄의 편집 규칙을 그대로 전달했더니, 모델이 커밋 내용 대신 지침 문구를 릴리즈 노트처럼 출력하기 시작했다.
실제로 “가장 영향력 있는 사용자 중심 변경”이라는 제목의 릴리즈 노트를 받아보면 어디서부터 손봐야 할지 막막해진다.
해결책은 프롬프트를 모델 크기에 맞추는 것이었다. 각 실행기가 promptStyle을 선언하도록 하고, Claude에는 자세한 규칙을, 온디바이스 모델에는 짧고 명령형인 프롬프트를 전달했다.
언어 지시는 프롬프트의 가장 앞에 배치했다. 작은 모델은 긴 지시문 중간에 들어 있는 언어 조건을 자주 놓쳤다.
그래도 hero를 하이라이트에 다시 넣는 것처럼 사소한 실수는 남았다. 이런 문제까지 프롬프트로 해결하려 하지 않고 코드에서 중복을 제거했다.
작은 모델을 사용할 때는 프롬프트로 요청할 일과 코드로 확실히 막을 일을 구분해야 했다.
배포에서 만난 함정
Foundation Models를 포함하려면 macOS 26 SDK로 컴파일해야 한다. 이전 버전의 macOS 러너에서 빌드하면 컴파일 시점에 Foundation Models를 사용할 수 없다고 판단되어, Homebrew로 배포하는 바이너리에서 온디바이스 실행 경로가 빠진다.
이를 해결하기 위해 GitHub Actions의 릴리즈 빌드를 macos-26 러너로 변경했다.
Foundation Models는 availability annotation을 통해 weak-link된다. 따라서 macOS 26 SDK로 빌드해도 바이너리 자체는 macOS 13에서 실행된다. 구형 macOS에서는 나머지 기능은 그대로 사용할 수 있고, apple 실행기만 사용할 수 없다고 안내한다.
세 가지 실행 방식
| 실행 방식 | 품질 | 비용 및 속도 | 오프라인 |
|---|---|---|---|
--executor claude | 가장 안정적 | 구독 사용량 사용 커밋 7개 기준 약 $0.15 | ✗ |
--executor apple | 작은 릴리즈에는 무난 | $0 커밋 7개 기준 약 8초 | ✓ |
--no-llm | 규칙 기반 분류 | $0 | ✓ |
비용과 실행 시간은 직접 테스트한 환경을 기준으로 한 대략적인 값이다.
기본값인 auto는 Claude Code를 사용할 수 있으면 claude를 선택하고, 그렇지 않으면 사용 가능한 Apple 온디바이스 모델로 폴백한다. 모델 없이 처리하려면 --no-llm을 명시하면 된다.
사용자는 API 키를 새로 발급받지 않아도 된다. Apple 실행기를 사용할 수 있는 환경이라면 별도의 모델이나 CLI를 설치할 필요조차 없다.
만들면서 배운 것
- 사용자가 이미 쓰는 도구에 위임하면 시작 장벽이 낮아진다. Claude Code 구독과 로그인을 그대로 활용하면 별도의 API 키 발급 과정이 사라지고, relnote도 인증과 과금 로직을 관리하지 않아도 된다.
- 실행 방식이 달라도 결과에 대한 계약은 같게 만들 수 있다.
--json-schema든@Generable이든, 정해진 구조의 결과를 돌려준다는 규칙만 같으면 나머지 파이프라인은 그대로 유지할 수 있다.
- 프롬프트는 모델 크기에 맞춰야 한다. 큰 모델에 효과적인 상세한 지침이 작은 모델에는 오히려 소음이 될 수 있다. 실행기를 분리할 때 프롬프트 스타일도 함께 분리해야 한다.
- LLM에는 판단이 필요한 일을 맡기고, 확실한 사실은 코드로 처리하는 편이 낫다. 버전과 날짜를 채우거나 중복 항목을 제거하는 일처럼 결과가 정해져 있는 작업은 코드가 맡는 것이 더 빠르고 안전하다.
relnote는 MIT 라이선스로 공개되어 있다. 앞으로 Slack Block Kit 전송, GitHub Release 본문 갱신, Codex CLI·Copilot CLI·opencode 실행기 등을 추가할 계획이다.
