콘텐츠로 이동

ADR 0004: 버전 정책 — 독립 SemVer, 사람이 누르는 릴리스

상태

채택됨(Accepted) — 2026-09-27

요약 (English summary)

kpubdata is a library and versions on its own. kpubdata-builder and kpubdata-studio are one application, deployed together and already coupled by a contract, so they share a version. The builder's HTTP contract version stays separate from that package version. A release happens when a person decides one has; the policy's job is to make that a single button, not to define when to press it.

문제

2026-09-27 실측이다.

저장소 선언 버전 태그 릴리스
kpubdata 0.6.0 10개 10건
kpubdata-builder 0.4.0.dev0 1개 (v0.1.0) 1건
kpubdata-studio 0.4.0 0개 0건

builder 의 선언이 태그보다 세 마이너 앞서 있고, studio 는 버전이 아무 산출물도 가리키지 않는다.

원인 — 자동화가 있는 곳만 버전이 움직였다

이 절의 흐름도는 2026-09-27 당시의 기록이다. 지금의 릴리스 흐름(release PR → 게이트 → 태그 → publish-pypi.yml 수동 실행)은 PACKAGING.md 6절에 있다 (#623).

세 저장소의 결정적 차이는 하나다.

kpubdata          publish-pypi.yml   workflow_dispatch(patch/minor/pre)
                                     → 버전 올림 → 태그 → 릴리스 → PyPI
kpubdata-builder  없음
kpubdata-studio   없음

kpubdata 의 버전이 움직인 이유는 워크플로가 움직이기 때문이다. 버튼 한 번이면 끝나므로 10번 일어났다.

builder 의 버전 변경은 세 번뿐이고, 마지막이 #592 다 — 그 커밋 메시지는 "패키지 버전을 CHANGELOG 라인에 맞춤" 이라고 적혀 있다. 릴리스를 해서 버전이 오른 것이 아니라 문서를 보고 숫자를 맞춘 것이다. 방향이 거꾸로다.

문서가 버전을 정하면, 문서가 앞서갈 때마다 버전도 앞서간다. ROADMAP 은 v0.1 · v0.2 · v0.3 · v0.5 를 ✅ 완료라고 하는데 발행된 태그는 v0.1.0 하나다. 작업은 끝났고 발행된 적이 없다.

결정

1. 라이브러리 하나, 애플리케이션 하나 — 버전도 둘

kpubdata                    독립 SemVer      라이브러리
kpubdata-builder ┐
                 ├─ 같은 버전                 애플리케이션
kpubdata-studio  ┘

경계는 배포 단위다. 함께 배포되는 것은 함께 버전을 매기고, 따로 설치되는 것은 따로 매긴다.

근거가 이미 저장소 안에 있다.

  • 자동화가 없는 저장소는 릴리스가 나가지 않는다. 태그 수가 그대로 말한다 — 워크플로가 있는 kpubdata 10개, 없는 builder 1개, 없는 studio 0개. 1인 개발에서 릴리스 열차가 둘이면 한 대는 굴러가지 않고, 굴러가지 않은 쪽이 studio 다. 분리하면 릴리스마다 "저쪽도 내야 하나" 를 판단해야 하고, 반복되는 판단은 건너뛰게 된다.
  • .github/workflows/cross-repo-contract.yml 이 이미 둘을 묶고 있다. 계약이 깨지면 양쪽이 같이 깨진다.
  • 반면 builder 는 kpubdata>=0.6.0,<0.7 로 버전 범위로 의존한다. 그것이 라이브러리 관계의 정의다 — 범위 안의 어느 것이든 된다.

그래서 사용자가 "무엇을 돌리고 있나" 에 답할 때 필요한 숫자는 둘이다. 애플리케이션 버전 하나와, 그 안에 든 라이브러리 버전 하나.

근거 하나를 철회했다 — 2026-09-28

이 절의 첫 근거는 "docker-compose.prod.app.yml 이 builder 와 studio 를 한 애플리케이션으로 띄운다" 였다. 파일을 열어보니 사실이 아니다.

$ grep -E '^  [a-z-]+:|image:' docker-compose.prod.app.yml
  builder:   ghcr.io/kpubdata-lab/kpubdata-builder:latest
  caddy:     caddy:2.8-alpine     (BACKEND_UPSTREAM: builder:8000)

studio 가 없다. caddy 는 builder 로만 프록시한다. 측정하지 않고 쓴 문장이고, 바로 아래에서 같은 실수를 한 번 더 적어둔 그 실수다.

결론은 바뀌지 않는다. studio 가 다른 곳에 배포돼서 빠진 게 아니라 아직 배포된 적이 없어서 빠졌기 때문이다 — 태그 0개, 이미지 없음(kpubdata-studio#411). Pages 워크플로의 이름은 Deploy demo + docs 이고 제품 배포가 아니다. 근거를 위와 같이 바꾼다.

처음에 셋 다 독립으로 쓴 것을 고쳤다

이 ADR 의 초안은 셋 다 독립이었고, 근거로 "변경 빈도가 다르다" 를 들었다. 재보니 사실이 아니다 — 최근 60일 커밋이 kpubdata 253, builder 323, studio 278 로 거의 같다. 측정하지 않고 쓴 근거였다.

빈도가 같고 배포가 같으면 따로 매길 이유가 없다. 1인 개발에서는 특히 그렇다 — 릴리스가 두 번이면 한 번은 빠뜨리게 되고, 빠뜨린 쪽이 지금의 studio 다(태그 0개).

비용은 받아들인다

한쪽만 바뀌어도 둘 다 버전이 오른다. builder 만 고친 릴리스에서 studio 는 내용이 같은 채로 숫자가 오른다.

그 대가로 얻는 것은 "이 애플리케이션은 0.5.0 이다" 라고 한 문장으로 말할 수 있는 것이다. 두 숫자를 맞춰 보는 일이 사라진다.

호환성 표는 열이 셋에서 둘로 줄어든다 — 애플리케이션 버전 × kpubdata 버전.

지금 숫자를 어떻게 맞추나

builder 가 0.4.0.dev0, studio 가 0.4.0 이다. 이미 같은 0.4 다 — 우연이었지만 맞추는 비용이 없다. 첫 공동 릴리스를 0.4.0 으로 낸다.

2. 0.x 의 뜻

셋 다 0.x 다. minor 가 호환성을 깰 수 있다. 0.6.0 → 0.7.0 에서 public API 가 바뀔 수 있고, patch 는 바꾸지 않는다.

1.0 은 공개 API 를 고정하겠다는 약속이므로, 그 약속을 지킬 준비가 되기 전에는 올리지 않는다. 무엇이 1.0 인지는 kpubdata#528 이 정한다.

3. HTTP 계약 버전은 패키지 버전과 별개다

builder 에는 버전이 두 개다.

패키지     0.4.0.dev0     배포물을 가리킨다
API 계약   1.28.0         studio 가 의존하는 것을 가리킨다

서로 다른 것을 센다. 계약은 studio 가 무엇을 기대해도 되는지 말하고, 패키지는 무엇이 설치됐는지 말한다. 계약을 패키지에 종속시키면 계약이 오를 때마다 릴리스가 강제되고, 반대로 리팩터링 릴리스가 계약이 바뀐 것처럼 보인다.

둘을 잇는 것은 버전이 아니라 GET /version 응답이다 — api_version 과 패키지 버전을 함께 돌려주므로, studio 는 붙어 있는 서버가 무엇인지 런타임에 안다.

3.1 런타임 정합성은 한 번의 비교로 끝낸다

버전을 통합하는 진짜 이득이 여기 있다.

현재 상태 (2026-09-27 실측). studio 는 GET /version 의 api_version 을 builderApi.schema.ts 에서 파싱만 하고 검사하지 않는다. 불일치를 사용자에게 알리는 코드가 한 줄도 없다.

grep -riE 'version.*mismatch|incompatib' src/   →  0건

그래서 studio 0.5 가 builder 0.4 에 붙으면 없는 엔드포인트를 404 로, 바뀐 필드를 zod 파싱 오류로 만난다. 사용자는 "버전이 안 맞는다" 대신 "불러오기 실패" 를 본다. 원인과 증상이 멀어서 진단이 어렵다.

통합 버전이 이것을 한 줄로 만든다. builder 와 studio 가 같은 버전이므로 studio 는 자기 빌드 버전과 서버가 말한 버전을 그냥 비교하면 된다.

studio 빌드 버전   0.4.0   (빌드 시 주입)
GET /version       0.4.0   (서버가 말하는 것)
                   ────
                   같으면 진행, 다르면 한 문장으로 알린다

독립 버전이면 이 비교가 불가능하다 — 0.5.0 studio 가 0.4.2 builder 에 붙어도 되는지는 호환성 표를 런타임에 읽어야 알 수 있고, 그것은 표를 코드에 넣는 일이다. 1인 개발에서 유지될 수 없다.

무엇을 만드나

  • [ ] studio 빌드에 자기 버전을 주입 (package.json → import.meta.env)
  • [ ] 첫 API 호출에서 GET /version 과 비교
  • [ ] 다르면 막지 않고 알린다 — 배너 한 줄. 막으면 patch 차이로도 못 쓰게 된다
  • [ ] major·minor 가 다르면 경고, patch 만 다르면 조용히 통과
  • [ ] api_version(HTTP 계약)은 이 비교와 별개로 남는다 — 3번

마지막 항목이 중요하다. 계약 버전은 studio 가 무엇을 기대해도 되는지 말하고, 애플리케이션 버전은 같은 릴리스에서 나왔는지 말한다. 전자는 기능 판정이고 후자는 짝 판정이다.

4. 릴리스는 사람이 누른다

정책이 정하는 것은 "언제 눌러야 하는가" 가 아니라 "누를 수 있게 되어 있어야 한다" 이다.

main 병합마다 자동 릴리스를 기각한 이유는, 그러면 버전이 절대 뒤처지지 않는 대신 릴리스가 의미를 잃기 때문이다. 릴리스 목록이 커밋 목록과 같아지면 사용자는 어느 것을 올려야 하는지 알 수 없다.

Epic 완료 시점 자동 릴리스도 기각했다. Epic 이 끝났다는 판정 자체가 사람의 일이고 (POLICY 4.1), 판정을 자동화하면 라벨이 릴리스를 결정하게 된다.

그래서 무엇을 만드는가

  • [ ] builder·studio 에 bump 워크플로 — 하나를 올리면 둘 다 올라가야 하므로 kpubdata 의 것을 그대로 복사할 수 없다. 한쪽에서 발행하고 다른 쪽을 따라 올리거나, 두 저장소가 같은 입력을 받는다
  • [x] 버전 출처 일관성 게이트 (kpubdata-builder#717) — 어긋난 채로 발행되지 않는다
  • [ ] docs/compatibility.md 를 릴리스 시 갱신하는 절차
  • [ ] studio 의 런타임 버전 비교 (3.1절) — 불일치를 증상이 아니라 원인으로 보여준다
  • [ ] builder·studio 를 0.4.0 으로 확정하고 첫 공동 태그 — 사람이 한다

마지막 항목이 사람의 일인 이유는 태그와 릴리스가 외부로 나가는 발행이기 때문이다. 검사를 추가한 부수 효과로 일어날 일이 아니다.

기각한 대안

셋 다 독립. 이 문서의 초안이었다. 근거로 든 "변경 빈도가 다르다" 가 측정해 보니 틀려서 기각했다 — 위 1번.

셋 다 동반. 숫자 하나만 보면 된다. kpubdata 는 PyPI 라이브러리이고 버전 범위로 소비되므로 기각했다 — 애플리케이션이 릴리스될 때마다 라이브러리 버전이 오르면 범위 지정(>=0.6.0,<0.7)이 뜻을 잃는다.

계약 버전을 패키지 버전에 종속. 버전 하나로 계약까지 추론할 수 있다. 계약 변경마다 릴리스가 강제되어 기각했다 — 위 3번.

main 병합마다 자동 릴리스. 버전이 절대 뒤처지지 않는다. 릴리스가 의미를 잃어 기각했다 — 위 4번.

관련

  • POLICY.md — 33절(Target Release) · 34절(Stable) 은 미수신
  • compatibility.md
  • kpubdata#528 — 무엇이 1.0 인가
  • kpubdata-builder#690 #717 — 어긋남을 막는 게이트
  • kpubdata-studio#411 — studio 릴리스 산출물