테이블 이름을 바꿔도 DB 는 모른다 — RENAME 과 뷰 별칭으로 무중단 개명하기

테이블 개명은 시스템 카탈로그의 문자열 하나를 바꾸는 일이다 — 데이터도, FK 도, 인덱스도, 기존 뷰도 따라 움직이지 않는다.

환경

  • PostgreSQL 15.8
  • PostgREST 기반 자동 REST API — 테이블명이 URL 과 클라이언트 SDK 에 문자열로 박힌다
  • 하나의 DB 를 8개 이상의 독립 서비스(백엔드 API, 크론 워커, 봇, 프론트 클라이언트)가 같은 DB 롤로 직접 읽고 쓴다
  • 파이썬 백엔드 + JS 클라이언트 혼재

문제 상황

레거시 네이밍으로 굳어버린 테이블 45개가량을 새 이름 체계로 정리해야 했다.

막힌 지점은 개명 자체가 아니었다. 개명하는 순간에 무엇이 깨지는지 아무도 자신 있게 말하지 못한다는 것이었다. 질문은 세 개였다.

  • FK 제약, 인덱스, 트리거, 그 테이블을 참조하는 기존 뷰가 있다. 개명하면 이것들이 끊어지나?
  • 여러 서비스가 같은 테이블을 직접 쓴다. 전부 동시에 배포해야 하나?
  • 수백만 행짜리 테이블이 여럿이다. 데이터 복사를 감당할 수 있나?

세 질문 모두 “모르니까 최악을 가정하자”로 흘렀고, 그래서 기본안은 전면 병행이었다. 새 이름의 테이블을 신설하고, dual-write 트리거를 걸고, 전량 backfill 하고, 검증 기간을 둔 뒤 구 테이블을 드롭한다. 안전해 보이는 계획이었다.

여기에 리스크 하나가 더 붙어 있었다. 구 이름을 뷰로 남겨 흡수하는 안을 검토하다가, INSERT … ON CONFLICT(upsert)는 뷰에서 지원되지 않는다” 는 통념 때문에 그 안을 위험 항목으로 문서에까지 적었다. upsert 는 여러 서비스의 쓰기 경로에 흩어져 있어서, 사실이라면 코드 수정 범위가 확 늘어나는 조건이었다.

결과부터 쓰면 그 통념은 틀렸고, 확인하는 데 5분이 걸렸다. 그 얘기는 「통념을 뒤집는 데 5분이 걸렸다」에서 한다.

재현

먼저 확인해야 할 것은 “개명이 DB 안의 의존 객체를 끊는가”다. 임시 객체 세 줄이면 답이 나온다.

CREATE TEMP TABLE tb_user_mst (id int PRIMARY KEY, name text);
CREATE TEMP VIEW v_active_user AS SELECT id, name FROM tb_user_mst;

ALTER TABLE tb_user_mst RENAME TO users;

SELECT * FROM v_active_user;      -- 정상 동작한다
SELECT pg_get_viewdef('v_active_user'::regclass, true);

마지막 줄이 돌려주는 정의는 이렇다.

 SELECT users.id,
    users.name
   FROM users;

아무도 뷰 정의를 고치지 않았는데 신 이름으로 바뀌어 있다. 뷰가 이름을 따라간 게 아니라, 애초에 이름을 저장한 적이 없기 때문이다.

원인

PostgreSQL 에서 ALTER TABLE … RENAME TO … 가 하는 일은 정확히 하나다. 시스템 카탈로그 pg_class 행의 relname 컬럼 값을 바꾼다. 그게 전부다.

테이블의 실체 — 힙 파일, 인덱스 파일, TOAST, 통계 — 는 pg_class.oid 로 식별된다. 디스크 위의 파일명조차 relfilenode 라는 숫자라서 테이블 이름과 아무 관계가 없다. 그래서 데이터는 0바이트 이동하고, 리라이트도 없다. 수백만 행이든 수십억 행이든 개명 비용은 동일하다.

DB 안에서 그 테이블에 물려 있는 것들도 전부 이름이 아니라 OID 로 걸려 있다. pg_depend 가 의존성을 추적하는 단위 자체가 OID 쌍이다.

