되돌릴 수 없는 변경 · 구·신 스키마 공존과 안전한 축소 · 실습
옛 명찰 앱이 켜진 채로 열 이름 바꾸기
목표
외계인 축제의 명찰 앱을 name에서 display_name으로 이전합니다. 옛 앱이 여전히 이름을 고치는 동안에도 동작하게 하고, 전환용 앱까지 퇴역한 뒤 옛 열을 제거합니다.
왜 중요한가
열 이름 하나를 바꾸는 일도 동시에 실행 중인 앱·배치·조회 도구를 깨뜨릴 수 있습니다. 확장 → 호환 읽기·쓰기 → 백필 → 제약 강화 → 퇴역 확인 → 축소를 분리해 연습합니다. Python 함수·예외, SQL 트랜잭션과 앞의 조건부 변경 실습을 먼저 학습하세요. 예상 130분입니다. 만료 전에 +시간으로 연장하고 최대 180분 안에 마치세요. 세션이 끝나면 파일이 사라집니다. 필요한 코드는 별도로 보관하세요.
환경과 공통 계약
산출물은 /root/schema/worker.py입니다. 이미지의 Python 3·PostgreSQL 16·psycopg 3.2.3을 사용하며 설치·다운로드·추가 권한은 필요 없습니다. postgres 사용자로 /root에 쓸 수 있습니다.
채점기는 로컬 labdb의 고유 임시 스키마에 가상 명찰을 준비하고 자신이 만든 스키마만 정리합니다. 전달받은 con의 search_path와 DSN을 사용하세요. public 테이블을 건드리거나 스키마 이름·데이터·ID를 하드코딩하지 않습니다. 테이블·열·트리거 이름은 아래 고정 계약이고 SQL 데이터 값은 매개변수로 전달합니다.
CREATE TABLE badges(id integer PRIMARY KEY, name text NOT NULL);ID는 bool이 아닌 정확한 int 1–2147483647입니다. 이름은 정확한 str 1–100자이며 앞뒤 공백, 문자 코드 0–31·127을 허용하지 않습니다. 한글·이모지·작은따옴표는 허용합니다. 백필 ids는 정확한 list 1–16개이며 ID 중복은 거절합니다. 잘못된 직접 입력은 ValueError이고 SQL 오류를 임의의 성공값으로 바꾸지 않습니다.
모든 con은 빌린 연결이며 autocommit=True·Read Committed, 외부 호출 시작 시 열린 트랜잭션이 없습니다. 연결을 닫지 않고 성공·실패 후 열린 트랜잭션도 남기지 않습니다. expand·install_bridge·backfill·add_guard·enforce·cutover는 자기 트랜잭션에서 lock_timeout=500ms·statement_timeout=2000ms를 적용하고 원 연결 설정은 성공·실패 후 복원합니다. 제한은 각 SQL 기준이지 전체 함수 시간의 합이 아닙니다. 이 함수들을 별도의 외부 트랜잭션으로 감싸지 않습니다. cutover_file만 연결을 소유합니다.
fault는 없으면 생략하고 있으면 단계에 명시한 문자열을 인자로 호출합니다. 커밋 전 훅 오류는 그 함수의 변경 전체를 롤백하고 원 오류를 전달합니다. after-commit의 오류는 이미 확정한 변경을 보존한 채 전달합니다. expand·install_bridge·add_guard·cutover는 각 이전 단계에서 한 번 실행하는 함수입니다. 성공한 DDL의 무조건 재실행을 약속하지 않습니다. 커밋 후 응답을 잃으면 현재 스키마와 배포 기록을 확인해야 합니다.
호환 트리거 규칙
BEFORE INSERT에서 name만 있으면 display_name으로, display_name만 있으면 name으로 복사합니다. 둘 다 같은 값이면 그대로 허용하고, 둘 다 NULL이거나 서로 다르면 SQLSTATE 23514로 거절합니다.
BEFORE UPDATE에서 name만 달라졌으면 새 name을 display_name으로, display_name만 달라졌으면 새 display_name을 name으로 복사합니다. 두 값 모두 바뀌었으면 서로 같은 비NULL 값만 허용합니다. 둘 다 바뀌지 않았는데 display_name이 NULL인 기존 행에는 name을 채웁니다. 최종 두 필드 중 NULL이 남거나 값이 서로 다르면 SQLSTATE 23514입니다. 이 규칙은 유효한 단일 필드 쓰기를 동기화하지만 NULL로 지우는 요청이나 모순된 두 값을 자동 교정하지 않습니다.
진행 순서
확장 전에는 옛 클라이언트의 name 조회·쓰기가 동작합니다. 확장 뒤 read_compatible이 기존 행을 읽습니다. 호환 트리거 설치 이후 구·신 클라이언트의 쓰기를 동시에 받으면서 대상별 백필을 수행합니다. NOT VALID 제약은 전체 백필 전에 추가할 수도 있지만 enforce는 남은 NULL이 없어야 성공합니다. 축소 전에는 실제 NOT NULL과 검증된 제약, 구·신 이름의 동일성까지 모두 필요합니다.
legacy_retired는 name만 쓰는 옛 앱, transition_retired는 COALESCE(display_name,name) 같은 전환용 조회·배치의 퇴역 승인입니다. 최종 앱은 옛 열을 전혀 참조하지 않습니다. 두 승인 플래그는 외부 확인의 기록일 뿐, 함수가 조직의 모든 앱이 종료됐다고 자동 증명하는 것은 아닙니다. 실제 서비스에서는 소유자·배포 버전·쿼리 관측·롤백 계획을 별도로 확인해야 합니다.
단계
1. 명찰 입력의 경계를 정한다 — Exception 하위 Conflict와 request(person_id,name)를 구현합니다. 아래 입력 계약을 검증하고 id·name 두 키의 새 dict를 반환합니다. 잘못된 입력은 ValueError이며 임의로 공백을 자르거나 숫자로 변환하지 않습니다.
2. 옛 열을 보존하며 새 열을 연다 — expand(con,fault=None)는 자기 트랜잭션에서 제한 시간을 설정하고 badges에 nullable text 열 display_name을 추가합니다. after-column 훅, 실제 COMMIT 뒤 after-commit 훅 순서입니다. 기존 id·name·행·제약을 보존하고 기존 행의 새 열은 NULL입니다. rename·기본값·즉시 백필은 하지 않습니다. 정상 반환값은 None입니다.
3. 백필 전후를 읽는 전환용 앱을 만든다 — read_compatible(con,person_id)는 ID를 검증하고 새 열이 NULL이 아니면 새 이름, NULL이면 옛 이름을 반환합니다. 없는 ID는 None이고 데이터를 바꾸지 않습니다. 이 함수는 확장 이후부터 옛 열 제거 전까지의 전환용 클라이언트이며 최종 클라이언트가 아닙니다.
4. 구·신 앱의 쓰기를 함께 받는다 — install_bridge(con,fault=None)는 sync_badge_name() 트리거 함수를 만들고 after-function, badge_compat BEFORE INSERT OR UPDATE FOR EACH ROW 트리거를 만든 뒤 after-trigger, 실제 COMMIT 뒤 after-commit을 호출합니다. 두 객체의 설치는 원자적이며 기존 행은 백필하지 않습니다. 트리거는 아래 양방향 규칙을 지킵니다. 정상 반환값은 None입니다.
5. 동시 수정을 덮지 않는 백필을 쓴다 — backfill(con,ids,fault=None)는 대상 목록을 쓰기 전에 검증하고 자기 트랜잭션에서 제한 시간을 설정합니다. 지정한 ID 중 현재 display_name이 NULL인 행만 DB의 현재 name으로 채웁니다. 변경한 ID의 오름차순 list를 반환합니다. 변경 뒤 after-write, 실제 COMMIT 뒤 after-commit이며 실패하면 이번 백필 전체를 롤백합니다. 없는 ID·이미 이전된 ID는 건너뜁니다. 같은 대상의 재실행은 빈 목록입니다.
6. 기존 행 검사와 새 쓰기 제한을 나눈다 — add_guard(con)는 제한 시간을 설정한 자기 트랜잭션에서 display_present CHECK(display_name IS NOT NULL) NOT VALID를 추가하고 None입니다. enforce(con,fault=None)는 같은 제약을 VALIDATE하고 after-validate, 실제 열에 SET NOT NULL 후 after-notnull, 실제 COMMIT 뒤 after-commit을 호출하고 None입니다. 남은 NULL은 원래 DB 예외를 전달하며 자동 백필하지 않습니다.
7. 두 세대의 퇴역 승인 뒤에만 옛 열을 지운다 — cutover(con,approvals,fault=None)는 legacy_retired·transition_retired 두 키만 가진 정확한 dict와 각 값이 정확히 True인지를 쓰기 전에 검증합니다. 자기 트랜잭션에서 제한 시간을 설정하고 badges의 ACCESS EXCLUSIVE 잠금을 얻습니다. display_name의 실제 NOT NULL, display_present의 검증 완료, 모든 행의 name·display_name 일치를 확인하며 미완료면 Conflict입니다. badge_compat와 sync_badge_name()만 제거하고 after-bridge-drop, name 열만 제거하고 after-column-drop, 실제 COMMIT 뒤 after-commit 순으로 호출합니다. True를 반환하고 기존 ID·새 이름은 보존합니다.
8. 최종 앱과 DDL 중 클라이언트 종료를 검증한다 — read_current(con,person_id)는 ID를 검증하고 display_name만 조회해 이름 또는 없는 ID의 None을 반환합니다. write_current(con,person_id,name)는 입력을 검증한 뒤 자기 트랜잭션에서 해당 ID의 display_name만 수정하고 id·name dict를 반환합니다. 없는 ID는 Conflict이며 추가하지 않습니다. cutover_file(dsn,approvals,fault=None)는 psycopg.connect(dsn,autocommit=True,connect_timeout=2)로 연결을 소유해 cutover의 결과·오류를 전달하고 성공·실패 모두 닫습니다. 최종 앱은 축소 전후 모두 동작해야 합니다.
참고
- 직접 진단: python3 -B /opt/lab/fixtures/schema/check.py 8 /root/schema/worker.py. 숫자를 현재 단계로 바꾸면 누적 검사합니다. 채점 한 번의 제한은 40초입니다.
- 다른 연결의 실제 행 잠금 아래에서 백필이 최신 변경을 보존하는지 검사합니다. 단순히 같은 SQL 문자열을 제출했는지 보지 않습니다.
- 옛 열을 제거하면 옛 클라이언트와 전환용 클라이언트는 실제 UndefinedColumn 오류가 나야 합니다. 최종 클라이언트는 계속 동작해야 합니다. 이 차이를 관찰하는 것도 학습 내용입니다.
- DDL 클라이언트 종료는 트리거 제거 뒤·열 제거 뒤·커밋 뒤 세 지점입니다. DB 서버는 종료하지 않습니다. 실습 이미지의 fsync·full_page_writes가 꺼져 있으므로 전원 장애 내구성이나 무중단 처리량을 증명하지 않습니다.
- 운영 DB·실제 개인정보·외부 서비스는 쓰지 마세요. 전체 과정은 학생별 일회용 DB 안에서만 실행합니다.
단계 8개
- 명찰 입력의 경계를 정한다
- 옛 열을 보존하며 새 열을 연다
- 백필 전후를 읽는 전환용 앱을 만든다
- 구·신 앱의 쓰기를 함께 받는다
- 동시 수정을 덮지 않는 백필을 쓴다
- 기존 행 검사와 새 쓰기 제한을 나눈다
- 두 세대의 퇴역 승인 뒤에만 옛 열을 지운다
- 최종 앱과 DDL 중 클라이언트 종료를 검증한다