Database · Backend · Schema

ALTER TABLE - 컬럼 하나 더하는 데 테이블이 멈춘다

추가만 하는 변경이 왜 서비스를 세우는가. 앞사람을 기다리는 줄, 테이블 재작성, 그리고 온라인 DDL의 값.

·멈추지 않고 스키마를 바꾼다 2편
목차
  1. 추가만 했는데 서비스가 멈췄다
  2. 통로를 막는 공사가 따로 있다
  3. 첫 관문: 앞사람이 끝나야 시작한다
  4. 둘째 관문: 테이블을 통째로 다시 쓴다
  5. 무엇이 즉시 되고 무엇이 다시 쓰나
  6. 온라인 DDL도 공짜가 아니다
  7. 걸기 전에 재는 것
  8. 여기서 비유가 깨진다
  9. 실무에서: 그림자 테이블로 바꿔치기한다
  10. 정리

앞 글에서 “더하기만 하면 안전하다”는 규율을 봤다. 그런데 더하는 일 자체가 서비스를 세울 때가 있다.

추가만 했는데 서비스가 멈췄다

users 테이블에 컬럼 하나를 더하는 작업이었다. 지우는 것도 아니고 바꾸는 것도 아니니 안전하다고 판단했다.

sql
ALTER TABLE users ADD COLUMN display_name VARCHAR(50);

실행하고 10초쯤 지나자 알림이 쏟아졌다. 로그인이 안 되고, 주문 조회가 타임아웃 나고, 커넥션 풀이 꽉 찼다. users를 건드리는 API가 전부 죽었다. 몇 분 뒤 ALTER가 끝나자 저절로 정상으로 돌아왔다.

이상한 건 이거다. 아무것도 지우지 않았고, 기존 컬럼은 그대로였다. 그런데 왜 읽기까지 막혔나.

통로를 막는 공사가 따로 있다

가게 개조에도 두 종류가 있다. 구석에 진열대 하나 더 놓는 건 손님이 다녀도 된다. 그런데 바닥을 갈아엎는 공사는 통로를 막는다. 손님은 물건을 사러 온 것뿐이고 바닥과 아무 상관이 없는데도, 통로가 막히면 못 들어온다.

ALTER TABLE이 그 바닥 공사다. 그리고 여기서 두 가지가 따로 논다.

  • 얼마나 오래 걸리는가 - 공사 자체의 크기
  • 그동안 통로를 막는가 - 남이 지나갈 수 있는가

이 둘이 어긋나기 때문에 사고가 난다. 짧은 공사가 통로를 막을 수도 있고, 긴 공사가 통로를 안 막을 수도 있다. 위 사고는 앞쪽이었다.

첫 관문: 앞사람이 끝나야 시작한다

DDL은 시작하기 전에 그 테이블에 대한 권한을 독점해야 한다. 그런데 이미 그 테이블을 보고 있는 트랜잭션이 있으면 시작하지 못하고 기다린다.

문제는 기다리는 방식이다. DDL은 얌전히 옆으로 비켜서 기다리지 않는다. 줄을 서고, 그 뒤로 오는 모든 요청이 DDL 뒤에 다시 줄을 선다.

여기서 한꺼번에 풀린다앞사람이 끝나야
월말 정산 조회먼저 와 있었다
users를 3분간 읽는다
원인 ALTER TABLE내가 실행한 것
기다린다
0.1초 로그인
막힌다
주문 조회
막힌다
프로필 수정
막힌다
그 밖의 요청 수천 개
막힌다
이 3분 동안 users를 쓰는 API가 전부 죽어 있다
시간 →

멈춘 시간을 만든 건 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를 갉아먹으며 진행하는 게 나은지는 상황이 정한다.

걸기 전에 재는 것

사고를 막는 확인은 네 가지고, 순서가 있다.

  1. 이 변경이 테이블을 다시 쓰는가 - 제품 문서 확인. 아니면 나머지는 대개 문제가 안 된다.
  2. 테이블이 얼마나 큰가 - 행 수와 크기. 재작성이면 여기서 시간이 나온다.
  3. 지금 이 테이블에 긴 트랜잭션이 있는가 - 이게 첫 관문의 지뢰다. 대부분 여기서 터진다.
  4. 대기 시간에 상한이 걸려 있는가 - 락 대기 타임아웃을 짧게 걸어 두면, 못 잡았을 때 서비스를 세우는 대신 DDL이 혼자 실패한다. 실패하고 다시 시도하는 게 서비스를 세우는 것보다 낫다.

4번이 특히 값이 크다. 통로를 막고 무한정 기다리는 대신 **“30초 안에 못 들어가면 오늘은 안 한다”**로 만드는 것이다.

여기서 비유가 깨진다

가게 공사는 눈에 보인다. 손님도 공사 중인 걸 알고, 사장도 통로가 막힌 걸 본다.

DDL은 안 보인다. 문장 하나를 실행했을 뿐이고 콘솔에는 아무 표시도 없다. 막힌 걸 아는 사람은 에러를 받은 사용자뿐이다. 그래서 이 세계에서는 “공사 중” 팻말을 스스로 걸어야 한다 - 실행 전에 재고, 대기 상한을 두고, 거는 동안 지표를 본다.

실무에서: 그림자 테이블로 바꿔치기한다

제품이 주는 온라인 DDL로도 부족할 때, 아예 밖에서 같은 일을 하는 도구를 쓴다. 큰 서비스에서 널리 쓰이는 방식이다.

  1. 빈 새 테이블을 원하는 구조로 만든다.
  2. 기존 행을 조금씩 끊어서 복사한다. 한 번에 하지 않는 게 핵심이다.
  3. 복사하는 동안 들어온 변경을 따라잡는다. 트리거로 잡거나 변경 로그를 읽는다.
  4. 다 따라잡으면 이름만 맞바꾼다. 이 순간만 짧게 막는다.

가게로 치면 옆 건물에 똑같은 가게를 차려 놓고, 손님이 새로 산 물건을 양쪽에 계속 반영하다가, 어느 날 간판만 옮겨 다는 것이다.

값도 분명하다. 디스크 두 배, 복사 시간, 그리고 복잡도다. 도구가 중간에 실패하면 남은 찌꺼기 테이블과 트리거를 손으로 치워야 한다. 그래서 처음부터 이걸 쓰지는 않는다 - 테이블이 커져서 제품 기능만으로 감당이 안 될 때 꺼내는 카드다.

정리

  • 실행 시간과 남을 막는지는 다른 축이다. 0.1초짜리 DDL이 3분간 서비스를 세울 수 있다.
  • 첫 관문은 앞사람이다. 내 앞의 긴 트랜잭션이 DDL을 세우고, DDL이 그 뒤 전부를 세운다.
  • 둘째 관문은 재작성이다. 타입·기본키·NOT NULL 변경은 모든 행을 다시 쓴다.
  • “컬럼 추가는 가볍다”는 제품과 버전이 정한다. 문장만 보고 판단하지 않는다.
  • 락 대기 상한을 걸어 둔다. 못 잡으면 서비스가 아니라 DDL이 실패하게 만든다.