graph LR
  NAME["pg_class.relname<br/>이 문자열 하나가 이름이다<br/>RENAME 이 바꾸는 것의 전부"] --> ENT["pg_class.oid<br/>relfilenode 가 가리키는 힙 파일<br/>인덱스 파일 · TOAST · 통계<br/>여기는 1바이트도 안 움직인다"]
  FK["FK 제약<br/>pg_constraint.confrelid"] -->|OID| ENT
  IDX["인덱스<br/>pg_index.indrelid"] -->|OID| ENT
  VW["기존 뷰<br/>pg_rewrite 에 담긴 쿼리 트리"] -->|OID| ENT
  TRG["트리거 · 시퀀스 소유 · RLS 정책"] -->|OID| ENT
  OUT["DB 바깥<br/>앱 SQL 문자열 · ORM 매핑<br/>REST 클라이언트가 보내는 테이블명<br/>여기만 이름으로 접근한다"] -->|이름 문자열| NAME

그림의 화살표 방향이 요점이다. 왼쪽 아래 네 갈래는 전부 실체(OID) 를 가리키고, 이름 라벨을 거치지 않는다. 위쪽 라벨의 문자열을 바꿔도 저 화살표들은 픽셀 하나 움직이지 않는다.

특히 헷갈리기 쉬운 게 뷰다. 뷰 정의는 SQL 텍스트로 저장되지 않는다. CREATE VIEW 시점에 파싱을 마친 쿼리 트리pg_rewrite 에 들어가고, 그 트리 안의 테이블 참조도 OID다. pg_get_viewdef() 가 보여주는 SQL 은 저장된 원문이 아니라 그 트리를 지금 시점의 카탈로그로 역출력한 결과다. 그래서 기반 테이블을 개명하면 뷰 정의가 “자동으로 갱신된” 것처럼 보인다. 실제로는 갱신될 것이 처음부터 없었다.

여기서 결론이 하나 떨어진다. 개명으로 깨질 수 있는 것은 DB 바깥에서 이름 문자열로 접근하는 것뿐이다.

  • 앱 코드 안의 SQL 문자열
  • ORM 의 테이블 매핑
  • PostgREST 클라이언트가 URL 로 보내는 테이블명
  • 함수 본문에 문자열로 박힌 동적 SQL (EXECUTE format(...))

DB 안쪽은 알아서 따라온다. 문제는 정확히 DB 바깥 한 겹이고, 그 한 겹만 어떻게 흡수하느냐의 문제로 좁혀진다.

해결

RENAME 과 구 이름 뷰 별칭을 한 트랜잭션 안에서 처리한다.

BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE tb_user_mst RENAME TO users;
CREATE VIEW tb_user_mst AS SELECT * FROM users;
COMMIT;

네 줄이 각각 일을 한다.

1. SELECT * 한 줄짜리 뷰는 자동으로 쓰기가 된다. PostgreSQL 9.3+ 의 auto-updatable view 다. INSTEAD OF 트리거 없이도 INSERT/UPDATE/DELETE 가 통한다. 조건이 붙지만 — FROM 에 릴레이션 하나, DISTINCT·GROUP BY·HAVING·집계·윈도우·LIMIT/OFFSET·UNION 없음, SELECT 목록이 기반 컬럼의 단순 참조일 것 — “구조가 같은 개명”은 SELECT * 한 줄로 조건을 전부 만족한다. 개명은 auto-updatable view 가 다룰 수 있는 가장 쉬운 형태다.

2. 읽기 오버헤드가 사실상 0이다. 뷰는 별도의 실행 노드가 아니라 재작성 단계에서 인라인 전개되는 규칙이다. 자세한 건 아래 관련 이론에서 다룬다.

3. DDL 이 트랜잭셔널이다. RENAMECREATE VIEW 를 한 트랜잭션에 묶으면 “구 이름도 신 이름도 존재하지 않는 순간”이 외부에 절대 노출되지 않는다. 커밋 전까지 다른 세션에는 구 이름 테이블이 그대로 보이고, 커밋 이후에는 구 이름 뷰와 신 이름 테이블이 동시에 보인다. 중간 상태가 없다.

