사전 스키마 진화
개념

스키마 진화

gabury1고친 사람 github-actions[bot]

스키마 진화는 데이터의 모양을 바꿔도 옛 데이터와 옛 프로그램이 계속 돌게 하는 일입니다. 이미 쌓인 기록과 아직 안 바뀐 서버는 새 모양의 데이터와 한동안 섞여 지냅니다. 필드를 더하고 빼는 방법을 미리 정해 그동안 아무것도 깨지지 않게 합니다.

쉽고 빠른 이해

무슨 일을 하는 물건인가 — 데이터 모양이 바뀌어도 옛 기록과 새 기록을 둘 다 읽게 해 주는 방법입니다. 회원 기록에 이메일 필드를 더해도, 이메일이 없던 옛 회원 기록은 빈 이메일로 읽힙니다.

왜 이렇게 하나 — 저장된 데이터는 옛 모양으로 남아 있습니다. 서버도 한 대씩 차례로 바뀝니다. 새 모양만 읽을 줄 알면 옛 기록을 여는 순간 오류를 냅니다.

어떻게 도나

  1. 새 필드는 없어도 되게 둡니다. 없으면 채울 값도 정합니다
  2. 읽는 프로그램은 모르는 필드를 만나면 건너뜁니다
  3. 이름을 바꿀 때는 새 필드를 먼저 더하고 옛 필드는 나중에 걷어 냅니다

대가 — 걷어 내지 못한 옛 필드와 옛 읽기 코드를 오래 들고 다닙니다. 한 번에 끝날 변경이 여러 번의 배포로 늘어납니다. 쓰는 쪽과 읽는 쪽을 한 번에 같이 올릴 수 있으면 덜 챙겨도 됩니다.

상세

은행 창구의 신청서 양식을 새로 찍었다고 해 봅시다. 새 양식에는 이메일 칸이 하나 늘었습니다. 서고에는 옛 양식으로 받은 신청서가 몇 년 치 쌓여 있습니다. 창구에는 옛 양식을 들고 오는 손님도 한동안 찾아옵니다. 직원은 두 양식을 모두 받아서 처리해야 합니다.

스키마 진화는 스키마를 바꿔도 옛 데이터와 새 데이터, 옛 프로그램과 새 프로그램이 함께 돌게 하는 일입니다. 스키마는 데이터에 어떤 필드가 있고 각 필드에 어떤 타입의 값이 오는지 정한 설계도입니다. 회원 기록이라면 「번호는 정수, 이름은 문자열」 같은 약속이 스키마입니다.

「스키마」라는 말은 데이터베이스에서 테이블 여럿을 묶는 이름공간을 가리키기도 합니다. 이 편의 스키마는 그쪽이 아닙니다. 데이터 한 건이 어떤 모양인지를 정한 설계도를 말합니다.

스키마 진화가 다루는 데이터는 크게 둘입니다. 하나는 기록을 바이트로 바꿔 파일이나 메시지로 내보내는 직렬화 데이터입니다. 다른 하나는 데이터베이스의 테이블입니다. 둘 다 같은 물음을 풉니다. 모양이 바뀐 뒤에 옛것과 새것이 만나면 어떻게 읽느냐는 물음입니다.

한 번에 바꿀 수 없는 까닭

스키마를 새로 정하는 일은 금방 끝납니다. 어려운 것은 바꾼 뒤에도 남아 있는 옛것들입니다. 옛것이 남는 까닭은 둘입니다.

첫째는 이미 쌓인 데이터입니다. 파일, 메시지 큐, 백업에 적힌 기록은 적힐 때의 스키마를 따릅니다. 스키마를 바꿔도 그 기록은 저절로 바뀌지 않습니다. 몇 년 치 기록을 전부 새 모양으로 다시 쓰려면 오래 걸립니다. 백업처럼 손대면 안 되는 기록도 있습니다.

둘째는 프로그램이 한꺼번에 안 바뀐다는 점입니다. 서버 여러 대를 한 대씩 차례로 새 버전으로 바꾸는 롤링 배포 동안에는 옛 서버와 새 서버가 함께 돕니다. 사용자 휴대폰에 깔린 앱은 사용자가 업데이트할 때까지 옛 버전으로 남습니다.

