데이터 파이프라인 · 스키마 변화와 계약 판 · 이론
스키마는 바뀐다 — 누가 먼저 바뀌어도 안 깨지게
한 줄 요약
스키마 변경의 진짜 질문은 "무엇을 바꿀까" 가 아니라 "쓰는 쪽과 읽는 쪽 중 누가 먼저 바뀌어도 안 깨지는가" 이고, 그 답은 기본값·별칭·승격 규칙 세 가지로 결정된다.
왜 이게 필요했나
파이프라인이 내보내는 이벤트에 필드를 하나 더해야 한다. 스키마 파일을 고치고, 생산하는 쪽을 배포한다. 그날 밤 다운스트림 세 곳이 멈춘다. 그중 하나는 우리 팀이 존재하는 줄도 몰랐던 팀이다.
다음에는 조심해서, 읽는 쪽을 먼저 고치고 쓰는 쪽을 나중에 배포한다. 이번에는 읽는 쪽이 새 필드를 찾지 못해 멈춘다. 아직 아무도 그 필드를 쓰지 않기 때문이다.
두 사고는 같은 원인이다. 배포는 한순간에 일어나지 않는다. 쓰는 쪽과 읽는 쪽 사이에는 반드시 한쪽만 새 코드인 기간이 있고, 그 기간에 자료는 계속 흐른다. 그래서 변경을 설계할 때 물어야 할 것은 "이 변경이 맞는가" 가 아니라 "이 변경을 어느 순서로 배포해도 살아남는가" 다.
이 코스의 앞 모듈과 갈라야 할 자리가 여기다. 남이 주는 파일이 통보 없이 바뀐 것을 탐지하는 일은 받는 쪽의 방어다. 여기는 바꾸는 쪽이 우리이고, 우리가 판 번호를 매겨 바꾸면서 옛 자료와 옛 읽기 코드를 동시에 살려 두는 일이다.
어떻게 동작하나
두 방향을 이름으로 나눠 부른다. [Confluent 의 호환성 문서](https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html)가 쓰는 이름을 그대로 쓴다.
- 뒤로 호환(backward): 새 스키마를 쓰는 읽기 코드가 옛 스키마로 적힌 자료를 읽는다. 이것이 되면 읽는 쪽을 먼저 올려도 된다.
- 앞으로 호환(forward): 옛 스키마를 쓰는 읽기 코드가 새 스키마로 적힌 자료를 읽는다. 이것이 되면 쓰는 쪽을 먼저 올려도 된다.
- 둘 다 되면 완전 호환이고, 그때만 양쪽을 독립적으로 배포할 수 있다.
판정 규칙은 [Avro 명세의 스키마 해석](https://avro.apache.org/docs/1.11.1/specification/)에 이미 적혀 있다. 세 줄이 전부다.
- 쓰는 쪽에만 있는 필드는 읽는 쪽이 그냥 버린다. 그래서 필드를 더하는 것은 옛 읽기 코드를 깨뜨리지 않는다.
- 읽는 쪽에만 있는 필드는 기본값이 있어야 한다. 없으면 오류다. 그래서 기본값 없는 필수 필드를 더하면 새 읽기 코드가 옛 자료를 못 읽는다.
- 같은 필드의 타입은 쓰는 쪽에서 읽는 쪽으로 승격되어야 한다. 명세가 허용하는 승격은 int 에서 long·float·double 로, long 에서 float·double 로, float 에서 double 로다. 반대 방향은 승격이 아니다.
이 세 줄에서 실무 규칙이 곧바로 나온다. 기본값이 있는 필드를 더하는 것은 양방향으로 안전하다. 읽는 쪽이 옛 자료를 만나면 기본값으로 메우고, 옛 읽기 코드는 새 필드를 버리기만 하면 된다. 반대로 기본값 없는 필수 필드를 더하는 것은 뒤로 호환을 깨뜨린다. 옛 자료에는 그 값이 아예 없는데 읽는 쪽에는 메울 방법이 없기 때문이다.
이름 변경은 더 미묘하다. 스키마만 놓고 보면 이름 변경은 추가 하나와 삭제 하나다. 그것을 한 사건으로 되돌리는 유일한 장치가 별칭(alias)이다. 그런데 Avro 는 별칭을 읽는 쪽 스키마의 것만 쓴다 — 쓰는 쪽 스키마를 읽는 쪽의 이름으로 고쳐 읽는 방식이기 때문이다. 그래서 이름 변경은 한쪽 방향으로만 산다. 새 읽기 코드는 별칭을 들고 있으니 옛 자료를 읽지만, 옛 읽기 코드에는 새 이름을 가리킬 별칭이 없다. [프로토콜 버퍼](https://protobuf.dev/programming-guides/proto3/)가 이름 대신 필드 번호를 쓰면서 그 번호는 한번 쓰이면 바꿀 수 없다고 못박은 이유가 같다.
타입 변경도 방향이 갈린다. int 를 long 으로 넓히면 뒤로 호환은 되고 앞으로 호환은 깨진다. long 을 int 로 좁히면 정확히 반대다. 그래서 "타입을 바꿨다" 는 말만으로는 아무것도 못 정하고, 어느 방향으로 바꿨는지를 봐야 한다.
현장에서 만나는 모습
첫째, 한 판만 읽는 코드를 짠다. 전환 기간에는 여러 판이 섞여 흐른다. 읽는 쪽이 최신 판만 안다면 그 기간 내내 옛 자료를 통째로 버리고, 버렸다는 사실은 건수가 줄어든 것으로만 나타난다. 전환 기간을 넘기는 방법은 하나다 — 읽기 스키마에 기본값을 넉넉히 달아 옛 판까지 읽히게 만드는 것.
둘째, 판 번호를 자료에 안 적는다. 줄마다 어느 판으로 적힌 것인지 표시가 없으면 읽는 쪽은 추측할 수밖에 없고, 추측은 틀린다. 판 번호는 자료에 붙어 다녀야 한다.
셋째, 필드를 지우면서 기본값을 안 본다. 필드를 지우면 옛 읽기 코드는 그 필드를 못 찾는다. 그 필드가 옛 스키마에서 기본값을 갖고 있었다면 메워지고, 아니면 깨진다. 그래서 지울 수 있는 필드는 처음부터 기본값이 있던 필드뿐이고, 이 사실은 필드를 더할 때 미리 결정된다.
넷째, 호환을 문서로만 관리한다. 문서는 배포를 막지 않는다. 판정을 코드로 만들어 두고, 새 판을 올릴 때 그 코드가 먼저 돌게 해야 한다. 사유를 사람이 읽는 문장이 아니라 고정된 코드로 내면 자동화가 쉬워진다.
실무에서 진짜 중요한 것
- 기본값이 있는 필드를 더하는 것부터 고른다. 양방향으로 안전한 변경은 그것 하나뿐이다.
- 이름을 바꾸지 말고, 꼭 바꿔야 하면 별칭과 배포 순서를 함께 정한다. 읽는 쪽이 먼저다.
- 타입은 넓히는 방향만 쓴다. 좁히기는 옛 읽기 코드를 위해서만 성립하고, 대개 그 반대가 필요하다.
- 판 번호를 줄마다 적고, 읽기 스키마 하나로 여러 판을 덮는다. 전환 기간은 예외가 아니라 정상 상태다.
다음 실습에서 할 것
주문 이벤트의 다섯 판을 만들어 내보낸 뒤, 계약 도구 contract.py 를 한 단계씩 키운다. 필수와 선택을 기본값으로 가르고, 판 사이의 변화를 추가·삭제·이름 변경·타입 변경으로 분류하고, 뒤로·앞으로 호환을 Avro 규칙대로 판정해 사유를 고정된 코드로 낸다. 마지막에는 다섯 판이 섞여 흐르는 줄을 엄격한 읽기 스키마와 너그러운 읽기 스키마로 각각 읽어, 기본값 하나가 몇 건을 살리는지 숫자로 본다. 채점기는 매번 다른 필드 이름과 타입으로 자기 스키마를 만들어 여러분의 도구를 실제로 돌리고 답을 대조한다.