4. lock_timeout 이 유일한 운영 리스크를 막는다. RENAMECREATE VIEWACCESS EXCLUSIVE 락을 잡는다. 연산 자체는 밀리초로 끝나지만, 긴 트랜잭션이 그 테이블을 붙잡고 있으면 마이그레이션이 락 대기 큐에 서고 그 뒤에 도착한 모든 쿼리가 — SELECT 까지 포함해서 — 함께 막힌다. 밀리초짜리 DDL 이 장애처럼 보이는 경로가 이거다. lock_timeout 을 걸면 락을 못 잡았을 때 마이그레이션만 실패하고 서비스는 무사하다. 재시도하면 된다.

배포 순서 얘기가 사라진다는 게 실질적인 이득이다. 여덟 개 서비스는 구 이름으로 계속 요청하고, 뷰가 그걸 받아 기반 테이블로 넘긴다. 각 서비스는 각자의 일정으로 신 이름으로 갈아타면 되고, 다 갈아탄 뒤에 뷰를 걷는다.

고려했다 버린 대안 — 전면 병행

신설 + dual-write 트리거 + backfill + 검증 + cutover. 버린 이유는 셋이다.

  • 구조가 동일한 데이터를 수백만 행 복사한다. RENAME 은 데이터 이동이 0이다. 같은 결과를 얻는 데 비용 차이가 무한대다.
  • 병행 기간 내내 모든 쓰기 경로에 트리거가 살아 있어야 한다. 그것도 독립적으로 배포되는 여러 서비스에서. 뷰 별칭은 그 서비스들의 코드를 한 줄도 고치지 않고 흡수한다.
  • 안전해 보이는 쪽이 실제로는 움직이는 부품이 훨씬 많다. 전면 병행은 신 테이블, 트리거, 백필 잡, 정합성 검증, cutover 조율까지 다섯 개의 부품이 각각 실패할 수 있다. RENAME + 뷰는 부품이 두 개고 둘 다 한 트랜잭션 안에 있다.

세 번째가 이 글에서 가장 중요한 지점이다. “안전”과 “안전해 보임”은 자주 반대 방향을 가리킨다.

관련 이론

근본 원리는 한 문장이다. 카탈로그가 객체를 식별하는 키는 이름이 아니라 OID 다. 이름은 사람이 읽는 라벨이고, 라벨을 바꾸는 것은 실체를 건드리지 않는다.

이 문장이 왜 참인지, 그리고 어디까지만 참인지를 아래에서 층층이 풀어 본다.

쿼리 재작성기가 하는 일

“뷰를 한 겹 끼우면 느려지지 않나”라는 질문에 답하려면 뷰가 실행되는 위치를 봐야 한다. 답은 실행되지 않는다이다.

graph LR
  Q["앱이 던진 SQL<br/>구 이름으로 작성되어 있다"] --> P["파서<br/>구문 분석과 이름 해석"]
  P --> R["재작성기<br/>pg_rewrite 의 _RETURN 규칙을<br/>쿼리 트리에 인라인 전개한다"]
  R --> PL["플래너<br/>이 시점엔 기반 테이블만 남은 트리를 본다"]
  PL --> EX["실행 계획<br/>기반 테이블을 직접 스캔<br/>뷰라는 노드는 존재하지 않는다"]

뷰는 pg_rewrite 에 저장된 _RETURN 규칙이고, 재작성 단계에서 쿼리 트리 안으로 펼쳐진다. 플래너가 그 트리를 받을 때는 이미 기반 테이블만 남아 있다. 그래서 실행 계획에는 “뷰”라는 노드가 아예 없고, 기반 테이블을 직접 스캔하는 계획이 나온다. 오버헤드가 작은 게 아니라 오버헤드를 낼 자리가 없다.

쓰기도 같은 자리에서 처리된다. 재작성기가 문장의 대상 릴레이션을 뷰에서 기반 테이블로 치환한다. 그래서 기반 테이블의 제약·트리거·RLS 가 그대로 적용되고, RETURNING 도 정상 동작한다. 우회로가 생기는 게 아니라, 문장이 원래 기반 테이블을 향했던 것처럼 다시 쓰이는 것이다.

