에이전트의 RENAME COLUMN이 모든 구형 Pod를 망가뜨리는 이유: 4단계로 확장 및 축소하기
요약
코딩 에이전트가 실행한 단일 스키마 마이그레이션(`ALTER TABLE RENAME COLUMN`)이 쿠버네티스 배포 환경에서 구형 Pod와 신규 Pod 간의 트래픽 처리 과정에 문제를 일으킬 수 있습니다. 이는 코드-스키마 불일치와 데이터베이스 잠금 문제라는 두 가지 측면에서 발생합니다.
핵심 포인트
- 단순한 스키마 변경도 쿠버네티스 배포 환경에서는 복잡한 문제가 될 수 있다.
- 코드와 스키마의 동시 버전 관리가 필수적이며, 마이그레이션 순서가 중요하다.
- ALTER TABLE 같은 작업은 예상치 못한 데이터베이스 잠금(ACCESS EXCLUSIVE)을 유발할 수 있다.
- 도서: Database Playbook
- 저자 소개: Thinking in Go (2권 시리즈) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구로 배포하는 개발자를 위한 IDE
- 저자: xgabriel.com | GitHub
코딩 에이전트에게 users.name을 full_name으로 이름을 바꾸라고 요청했다고 가정해 봅시다. 에이전트는 모델을 업데이트하고, 모든 쿼리를 수정하며, 다음과 같은 한 줄의 마이그레이션을 작성합니다: ALTER TABLE users RENAME COLUMN name TO full_name;. diff는 깔끔하고 테스트도 통과하는데, 이는 테스트가 코드의 단일 버전을 대상으로 실행되기 때문입니다. 하지만 프로덕션 환경은 두 개의 버전을 실행합니다.
A Kubernetes Deployment는 새로운 ReplicaSet을 확장하는 동시에 이전 ReplicaSet을 축소하면서 배포됩니다. 이때 maxUnavailable과 maxSurge는 기본값으로 25%입니다. 배포가 진행되는 동안, 구형 Pod와 신규 Pod가 사이드 바이 사이드로 트래픽을 처리합니다. 이때 마이그레이션은 그 시간의 시작 직전이나 어느 시점에 실행됩니다. 이름 변경 후에도 여전히 살아있는 모든 구형 Pod는 더 이상 name 컬럼이 없는 테이블에 SELECT name ...을 전송하게 됩니다.
하나의 RENAME이 배포를 망가뜨리는 이유
이 한 줄에는 두 가지 별개의 실패 사례가 담겨 있습니다.
첫 번째는 코드와 스키마의 불일치(code-schema mismatch)입니다. 이름 변경을 새로운 코드보다 먼저 실행하면 구형 Pod들이 실패합니다. 코드를 먼저 배포하고 이름을 변경하는 마이그레이션을 실행하면, 마이그레이션이 완료될 때까지 신규 Pod들이 실패합니다. 어떤 순서로도 두 버전 모두를 만족시킬 수 없기 때문에 strong_migrations README는 이를 명확하게 설명합니다:
두 번째는 잠금(lock)입니다. 이름 변경은 데이터를 건드리지 않지만, Postgres 문서에 따르면 "저장된 데이터에는 아무런 영향이 없다"고 합니다. 하지만 ALTER TABLE은 명시적으로 언급되지 않는 한 ACCESS EXCLUSIVE 잠금을 사용하며, 이 모드는 일반적인 SELECT가 사용하는 ACCESS SHARE 잠금을 포함하여 다른 모든 잠금 모드와 충돌합니다. 만약 긴 트랜잭션이 테이블을 점유하고 있다면, 이름 변경은 기다리게 됩니다. 기다리는 동안, Xata의 잠금 큐에 대한 글에서 설명하듯이, 해당 테이블에 잠금이 필요한 모든 후속 구문들은 그 뒤로 대기열(queue)을 만듭니다. 이름 변경이 완료되거나 포기할 때까지 테이블은 읽기와 쓰기가 차단됩니다.
해결책은 오래된 방식입니다. Danilo Sato는 2014년에 이를 병렬 변경(Parallel Change)이라고 설명했습니다: 인터페이스를 확장하여 구 버전과 신 버전을 모두 지원하게 하고, 클라이언트를 마이그레이션한 다음, 다시 새 버전만 사용하도록 축소하는 것입니다. Postgres에서 컬럼 이름을 변경할 경우, 이는 네 단계가 필요합니다.