그래서 옛 스키마로 적힌 데이터를 새 프로그램이 읽는 일이 생깁니다. 거꾸로 새 스키마로 적힌 데이터를 옛 프로그램이 읽는 일도 생깁니다. 스키마 진화는 이 두 만남에서 아무것도 깨지지 않게 하는 방법입니다.

쓰는 쪽과 읽는 쪽

데이터를 만들어 내는 프로그램을 쓰는 쪽, 그 데이터를 받아 쓰는 프로그램을 읽는 쪽이라고 부르겠습니다. 쓰는 쪽은 자기가 아는 스키마로 적습니다. 읽는 쪽은 자기가 아는 스키마로 읽습니다. 두 스키마가 같으면 문제가 없습니다.

쓰는 쪽과 읽는 쪽이 만나는 경우는 넷입니다. 그중 버전이 엇갈리는 것은 둘입니다. 아래 표의 칸마다 그 만남에 무엇이 필요한지 적었습니다.

옛 스키마로 읽는 쪽 새 스키마로 읽는 쪽
옛 스키마로 쓴 데이터 문제없음 새 읽는 쪽이 옛 데이터를 읽어야 함
새 스키마로 쓴 데이터 옛 읽는 쪽이 새 데이터를 읽어야 함 문제없음

대각선의 두 칸은 버전이 같아서 걱정할 것이 없습니다. 스키마 진화가 챙기는 것은 버전이 엇갈린 나머지 두 칸입니다.

하위 호환과 상위 호환

새로 바꾼 읽는 쪽이 옛 데이터를 읽을 수 있는 성질이 하위 호환입니다. 쌓여 있는 옛 기록을 버리지 않으려면 이것이 먼저 필요합니다. 새 프로그램을 올린 첫날부터 옛 기록을 읽어야 하기 때문입니다.

옛 읽는 쪽이 새 데이터를 읽을 수 있는 성질은 상위 호환입니다. 새 서버가 새 모양의 메시지를 내보냈는데 아직 안 바뀐 서버가 그것을 받는 경우에 필요합니다. 옛 읽는 쪽은 새 필드를 모르므로, 모르는 필드를 건너뛰고 읽을 줄 알아야 합니다.

둘 다 지키면 양방향 호환이라고 합니다. 이때는 쓰는 쪽과 읽는 쪽을 어느 순서로 올려도 됩니다.

한쪽만 지키면 올리는 순서가 정해집니다. 먼저 올린 쪽이 아직 안 바뀐 상대와 만나도 버텨야 하기 때문입니다.

하위 호환만 있으면 읽는 쪽을 먼저 올립니다. 새 읽는 쪽은 옛 데이터를 읽을 줄 압니다. 읽는 쪽을 다 올린 뒤에 쓰는 쪽을 올리면 어느 순간에도 못 읽는 데이터가 없습니다.

상위 호환만 있으면 쓰는 쪽을 먼저 올립니다. 옛 읽는 쪽은 새 데이터를 읽을 줄 압니다. 쓰는 쪽이 새 데이터를 내보내도 아직 안 바뀐 읽는 쪽이 버팁니다.

버티는 변경과 깨는 변경

스키마를 바꾸는 흔한 방법 여섯 가지를 두 방향의 읽기에 대 보겠습니다. ✓ 는 읽기가 버틴다는 뜻이고 ✗ 는 깨진다는 뜻입니다.

변경 새 읽는 쪽이 옛 데이터를 읽을 때 옛 읽는 쪽이 새 데이터를 읽을 때
기본값 있는 필드를 더한다 ✓ 빈 필드를 기본값으로 채운다 ✓ 모르는 필드를 건너뛴다
기본값 없는 필수 필드를 더한다 ✗ 옛 데이터에 그 값이 없다 ✓ 모르는 필드를 건너뛴다
없어도 되던 필드를 지운다 ✓ 남은 값을 건너뛴다 ✓ 빠진 필드를 기본값으로 채운다
필수 필드를 지운다 ✓ 남은 값을 건너뛴다 ✗ 옛 읽는 쪽이 그 값을 찾다 오류를 낸다
필드 이름을 바꾼다 ✗ 옛 이름의 값을 못 알아본다 ✗ 새 이름의 값을 못 알아본다
필드 타입을 바꾼다 ✗ 대개 옛 값을 못 해석한다 ✗ 대개 새 값을 못 해석한다