아래 그림이 그 치환이 일어나는 자리를 표시한 것이다. 왼쪽 레일의 색이 바뀌는 한 지점만 보면 된다 — 구 이름 뷰에 던진 문장이 그 지점에 닿기도 전에 떨어지는지, 지점을 넘어 기반 테이블 스캔까지 그대로 가는지가 거기서 갈린다.

구 이름 뷰 tb_user_mst 에 문장을 던졌을 때, 그 문장이 지목하는 대상 릴레이션은 파이프라인 한 지점에서 딱 한 번 users 로 바뀐다. 아래 레일의 색이 바뀌는 자리가 그 지점이고, 뷰 별칭이 무엇을 흡수하고 무엇을 못 흡수하는지는 전부 그 자리를 기준으로 갈린다.

대상 = 구 이름 뷰 대상 = 기반 테이블 여기서 떨어진다 떨어지진 않는데 결과가 다르다
tb_user_mst대상 = 뷰

1 · 파서문장에 적힌 이름을 해석한다

대상은 아직 적힌 그대로다. 이 단계에서 이름으로 무언가를 더 찾는 문장은 기반 테이블이 아니라 뷰에서 찾게 된다.

42704

INSERT INTO tb_user_mst … ON CONFLICT ON CONSTRAINT users_pkey

제약을 문장에 적힌 릴레이션에서 이름으로 찾는다. 제약은 기반 테이블에 붙어 있고 뷰에는 붙어 있지 않다. 치환은 두 칸 아래에서 일어나므로 늦는다.

tb_user_mst대상 = 뷰

2 · 분기이 문장이 재작성기를 거치는가

유틸리티 명령은 재작성기와 플래너를 통째로 건너뛰고 곧장 실행된다. 건너뛴다는 것은 대상을 바꿔줄 단계 자체가 없다는 뜻이다.

42809

TRUNCATE tb_user_mst;

대상이 끝까지 뷰인 채로 “테이블인가” 검사에 들어간다. is not a table.

추정 · 미검증

COPY tb_user_mst FROM …

같은 이유로 실패할 것으로 본다. 문서상 COPY FROMINSTEAD OF INSERT 트리거가 있는 뷰에서만 가능하다. 직접 확인하지 못했다.

tb_user_mst↓ 치환users

3 · 재작성기_RETURN 규칙을 쿼리 트리에 인라인 전개한다

여기서 대상 릴레이션이 tb_user_mst 에서 users 로 치환된다. 레일의 색이 바뀌는 유일한 자리이고, 뷰 별칭이 하는 일의 전부다. 이 자리에 닿기 전에 뷰에게 없는 것을 요구한 문장은 위에서 떨어졌고, 여기를 넘은 문장은 아래로 기반 테이블만 데리고 간다.

users대상 = 기반 테이블

4 · 플래너 · 실행기기반 테이블을 직접 스캔한다

플래너가 받는 트리에는 뷰라는 노드가 이미 없다. 기반 테이블의 제약·트리거·RLS 가 그대로 걸리고 RETURNING 도 동작한다. 구 이름으로 던졌다는 흔적은 남지 않는다.

  • SELECT
  • INSERT
  • INSERT … RETURNING
  • UPDATE
  • DELETE
  • ON CONFLICT (id) DO NOTHING
  • ON CONFLICT (id) DO UPDATE

맨 위에서 떨어진 ON CONFLICT ON CONSTRAINT 와 여기 도착한 ON CONFLICT (id) 는 같은 기능이다. 컬럼 추론은 치환이 끝난 뒤 기반 테이블 위에서 이뤄지므로 여기까지 온다. 뷰가 upsert 를 못 하는 게 아니라, 뷰에 없는 것의 이름을 부른 것이다.

에러 없음 · 결과만 다름

ALTER TABLE users ADD COLUMN …; SELECT * FROM tb_user_mst;

