v1이라고 말하는 비용: 세 Go 프로젝트에서 호환성 약속을 기계로 지키는 법
들어가며
올해 Aho-Corasick 라이브러리 ACOR, 키-값 스토어 KVS, AWS 에뮬레이터 DevCloud, 세 오픈소스 프로젝트에 v1을 붙였다. 다만 KVS는 뒤에서 설명할 이유로 v1.0.0을 retract해서, 이 약속이 실제로 효력을 갖는 다음 릴리즈를 아직 준비하고 있다.
Go에서 v1을 붙이는 일에는 호환성 약속이 따른다. Go 모듈의 import 호환성 규칙에 따르면 같은 import 경로를 쓰는 코드는 새 버전에서도 동작해야 하므로, 이를 깨는 변경에는 /v2 같은 새 경로가 필요하다. v0에서는 자유롭게 바꿀 수 있었던 공개 API도 v1부터는 v2가 나올 때까지 유지해야 하는 셈이다.
그래서 세 프로젝트를 v1으로 올리면서 이 약속을 기억에만 맡기지 않고 자동으로 검사하는 방법도 함께 정리했는데, 잘못 배포한 버전을 회수하는 일부터 공개 API를 줄여 목록으로 관리하는 일, 그리고 문서에 적힌 동작과 데이터 포맷, 실행 설정까지 호환성의 경계를 어디에 둘지 정한 과정을 차례로 살펴보려 한다.
1. 유령 버전 회수하기: retract
ACOR에서는 v1을 준비하자 이미 사라진 태그가 먼저 문제가 됐다. 예전에 v1.0.0~v1.4.0으로 배포했다가 태그를 지웠지만 proxy.golang.org는 한 번 본 버전을 영원히 캐시하므로 모듈 버전까지 사라지지는 않았던 것이다. 그 결과 go get github.com/skyoo2003/acor은 현재 지원하는 v0.11.x 대신 지워진 v1.4.0을 선택하고 있었다.
이 문제는 go.mod의 retract 지시어로 해결할 수 있다.
| |
주석은 사용자와 메인테이너가 각각 필요한 내용을 읽도록 나눠 썼다. go 명령이 retract 근거를 첫 줄에서 자르므로, 첫 줄에는 사용자에게 보여 줄 완결된 문장을 두고 나머지에는 메인테이너를 위한 경고를 남겼다. 회수 범위를 v1.5.0까지 넓히면 첫 지원 버전까지 스스로 회수하게 되기 때문이다.
v1.0~v1.4 번호는 이미 써 버렸으니 첫 지원 v1에는 v1.5.0을 붙일 수밖에 없었고, 그래서 버전이 v0.11.x에서 v1.5.0으로 건너뛰었다. retract 블록을 담은 첫 릴리즈인 이 v1.5.0이 이후 v1 호환성 약속의 기준점이 됐다.
회수한 버전을 계속 회수 상태로 유지하는 것도 필요했다. go 명령은 가장 높은 버전의 go.mod에 있는 retract만 읽으므로, 다음 릴리즈에서 블록을 빼면 해당 버전이 조용히 되살아나기 때문이다. 그래서 ACOR의 CI에 retract 줄이 go.mod에 남아 있는지 검사하는 절차를 넣어, 누군가 기억하는 데 의존하던 규칙을 자동으로 확인하도록 했다.
KVS도 retract로 [v0.1.0, v0.1.1]과 v1.0.0을 회수했다. v1.0.0에서는 라이브러리가 모듈 루트에 있었지만 2026-09-29에 표준 Go 레이아웃으로 정리하면서 pkg/kvs로 옮겼고, 같은 날 v1.0.0도 retract했다. 호환성 문서에 “예전 모듈 루트 경로는 더 이상 제공하지 않는다"고 적어 둔 것도 이 때문이다. 다만 retract에 근거 주석은 아직 없어 사용자에게 이유가 보이지 않으므로, ACOR처럼 첫 줄에 설명 한 문장을 남기는 편이 좋겠다. 앞서 본 규칙에 따라 이 회수는 해당 블록을 담은 다음 KVS 릴리즈가 나와야 효력이 생기며 그 릴리즈는 아직 나오지 않았다.
2. v1 전에 공개 API 정리하기
공개한 식별자는 v1 이후에도 호환성을 유지해야 하므로, ACOR v1.5.0에서는 먼저 공개 API를 줄였다.
- 내부 패키지로 가는 별칭을 실제 정의로 바꿨다.
KVStorage,Preset,Match같은 공개 타입이 internal 패키지 타입의 별칭이었는데, 별칭으로 두면 internal 패키지를 바꿀 때마다 공개 API도 함께 바뀐다. 그래서 이제는 모두pkg/acor안에 직접 선언한다. - 저장소 추상화를 비공개로 돌렸다.
KVStorage,Pipeliner,Subscription,StringMapResult,PubSubMessage,Z를 unexport해서 고정될 표면 223개 항목 중 43개를 덜어냈다. 이 인터페이스들을 받거나 돌려주는 공개 함수가 없어 외부에서 구현을 넣을 방법도 없었는데, 공개 상태로 고정해 버리면 v1 동안 메서드를 추가할 수 없으니 언젠가 할 저장소 플러그인 작업의 모양까지 미리 묶어 버리는 셈이었다. - 쓰지 않는 것을 지웠다.
AhoCorasickInfo와 필드까지 같은InMemoryInfo,PresetBalanced의 deprecated 별칭PresetUltimate를 삭제했다.
이 정리 때문에 코드 변경이 필요할 수 있는 업그레이드는 v0.11.x에서 v1.5.0으로 넘어가는 경우뿐이라는 예외를 문서에 남겼고, 그 이후의 모든 업그레이드는 호환성 약속의 범위에 들어간다.
DevCloud에서는 같은 목적을 위해 v1.0.0 직전에 저장소 전체의 과잉 설계를 검토했다. 이벤트 버스와 admin WebSocket, GetMetrics 플러그인 API, /devcloud/api/metrics를 없애고 웹 대시보드는 별도 저장소로 분리했다. 바이너리에서 UI를 빼고 opt-in admin API만 제공하도록 정리해, 호환성을 약속할 범위부터 줄인 것이다.
3. 공개 API 목록 관리하기
공개 API를 정리한 뒤에는 그 목록이 바뀌는지도 확인해야 한다. ACOR과 KVS에서는 API 목록을 텍스트 파일로 생성해 커밋하고 CI가 같은 목록을 다시 생성해 비교하도록 했다.
예를 들어 ACOR의 api/v1.txt는 이렇게 시작한다.
| |
목록에는 함수와 메서드, 구조체 필드, 인터페이스 메서드를 한 줄씩 담고 구조체 태그도 포함한다. json:"status" 같은 태그가 바뀌면 Go 시그니처는 그대로여도 직렬화 결과를 읽는 코드가 깨질 수 있기 때문이다.
공개 API를 바꾸는 PR에서는 목록 파일도 같은 diff에서 갱신해야 하므로, API를 삭제하면 리뷰어가 해당 줄이 지워지는 모습을 확인할 수 있다. 다만 스냅샷을 함께 갱신하면 CI는 통과하기 때문에, 그 변경이 호환성을 깨는지 판단하는 일은 여전히 리뷰에서 해야 한다.
KVS의 pkg/kvs/testdata/api-surface.txt도 같은 역할을 하는데, 테스트가 Go AST로 패키지의 공개 API 목록을 생성해 골든 파일과 비교한다.
| |
마지막에서 두 번째 줄처럼 줄을 추가하는 것도 새 약속이 되어 v1 안에서는 되돌릴 수 없다는 점에 주의해야 한다.
KVS에서는 export한 메서드라고 모두 호환성을 약속하지는 않는다. Store의 SetReplicator, ReplaceWith, ApplyReplicated는 internal/cluster에서 패키지 경계를 넘어 사용하려고 공개한 것이므로, 문서에서 이 세 메서드를 이름으로 명시해 예외로 두었다. 도구는 공개 API 목록을 고정하고 문서는 그 안에서 외부 사용자에게 약속하는 범위를 정하는 방식이다.
4. godoc의 동작 설명 검토
API 시그니처를 비교하는 일은 도구에 맡길 수 있고, “매칭 순서”, “FindSet은 처음 나온 순서를 지킨다”, “FindParallel은 중복을 제거한다” 같은 문서화된 동작은 개별 테스트로 확인할 수 있다. 다만 자연어 문서 전체에서 호환성 조건을 추출하고 버전 사이의 의미까지 자동으로 비교하기는 어렵다.
ACOR은 동작 테스트와 함께 항목별 검토 기록도 검사한다. api/v1-audit.txt에는 v1.txt의 항목마다 판정 한 줄이 있다.
| 판정 | 의미 |
|---|---|
ok | godoc이 코드와 일치 |
fixed | 불일치를 찾아 고침 |
risk | 위험 요소 있음 |
unaudited | 아직 확인 안 함 (허용하되 매 실행마다 집계해 출력) |
unaudited 이외의 판정에는 실제로 존재하는 file:line을 근거로 인용해야 하며, CI는 항목의 판정이 빠졌거나 중복됐을 때뿐 아니라 인용한 줄이 존재하지 않을 때도 실패한다.
v1.5.0의 약속을 고정하기 전에 180개 항목을 코드와 대조하자 38개가 실제로는 하지 않는 동작을 설명하고 있었다. 이를 고친 뒤에 고정했으므로 v1이 약속하는 것은 v1.4.0까지 배포한 문장이 아니라 수정한 설명인데, 먼저 확인하지 않았다면 잘못된 문장을 v2까지 유지하겠다고 약속할 뻔했다.
5. 데이터 포맷의 호환성 관리
호환성을 확인할 대상은 라이브러리 API뿐만이 아니다. 디스크나 Redis에 남긴 데이터도 버전이 바뀐 뒤 계속 사용되므로 포맷에 관한 정책이 필요한데, 여기에서는 두 프로젝트가 정반대의 방식을 택했다.
ACOR: Redis V2 포맷은 덧붙이기만 한다. 여러 인스턴스가 사전 하나를 공유해 롤링 배포 중에도 서로 다른 버전이 같은 키를 읽고 쓰므로, v1 동안에는 V2 포맷에 새 내용을 추가하는 변경만 허용한다.
- 키 이름, 해시 태그, 기존 필드의 이름과 의미는 바뀌지 않는다.
- 새 필드는
{name}:trie해시나 새 키에 추가할 수 있고, 모르는 필드는 무시한다. {name}:outputs해시에는 아무것도 추가하지 않는다. 필드 이름이 automaton 상태라서 메타데이터를 넣을 자리가 없기 때문이다.
이 규칙으로 서로 다른 버전의 인스턴스가 같은 데이터를 읽고 쓸 수 있지만 새 필드를 사용하는 기능에는 제약이 남는다. 옛 인스턴스의 Flush가 새 인스턴스에서 추가한 필드를 지울 수 있으므로, 그런 기능은 모든 인스턴스의 업그레이드가 끝난 뒤에야 동작하며 문서에도 이 조건을 명시해 두었다.
KVS: 포맷은 약속하지 않고 거부를 약속한다. 데이터 디렉토리의 format 파일을 확인해 모르는 버전이면 시작을 거부하는 방식으로, 호환성 문서에서도 “그 거부가 약속이고, 내용은 약속이 아니다"라고 설명한다. 다른 릴리즈에서 쓴 데이터를 읽는다는 보장이나 변환 코드는 없지만 읽을 수 없는 데이터를 반쯤 읽는 상황은 막는다. 자세한 동작은 KVS 내구성 글에서 다뤘다.
이 차이는 데이터를 공유하는 방식에서 나온다. 공유 저장소를 두고 롤링 배포하는 ACOR에는 추가만 허용하는 정책이 맞고 노드마다 디렉토리를 갖는 KVS에는 버전 불일치를 알리고 시작을 거부하는 방식이 맞았다.
6. 설정과 API의 런타임 호환성
DevCloud 사용자는 Go 패키지를 import하기보다 바이너리를 실행하므로, 호환성도 실제로 사용하는 설정과 API를 기준으로 정했다.
| 표면 | 1.x 동안 약속 |
|---|---|
| 설정 파일 키 | server.port, services.<id>.enabled, admin.enabled 등. 추가는 가능, 제거나 용도 변경은 불가 |
| 환경 변수 | DEVCLOUD_PORT, DEVCLOUD_SERVICES, DEVCLOUD_DATA_DIR. 환경 변수가 설정 파일을 이기는 우선순위까지 |
| admin API | 경로가 계속 응답하고, JSON 응답은 필드가 늘기만 한다 |
| fidelity 등급 이름 | hand-verified, auto-crud, unimplemented. 집합이 줄지 않고 이름을 다른 뜻으로 재사용하지 않는다 |
| 와이어 동작 | 호환성 스위트의 테스트가 단언하는 속성. 그 이상은 아님 |
마지막 줄의 보장 범위는 AWS 응답 전체가 아니라 test/compatibility/의 각 단언이 확인하는 속성까지다. 문서에 있는 예시를 보면 이 경계를 더 쉽게 알 수 있다.
| 필드 | 단언 방식 | 약속 |
|---|---|---|
FunctionName | 보낸 이름과 같음 | 키와 값 |
FunctionArn | 존재함 | 존재만. ARN 모양을 유지한다는 약속은 아님 |
Runtime, Handler | 단언 없음 | 없음. 지금 응답에 들어 있어도 |
이렇게 정한 보장 범위는 1,530개 테스트로 확인한다. 모든 push와 릴리즈 직전 태그 커밋에서 테스트를 실행해 단언을 깨는 변경이 들어오면 빌드가 실패하도록 했으며 보장 범위를 넓히려면 그에 맞는 단언을 추가해야 한다.
auto-crud 응답 내용과 테스트가 없는 hand-verified 오퍼레이션, 데이터 내구성, 에러 코드와 메시지 문구, internal/ 아래의 모든 항목은 이 약속에서 제외한다고 명시했다.
기존 설정을 바꾸는 절차도 정했다. DevCloud에서는 마이너 릴리즈에서 deprecated 표시를 하더라도 옛 형식은 계속 동작하게 두고 대체 형식을 알려 주는 경고를 내며, 실제로 제거하는 것은 빨라도 다음 메이저 릴리즈부터다. dashboard → admin 설정 이름 변경에서도 옛 키는 여전히 admin API를 켜되 경고를 남기고 명시적인 admin 블록이 있으면 그 설정을 우선하도록 했다. 문서의 “침묵은 deprecated가 아니다"라는 원칙에 따라, 제거한 키도 YAML이 조용히 무시하지 않도록 파서에 남겨 경고를 낸다.
세 프로젝트 비교
| ACOR | KVS | DevCloud | |
|---|---|---|---|
| 약속 대상 | Go 라이브러리 pkg/acor | 와이어 프로토콜 3종, CLI, pkg/kvs | 설정, 환경 변수, CLI, admin API, 와이어 동작 |
| 표면 고정 | api/v1.txt + make api-check | testdata/api-surface.txt + 골든 테스트 | 설정·admin API 목록 + ServicePlugin 적합성 테스트 |
| 유령 버전 | retract [v1.0.0, v1.4.0] + CI 검사 | retract [v0.1.0, v0.1.1], retract v1.0.0 | 없음 |
| 문서 검증 | godoc 180개 항목 감사, 38개 수정 | 표면 파일 + 예외 목록 | 와이어 약속 = 스위트 단언 |
| 데이터 포맷 | V2 덧붙이기만 | 약속 안 함, 모르는 포맷 거부 | 약속 안 함 |
마치며
세 프로젝트를 업그레이드할 때는 공개 API 목록과 함께 호환성 문서의 예외, 데이터 포맷 조건도 살펴야 한다. API 스냅샷으로 변경을 드러내는 것에 더해 동작 테스트와 감사 기록으로 문서의 설명을 확인해야 약속의 범위를 유지할 수 있기 때문이다. DevCloud 역시 응답에 들어 있는 모든 값이 아니라 호환성 스위트가 검사하는 속성까지 보장하므로, 실제 사용하려는 항목이 그 안에 있는지 확인해야 한다.
각 프로젝트의 호환성 문서와 전체 소스 코드는 ACOR, KVS, DevCloud 저장소에서 확인할 수 있다.