버티는 줄에는 공통점이 둘 있습니다. 첫째로 새 필드는 없어도 되는 필드로 둡니다. 없을 때 채울 기본값도 함께 정합니다. 둘째로 읽는 쪽은 모르는 필드를 만나면 오류를 내지 않고 건너뜁니다.

이름 바꾸기와 타입 바꾸기는 어느 방향으로도 버티지 못하는 경우가 많습니다. 이런 변경은 한 번에 하지 않고 여러 단계로 쪼갭니다. 쪼개는 방법은 아래 「데이터베이스 테이블의 열」 소절에서 봅니다.

기본값이 메우는 빈 필드

회원 기록 하나로 보겠습니다. 옛 스키마에는 번호와 이름만 있었습니다. 새 스키마에는 이메일 필드를 더했습니다. 새 스키마는 이메일이 없으면 빈 문자열로 채우라고 정합니다.

아래는 JSON(JavaScript Object Notation)으로 적힌 옛 기록을 새 읽는 쪽이 읽는 모습입니다. JSON 은 필드 이름과 값을 글자로 적는 형식입니다. read 는 새 스키마를 따라 읽는 함수라고 해 봅시다. 줄 오른쪽 주석이 그 줄이 내는 값입니다.

Java
// 옛 기록: {"id": 7, "name": "kim"}
Member m = read(oldJson);
m.id();      // 7
m.name();    // "kim"
m.email();   // "" (기본값)

옛 기록에는 이메일이 없었지만 읽는 쪽은 오류를 내지 않았습니다. 빠진 필드를 스키마에 적힌 기본값으로 메웠기 때문입니다.

거꾸로 옛 읽는 쪽은 이메일이 든 새 기록을 받으면 번호와 이름만 읽습니다. 이메일은 모르는 필드라 건너뜁니다.

그런데 읽는 라이브러리 중에는 모르는 필드를 만나면 오류를 내게 설정된 것이 있습니다. 그렇게 설정돼 있으면 필드 하나 더한 것만으로 옛 읽는 쪽이 오류를 냅니다. 상위 호환이 깨진 것입니다.

기본값을 고를 때는 「값이 없음」과 헷갈리지 않는지 봅니다. 빈 문자열을 기본값으로 두면 이메일 필드가 생기기 전의 옛 기록인지, 이메일을 비워 둔 새 기록인지 구별이 안 됩니다. 이 차이가 중요하면 빈 문자열 대신 값이 없음을 뜻하는 널(null)을 기본값으로 둡니다.

필드를 알아보는 두 방법

읽는 쪽이 필드를 알아보는 방법은 형식마다 다릅니다. JSON 처럼 글자로 적는 형식은 데이터에 필드 이름을 함께 적습니다. 읽는 쪽은 그 이름으로 필드를 찾으므로, 이름을 바꾸면 옛 이름으로 적힌 값을 못 찾습니다.

바이트 수를 줄이려는 이진 포맷 중에는 이름 대신 번호를 적는 것이 있습니다. 스키마에 「1번은 번호, 2번은 이름」처럼 필드마다 번호를 매깁니다. 데이터에는 번호와 값만 적습니다. 이런 형식에서는 이름을 바꿔도 번호가 같으면 옛 데이터를 읽습니다.

대신 번호가 필드의 신원이 됩니다. 지운 필드의 번호를 새 필드에 다시 주면, 옛 데이터의 값이 새 필드의 값으로 잘못 읽힙니다.

flowchart TD
    A["옛 스키마 · 3번 = 전화번호"] --> B["옛 기록 · 3번에 전화번호 값"]
    C["새 스키마 · 전화번호를 지우고 3번 = 이메일"] --> D["새 읽는 쪽"]
    B --> D
    D --> E["옛 기록의 전화번호를 이메일로 읽는다"]

그림에서 새 읽는 쪽은 오류를 내지 않습니다. 3번 값을 이메일이라고 믿고 조용히 틀린 값을 씁니다. 그래서 지운 필드의 번호는 비워 두고 다시 쓰지 않습니다.

데이터베이스 테이블의 열