CREATE VIEW … AS SELECT **생성 시점에 전개되어 트리에 박힌다. 문장은 네 칸을 전부 통과하는데, 뷰를 만든 뒤 기반 테이블에 추가한 컬럼만 없다.

관문에서 떨어지지 않으니 조용하다. 별칭이 살아 있는 동안 컬럼을 추가한다면 뷰도 같이 다시 만들어야 한다.

같은 문장들을 처음부터 users 에 던지면 이 그림에는 관문이 하나도 없다 — 레일이 맨 위에서부터 초록이고, 떨어지는 것도 조용히 달라지는 것도 없다. 구 이름 뷰에서 막히는 세 가지는 전부 “치환 지점보다 이른 자리에서 뷰에게 없는 것을 요구했다” 는 한 가지 이유다.

구 이름 뷰에 무엇을 던질 수 있나

아래는 PostgreSQL 15.8 에서 실제로 돌려 얻은 결과다.

SQL 문장 기반 테이블 직접 구 이름 뷰 (auto-updatable)
SELECT 동작 동작 (계획에 뷰 노드 없음)
INSERT 동작 동작
INSERT … RETURNING 동작 동작
UPDATE 동작 동작
DELETE 동작 동작
INSERT … ON CONFLICT (컬럼) DO NOTHING 동작 동작
INSERT … ON CONFLICT (컬럼) DO UPDATE 동작 동작
INSERT … ON CONFLICT ON CONSTRAINT 제약명 동작 실패42704, 그 제약이 뷰에는 없다
TRUNCATE 동작 실패42809, is not a table
COPY … FROM 동작 실패(추정) — 아래 「검증하지 못한 것」 참고
기반 테이블에 ADD COLUMNSELECT * 신 컬럼 보임 신 컬럼 안 보임
뷰가 참조 중인 컬럼을 DROP COLUMN 실패2BP01, 의존 객체가 있다 (해당 없음)
기반 테이블 RENAME 후 뷰 조회 (해당 없음) 동작 + 뷰 정의가 신 이름으로 재출력

실무에서 중요한 결론은 짧다. 막히는 건 세 개뿐이고, 그중 upsert 는 컬럼 추론을 쓰는 한 막히지 않는다.

SELECT * 뷰가 신 컬럼을 못 보는 건 실패는 아니지만 조용해서 더 위험하다. CREATE VIEW … AS SELECT *생성 시점의 컬럼 목록으로 고정된다 — * 가 그때 전개되어 트리에 박히기 때문이다. 뷰 별칭이 살아 있는 동안 기반 테이블에 컬럼을 추가하면, 구 이름으로 접근하는 서비스에는 그 컬럼이 존재하지 않는다. 별칭 기간 중 컬럼을 추가한다면 뷰도 같이 다시 만들어야 한다.

통념을 뒤집는 데 5분이 걸렸다

앞에서 적은 대로, 나는 “뷰에서는 upsert 가 안 된다”고 믿고 있었고 그걸 이 마이그레이션의 최대 리스크로 문서에 적었다. 확인은 이게 전부였다.

CREATE TEMP TABLE users (id int PRIMARY KEY, name text);
CREATE TEMP VIEW tb_user_mst AS SELECT * FROM users;

INSERT INTO tb_user_mst (id, name) VALUES (1, 'a')
  ON CONFLICT (id) DO NOTHING;                              -- 동작

INSERT INTO tb_user_mst (id, name) VALUES (1, 'b')
  ON CONFLICT (id) DO UPDATE SET name = excluded.name;      -- 동작

INSERT INTO tb_user_mst (id, name) VALUES (1, 'c')
  ON CONFLICT ON CONSTRAINT users_pkey DO NOTHING;          -- 42704

컬럼 추론 방식은 둘 다 정상 동작했다. 실패한 것은 제약 이름으로 추론하는 형태뿐이었다.

