사전 배포 중 스키마 불일치
문제

배포 중 스키마 불일치

gabury1고친 사람 github-actions[bot]

배포 중 스키마 불일치는 배포하는 동안 코드와 데이터베이스가 서로 다른 모양을 기대해서 요청이 깨지는 일입니다. 코드는 서버마다 차례로 바뀌지만 데이터베이스는 하나라서 한 번에 바뀝니다. 그 틈에 옛 코드가 사라진 칸을 찾거나 새 코드가 아직 없는 칸을 찾습니다. 배포가 끝나면 저절로 사라지기 때문에 원인을 놓치기 쉽습니다.

쉽고 빠른 이해

배포하는 몇 분 동안 코드와 데이터베이스의 생김새, 곧 스키마가 서로 어긋나는 일입니다. 회원 테이블의 칸 이름을 name 에서 full_name 으로 바꾸는 배포를 떠올려 봅시다. 칸 이름은 바로 바뀝니다. 아직 안 바뀐 서버는 여전히 name 을 찾습니다.

곤란한 까닭은 데이터베이스는 하나이고 코드는 여러 벌이라는 데 있습니다. 서버를 몇 대씩 바꾸는 동안 옛 코드와 새 코드가 같은 데이터베이스를 함께 씁니다.

어떻게 터지나:

  1. 배포가 데이터베이스의 칸을 지우거나 이름을 바꿉니다
  2. 아직 안 바뀐 서버가 옛 이름으로 데이터를 찾다 오류를 냅니다
  3. 서버가 전부 바뀌면 오류가 멎습니다

칸을 새로 더하기만 하는 변경은 대개 안 깨집니다. 옛 서버는 새 칸을 모른 채 지나갑니다.

대가는 이렇습니다. 오류가 배포하는 몇 분에만 나서 원인을 찾기 어렵습니다. 안 깨지게 바꾸려면 배포 한 번으로 끝낼 변경을 여러 번에 나눠야 합니다.

상세

이 절은 코드와 데이터베이스가 서로 무엇을 약속하는지 봅니다. 그다음 칸 이름 하나를 바꾸는 배포를 따라가며 그 약속이 언제 깨지는지 봅니다.

스키마는 코드와 맺은 약속

스키마는 데이터베이스에 어떤 테이블이 있고 각 테이블에 어떤 칸이 있는지 정해 둔 틀입니다. 테이블의 칸 하나하나가 컬럼입니다. 컬럼마다 이름과 담을 값의 종류가 정해져 있습니다.

테이블에 든 데이터 한 건은 행입니다. 회원 테이블이라면 회원 한 명이 행 하나이고, 그 회원의 이름은 name 컬럼에 담깁니다.

애플리케이션 코드는 이 틀을 믿고 짜여 있습니다. 데이터를 꺼내는 SQL(Structured Query Language, 구조화 질의 언어) 문에는 테이블 이름과 컬럼 이름이 박혀 있습니다. 데이터베이스에 보내는 SQL 문을 쿼리라고도 합니다. SELECT name FROM users 는 users 테이블에 name 컬럼이 있다고 믿고 보내는 쿼리입니다.

SQL 을 손으로 쓰지 않아도 사정은 같습니다. ORM(Object-Relational Mapping, 객체-관계 매핑)은 테이블의 행을 코드의 객체로 옮겨 주는 라이브러리입니다. ORM 도 객체의 필드를 어느 컬럼에 맞출지 미리 정해 두고 그대로 쿼리를 만듭니다.

스키마를 바꾸면 그 스키마를 믿던 코드도 같이 바뀌어야 합니다. 둘 중 하나만 바뀌면 코드가 찾는 컬럼과 데이터베이스에 있는 컬럼이 달라집니다. 이 어긋남이 배포 도중에 생기는 것이 배포 중 스키마 불일치입니다.

스키마를 바꾸는 스크립트

스키마를 바꾸는 일은 대개 스크립트로 적어 둡니다. 컬럼을 더하거나 지우거나 이름을 바꾸는 SQL 을 파일에 적습니다. 배포할 때 그 파일을 데이터베이스에 돌립니다. 이 일을 스키마 마이그레이션이라 부릅니다.

마이그레이션을 스크립트로 두는 까닭은 모든 환경에서 같은 순서로 같은 변경을 하려는 것입니다. 개발 서버와 운영 서버의 스키마가 서로 달라지는 것을 막아 줍니다.

아래는 컬럼 이름을 바꾸는 마이그레이션 한 줄입니다. 이 한 줄이 돌고 나면 옛 이름으로 보내는 쿼리는 오류를 냅니다.

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 입니다. 이 제약이 붙은 컬럼은 행을 넣을 때 반드시 값을 받아야 합니다. 옛 코드는 그 컬럼이 있는 줄 모르니 값을 줄 수 없습니다.

이 표의 위험한 줄들은 하위 호환을 깨는 변경입니다. 하위 호환은 새 버전이 옛 버전의 쓰임새를 계속 받아 주는 성질입니다. 스키마에서는 새 스키마가 옛 코드의 쿼리를 계속 받아 주느냐를 말합니다.

배포 중 스키마 불일치가 생기는 조건

아래 셋이 모두 갖춰지면 생깁니다. 하나라도 빠지면 안 생깁니다.

  1. 스키마가 바뀌는 순간과 코드가 바뀌는 순간이 다릅니다. 서버가 여럿이면 거의 늘 다릅니다. 한 대여도 재시작 사이에 틈이 있습니다
  2. 그 틈에 요청이 들어옵니다. 배포 동안 서비스를 내리지 않으면 들어옵니다
  3. 스키마 변경이 틈 동안 도는 코드 버전과 안 맞습니다. 앞 표의 위험한 줄에 드는 변경입니다

첫째와 둘째는 서비스를 멈추지 않고 배포하는 팀이라면 늘 갖춰져 있습니다. 그래서 대책은 셋째에 몰립니다. 변경을 두 버전 코드 모두와 맞게 고르면 틈이 있어도 안 깨집니다.

되돌릴 때 다시 생기는 불일치

새 코드에 버그가 있으면 옛 코드로 되돌립니다. 이 일이 롤백입니다. 코드는 옛 버전으로 돌아갑니다. 스키마는 대개 새 버전에 머뭅니다. 그러면 옛 코드가 새 스키마를 만나는 불일치가 다시 생깁니다.

스키마까지 되돌리려 해도 안 되는 때가 있습니다. 컬럼을 지우는 마이그레이션은 그 컬럼에 든 데이터까지 지웁니다. 컬럼을 다시 만들 수는 있어도 지운 값은 돌아오지 않습니다. 이런 변경을 롤백 불가 마이그레이션이라 부릅니다.

배포가 끝난 뒤에도 이 문제는 끝난 것이 아닙니다. 롤백할 가능성이 남아 있는 동안은 옛 코드도 새 스키마와 맞아야 합니다.

어긋나지 않게 나눠 바꾸기

널리 쓰는 대책은 위험한 변경 하나를 안전한 변경 여럿으로 쪼개는 것입니다. 각 단계는 그 순간 함께 도는 두 버전 코드 모두와 맞습니다. 이 방법을 확장-축소 패턴이라 부릅니다.

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 · 배포 중 스키마 어긋남 · 스키마-코드 불일치