데이터베이스 테이블의 열도 필드와 같은 구실을 합니다. 테이블에 열을 더하는 것은 기록에 필드를 더하는 것과 같습니다. 다른 점은 옛 데이터가 여러 파일에 흩어져 있지 않고 데이터베이스 한 곳에 모여 있다는 것입니다.

열을 더할 때는 SQL(Structured Query Language)의 ALTER TABLE 문을 씁니다. 아래 문장은 member 테이블에 email 열을 더하고 기본값을 빈 문자열로 정합니다.

SQL
ALTER TABLE member
  ADD COLUMN email VARCHAR(100) DEFAULT '';

이미 있던 행에는 새 열의 값이 없으므로 기본값이 들어갑니다. 앞에서 본 「기본값 있는 필드를 더한다」와 같은 모양입니다.

열 이름을 바꾸는 일은 더 까다롭습니다. 데이터베이스 안에서는 한 문장이면 끝납니다. 그런데 옛 이름으로 조회하는 서버가 아직 돌고 있으면 그 서버의 조회가 바로 오류를 냅니다. 롤링 배포 동안에는 옛 서버와 새 서버가 한 테이블을 함께 쓰기 때문입니다.

그래서 이름 바꾸기를 여러 번의 배포로 쪼갭니다. 이 방법이 확장과 축소(expand and contract)입니다. 넓혔다가 좁힌다는 뜻의 이름입니다.

단계마다 옛 서버와 새 서버가 둘 다 멀쩡히 돌 수 있게 순서를 잡습니다. 단계는 다섯입니다.

  1. 새 열을 더합니다. 옛 열은 그대로 남깁니다
  2. 새 서버는 두 열에 같은 값을 씁니다. 옛 서버가 아직 옛 열을 읽기 때문입니다
  3. 옛 행에 남은 값을 새 열로 옮겨 적습니다
  4. 새 열이 다 채워지면 서버가 새 열에서 읽습니다
  5. 옛 열을 읽는 서버가 하나도 안 남으면 옛 열을 지웁니다

이런 변경을 파일로 적고 차례대로 적용하는 일이 스키마 마이그레이션입니다. 스키마 마이그레이션은 바꾸는 동작 한 번을 가리킵니다. 스키마 진화는 그 변경이 이어지는 동안 옛것과 새것이 함께 도는 것까지 다룹니다.

쓴 쪽의 스키마를 읽는 쪽에 알리는 방법

앞 소절의 읽는 쪽은 데이터에 적힌 필드 이름이나 번호로 필드를 알아봤습니다. 그런데 이진 포맷 중에는 이름도 번호도 없이 값만 스키마 순서대로 적는 것도 있습니다. 이런 데이터는 쓴 쪽의 스키마가 없으면 바이트를 필드로 나눌 수조차 없습니다.

그래서 쓴 쪽의 스키마를 읽는 쪽이 알 수 있게 합니다. 읽는 쪽은 쓴 쪽의 스키마와 자기 스키마를 맞대어 옛것과 새것을 가려 읽습니다. 알리는 흔한 방법은 셋입니다.

방법 스키마를 두는 곳 흔히 쓰는 데이터
데이터와 함께 적는다 파일 맨 앞 기록을 많이 담는 큰 파일
스키마 번호만 적는다 기록마다 스키마 번호, 본문은 스키마를 모아 둔 별도 저장소 메시지가 끝없이 흐르는 스트림
한 곳이 하나만 쥔다 데이터베이스 안의 목록표 테이블

파일 하나에 기록이 많으면 파일 맨 앞에 스키마를 한 번만 적어도 부담이 적습니다. 메시지는 한 건이 작아서 스키마를 매번 실으면 메시지보다 스키마가 더 커집니다.

메시지에는 스키마 번호만 싣고 스키마 본문은 스키마 레지스트리에 둡니다. 스키마 레지스트리는 스키마를 버전별로 모아 두는 저장소입니다. 읽는 쪽은 메시지의 번호로 레지스트리에서 스키마를 찾아옵니다.

레지스트리는 새 스키마를 받을 때 옛 스키마와 호환되는지 검사합니다. 깨는 변경이면 등록을 막기도 합니다.