롤링 배포(rolling deploy) 동안, 두 버전의 코드가 하나의 스키마를 공유합니다.
실험: 테이블 하나에 두 개의 앱 버전
임시 Postgres 18을 시작하고 테이블을 시딩합니다:
docker run -d --name pg -e POSTGRES_PASSWORD=pw postgres:18
# 연결 수락까지 몇 초 기다립니다
docker exec -i pg psql -U postgres <<'SQL'
...
"앱"은 이미지 버전별로 파일 하나씩, 총 두 개의 파일로 구성됩니다. 각각은 요청 핸들러가 할 일을 수행합니다: 행을 작성하고, 다시 읽어옵니다.
-- old_app.sql: 이전 이미지가 실행하는 내용
INSERT INTO users (name) VALUES ('Old Pod Writer');
SELECT id, name FROM users ORDER BY id DESC LIMIT 1;
이전 청크와 일관된 용어 및 톤을 유지하며 번역합니다.
-- new_app.sql: 새로운 이미지가 실행되는 내용
INSERT INTO users (full_name) VALUES ('New Pod Writer');
SELECT id, full_name FROM users ORDER BY id DESC LIMIT 1;
docker exec -i pg psql -U postgres < old_app.sql로 둘 중 하나를 실행해 보세요. 현재 old_app.sql은 작동하고 new_app.sql은 실패합니다. 에이전트의 컬럼 이름 변경을 적용하면 결과가 뒤집히는데, old_app.sql이 컬럼 존재하지 않음(column-does-not-exist) 오류와 함께 실패합니다. 이 변화가 바로 여러분의 롤링 배포입니다. 계속 진행하기 전에 컬럼 이름을 다시 name으로 변경하세요.
또한 잠금 대기열(lock queue)을 관찰할 수도 있습니다. 세 개의 터미널을 열고 docker exec -it pg psql -U postgres를 실행하세요:
- 터미널 A:
BEGIN; SELECT count(*) FROM users;를 입력하고 트랜잭션을 열어 둡니다. - 터미널 B:
ALTER TABLE users RENAME COLUMN name TO full_name;는 A를 기다리며 멈춥니다(hangs). - 터미널 C:
SELECT id FROM users LIMIT 1;역시 B 뒤에 대기하며 멈춥니다.
B에서 Ctrl-C로 취소하면 C가 즉시 반환됩니다. 이제 B에서 SET lock_timeout = '2s';를 실행한 후 이름 변경을 다시 시도해 보세요. B는 모든 사람을 인질로 잡는 대신, 두 초 후에 잠금 시간 초과(lock-timeout) 오류와 함께 포기합니다. lock_timeout은
이 세 가지 디테일 덕분에 안전합니다. 기본값이 없는 컬럼을 추가하면 기존 행은 NULL 값을 갖게 되고, ALTER TABLE 페이지에는 '어떤 경우에도 테이블 재작성이 필요하지 않다'고 명시되어 있어 배타적 잠금(exclusive lock)이 아주 짧은 시간 동안만 유지됩니다. 트리거는 BEFORE 행 트리거이기 때문에, NOT NULL 검사가 실행되기 전에 name을 채우므로, new_app.sql이 이전 컬럼 이름을 지정하지 않고도 삽입할 수 있게 합니다. 그리고 UPDATE 분기는 한 방향으로만 복사합니다. 즉, 변경된 어느 컬럼에서든 값을 가져오기 때문에, 어떤 버전의 작성자(writer)도 다른 컬럼에 의해 값이 덮어쓰여지는 일이 없습니다.
지금 두 앱 파일을 모두 실행해 보세요. 둘 다 성공하고, 마지막 두 행에는 name과 full_name이 채워져 있습니다. 이전 코드와 새 코드가 공존할 수 있게 되었는데, 이것이 바로 확장(expand) 단계의 핵심 목적입니다.
2단계: 빠르게 포기하는 배치로 백필(backfill)하기
여전히 20만 개의 시드 행은 full_name이 비어 있습니다. 이를 단순히 UPDATE users SET full_name = name으로 고치지 마세요. 모든 행에 대해 행 잠금(row lock)을 유지하는 하나의 긴 트랜잭션인 대형 테이블에서는, strong_migrations가 안전하게 처리할 수 있는 세 가지 핵심 요소를 명시합니다: '배칭(batching)', '스로틀링(throttling)', 그리고 '트랜잭션 외부에서 실행하기'.
기본 키(primary key)를 기준으로 배치화하여 각 배치가 이전 배치가 멈춘 곳부터 시작하도록 합니다:
-- backfill.sql: 1,000개 행의 한 배치
SET lock_timeout = '2s';
SET statement_timeout = '30s';
...
upd 단계에는 RETURNING이 없고 아무것도 그것을 읽지 않지만, 여전히 실행됩니다. Postgres는 WITH를 사용한 데이터 수정 구문을 '정확히 한 번, 그리고 항상 완료될 때까지' 실행합니다. 이 쿼리는 처리한 마지막 id 또는 아무것도 남지 않았을 경우 0을 출력합니다.
짧은 루프가 이것을 구동합니다. psql은 자동 커밋 모드(autocommit mode)로 실행되므로, 각 배치가 자체 트랜잭션이 됩니다:
#!/usr/bin/env bash
# backfill.sh: deploy 1 이후, deploy 2 이전에 실행
last=0
...
배치 크기를 조정하여 각 배치가 30초의 statement_timeout보다 훨씬 짧게 완료되도록 합니다. 배치에 타임아웃이 발생하면 스스로 롤백되고 루프는 동일한 범위로 재시도합니다. 그런 다음 아무것도 표류(drifted)하지 않았는지 확인하세요:
SELECT count(*) FROM users
WHERE full_name IS DISTINCT FROM name;
이 개수가 0이어야 다음 단계로 진행할 수 있습니다.

