JSON 계약 버전 관리: 호환성, 폐기, 데이터 이전
핵심 요약: 필드를 더한다고 자동으로 안전하지 않습니다. 호환성 경계, 버전 필드, 폐기 창, 듀얼 쓰기 이전, 계약 테스트, 카나리 지표를 다룹니다.

핵심부터 말하면, 버전 진화를 expand, backfill, contract 순으로 운영하고 버전을 문서 안에 영속화하세요. 출시 전에 소비자가 알 수 없는 필드와 열거 값을 용인하는지 확인하고, 폐기는 freeze, announce, observe, remove 순서로 거치세요.
버전 관리의 어려운 점은 코드를 바꾸는 것이 아니라, 누가 아직 옛 필드를 읽는지 알 수 없다는 것입니다. 호환성은 결코 단방향 약속이 아니라 발행자와 알 수 없는 소비자 사이의 지속적인 정렬입니다.
1. 호환성 경계: 추가가 자동으로 안전하지 않음
교과서는 새 선택 필드를 "역호환"이라고 부르지만, 그 명제는 소비자가 알 수 없는 필드를 용인할 때만 성립합니다. 필드를 더하는 것도 적어도 세 상황에서 클라이언트를 깨뜨립니다:
- 소비자가
additionalProperties: false를 설정한 스키마로 검증할 때 - 소비자가 객체를 고정 순서로 순회하거나 키가 아니라 위치로 값을 읽을 때
- 새 필드가 옛 이름을 재사용하면서 의미를 바꿀 때. 예:
status의 의미를 "주문 상태"에서 "이행 상태"로 넓힘
안전한 추가에는 세 가지 전제가 필요합니다. 필드가 선택적이고, 의미가 독립적이며, 소비자가 알 수 없는 키를 무시해야 합니다. 버전 번호를 논의하기 전에 이 셋을 확인하세요.
{
"schemaVersion": 2,
"orderId": "ord-8842",
"status": "paid",
"refundDeadline": "2026-11-30T00:00:00+08:00",
"channel": "wechat"
}
여기서 refundDeadline과 channel은 선택적인 v2 추가이고, orderId와 status는 원래 의미를 유지합니다. schemaVersion은 HTTP 헤더에만 사는 것이 아니라 문서와 함께 영속화됩니다.
2. 출시할 수 없는 호환성 파괴 변경
다음 변경을 새 버전이나 듀얼 쓰기 전환이 필요한 적색 선으로 취급하세요. 문서를 갱신하는 것으로는 구할 수 없습니다:
| 변경 | 호환성을 깨는 이유 | 허용 가능한 대안 |
|---|---|---|
| 필드 제거 | 소비자가 즉시 undefined를 읽음 |
폐기하고 관찰한 뒤 창이 끝나면 제거 |
| 필드 타입 변경 | "1"이 1이 됨, 강타입 클라이언트는 바로 실패하고 약타입도 검증에서 자주 실패 |
새 필드 이름을 더하고 전환 중 듀얼 쓰기 |
| 필수 필드 추가 | 옛 호출자가 보내지 않아 검증이 항상 실패 | 기본값을 제공하거나 새 버전에서만 필수로 |
| 열거 값 집합 변경 | 소비자의 switch에 폴백 분기가 없음 |
먼저 클라이언트가 알 수 없는 값을 받아들이게 한 뒤 열거를 넓힘 |
| 필드 의미 변경 | 타입은 유효한데 동작이 바뀜 — 디버깅이 가장 어려운 사례 | 새 필드로 취급하고 옛 의미는 동결 |
| 중첩 깊이 변경 | 모든 접근 경로를 바꿔야 함 | 옛 모양을 유지하고 평평한 필드나 expand 파라미터를 더함 |
의미 변경이 가장 위험한 행입니다. 스키마 검증은 초록으로 남고, 모니터링은 오류를 보고하지 않으며, 오직 업무 지표만 어긋납니다. 의미가 바뀌는 변경은 새 필드로 출시해야 합니다.
3. 버전이 사는 곳: 문서, URL, 미디어 타입
세 가지 흔한 배치는 서로 다른 목적을 수행합니다:
| 위치 | 예 | 적합 | 한계 |
|---|---|---|---|
| 문서 필드 | "schemaVersion": 2 |
데이터베이스, 메시지 큐, 오프라인 백업 | 전송 계층이 라우팅에 쓸 수 없음 |
| URL 경로 | /v2/orders/8842 |
공개 API | 리소스 주소를 버전에 묶어 이전이 비쌈 |
| 미디어 타입 | application/vnd.acme.order.v2+json |
콘텐츠 협상이 필요한 내부 서비스 | 호출자가 Accept를 올바로 설정해야 함 |
실용적인 분업: 전송에는 미디어 타입 협상을 쓰고, 문서에는 항상 버전 필드를 영속화하세요. 큐의 옛 메시지는 URL을 바꿨다고 업그레이드되지 않고, 백업 파일도 마찬가지입니다. 문서 내 schemaVersion만이 재생, 백필, 감사에 기반을 줍니다.
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
URL에 버전을 둔다면, 적어도 폐기 창 전체에서 옛 경로가 옛 모양을 반환하게 하고 조용히 새 구현을 가리키지 마세요. 더 넓은 API 계약 결정은 JSON API 데이터 계약 설계 요점을 참고하세요.
4. 폐기 창 열기
폐기는 한 번의 공지가 아니라 시작, 관찰 기간, 끝이 있는 창입니다. 운용 가능한 리듬:
- 동결: 옛 필드 쓰기를 멈추되 읽기는 유지합니다.
- 공지: 문서, 변경 로그, 응답 헤더에서 동시에 제거 날짜를 표시합니다.
- 관찰: 호출자별 옛 필드 읽기를 세어 두 기간 연속 0으로 유지됩니다.
- 제거: 창이 닫히고 읽기가 0이며 회귀 테스트가 통과한 뒤에야 필드와 호환 코드를 삭제합니다.
Sunset 응답 헤더(RFC 8594)는 클라이언트에 기계 가독 제거 날짜를 줍니다. Deprecation은 아직 IETF 초안입니다. 쓸 수 있지만 모든 클라이언트가 이해한다고 가정하지 마세요. 이전 가이드를 가리키는 Link 헤더와 함께 쓰세요:
Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"
창 길이는 팀 관례입니다. 흔한 하한은 릴리스 주기 하나 또는 90일 중 더 긴 것입니다. 공개 API는 더 길게, 내부 서비스는 더 짧게 할 수 있지만, 모든 호출자가 업그레이드하는 데 필요한 시간보다 짧아서는 안 됩니다.
5. 폐기 전에 소비자 찾기
폐기 전에 "누가 아직 이걸 읽나?"에 답할 수 있어야 합니다. 그렇지 않으면 창은 자기기만입니다. 증거를 신뢰도순으로 나열합니다:
- 게이트웨이 로그: 옛 필드 접근을
appId,User-Agent, 클라이언트 버전별로 집계합니다. - 분산 트레이스: 직렬화 계층에서 옛 필드에 태그를 달아 트레이스가 호출 서비스 이름을 직접 보여 주게 합니다.
- 큐 소비자 등록부: 소비자 그룹 목록이 코드 검색보다 신뢰할 만합니다.
- 저장소 검색:
rg는 자체 코드만 덮고, 외부 호출자는 여전히 로그가 필요합니다.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
검색 히트는 "이 저장소의 누군가가 읽는다"를 증명할 뿐, "다른 누구도 읽지 않는다"를 증명하지 않습니다. 게이트웨이 로그를 매일 내보내 옛 필드 읽기가 0으로 유지됨을 확인한 뒤에야 무언가를 제거하세요.
6. 데이터 이전: expand, backfill, contract
Martin Fowler의 Parallel Change는 세 단계를 설명합니다. expand(옛 필드와 새 필드가 공존하고 두 버전을 읽을 수 있음), backfill(과거 레코드를 새 모양으로 옮김), contract(옛 필드와 호환 분기 제거). 순서를 뒤집지 마세요. 아니면 롤백 경로가 사라집니다.
// Migration skeleton: cursor by primary key, conditional writes for idempotency
async function migrateBatch(collection, cursor, batchSize = 500) {
const records = await collection
.find({ _id: { $gt: cursor } })
.limit(batchSize)
.toArray();
if (records.length === 0) {
return { nextCursor: cursor, done: true };
}
const operations = records
.filter((record) => record.schemaVersion < 2)
.map((record) => ({
updateOne: {
filter: { _id: record._id, schemaVersion: record.schemaVersion },
update: {
$set: {
schemaVersion: 2,
status: record.legacyStatus ?? record.status,
refundDeadline: record.refundDeadline ?? null,
},
},
},
}));
if (operations.length > 0) {
await collection.bulkWrite(operations);
}
return { nextCursor: records.at(-1)._id, done: false };
}
놓치기 쉬운 디테일:
- 멱등성:
filter에 옛 버전 번호를 포함해 재실행이 이미 이전된 레코드를 다시 쓰지 않게 하세요. - 배치와 체크포인트:
_id커서로 진행하고 체크포인트를 저장하며 실패 후 그곳에서 재개하세요. - 리허설: 운영 스냅샷에 스크립트를 돌리고 전후 레코드 수와 체크섬을 비교하세요.
- 롤백: 새 필드가 검증을 통과할 때까지 옛 필드를 유지하고, 플래그로 읽기 경로를 전환하며
schemaVersion만 고쳐 롤백하지 마세요. - 검증: 이전 후 JSON 파싱 검사만 아니라 업무 제약(음수 아닌 금액, 합법적 상태 전이)을 실행하세요.
설정 데이터도 같은 과정을 따릅니다. 파일 조직 방법은 설정, 픽스처, 로컬 데이터를 위한 JSON 관리를 참고하세요.
7. 계약 테스트: 호환성을 CI에 넣기
호환성은 코드 리뷰 기억에 의존할 수 없고 테스트가 필요합니다. 두 계층이면 충분합니다:
- 형태 테스트: JSON Schema로 응답을 검증하되, 소비자가 쓰는 스키마에
additionalProperties: false를 설정하지 마세요. - 소비자 부분집합 테스트: 옛 소비자가 진실로 의존하는 필드를 하위 스키마로 추출하고 새 응답이 여전히 이를 만족함을 단언합니다.
// Contract test: new responses must still satisfy the old consumer subset
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';
const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);
test('a v2 response still satisfies the v1 consumer subset', () => {
const response = buildOrderResponse({ schemaVersion: 2 });
const { schemaVersion, ...legacyFields } = response;
const isValid = validateLegacySubset(legacyFields);
assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});
하위 스키마는 옛 소비자가 읽는 필드와 타입만 선언하고 모든 새 필드를 통과시킵니다. 필드 제거와 타입 변경은 잡고 추가에 대한 거짓 경보는 피합니다. 사례 데이터는 픽스처 디렉터리에 두고 순서와 diff 가능성을 유지하세요. 그 파일들의 머지 충돌은 JSON 파일 Git 충돌: union은 깨진 JSON을 쓰니 키로 머지하세요를 참고하세요.
8. 카나리 롤아웃: 차원으로 확장, 지표로 중단
호환성 문제는 단위 테스트에서 드물게 드러나고 카나리에서 드러납니다. 노출을 좁은 것에서 넓은 것으로 넓히세요:
| 차원 | 실행 | 검증하는 것 |
|---|---|---|
| 테넌트 허용 목록 | 내부나 우호적 고객에게 먼저 새 읽기 경로 활성화 | 실제 데이터 모양 차이 |
| 클라이언트 버전 | 이미 새 모양으로 업그레이드한 클라이언트에만 활성화 | 옛 클라이언트와 새 클라이언트의 병존 |
| 트래픽 비율 | 5% → 25% → 100%, 각 단계마다 전체 업무 주기 하나 관찰 | 롱테일 호출과 캐시 적중 동작 |
최소 세 지표를 보세요. API 오류율, 계약 검증 실패율, 옛 필드와 새 필드의 읽기 비율. 자동 중단 조건(예: 5xx가 1% 초과, 옛 필드 읽기의 갑작스러운 반등)을 정의하고 플래그를 롤백하세요. 카나리 중 듀얼 쓰기를 켜 두어 롤백이 데이터 복구가 아니라 읽기 경로 전환만 요구하게 하세요.
9. 출시 전 호환성 체크리스트
- [ ] 새 필드가 선택적이고, 의미상 독립적이며, 옛 이름을 재사용하지 않습니다
- [ ] 소비자가 알 수 없는 필드와 알 수 없는 열거 값을 폴백으로 처리합니다
- [ ] 버전 필드가 문서와 함께 영속화되고 전송에서 협상 가능합니다
- [ ] 폐기 창에 공지,
Sunset헤더, 관찰이 있습니다 - [ ] 옛 필드 읽기를 로그나 트레이스로 특정 호출자까지 추적할 수 있습니다
- [ ] 이전 스크립트가 멱등하고, 배치 처리되며, 재개 가능하고, 롤백 경로가 있습니다
- [ ] 계약 테스트가 레거시 소비자 부분집합을 덮고 CI에서 실행됩니다
- [ ] 카나리에 명시적 노출 차원과 중단 지표가 있습니다
FAQ
선택 필드를 더하는 것이 항상 역호환인가?
아니요. 소비자가 알 수 없는 필드를 무시하고 엄격한 스키마 검증을 피할 때만 호환됩니다. additionalProperties: false를 쓰거나 위치로 읽는 소비자는 여전히 실패합니다. 출시 전에 계약 테스트로 용인도를 확인하세요.
schemaVersion은 URL에 두어야 하나 응답 본문에 두어야 하나?
전송에서는 미디어 타입이나 URL 경로로 버전을 협상할 수 있지만, 저장된 레코드는 문서 안에 schemaVersion을 지녀야 합니다. 큐, 오프라인 백업, 감사는 URL을 거치지 않으므로 문서 내 버전만이 재생과 백필을 지원합니다.
폐기한 필드를 즉시 삭제할 수 있나?
아니요. 쓰기를 멈추고 제거 날짜를 공지하며 호출자별 읽기를 세세요. 창이 닫히고 읽기가 0으로 유지된 뒤에야 삭제합니다. 삭제 전에 전체 회귀를 돌려 숨은 크론 작업이나 리포트가 아직 읽지 않는지 확인하세요.
이전 스크립트가 중간에 실패하면?
멱등 작업으로 설계하세요. 조건부 업데이트, 기본 키 커서, 체크포인트. 실패 후 체크포인트에서 재개하고 반복 실행이 두 번 쓰지 않아야 합니다. 롤백을 위해 옛 필드와 스냅샷을 유지하고 schemaVersion만 되돌리지 마세요.
데이터 이전 순서는 어떻게 해야 하나?
Parallel Change를 따르세요. 먼저 expand로 옛 필드와 새 필드가 공존하고 두 버전을 읽을 수 있게 하고, 과거 레코드를 새 모양으로 백필한 뒤, 옛 필드와 호환 분기를 제거해 contract합니다. 순서를 뒤집으면 롤백 경로를 잃고, 이전 스크립트를 멱등하고 배치 처리되며 재개 가능하게 만드세요.