데이터 파이프라인 · 스키마 변화와 계약 판 · 실습
판을 매겨 바꾸고 양쪽을 다 살린다
목표
판 번호가 붙은 스키마로 JSON Lines 를 다루는 계약 도구 contract.py 를 만든다. 판 사이의 변화를 갈래별로 분류하고, 뒤로 호환과 앞으로 호환을 Avro 의 스키마 해석 규칙대로 판정하고, 여러 판이 섞여 흐르는 전환 기간을 읽기 스키마 하나로 넘긴다.
왜 중요한가
쓰는 쪽과 읽는 쪽은 같은 순간에 바뀌지 않는다. 그 사이에는 반드시 한쪽만 새 코드인 기간이 있고, 그 기간에도 자료는 계속 흐른다. 그래서 스키마 변경을 설계할 때 물어야 할 것은 "이 변경이 맞는가" 가 아니라 "어느 순서로 배포해도 살아남는가" 다.
답은 세 가지로 갈린다. 기본값이 있는 필드를 더하는 것은 양방향으로 안전하고, 기본값 없는 필수 필드를 더하는 것은 새 읽기 코드가 옛 자료를 못 읽게 만들고, 타입 변경은 넓히는 방향과 좁히는 방향이 정확히 반대의 결과를 낸다. 이름 변경은 스키마만 보면 추가 하나와 삭제 하나이고, 별칭으로만 한 사건으로 되돌아온다. 그런데 별칭은 읽는 쪽 스키마의 것만 쓰이므로 이름 변경은 한쪽 방향으로만 산다.
전환 기간을 넘기는 방법은 하나뿐이다. 읽기 스키마에 기본값을 넉넉히 달아 옛 판까지 읽히게 만드는 것이다. 이 실습은 그 기본값 하나가 몇 건을 살리는지를 숫자로 보인다.
채점기는 여러분의 문구를 믿지 않는다. 임시 디렉터리에 채점기가 만든 스키마와 줄을 차려 놓고 여러분의 도구를 실제로 실행해 분류와 판정을 채점기가 따로 구현한 값과 대조한다. 필드 이름과 타입과 금액은 실행마다 바뀝니다.
단계
1. /root/evolve/gen_stream.py 를 만들어 실행해 /root/evolve/schemas 에 v1.json 부터 v5.json 까지, /root/evolve/stream 에 판마다 12줄짜리 v1.jsonl 부터 v5.jsonl 까지와 다섯 판이 섞인 mixed.jsonl 을 만드세요.
2. /root/evolve/contract.py 에 fields <스키마> 를 만들어 판 번호와 필드 이름, 필수·선택, 기본값을 내게 하세요.
3. diff <옛 스키마> <새 스키마> 를 더해 추가와 삭제를 분류하게 하세요. 추가는 기본값이 있는 것과 없는 것으로 갈라 냅니다.
4. diff 가 새 판의 aliases 를 보고 이름 변경을 한 사건으로 묶게 하세요. 묶인 이름은 추가와 삭제 목록에서 빠집니다.
5. diff 가 타입 변경을 넓히기와 좁히기로 갈라 내게 하세요. 승격 표에 있으면 넓히기, 없으면 좁히기입니다.
6. compat <옛 스키마> <새 스키마> 를 더해 뒤로 호환과 앞으로 호환을 판정하고 사유를 고정된 코드로 내게 하세요.
7. read <읽기 스키마> <스키마폴더> <파일> 을 더해 여러 판이 섞인 줄을 한 판으로 읽게 하고, 너그러운 읽기 스키마 /root/evolve/reader.json 을 만들어 두 읽기의 차이를 /root/evolve/window.json 에 적으세요.
8. 다섯 판의 이력을 /root/evolve/evolve_report.json 과 /root/evolve/evolve_report.md 로 남기세요.
참고
- 실행 계약:
python3 /root/evolve/contract.py <명령> .... 성공하면 종료 코드 0, 준 경로가 없으면 3, 모르는 명령이거나 인자 수가 다르면 2 입니다. 답은 JSON 한 덩어리로 표준출력에 냅니다. - 스키마 파일 모양:
{"version": 정수, "name": 문자열, "fields": [{"name": 이름, "type": 타입, "default": 기본값, "aliases": [옛이름...]}, ...]}.default와aliases는 있을 수도 없을 수도 있습니다. - 기본값이 있는 필드가 선택이고, 없는 필드가 필수입니다. 이 실습에서 필수와 선택을 가르는 것은 그 키 하나뿐입니다.
- 타입은
int·long·float·double·string·boolean여섯입니다. 승격 표는 Avro 명세 그대로입니다 — int 는 long·float·double 로, long 은 float·double 로, float 은 double 로 승격됩니다. 같은 타입도 승격으로 봅니다. 그 밖의 조합은 승격이 아닙니다. fields응답:{"version": 정수, "names": [선언 순서대로], "required": [정렬], "optional": [정렬], "defaults": {이름: 기본값}}.diff응답:{"added_with_default": [정렬], "added_required": [정렬], "removed": [정렬], "renamed": [[옛이름, 새이름]...], "widened": [[새이름, 옛타입, 새타입]...], "narrowed": [[새이름, 옛타입, 새타입]...]}. 목록은 전부 정렬해 냅니다. 3단계에서는 앞의 세 키만, 4단계에서renamed가, 5단계에서widened·narrowed가 더해집니다.- 이름 변경 판정: 새 판에만 있는 필드의
aliases안에 옛 판에만 있는 이름이 들어 있으면 그 둘은 같은 필드입니다. 값 표본으로 추측하지 않습니다 — 여기서는 우리가 별칭을 적는 쪽입니다. compat응답:{"backward": 참거짓, "forward": 참거짓, "reasons": [정렬된 코드...]}. 사유 코드는 네 가지뿐입니다.backward:missing_default:<필드>새 판에만 있는 필수 필드라 옛 자료를 못 읽습니다backward:no_promotion:<필드>옛 타입이 새 타입으로 승격되지 않습니다forward:missing_default:<필드>옛 판에만 있는 필수 필드라 새 자료에서 값을 못 찾습니다forward:no_promotion:<필드>새 타입이 옛 타입으로 승격되지 않습니다- 판정은 "읽는 쪽이 쓰는 쪽 자료를 읽을 수 있는가" 한 함수로 하고 인자를 뒤집어 두 방향을 만드는 편이 짧습니다. 뒤로 호환은 읽는 쪽이 새 판, 앞으로 호환은 읽는 쪽이 옛 판입니다.
- 별칭은 읽는 쪽 스키마의 것만 씁니다. 쓰는 쪽 스키마를 읽는 쪽 이름으로 고쳐 읽는 방식이기 때문입니다. 그래서 이름 변경은 한쪽 방향으로만 삽니다.
read응답:{"records": 정수, "by_version": {"판번호": 정수}, "resolved": 정수, "failed": 정수, "amount_total": 정수}. 판 번호 키는 문자열입니다. 줄마다_v로 판 번호를 읽고, 스키마 폴더에서v<판번호>.json을 찾습니다. 읽기 스키마가 그 판을 못 읽으면failed로 세고 금액에 넣지 않습니다.amount_total은 읽기 스키마의amount필드를 해석한 뒤 더한 값입니다.- 이미지에 Avro 라이브러리는 없습니다. 규칙만 명세에서 가져와 JSON Lines 와 판 번호가 붙은 스키마 파일로 직접 구현합니다. 표준 라이브러리만 씁니다.
- 공식 문서: [Avro 명세의 스키마 해석](https://avro.apache.org/docs/1.11.1/specification/) · [Confluent 호환성 유형](https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html) · [Protocol Buffers 언어 안내](https://protobuf.dev/programming-guides/proto3/) · [python json](https://docs.python.org/3/library/json.html)
- 흔한 실수: 기본값 없는 필드를 더해 놓고 뒤로 호환이 된다고 믿기, 이름 변경을 양방향으로 안전하다고 믿기, 타입을 넓히면 항상 안전하다고 믿기, 전환 기간에 최신 판만 읽어 옛 판을 조용히 버리기.
단계 8개
- 다섯 판을 만들어 내보내기
- 필수와 선택을 기본값으로 가르기
- 더해진 것과 없어진 것 가르기
- 이름 변경을 한 사건으로 묶기
- 넓히기와 좁히기 갈라 내기
- 뒤로와 앞으로를 따로 판정하기
- 전환 기간을 읽기 스키마 하나로 넘기기
- 판 이력을 한 장으로 남기기