배포 중 스키마 불일치
고친 사람 github-actions[bot]
배포 중 스키마 불일치는 배포하는 동안 코드와 데이터베이스가 서로 다른 모양을 기대해서 요청이 깨지는 일입니다. 코드는 서버마다 차례로 바뀌지만 데이터베이스는 하나라서 한 번에 바뀝니다. 그 틈에 옛 코드가 사라진 칸을 찾거나 새 코드가 아직 없는 칸을 찾습니다. 배포가 끝나면 저절로 사라지기 때문에 원인을 놓치기 쉽습니다.
쉽고 빠른 이해
배포하는 몇 분 동안 코드와 데이터베이스의 생김새, 곧 스키마가 서로 어긋나는 일입니다. 회원 테이블의 칸 이름을 name 에서 full_name 으로 바꾸는 배포를 떠올려 봅시다. 칸 이름은 바로 바뀝니다. 아직 안 바뀐 서버는 여전히 name 을 찾습니다.
곤란한 까닭은 데이터베이스는 하나이고 코드는 여러 벌이라는 데 있습니다. 서버를 몇 대씩 바꾸는 동안 옛 코드와 새 코드가 같은 데이터베이스를 함께 씁니다.
어떻게 터지나:
- 배포가 데이터베이스의 칸을 지우거나 이름을 바꿉니다
- 아직 안 바뀐 서버가 옛 이름으로 데이터를 찾다 오류를 냅니다
- 서버가 전부 바뀌면 오류가 멎습니다
칸을 새로 더하기만 하는 변경은 대개 안 깨집니다. 옛 서버는 새 칸을 모른 채 지나갑니다.
대가는 이렇습니다. 오류가 배포하는 몇 분에만 나서 원인을 찾기 어렵습니다. 안 깨지게 바꾸려면 배포 한 번으로 끝낼 변경을 여러 번에 나눠야 합니다.
상세
이 절은 코드와 데이터베이스가 서로 무엇을 약속하는지 봅니다. 그다음 칸 이름 하나를 바꾸는 배포를 따라가며 그 약속이 언제 깨지는지 봅니다.
스키마는 코드와 맺은 약속
스키마는 데이터베이스에 어떤 테이블이 있고 각 테이블에 어떤 칸이 있는지 정해 둔 틀입니다. 테이블의 칸 하나하나가 컬럼입니다. 컬럼마다 이름과 담을 값의 종류가 정해져 있습니다.
테이블에 든 데이터 한 건은 행입니다. 회원 테이블이라면 회원 한 명이 행 하나이고, 그 회원의 이름은 name 컬럼에 담깁니다.
애플리케이션 코드는 이 틀을 믿고 짜여 있습니다. 데이터를 꺼내는 SQL(Structured Query Language, 구조화 질의 언어) 문에는 테이블 이름과 컬럼 이름이 박혀 있습니다. 데이터베이스에 보내는 SQL 문을 쿼리라고도 합니다. SELECT name FROM users 는 users 테이블에 name 컬럼이 있다고 믿고 보내는 쿼리입니다.
SQL 을 손으로 쓰지 않아도 사정은 같습니다. ORM(Object-Relational Mapping, 객체-관계 매핑)은 테이블의 행을 코드의 객체로 옮겨 주는 라이브러리입니다. ORM 도 객체의 필드를 어느 컬럼에 맞출지 미리 정해 두고 그대로 쿼리를 만듭니다.
스키마를 바꾸면 그 스키마를 믿던 코드도 같이 바뀌어야 합니다. 둘 중 하나만 바뀌면 코드가 찾는 컬럼과 데이터베이스에 있는 컬럼이 달라집니다. 이 어긋남이 배포 도중에 생기는 것이 배포 중 스키마 불일치입니다.
스키마를 바꾸는 스크립트
스키마를 바꾸는 일은 대개 스크립트로 적어 둡니다. 컬럼을 더하거나 지우거나 이름을 바꾸는 SQL 을 파일에 적습니다. 배포할 때 그 파일을 데이터베이스에 돌립니다. 이 일을 스키마 마이그레이션이라 부릅니다.
마이그레이션을 스크립트로 두는 까닭은 모든 환경에서 같은 순서로 같은 변경을 하려는 것입니다. 개발 서버와 운영 서버의 스키마가 서로 달라지는 것을 막아 줍니다.
아래는 컬럼 이름을 바꾸는 마이그레이션 한 줄입니다. 이 한 줄이 돌고 나면 옛 이름으로 보내는 쿼리는 오류를 냅니다.
ALTER TABLE users
RENAME COLUMN name TO full_name;
SELECT name FROM users; -- 컬럼 없음 오류
마이그레이션은 데이터베이스에서 한 번 돌면 끝납니다. 서버가 열 대여도 데이터베이스가 하나면 스키마는 한 순간에 바뀝니다.
코드는 차례로, 스키마는 한 번에
서버 여러 대가 한 서비스를 나눠 맡으면 새 코드를 몇 대씩 차례로 올리는 일이 많습니다. 서비스를 멈추지 않으려는 것입니다. 이 방식을 롤링 배포라 부릅니다.
롤링 배포에는 옛 코드와 새 코드가 함께 도는 시간이 있습니다. 그동안 두 코드는 하나의 데이터베이스를 함께 씁니다. 그런데 그 데이터베이스의 스키마는 둘 중 한 버전에만 맞습니다.
아래는 앞의 이름 바꾸기 마이그레이션을 배포 첫머리에 돌린 경우입니다. 배포 도구는 마이그레이션을 돌리고 서버마다 새 코드를 올리는 프로그램입니다.
sequenceDiagram
participant 도구 as 배포 도구
participant DB as 데이터베이스
participant 옛 as 옛 코드 서버
participant 새 as 새 코드 서버
도구->>DB: name 을 full_name 으로 바꾼다
옛->>DB: name 을 읽는다
DB-->>옛: 컬럼 없음 오류
도구->>새: 새 코드를 올린다
새->>DB: full_name 을 읽는다
DB-->>새: 성공
Note over 옛: 차례가 오기 전까지 계속 실패한다
배포 도구가 마이그레이션을 돌린 순간부터 옛 코드 서버는 실패하기 시작합니다. 새 코드 서버가 하나씩 늘면서 실패하는 요청의 몫이 줄어듭니다. 마지막 옛 서버가 바뀌면 오류가 멎습니다.
서버가 한 대여도 틈은 남습니다. 마이그레이션을 돌린 뒤 새 코드로 다시 띄우기까지 몇 초에서 몇 분이 걸립니다. 그동안 옛 코드가 새 스키마를 만납니다.
어느 쪽을 먼저 바꾸나
마이그레이션과 코드 배포 가운데 무엇을 먼저 하느냐에 따라 어긋나는 방향이 갈립니다. 어느 순서를 골라도 틈은 생깁니다.
| 순서 | 틈 동안 만나는 짝 | 깨지는 모습 |
|---|---|---|
| 마이그레이션 먼저 | 옛 코드 · 새 스키마 | 옛 코드가 지워졌거나 이름이 바뀐 컬럼을 찾는다 |
| 코드 먼저 | 새 코드 · 옛 스키마 | 새 코드가 아직 없는 컬럼이나 테이블을 찾는다 |
배포 방식을 바꿔도 마찬가지입니다. 블루-그린 배포는 새 버전 서버 묶음을 따로 다 띄운 뒤 요청을 한 번에 옮기는 방식입니다. 두 묶음이 같은 데이터베이스를 쓰면 이 문제를 못 피합니다. 요청을 옮기기 전부터 스키마는 이미 한쪽 버전에 맞춰져 있기 때문입니다.
순서나 배포 방식을 바꾸는 것만으로는 이 문제가 안 풀립니다. 풀려면 변경 자체가 두 버전 코드 모두와 맞아야 합니다.
깨지는 변경과 안 깨지는 변경
모든 스키마 변경이 옛 코드를 깨지는 않습니다. 옛 코드가 믿던 것을 그대로 두는 변경은 안전합니다. 믿던 것을 없애거나 바꾸는 변경이 위험합니다.
| 변경 | 옛 코드가 겪는 일 |
|---|---|
| 빈 값을 허용하는 컬럼 더하기 | 대개 아무 일도 없다. 옛 코드는 새 컬럼을 모른 채 지나간다 |
| 빈 값을 막고 기본값도 없는 컬럼 더하기 | 옛 코드가 새 행을 넣을 때 그 컬럼 값을 안 줘서 실패한다 |
| 컬럼 지우기 | 그 컬럼을 읽거나 쓰는 쿼리가 실패한다 |
| 컬럼 이름 바꾸기 | 옛 이름을 쓰는 쿼리가 실패한다. 새 코드보다 먼저 하면 옛 코드가, 나중에 하면 새 코드가 깨진다 |
| 컬럼의 값 종류 바꾸기 | 옛 코드가 넣는 값이 새 종류에 안 맞으면 실패한다 |
빈 값을 막는 제약이 NOT NULL 입니다. 이 제약이 붙은 컬럼은 행을 넣을 때 반드시 값을 받아야 합니다. 옛 코드는 그 컬럼이 있는 줄 모르니 값을 줄 수 없습니다.
이 표의 위험한 줄들은 하위 호환을 깨는 변경입니다. 하위 호환은 새 버전이 옛 버전의 쓰임새를 계속 받아 주는 성질입니다. 스키마에서는 새 스키마가 옛 코드의 쿼리를 계속 받아 주느냐를 말합니다.
배포 중 스키마 불일치가 생기는 조건
아래 셋이 모두 갖춰지면 생깁니다. 하나라도 빠지면 안 생깁니다.
- 스키마가 바뀌는 순간과 코드가 바뀌는 순간이 다릅니다. 서버가 여럿이면 거의 늘 다릅니다. 한 대여도 재시작 사이에 틈이 있습니다
- 그 틈에 요청이 들어옵니다. 배포 동안 서비스를 내리지 않으면 들어옵니다
- 스키마 변경이 틈 동안 도는 코드 버전과 안 맞습니다. 앞 표의 위험한 줄에 드는 변경입니다
첫째와 둘째는 서비스를 멈추지 않고 배포하는 팀이라면 늘 갖춰져 있습니다. 그래서 대책은 셋째에 몰립니다. 변경을 두 버전 코드 모두와 맞게 고르면 틈이 있어도 안 깨집니다.
되돌릴 때 다시 생기는 불일치
새 코드에 버그가 있으면 옛 코드로 되돌립니다. 이 일이 롤백입니다. 코드는 옛 버전으로 돌아갑니다. 스키마는 대개 새 버전에 머뭅니다. 그러면 옛 코드가 새 스키마를 만나는 불일치가 다시 생깁니다.
스키마까지 되돌리려 해도 안 되는 때가 있습니다. 컬럼을 지우는 마이그레이션은 그 컬럼에 든 데이터까지 지웁니다. 컬럼을 다시 만들 수는 있어도 지운 값은 돌아오지 않습니다. 이런 변경을 롤백 불가 마이그레이션이라 부릅니다.
배포가 끝난 뒤에도 이 문제는 끝난 것이 아닙니다. 롤백할 가능성이 남아 있는 동안은 옛 코드도 새 스키마와 맞아야 합니다.
어긋나지 않게 나눠 바꾸기
널리 쓰는 대책은 위험한 변경 하나를 안전한 변경 여럿으로 쪼개는 것입니다. 각 단계는 그 순간 함께 도는 두 버전 코드 모두와 맞습니다. 이 방법을 확장-축소 패턴이라 부릅니다.
name 을 full_name 으로 바꾸는 일을 이렇게 나누면 배포가 세 번이 됩니다. 단계 이름은 확장 · 옮기기 · 축소입니다. 옮기기는 코드가 읽는 컬럼을 새 컬럼으로 옮기는 단계입니다.
flowchart TD
A["1차 배포 · 확장<br/>full_name 컬럼을 더한다<br/>코드는 두 컬럼에 함께 쓰고<br/>읽기는 name 에서 한다"] --> B["옛 행의 name 값을<br/>full_name 으로 옮긴다"]
B --> C["2차 배포 · 옮기기<br/>코드가 full_name 에서 읽는다<br/>쓰기는 계속 두 컬럼에 한다"]
C --> D["3차 배포 · 축소<br/>name 을 읽고 쓰는 코드를 걷은 뒤<br/>name 컬럼을 지운다"]
확장 단계는 옛 코드가 믿던 name 을 건드리지 않습니다. 새 컬럼은 빈 값을 허용하게 더하므로 옛 코드는 그 컬럼을 모른 채 지나갑니다. 새 코드는 두 컬럼에 같은 값을 쓰지만 읽기는 아직 name 에서 합니다.
확장이 끝나도 full_name 이 비어 있는 행이 남습니다. 확장 전에 들어간 행과, 배포 도중 옛 코드가 name 에만 쓴 행입니다. 그 행들의 name 값을 full_name 으로 한꺼번에 채워 넣는 작업이 백필입니다.
옮기기 단계는 백필이 끝난 뒤에 합니다. 이때는 모든 행의 두 컬럼 값이 같습니다. 그러니 name 을 읽는 서버와 full_name 을 읽는 서버가 섞여 돌아도 같은 값을 얻습니다.
축소는 name 을 읽거나 쓰는 코드가 모두 사라진 뒤에만 합니다. 그 전에 지우면 남은 코드가 사라진 컬럼을 찾다 실패합니다.
대가는 배포 횟수와 시간입니다. 한 번에 끝날 변경이 세 번이 됩니다. 그 사이에는 두 컬럼에 함께 쓰는 코드를 잠시 들고 있어야 합니다.
관련 항목
배포 중 스키마 불일치가 끼어드는 배포 방식
배포 · 롤링 배포 · 블루-그린 배포 · 카나리 배포 · 무중단 배포 · 배포 파이프라인 · 지속적 배포
코드와 데이터베이스가 맞춰야 하는 구성 요소
스키마 · 컬럼 · 테이블 · NOT NULL · 기본값 · 제약 조건 · SQL · ORM
스키마를 바꾸는 명령과 절차
스키마 마이그레이션 · ALTER TABLE · 마이그레이션 도구 · 스키마 변경 · 스키마 진화 · 온라인 스키마 변경
어긋나지 않게 바꾸는 방법
확장-축소 패턴 · 하위 호환 · 상위 호환성 · 이중 쓰기 · 백필 · 기능 플래그
배포 중 스키마 불일치와 함께 쌓이는 장애
부분 배포 · 롤백 불가 마이그레이션 · 하위 비호환 변경 · 구성 드리프트 · 버전 불일치
불일치를 되돌리거나 찾아내는 장치
다른 이름: schema mismatch during deployment · 배포 중 스키마 어긋남 · 스키마-코드 불일치