되돌릴 수 없는 변경 · 구·신 스키마 공존과 안전한 축소 · 이론
구·신 스키마 공존과 안전한 축소
한 줄 요약
스키마를 바꿀 때는 새 코드가 성공하는지만 보지 말고, 아직 살아 있는 옛 코드가 어느 열을 읽고 쓰는지까지 호환 계약에 넣어야 합니다.
왜 이게 필요했나
외계인 축제의 명찰 시스템을 고칩니다. 고객은 name이라는 이름이 모호하니 display_name으로 바꿔 달라고 합니다. 열 이름을 한 번에 바꾸면 끝날 것 같지만, 행사장 태블릿에는 옛 프로그램이 남아 있습니다. 새 프로그램이 배포되는 동안 배치 출력기와 재시도 중인 작업도 옛 열을 읽습니다. 데이터가 하나도 사라지지 않았는데도 열 이름을 못 찾는 오류로 명찰 인쇄가 멈출 수 있습니다.
이 문제는 쿠버네티스의 롤링 업데이트에서도 생깁니다. 새 파드가 Ready가 됐다는 것은 모든 옛 소비자가 종료됐다는 뜻이 아닙니다. 화면이 접속되더라도 대기 중인 작업, 다른 서비스, 분석 쿼리, 운영자의 도구는 별개입니다. DB 변경의 성공을 새 파드 하나의 정상 응답만으로 판단하면 검증 범위가 너무 좁습니다.
이 수업은 문자 그대로 같은 이름 값을 옮기는 변경입니다. 이름을 분리하거나 번역하는 손실 있는 변환은 다루지 않습니다. 양쪽 값이 달라지면 어느 쪽을 버릴지 자동으로 결정하지 않습니다. 또한 일회용 실습 DB에서만 실행하며 LabHub 운영 DB의 스키마를 바꾸지 않습니다.
어떻게 동작하나
1. 넓히기는 옛 계약을 남기는 것이다
기존 name NOT NULL을 그대로 두고 nullable display_name을 추가합니다. 기존 행의 새 열에는 NULL이 있습니다. 이를 빈 문자열이나 “알 수 없음”으로 채워 버리면 실제 이름과 미이전 상태를 구분하기 어렵습니다. PostgreSQL의 ALTER TABLE은 작업 종류마다 필요한 잠금이 다르고, 열 추가도 실행 중인 읽기 트랜잭션을 기다릴 수 있습니다. [ALTER TABLE 공식 문서](https://www.postgresql.org/docs/16/sql-altertable.html)에서 각 형태의 설명을 확인하세요.
잠금 대기를 무한히 두지 않고 짧은 잠금 한도를 적용합니다. 실패하면 기존 스키마를 보존하고 열린 트랜잭션을 정리합니다. “단순 DDL이니 금방 끝난다”는 추측은 긴 SELECT가 열린 연결 앞에서 깨집니다. 실습은 실제 옛 읽기 연결을 살려 둬 이 대기를 재현합니다. 한도 값 500ms는 작은 교육용 DB의 계약이지 모든 운영 환경의 권장값이 아닙니다.
2. 클라이언트가 두 종류가 아니라 세 종류다
| 클라이언트 | 읽는 식 | 옛 열 제거 후 |
| --- | --- | --- |
| 구버전 | name | 열 없음 오류 |
| 전환용 | COALESCE(display_name, name) | 역시 열 없음 오류 |
| 최종 버전 | display_name | 새 열만으로 동작 |
전환용 코드는 아직 옮기지 않은 행도 읽기 위해 필요합니다. 그러나 COALESCE가 실행 시 옛 값을 선택하지 않더라도 SQL은 name이라는 열을 참조합니다. 새 열이 모두 채워졌다고 전환용 코드를 그대로 두고 옛 열을 지울 수는 없습니다. 백필과 제약 검증 뒤 최종 버전으로 옮기는 릴리스가 하나 더 필요합니다.
최종 버전은 옛 열 제거 전에도 시험할 수 있습니다. 이때는 구버전·전환용·최종 버전이 잠시 함께 존재합니다. 이 공존 구간을 실제 SQL로 확인해야 이후 축소가 데이터 보존과 클라이언트 호환을 모두 만족하는지 판단할 수 있습니다.
3. 양쪽 쓰기를 연결하되 모순은 숨기지 않는다
새 열만 추가한 뒤 옛 앱이 name을 바꾸면 display_name은 뒤처집니다. 이번 실습은 BEFORE INSERT OR UPDATE 트리거를 호환 다리로 사용합니다. 구버전이 name만 바꾸면 새 열에 복사하고, 최종 버전이 display_name만 바꾸면 옛 열에 복사합니다. 둘을 모두 같은 새 값으로 바꾸는 것은 허용하지만 서로 다른 값으로 바꾸면 거절합니다.
어느 필드가 바뀌었는지는 NEW와 OLD를 NULL까지 포함해 비교해야 합니다. 보통의 같음 비교만 쓰면 NULL에서 판정이 빠질 수 있습니다. [PL/pgSQL 트리거 공식 문서](https://www.postgresql.org/docs/16/plpgsql-trigger.html)의 NEW·OLD·TG_OP와 행 반환 규칙을 읽고, 입력 없는 필드를 자동 채우는 경우와 명시적으로 NULL로 지우려는 경우를 구분하세요.
INSERT에서 한쪽 필드만 있으면 다른 쪽을 채웁니다. UPDATE에서 값이 바뀌지 않았지만 아직 새 열이 NULL인 기존 행은 옛 값을 새 열에 채울 수 있습니다. 반면 이미 있는 이름을 NULL로 지우는 쓰기는 거절합니다. 이 규칙은 일반적인 모든 데이터 변환 규칙이 아니라, 같은 이름 두 표현의 동등성을 유지하기 위한 이번 고객 계약입니다.
트리거는 임시 복잡성입니다. 오래 남기면 어떤 코드가 실제 원본인지 알기 어려워지고, 애플리케이션 로그만으로는 추가 쓰기가 보이지 않을 수 있습니다. 제거할 조건과 검증을 설치할 때부터 함께 설계합니다. 사용자 인증이나 고객별 권한을 트리거가 대신해 준다고 생각하지 마세요.
4. 백필은 과거에 읽은 값이 아니라 현재 행을 기준으로 한다
백필 대상은 명시한 ID의 display_name IS NULL인 행입니다. UPDATE의 오른쪽 name도 DB 안에서 현재 행을 읽습니다. 앱이 먼저 SELECT로 옛 이름을 가져온 다음 나중에 UPDATE하면, 그 사이 다른 담당자가 바꾼 이름을 과거 값으로 덮을 수 있습니다.
실습에서는 옛 앱의 이름 변경 트랜잭션을 열어 잠금을 잡고, 별도 프로세스에서 백필을 시작합니다. 백필이 실제로 기다리는 것을 관측한 뒤 옛 앱을 커밋합니다. 올바른 백필은 잠금 해제 후 현재 NULL 조건을 다시 평가해 이미 옮겨진 행을 건드리지 않습니다. [Read Committed 공식 설명](https://www.postgresql.org/docs/16/transaction-iso.html)을 이번 행의 시간 순서와 함께 읽으세요. 먼저 가져온 옛 값을 나중에 무조건 쓰는 오답도 같은 상황에서 검사합니다.
5. 새 쓰기 보호와 과거 전체 검증을 나눈다
CHECK(display_name IS NOT NULL) NOT VALID는 기존의 미이전 행을 즉시 모두 검사하지 않으면서 새 쓰기에는 조건을 적용합니다. 호환 트리거를 먼저 준비해야 옛 앱의 새 삽입도 이 규칙을 만족합니다. 백필이 끝난 뒤 VALIDATE CONSTRAINT로 기존 행까지 확인하고, 마지막으로 열 자체를 NOT NULL로 강화합니다.
NOT VALID를 “아직 아무것도 검사하지 않음”으로 읽으면 안 됩니다. 반대로 제약 이름이 있다는 이유만으로 기존 데이터 검증이 끝났다고 믿어도 안 됩니다. 실습에서는 백필 전에 전체 검증이 실패하는지, 백필 후 convalidated와 attnotnull이 실제로 바뀌는지 확인합니다. 명령문에 VALIDATE라는 단어가 있는지만 보지 않습니다.
현장에서 만나는 모습
호환 기능을 먼저 지웠는데 뒤의 DDL이 실패한다면
트리거 제거, 함수 제거, 옛 열 제거가 서로 다른 커밋이면 중간 실패 뒤 이름은 두 개 남아 있는데 양쪽 쓰기를 연결할 기능은 사라질 수 있습니다. 이번 축소는 하나의 트랜잭션입니다. 중간 예외나 클라이언트 종료가 커밋 전에 일어나면 호환 다리까지 복원돼야 합니다. 커밋 후 응답만 잃은 경우에는 새 연결로 실제 스키마를 관측해야 합니다.
psycopg의 transaction 문맥이 어느 구간을 커밋하는지 확인하세요. 바깥 트랜잭션이 이미 있으면 내부 문맥 종료가 실제 커밋이 아닐 수 있습니다. 이 수업은 autocommit=True의 IDLE 연결에서 각 마이그레이션 함수를 시작하도록 계약합니다. [트랜잭션 관리 문서](https://www.psycopg.org/psycopg3/docs/basic/transactions.html)와 훅의 위치를 함께 보세요.
“퇴역 승인 True”가 실제 소비자 부재를 증명하지는 않는다
실습의 두 승인 값은 외부에서 구버전과 전환용 소비자의 퇴역을 확인했다는 입력입니다. 운영에서 그 사실을 입증하려면 실행 중인 버전, 예약 작업, 재시도 큐, 직접 DB를 읽는 도구까지 조사해야 합니다. 단순한 플래그나 며칠간 오류가 없었다는 관측만으로 모든 소비자가 없다고 증명할 수 없습니다.
FDE에게 필요한 일은 고객과 함께 이 소비자 지도를 확인하고, 언제 멈추거나 되돌릴지 합의하는 것입니다. [확인한 Palantir FDE 공고](https://jobs.lever.co/palantir/dab396d4-2f14-4796-aac0-0d82883dccf0)의 고객 문제 이해·해결책 구현 역량에 연결한 저자 설계 사례이며, 해당 회사가 이 트리거 패턴을 요구하거나 채용을 보장한다는 뜻은 아닙니다.
다음 실습에서 할 것
명찰의 이름과 ID를 보존하며 nullable 열 추가, 전환 읽기, 양방향 쓰기, 제한된 ID 백필, 제약 검증, 승인된 축소, 최종 클라이언트로 이동합니다. 잘못 옮겨진 두 열의 값이 다르면 건수가 같아도 축소를 거절합니다. 실제 DDL 잠금 대기·동시 백필·세 지점 프로세스 종료를 확인하고, 축소 뒤 옛 SQL과 전환 SQL이 왜 실패하는지 직접 봅니다.
이 실습은 서버 전원 장애 내구성이나 대용량 온라인 마이그레이션 성능을 증명하지 않습니다. 준비된 소규모 DB에서 클라이언트·스키마·트랜잭션의 호환 관계를 검증하는 것이 목표입니다.