테이블은 데이터베이스가 지금의 스키마 하나를 카탈로그에 들고 있습니다. 카탈로그는 데이터베이스가 자기 테이블과 열의 목록을 적어 두는 내부 표입니다. 옛 행도 이 스키마 하나로 읽히므로, 앞 소절의 확장과 축소처럼 바꾸는 순서가 중요해집니다.

데이터 레이크처럼 파일을 오래 쌓아 두는 저장소에서는 사정이 다릅니다. 파일마다 적힌 스키마가 다를 수 있습니다. 읽는 쪽은 여러 스키마를 맞춰 하나로 보고 읽습니다.

스키마를 적지 않는 데이터

스키마를 어디에도 적지 않고 JSON 을 주고받는 서비스도 많습니다. 그래도 읽는 코드는 「이 필드가 있고 문자열이다」라고 가정합니다. 스키마가 사라진 것이 아니라 코드 속 가정으로 옮겨 갔을 뿐입니다.

모양을 바꿀 때도 같은 문제가 생깁니다. 필드 이름을 바꾸면 옛 코드가 값을 못 찾습니다. 필드를 빼면 그 필드를 믿던 코드가 오류를 냅니다. 저장할 때가 아니라 읽을 때 모양을 정하는 이런 방식이 스키마 온 리드입니다.

스키마 진화의 대가

옛 필드와 옛 읽기 코드를 오래 들고 다닙니다. 옛 기록이 남아 있는 한 그 필드를 읽는 코드를 못 지웁니다. 잘못 지은 필드 이름도 쉽게 못 고칩니다.

새 필드는 필수로 두고 싶어도 옛 기록 때문에 없어도 되는 필드로 둡니다. 그러면 코드가 「이 필드는 늘 있다」고 믿을 수 없어서 빠진 경우를 곳곳에서 따져야 합니다.

한 번에 끝날 변경이 여러 번의 배포로 늘어납니다. 확장과 축소는 단계마다 배포가 하나씩 필요합니다. 모든 단계가 끝날 때까지 두 열이 함께 남습니다.

덜 챙겨도 되는 데이터

쓰는 쪽과 읽는 쪽을 한 팀이 쥐고 한 번에 같이 올릴 수 있으면 덜 챙겨도 됩니다. 데이터가 금방 사라지거나 언제든 다시 만들 수 있을 때도 그렇습니다.

캐시가 그런 예입니다. 캐시는 원본에서 언제든 다시 채울 수 있는 복사본이라, 스키마가 바뀌면 비우고 새로 채우면 됩니다. 반대로 오래 남는 기록, 남의 휴대폰에 깔린 앱, 여러 팀이 함께 읽는 메시지에는 이런 선택지가 없습니다.

관련 항목

스키마 진화가 지키려는 호환 성질

하위 호환 · 상위 호환성 · 양방향 호환성 · 브레이킹 체인지 · 상호운용성

스키마 진화가 바꾸는 데이터 구성 요소

스키마 · 필드 · 열 · 행 · 레코드 · 타입 · 기본값 · 필드 번호

스키마 진화를 겪는 직렬화 포맷

직렬화 · 역직렬화 · 포맷 · JSON · 이진 포맷 · Protocol Buffers · Avro · Apache Thrift · Parquet · 인터페이스 정의 언어

스키마를 보관하고 검사하는 장치

스키마 레지스트리 · 카탈로그 · 호환성 검사 · 테이블 포맷 · 메타데이터

옛 스키마의 데이터가 쌓이는 저장소

데이터 레이크 · 데이터 웨어하우스 · 메시지 큐 · Kafka · 백업 · 데이터베이스

데이터베이스 스키마를 바꾸는 절차

스키마 마이그레이션 · 데이터베이스 마이그레이션 · ALTER TABLE · DDL · 확장과 축소 · 백필 · 온라인 스키마 변경

옛 버전과 새 버전이 겹치는 배포 방식

배포 · 롤링 배포 · 블루-그린 배포 · 카나리 배포 · 무중단 배포

스키마를 정하는 시점으로 가르는 방식

스키마 온 리드 · 스키마 온 라이트 · 스키마리스 · EAV

스키마 변경을 알리는 버전 규칙

버전관리 · 시맨틱 버저닝 · API 버전 관리 · 지원 중단

다른 이름: schema evolution