스키마 변경과 배포
운영 중인 테이블을 고치는 순서와 되돌릴 수 있게 두는 방법입니다.
50만 행짜리 사본에 걸어 봅니다
스키마 변경은 표가 클수록 값이 커집니다. 507행에서는 무엇을 해도 순식간이라 차이가 드러나지 않습니다.
SELECT num, board_num, user_num, title, hit_count, reg_date INTO Board.T_ALTER FROM Board.POSTS_BIG; ALTER TABLE Board.T_ALTER ADD CONSTRAINT PK_T_ALTER PRIMARY KEY CLUSTERED (num); GO
고치는 값이 둘로 갈립니다
어떤 변경은 목록만 고치고 끝나고, 어떤 변경은 50만 행을 전부 다시 씁니다. 문법은 비슷하게 생겼는데 값이 수백 배 다릅니다.
DECLARE @t datetime2(7) = SYSDATETIME(); ALTER TABLE Board.T_ALTER ADD memo1 nvarchar(100) NULL; PRINT CONCAT(DATEDIFF(millisecond, @t, SYSDATETIME()), ' ms');
| 변경 | 걸린 시간 | 무엇을 합니까 |
|---|---|---|
| ADD 열 (NULL 허용) | 1 밀리초 | 목록에 이름만 적습니다 |
| ALTER COLUMN 길이 늘리기 | 1 밀리초 | 목록만 고칩니다 |
| DROP COLUMN | 2 밀리초 | 목록에서 빼기만 합니다 |
| sp_rename 열 이름 | 179 밀리초 | 이름만 바꿉니다 |
| ADD 열 (NOT NULL + 기본값) | 260~420 밀리초 | 모든 행에 값을 채웁니다 |
| ALTER COLUMN 길이 줄이기 | 393 밀리초 | 모든 행을 검사합니다 |
| ALTER COLUMN int → bigint | 429 밀리초 | 모든 행을 다시 씁니다 |
DROP COLUMN 이 2밀리초인 것에 주의하십시오.
열을 목록에서 뺐을 뿐 자리는 그대로 남아 있습니다. 공간을
실제로 돌려받으려면 인덱스를 다시 만들어야 합니다
(ALTER INDEX … REBUILD).
기본값 없는 NOT NULL 은 아예 막힙니다
ALTER TABLE Board.T_ALTER ADD cnt2 int NOT NULL;
기본값을 붙이거나, NULL 허용으로 넣고 채운 뒤 NOT NULL 로 바꿉니다. 뒤쪽이 세 문장이지만 각각이 짧아 잠금을 오래 잡지 않습니다.
-- 1. NULL 허용으로 넣습니다. 1밀리초입니다. ALTER TABLE Board.T_ALTER ADD cnt2 int NULL; -- 2. 배치로 나누어 채웁니다(5.7). DECLARE @n int = 1; WHILE @n > 0 BEGIN UPDATE TOP (3000) Board.T_ALTER SET cnt2 = 0 WHERE cnt2 IS NULL; SET @n = @@ROWCOUNT; END -- 3. 다 찼으면 조인다. ALTER TABLE Board.T_ALTER ADD CONSTRAINT DF_cnt2 DEFAULT 0 FOR cnt2; ALTER TABLE Board.T_ALTER ALTER COLUMN cnt2 int NOT NULL;
에디션에 따라 다릅니다. Enterprise 는 기본값 있는
NOT NULL 열 추가를 목록만 고치는 것으로
처리합니다. 위 실측은 Express 라 400밀리초가 나왔습니다.
배포할 서버의 에디션에서 재 보십시오.
고치는 동안 아무도 못 씁니다
ALTER TABLE 은 스키마 수정 잠금(Sch-M)
을 겁니다. 5.5 에서 본 X 잠금보다 강합니다 —
NOLOCK 으로 읽는 것까지 막습니다.
| 잠금 | 언제 | 막는 것 |
|---|---|---|
| Sch-S (스키마 안정) | 문장을 실행하는 동안 | Sch-M 만 막습니다 |
| Sch-M (스키마 수정) | DDL 을 실행하는 동안 | 읽기까지 전부 막습니다 |
그래서 걸리는 시간이 곧 멈추는 시간입니다. 1밀리초짜리 변경은 아무도 눈치채지 못하지만, 400밀리초짜리는 그동안 들어온 요청이 전부 기다립니다. 5천만 행에서 40초라면 장애로 신고됩니다.
더 위험한 것은 기다리는 쪽입니다. DDL 이 오래 도는 트랜잭션 뒤에서 Sch-M 을 기다리고 있으면, 그 뒤의 모든 조회가 DDL 뒤에 줄을 섭니다. 읽기끼리는 서로 막지 않는데 그 사이에 낀 DDL 하나가 전부를 세웁니다. DDL 은 한산한 시간에, 짧게 끊어서 하십시오.
인덱스는 만드는 동안 표를 잠급니다
CREATE INDEX IX_ALTER_reg ON Board.T_ALTER (reg_date) WITH (ONLINE = ON);
ONLINE = ON 을 쓸 수 없다면
인덱스를 만드는 시간이 곧 멈추는 시간입니다. 5.2 에서 50만
행에 인덱스 하나가 수백 밀리초였으니 감당할 만하지만,
수천만 행이라면 미리 재 보고 시간을 잡으십시오.
Enterprise 라면 RESUMABLE = ON 도 함께
볼 만합니다. 중간에 멈췄다가 이어서 만들 수 있습니다.
작업 시간이 모자랄 때 되돌리지 않고 다음 날 이어 가는 방법입니다.
검사할지 말지를 고를 수 있습니다
-- 있는 행을 전부 검사합니다. ALTER TABLE Board.T_ALTER WITH CHECK ADD CONSTRAINT CK_ALTER_hit CHECK (hit_count >= 0); -- 검사하지 않고 붙이기만 합니다. 앞으로 들어오는 것만 봅니다. ALTER TABLE Board.T_ALTER WITH NOCHECK ADD CONSTRAINT CK_ALTER_hit CHECK (hit_count >= 0);
| 방법 | 걸린 시간 | is_not_trusted |
|---|---|---|
| WITH CHECK | 62 밀리초 | 0 |
| WITH NOCHECK | 0 밀리초 | 1 |
| 나중에 검사시키기 | 38 밀리초 | 0 |
-- 한산한 시간에 검사시켜 신뢰 상태로 돌립니다. ALTER TABLE Board.T_ALTER WITH CHECK CHECK CONSTRAINT CK_ALTER_hit; -- 신뢰하지 않는 제약 조건을 찾습니다. SELECT name AS 제약, OBJECT_NAME(parent_object_id) AS 표 FROM sys.check_constraints WHERE is_not_trusted = 1 UNION ALL SELECT name, OBJECT_NAME(parent_object_id) FROM sys.foreign_keys WHERE is_not_trusted = 1;
신뢰하지 않는 제약 조건은 최적화에 쓰이지 않습니다.
엔진이 "이 조건이 참이다" 를 전제로 계획을 줄일 수 있는데, 검사하지 않은
제약은 믿을 수 없어 그 전제를 쓰지 못합니다.
WITH NOCHECK 로 붙였으면 반드시 나중에
검사시키십시오.
넓히고, 옮기고, 좁힙니다
운영 중인 표를 고칠 때 가장 어려운 것은 문법이 아니라 순서입니다. 코드와 스키마가 함께 바뀌어야 하는데 둘을 같은 순간에 바꿀 수는 없습니다.
그래서 세 번에 나누어 배포합니다. 각 단계 사이에는 옛 코드와 새 코드가 함께 돌아도 괜찮은 상태가 유지됩니다.
| 단계 | 무엇을 | 그동안 |
|---|---|---|
| 1. 넓히기 | 새 열을 NULL 허용으로 추가 | 옛 코드는 그 열을 모릅니다. 아무 일 없습니다 |
| 2. 채우기 | 배치로 값을 채웁니다(5.7) | 양쪽 다 옛 열을 봅니다 |
| 3. 옮기기 | 새 코드를 배포합니다 | 새 코드는 새 열, 옛 코드는 옛 열 |
| 4. 좁히기 | 옛 열을 지웁니다 | 옛 코드가 남아 있지 않은 것을 확인한 뒤 |
열 이름을 바꾸는 일이 가장 흔한 예입니다.
sp_rename 한 줄로 바꾸면
그 순간 옛 코드가 전부 깨집니다. 나누면 그런 일이
없습니다.
-- 배포 1 — 넓힙니다. ALTER TABLE Board.POSTS ADD view_count int NULL; GO -- 두 열을 함께 채우는 트리거를 잠시 둡니다(4.7). -- 옛 코드가 hit_count 에 쓰면 view_count 도 따라옵니다. CREATE OR ALTER TRIGGER Board.TR_POSTS_SYNC ON Board.POSTS AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; IF UPDATE(hit_count) UPDATE p SET view_count = i.hit_count FROM Board.POSTS p JOIN inserted i ON i.num = p.num; END GO -- 배포 2 — 옛 자료를 채웁니다. 배치로 나눕니다. DECLARE @n int = 1; WHILE @n > 0 BEGIN UPDATE TOP (3000) Board.POSTS SET view_count = hit_count WHERE view_count IS NULL; SET @n = @@ROWCOUNT; END GO -- 배포 3 — 새 코드를 올립니다. view_count 를 읽고 씁니다. -- 이때도 트리거가 양쪽을 맞춰 주므로 되돌릴 수 있습니다. -- 배포 4 — 며칠 지켜본 뒤 좁힙니다. DROP TRIGGER Board.TR_POSTS_SYNC; ALTER TABLE Board.POSTS DROP COLUMN hit_count;
4단계를 서두르지 마십시오. 옛 열을 지우는 순간 되돌릴 수 없습니다. 배포한 코드가 전부 새 열을 보고 있는지 확인하고, 문제가 없다는 것을 며칠 지켜본 뒤에 지웁니다. 그동안 열 하나가 더 있는 것은 값이 싼 쪽입니다.
되돌릴 수 있게 적습니다
| 이렇게 적으면 | 되돌릴 때 |
|---|---|
| 열을 추가 | 지우면 됩니다 |
| 열을 지움 | 자료가 이미 없습니다 |
| 열 이름을 바꿈 | 다시 바꾸면 되지만 그 사이 코드가 깨집니다 |
| 형식을 좁힘 | 잘린 값이 돌아오지 않습니다 |
더하는 변경은 안전하고 빼는 변경은 위험합니다. 배포 하나에 더하는 것만 담고, 빼는 것은 다음 배포로 미루면 언제든 되돌릴 수 있습니다.
몇 번을 돌려도 같아야 합니다
배포 스크립트는 실패해서 다시 돌리는 일이 반드시 생깁니다. 그때 앞부분이 다시 실행되어도 문제가 없어야 합니다.
-- 열이 없을 때만 추가합니다. IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('Board.POSTS') AND name = 'view_count') ALTER TABLE Board.POSTS ADD view_count int NULL; GO -- 인덱스도 마찬가지입니다. IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID('Board.POSTS') AND name = 'IX_POSTS_view') CREATE INDEX IX_POSTS_view ON Board.POSTS (view_count); GO -- 프로시저·뷰·함수는 CREATE OR ALTER 하나로 됩니다(4.2). CREATE OR ALTER PROCEDURE Board.P_POST_LIST … GO -- 표를 지우는 것은 이렇게 적습니다. DROP TABLE IF EXISTS Board.T_OLD; GO
| 지켜야 할 것 | 까닭 |
|---|---|
| 몇 번 돌려도 결과가 같게 | 실패 후 다시 돌리는 일이 생깁니다 |
| DDL 과 자료 이동을 나눠서 | 자료 이동이 오래 걸려 DDL 잠금을 잡고 있으면 안 됩니다 |
| 긴 것은 배치로 | 한 문장으로 하면 표가 잠깁니다(5.7) |
| 되돌리는 스크립트를 함께 | 되돌릴 방법이 없으면 배포하지 않습니다 |
| 시험 환경에서 먼저 | 걸리는 시간을 미리 알아야 시간을 잡습니다 |
DDL 을 트랜잭션으로 감싸는 것은 신중해야 합니다. SQL Server 는 DDL 도 롤백되지만, 그 트랜잭션이 도는 내내 Sch-M 이 유지 됩니다. 표 다섯 개를 한 트랜잭션에 고치면 다섯 개가 동시에 잠깁니다. 되돌릴 필요가 정말 있는지 따져 보고, 아니면 하나씩 나누는 편이 낫습니다.
직접 해보기
5천만 행짜리 표에 아래를 하려 합니다. 어느 것이 순식간이고 어느 것이 표를 다시 쓰는지 나누고, 위험한 것은 어떻게 나눌지 적어 보세요.
ALTER TABLE Log.ACCESS ADD ip_v6 varchar(45) NULL; ALTER TABLE Log.ACCESS ALTER COLUMN url nvarchar(500) NULL; -- 200 에서 ALTER TABLE Log.ACCESS ADD is_bot bit NOT NULL DEFAULT 0; ALTER TABLE Log.ACCESS ALTER COLUMN user_num bigint NOT NULL; -- int 에서 DROP INDEX IX_ACCESS_url ON Log.ACCESS;
배포 스크립트가 중간에서 실패했습니다. 다시 돌려도 안전하도록 고쳐 보세요. 어디까지 실행되었는지 모르는 상태입니다.
ALTER TABLE Board.POSTS ADD view_count int NULL; GO UPDATE Board.POSTS SET view_count = hit_count; GO CREATE INDEX IX_POSTS_view ON Board.POSTS (view_count); GO ALTER TABLE Board.POSTS ALTER COLUMN view_count int NOT NULL; GO
실습에서 만든 사본을 지웁니다.
DROP TABLE Board.T_ALTER;
- 스키마 변경은 목록만 고치는 것과 표를 다시 쓰는 것으로 갈립니다. 50만 행에서 1밀리초와 400밀리초입니다.
- 순식간에 끝나는 것 — NULL 허용 열 추가 · 열 길이 늘리기 · 열 지우기. 표를 다시 쓰는 것 — NOT NULL 열 추가 · 길이 줄이기 · 형식 바꾸기.
DROP COLUMN은 2밀리초지만 공간은 그대로 남습니다. 돌려받으려면 인덱스를 다시 만들어야 합니다.- 기본값 없는
NOT NULL열 추가는 4901 로 막힙니다. NULL 로 넣고 채운 뒤 조이는 것이 안전합니다. ALTER TABLE은 Sch-M 잠금을 겁니다.NOLOCK읽기까지 막으므로 걸리는 시간이 곧 멈추는 시간입니다.- 에디션에 따라 다릅니다.
ONLINE = ON은 Enterprise 전용이고(1712), 기본값 있는 NOT NULL 열 추가도 Enterprise 에서만 목록 수정으로 끝납니다. WITH NOCHECK로 붙인 제약 조건은 신뢰하지 않음으로 남아 최적화에 쓰이지 않습니다. 나중에WITH CHECK CHECK CONSTRAINT로 돌리십시오.- 운영 중 변경은 넓히고 · 채우고 · 옮기고 · 좁히는 네 번으로 나눕니다. 각 단계 사이에 옛 코드와 새 코드가 함께 돌아도 되는 상태가 유지됩니다.
- 더하는 변경은 안전하고 빼는 변경은 위험합니다. 한 배포에 더하는 것만 담으면 언제든 되돌릴 수 있습니다.
- 배포 스크립트는 몇 번을 돌려도 같아야 합니다.
IF NOT EXISTS·CREATE OR ALTER·DROP … IF EXISTS를 쓰고, 되돌리는 스크립트를 함께 두십시오.