이유는 원리를 따라가면 자명하다. ON CONFLICT (id)대상 릴레이션의 컬럼으로 유니크 인덱스를 추론하고, 그 추론은 재작성기가 대상을 기반 테이블로 치환한 뒤에 그 테이블 위에서 이뤄진다. 반면 ON CONFLICT ON CONSTRAINT users_pkey문장에 적힌 릴레이션에서 그 이름의 제약을 찾는다. 제약은 기반 테이블에 붙어 있고 뷰에는 붙어 있지 않으니, 존재하지 않는 이름을 찾다가 42704 로 끝난다. 뷰가 upsert 를 못 하는 게 아니라, 뷰에 없는 것의 이름을 부른 것이다.

교훈이 두 개 나왔다.

하나. 검증 비용이 이렇게 싼데 통념으로 설계 결정을 내릴 이유가 없다. PostgreSQL 은 DDL 이 트랜잭셔널이고 임시 테이블·임시 뷰는 세션이 끝나면 사라진다. 위 다섯 줄은 운영 DB 를 건드리지 않고, 롤백하면 흔적도 남지 않는다. 문서를 뒤지고 기억을 더듬는 시간이 실행해 보는 시간보다 길다.

둘. 과대 평가된 리스크도 리스크다. 저 착오는 나를 안전한 쪽으로 밀어준 게 아니라, 하지 않아도 될 코드 수정과 일정을 만들어냈다. 있지도 않은 제약을 피하려고 설계를 비틀면, 비튼 만큼의 복잡도는 실제로 생긴다. 리스크 평가가 틀린 방향은 두 개이고, 둘 다 비용을 만든다.

헷갈리는 짝들

이름이 비슷하거나 같은 범주로 묶여서 오해를 부르는 지점들이다.

A B 차이
뷰 정의가 SQL 텍스트로 저장된다 (오해) 파싱된 쿼리 트리로 pg_rewrite 에 저장된다 (사실) 그래서 기반 테이블을 개명하면 뷰가 자동으로 따라간다
auto-updatable view INSTEAD OF 트리거 뷰 전자는 재작성기가 대상 릴레이션을 치환하고, 후자는 트리거 본문이 대신 실행된다. 지원되는 문장 종류가 다르다
ON CONFLICT (컬럼) ON CONFLICT ON CONSTRAINT 제약명 전자는 컬럼으로 인덱스를 추론하고, 후자는 이름으로 제약을 찾는다. 뷰에서 전자는 되고 후자는 안 된다
DROP COLUMN 이 거부되는 것 = 불편 = 안전판 잔존 참조를 조용한 오동작 대신 시끄러운 에러로 바꿔준다

마지막 줄은 뷰를 걷는 단계에서 실제로 쓰는 장치다. 아래에서 이어서 다룬다.

이 해법이 못 하는 것과, 그걸 맡는 층위

뷰 별칭은 만능이 아니다. 중요한 건 각 구멍을 어느 층이 막느냐를 미리 정해 두는 것이다.

구멍 뷰가 막아주나 맡는 층
앱 SQL 문자열에 남은 구 이름 막아준다 (그게 목적이다)
신 이름으로 갈아탈 때의 누락 코드 층의 grep, 그리고 뷰를 걷는 순서
RLS 정책이 걸린 테이블 security_invoker = true
락 대기로 인한 연쇄 정지 lock_timeout
뷰를 걷을 때 남은 잔존 참조 DROP COLUMN 의 의존성 검사

RLS 는 반드시 짚어야 한다. 기본 뷰는 뷰 소유자의 권한으로 평가된다. 즉 기반 테이블에 걸린 RLS 정책이 호출자 기준으로 걸리지 않는다. 개명 전에는 행 단위로 걸러지던 것이 뷰를 거치면 전부 보이는 상태가 될 수 있다. PostgreSQL 15+ 에는 이걸 위한 옵션이 있다.

CREATE VIEW tb_user_mst WITH (security_invoker = true) AS SELECT * FROM users;

RLS 를 쓰는 테이블이 하나라도 있으면, 이 옵션은 선택이 아니라 필수다. 개명은 무해한 작업으로 분류되기 쉬워서 보안 경계가 조용히 넓어지는 것을 놓치기 쉽다.