확장(Expand), 마이그레이션(migrate), 축소(contract): 스키마가 좁아지기 전에 더 넓어집니다.
단계 3과 4: 전환 후 축소하기
단계 3은 일반적인 코드 배포(deploy 2)입니다. 이 이미지는 new_app.sql을 실행하며 오직 full_name만 읽고 씁니다. 이 롤아웃 동안 구형 Pod는 여전히 name을 쓰고 트리거가 이를 복사합니다. 신규 Pod는 full_name을 쓰고 트리거가 이를 다시 복사하므로, name을 읽는 구형 Pod는 신규 Pod가 작성한 행도 계속 볼 수 있습니다. 단계 4까지는 구형 이미지로 롤백해도 아무것도 손실되지 않습니다.
만약 해당 컬럼이 NOT NULL이어야 한다면, Postgres 18은 긴 잠금(long lock) 없이 이를 추가할 수 있게 합니다. ALTER TABLE 문서에서는 not-null 제약 조건에 대해 NOT VALID을 허용하며, 유효성 검사는 별도의 구문으로 실행됩니다:
SET lock_timeout = '2s';
SET statement_timeout = '15min';
ALTER TABLE users
...
statement_timeout은 다른 곳에서 사용된 10초보다 긴데, 이는 VALIDATE가 전체 테이블을 스캔하기 때문입니다. 이 과정 동안에는 SHARE UPDATE EXCLUSIVE 잠금만 필요하므로 읽기 및 쓰기가 계속 진행됩니다.
단계 4는 아무 코드도 name을 읽지 않는 시점이고, 롤백할 지점을 지나갔을 때까지 기다립니다. 그런 다음 자체 배포에서 구형 컬럼을 제거합니다:
-- 002_contract.sql (deploy 3, name을 읽는 코드는 없음)
SET lock_timeout = '2s';
SET statement_timeout = '10s';
...
DROP COLUMN도 빠르다. 같은 페이지에 따르면, Postgres는 '컬럼을 물리적으로 제거하지' 않고 SQL에게서 컬럼을 보이지 않게 만들며, 공간은 다음 테이블 재작성 시 돌아온다. 한 가지 주의할 점은 일부 ORM이 부팅 시 컬럼 목록을 캐싱한다는 것이다. strong_migrations는 Active Record가 '런타임에 데이터베이스 컬럼을 캐시한다'고 언급하므로, Rails에서는 해당 컬럼을 ignored_columns에 추가하고 드롭을 실행하기 전에 배포해야 한다.
에이전트에게 규칙 주기
에이전트는 아무도 코드의 두 번째 버전에 대해 알려주지 않았기 때문에 한 줄짜리 코드를 작성했다. 알려줘야 한다. Claude Code는 작업하는 파일과 일치하는 경우 .claude/rules/에서 경로별 규칙(path-scoped rules)을 로드한다. 따라서 이 파일은 에이전트가 마이그레이션에 접근할 때만 나타난다:
---
paths:
- "migrations/**/*.sql"
...
리포지토리의 다른 코딩 에이전트들은 AGENTS.md를 읽으므로, 여기에도 글머리 기호를 복사해라. Claude Code는 어쨌든 .claude/rules/을 계속 로드하며, 같은 문서 페이지가 언제 AGENTS.md를 읽는지 다루고 있다.
규칙 파일은 조언이다. 문서는 이를 직접적으로 말한다: Claude는 '강제되는 설정이 아닌 컨텍스트로 취급한다.' 그러니 SQL을 누가 작성했는지 신경 쓰지 않는 검사(check)와 함께 사용하라. CI에서 squawk 'migrations/*.sql'을 실행하거나, 풀 리퀘스트에서 squawk-action을 사용하라. 이 액션의 renaming-column 규칙은 한 줄짜리 코드를 포착하고, require-lock-timeout과 require-statement-timeout은 타임아웃을 잊어버린 마이그레이션을 포착한다. 또한 단계 4의 드롭도 플래그를 지정할 것이며, 이는 괜찮다: 그것은 사람이 배포하기 전에 읽어야 하는 유일한 마이그레이션이기 때문이다.
만약 확장-축소 장부 기록을 자동화하고 싶다면, pgroll이 add-column, backfill 및 sync-trigger 작업을 대신 처리해주고 뷰(views)를 통해 오래된 스키마 버전과 새로운 스키마 버전을 동시에 제공한다. 네 단계를 수동으로 아는 것이 여전히 그것이나 당신의 에이전트가 실행하려는 것을 검토할 수 있게 해준다.
이 내용이 유용했다면
여기 제시된 네 단계는 스토어가 실제 트래픽을 받고 있고 여러 버전의 코드가 이 스토어와 통신하는 상황에서 스키마 변경이 얼마나 큰 비용을 초래하는지를 보여줍니다. 저는 그보다 앞선 결정, 즉 시스템에 어떤 스토어를 선택할지—그리고 그 스키마를 수년간 실제 부하 하에서 변경해야 한다는 사실을 인지한 상태에서—에 대한 _Database Playbook_을 작성했습니다. 만약 이 글이 여러분으로 하여금 자신의 마이그레이션(migration)을 더 깊이 들여다보게 만들었다면, 그 책은 그러한 탐색의 보다 광범위한 버전입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기