목차
앞 글에서 “더하기만 하면 안전하다”는 규율을 봤다. 그런데 더하는 일 자체가 서비스를 세울 때가 있다.
추가만 했는데 서비스가 멈췄다
users 테이블에 컬럼 하나를 더하는 작업이었다. 지우는 것도 아니고 바꾸는 것도 아니니 안전하다고 판단했다.
ALTER TABLE users ADD COLUMN display_name VARCHAR(50);실행하고 10초쯤 지나자 알림이 쏟아졌다. 로그인이 안 되고, 주문 조회가 타임아웃 나고, 커넥션 풀이 꽉 찼다. users를 건드리는 API가 전부 죽었다. 몇 분 뒤 ALTER가 끝나자 저절로 정상으로 돌아왔다.
이상한 건 이거다. 아무것도 지우지 않았고, 기존 컬럼은 그대로였다. 그런데 왜 읽기까지 막혔나.
통로를 막는 공사가 따로 있다
가게 개조에도 두 종류가 있다. 구석에 진열대 하나 더 놓는 건 손님이 다녀도 된다. 그런데 바닥을 갈아엎는 공사는 통로를 막는다. 손님은 물건을 사러 온 것뿐이고 바닥과 아무 상관이 없는데도, 통로가 막히면 못 들어온다.
ALTER TABLE이 그 바닥 공사다. 그리고 여기서 두 가지가 따로 논다.
- 얼마나 오래 걸리는가 - 공사 자체의 크기
- 그동안 통로를 막는가 - 남이 지나갈 수 있는가
이 둘이 어긋나기 때문에 사고가 난다. 짧은 공사가 통로를 막을 수도 있고, 긴 공사가 통로를 안 막을 수도 있다. 위 사고는 앞쪽이었다.
첫 관문: 앞사람이 끝나야 시작한다
DDL은 시작하기 전에 그 테이블에 대한 권한을 독점해야 한다. 그런데 이미 그 테이블을 보고 있는 트랜잭션이 있으면 시작하지 못하고 기다린다.
문제는 기다리는 방식이다. DDL은 얌전히 옆으로 비켜서 기다리지 않는다. 줄을 서고, 그 뒤로 오는 모든 요청이 DDL 뒤에 다시 줄을 선다.
멈춘 시간을 만든 건 ALTER의 길이가 아니라 그 앞에 있던 조회의 길이다. DDL이 줄을 서는 순간 뒤에 오는 모든 요청이 같은 줄에 선다.
그림의 순서가 사고의 전부다. 월요일 리포트 쿼리가 3분간 users를 읽고 있었고, 그 뒤에 ALTER가 줄을 섰고, 그 뒤에 로그인 요청 수천 개가 줄을 섰다. ALTER 자체는 0.1초짜리였는데 3분간 서비스가 멎었다.
그래서 실행 시간만 보고 판단하면 안 된다. 진짜 위험은 내 DDL이 아니라 내 앞에 있는 누군가의 긴 트랜잭션이다.
이 대기가 특히 고약한 이유는 DDL을 취소해도 이미 늦다는 것이다. 뒤에 쌓인 요청이 커넥션 풀을 다 먹은 상태라 DDL을 죽여도 풀이 회복될 때까지 서비스는 계속 죽어 있다. 커넥션 풀이 얕을수록 이 구간이 길다.
둘째 관문: 테이블을 통째로 다시 쓴다
줄을 통과했다고 끝이 아니다. 어떤 변경은 테이블 전체를 새로 쓴다.
컬럼의 타입을 바꾼다고 하자. INT를 BIGINT로 바꾸면 한 행이 차지하는 크기가 달라진다. 그러면 기존에 저장된 블록 배치가 전부 어긋나므로, DB는 새 형태로 모든 행을 다시 써야 한다. 5천만 행이면 5천만 번 쓴다.
바닥 타일 규격을 바꾸는 공사와 같다. 타일 하나만 바꾸는 게 아니라 바닥 전체를 뜯고 다시 깐다. 그동안 통로는 막혀 있다.
재작성이 걸리는 대표적인 것들이다.
| 변경 | 왜 다시 쓰나 |
|---|---|
타입 변경 (INT→BIGINT) | 행 크기가 달라진다 |
NOT NULL 추가 | 모든 행이 조건을 만족하는지 확인해야 한다 |
| 기본키 변경 | 데이터가 정렬된 순서 자체가 바뀐다 |
| 문자셋 변경 | 저장된 바이트 표현이 달라진다 |
무엇이 즉시 되고 무엇이 다시 쓰나
여기서 가장 흔한 오해가 나온다. “컬럼 추가는 가볍다”는 절대적인 사실이 아니다. 제품과 버전이 정한다.
같은 ADD COLUMN이라도 이렇게 갈린다.
- 최신 버전들은 대개 메타데이터만 고치고 끝낸다. 기존 행은 손대지 않고 “이 컬럼은 값이 없으면 기본값으로 읽어라”는 규칙만 적어 둔다. 순식간이다.
- 예전 버전들은 기본값을 모든 행에 실제로 써 넣었다. 같은 SQL 한 줄이 몇 시간짜리 작업이 됐다.
그러니 판단 기준은 SQL 문장이 아니다.
확인해야 할 것은 하나다 - “우리가 쓰는 이 제품, 이 버전에서 이 변경이 테이블을 다시 쓰는가.” 제품 문서에 변경 종류별로 표가 있다. 스테이징에서 한 번 재보는 것도 방법인데, ⚠️ 행 수가 다르면 시간도 다르다. 만 행에서 0.2초였다고 5천만 행에서 안전한 게 아니다.
온라인 DDL도 공짜가 아니다
“통로를 막지 않고 바닥을 간다”는 기능이 있다. 온라인 DDL이라 부른다. 원리는 단순하다. 옆에 새 바닥을 깔고, 그동안 들어온 변경을 따라잡고, 마지막에 순간적으로 바꿔치기한다.
읽기와 쓰기가 계속되니 좋아 보이는데 값이 붙는다.
- 디스크가 두 배로 든다. 잠깐이지만 같은 테이블이 두 벌 존재한다. 남은 용량이 부족하면 작업 중간에 실패한다.
- I/O를 먹는다. 5천만 행을 복사하는 동안 디스크 대역폭을 나눠 쓰므로 평소 쿼리가 느려진다.
- 마지막 교체 순간엔 결국 막는다. 아주 짧지만 0은 아니고, 이때도 앞의 긴 트랜잭션이 있으면 다시 줄이 선다.
남을 안 막는 대신 시간과 자원을 쓴다. 몇 초 막고 끝낼 것을 한 시간 동안 I/O를 갉아먹으며 진행하는 게 나은지는 상황이 정한다.
걸기 전에 재는 것
사고를 막는 확인은 네 가지고, 순서가 있다.
- 이 변경이 테이블을 다시 쓰는가 - 제품 문서 확인. 아니면 나머지는 대개 문제가 안 된다.
- 테이블이 얼마나 큰가 - 행 수와 크기. 재작성이면 여기서 시간이 나온다.
- 지금 이 테이블에 긴 트랜잭션이 있는가 - 이게 첫 관문의 지뢰다. 대부분 여기서 터진다.
- 대기 시간에 상한이 걸려 있는가 - 락 대기 타임아웃을 짧게 걸어 두면, 못 잡았을 때 서비스를 세우는 대신 DDL이 혼자 실패한다. 실패하고 다시 시도하는 게 서비스를 세우는 것보다 낫다.
4번이 특히 값이 크다. 통로를 막고 무한정 기다리는 대신 **“30초 안에 못 들어가면 오늘은 안 한다”**로 만드는 것이다.
여기서 비유가 깨진다
가게 공사는 눈에 보인다. 손님도 공사 중인 걸 알고, 사장도 통로가 막힌 걸 본다.
DDL은 안 보인다. 문장 하나를 실행했을 뿐이고 콘솔에는 아무 표시도 없다. 막힌 걸 아는 사람은 에러를 받은 사용자뿐이다. 그래서 이 세계에서는 “공사 중” 팻말을 스스로 걸어야 한다 - 실행 전에 재고, 대기 상한을 두고, 거는 동안 지표를 본다.
실무에서: 그림자 테이블로 바꿔치기한다
제품이 주는 온라인 DDL로도 부족할 때, 아예 밖에서 같은 일을 하는 도구를 쓴다. 큰 서비스에서 널리 쓰이는 방식이다.
- 빈 새 테이블을 원하는 구조로 만든다.
- 기존 행을 조금씩 끊어서 복사한다. 한 번에 하지 않는 게 핵심이다.
- 복사하는 동안 들어온 변경을 따라잡는다. 트리거로 잡거나 변경 로그를 읽는다.
- 다 따라잡으면 이름만 맞바꾼다. 이 순간만 짧게 막는다.
가게로 치면 옆 건물에 똑같은 가게를 차려 놓고, 손님이 새로 산 물건을 양쪽에 계속 반영하다가, 어느 날 간판만 옮겨 다는 것이다.
값도 분명하다. 디스크 두 배, 복사 시간, 그리고 복잡도다. 도구가 중간에 실패하면 남은 찌꺼기 테이블과 트리거를 손으로 치워야 한다. 그래서 처음부터 이걸 쓰지는 않는다 - 테이블이 커져서 제품 기능만으로 감당이 안 될 때 꺼내는 카드다.
정리
- 실행 시간과 남을 막는지는 다른 축이다. 0.1초짜리 DDL이 3분간 서비스를 세울 수 있다.
- 첫 관문은 앞사람이다. 내 앞의 긴 트랜잭션이 DDL을 세우고, DDL이 그 뒤 전부를 세운다.
- 둘째 관문은 재작성이다. 타입·기본키·
NOT NULL변경은 모든 행을 다시 쓴다. - “컬럼 추가는 가볍다”는 제품과 버전이 정한다. 문장만 보고 판단하지 않는다.
- 락 대기 상한을 걸어 둔다. 못 잡으면 서비스가 아니라 DDL이 실패하게 만든다.