뷰를 걷는 순서에도 장치가 하나 있다. 뷰가 참조하는 컬럼은 CASCADE 없이 DROP COLUMN 이 거부된다(2BP01). 이걸 순서로 뒤집어 쓴다.

  1. 뷰 별칭의 SELECT 목록에서 그 컬럼을 먼저 뺀다 (뷰를 다시 만든다)
  2. 그 상태로 잠시 둔다 — 구 이름으로 그 컬럼을 읽던 코드가 남아 있으면 여기서 에러가 난다
  3. 조용하면 실 컬럼을 드롭한다

이러면 잔존 참조가 조용한 오동작이 아니라 에러로 강제 노출된다. 거부되는 제약을 불편으로 볼지 안전판으로 볼지는 순서를 어떻게 짜느냐에 달려 있다.

검증하지 못한 것

정직하게 남긴다.

  • COPY … FROM <뷰> — 직접 확인하려 했으나 파일 읽기 권한 에러가 먼저 발생해 검증하지 못했다. 공식 문서상 COPY FROMINSTEAD OF INSERT 트리거가 있는 뷰에서만 가능하므로 auto-updatable 뷰에서는 실패할 것으로 본다. 문서 근거, 미검증이다.
  • INSTEAD OF 트리거가 붙은 뷰에서의 ON CONFLICT 동작 — 테스트하지 않았다. 확인하지 않았으므로 아무것도 주장하지 않는다.

일반화 — expand / contract

이 작업은 스키마 마이그레이션의 일반 패턴 하나에 정확히 들어맞는다. expand / contract(= parallel change) 라고 부른다.

단계 하는 일 원칙
expand 신 구조를 추가한다. 구 구조는 그대로 둔다 additive 만 — 컬럼 추가, 인덱스 추가는 기존 읽기·쓰기를 깨지 않는다
migrate 읽는 쪽·쓰는 쪽을 하나씩 신 구조로 옮긴다 서비스마다 각자의 일정으로
contract 구 구조를 드롭한다 최후에. 되돌릴 수 없는 유일한 단계

이 글의 해법을 이 표에 얹으면 이렇게 된다. RENAME + 뷰 생성이 expand, 각 서비스의 코드 교체가 migrate, 뷰를 걷는 것이 contract 다. 특이한 점은 expand 단계의 비용이 0이라는 것이다 — 보통 expand 는 새 컬럼을 만들고 백필하는 비용을 치르는데, 개명은 구조가 동일하므로 카탈로그 한 줄과 뷰 하나면 끝난다.

의미가 바뀌는 변경은 사정이 다르다. 컬럼의 을 바꾸는 것(단위 변경, 타입 변경, 값 체계 변경)은 절대 제자리에서 하면 안 된다. 신 컬럼 병행 → 백필 → 읽기 전환 → 쓰기 전환 → 구 컬럼 드롭 순서를 밟아야 한다. 개명이 쉬운 이유는 의미가 바뀌지 않기 때문이지, 마이그레이션 일반이 쉬워서가 아니다.

간접 계층을 한 겹 끼운다는 것

마지막으로 한 층 더 올라가면, 이 해법은 DB 만의 것이 아니다.

“이름이 바뀌는 지점에 간접 계층을 한 겹 끼워 넣어 변경을 흡수한다” 는 구조는 도처에 있다.

도메인 간접 계층 흡수하는 것
DB 구 이름 뷰 테이블 개명
HTTP 301 리다이렉트 URL 변경
파일시스템 심볼릭 링크 경로 변경
API 버전 라우팅 계약 변경
DNS CNAME 호스트 변경

전부 같은 성질을 공유한다. 간접 계층은 공짜가 아니라 외상이다. 걷어야 끝나고, 안 걷으면 영구 부채가 된다. 뷰 별칭도 마찬가지다 — “임시”라고 부르는 순간 언제까지가 임시인지 정해야 한다. 위의 DROP COLUMN 순서 장치는 그 외상을 갚을 때 잔액을 정확히 세기 위한 것이다.

그리고 다섯 줄 모두, 간접 계층이 성립하는 조건은 하나로 같다. 바뀌는 것이 이름뿐이고 실체는 그대로일 것. 실체가 바뀌면 리다이렉트도 심볼릭 링크도 뷰도 거짓말이 된다.

참고