Skip to content

필사 모드: 마이그레이션 사고 사례와 체크리스트 — 남의 실패에서 배우기

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며 — 가장 비싼 학습은 남의 사고에서

장애는 비싼 선생님입니다. 새벽 3시에 깨어 운영 DB를 복구하며 배우는 교훈은 평생 잊히지 않지만, 그 수업료는 매출 손실과 신뢰 하락과 팀의 번아웃으로 지불됩니다. 다행히 모든 교훈을 직접 사고로 배울 필요는 없습니다. 남의 포스트모템에서 배우면, 같은 함정을 미리 피할 수 있습니다.

마이그레이션은 장애 통계에서 늘 상위에 오릅니다. 평소에 잘 돌던 시스템이, 스키마를 바꾸는 그 한순간에 무너지기 때문입니다. 이 글은 마이그레이션 사고의 전형적 유형을 정리하고, 세 건의 가상 포스트모템으로 "증상에서 원인과 교훈으로" 가는 사고 과정을 보여주고, 예방 패턴과 종합 체크리스트로 마무리합니다.

흔한 마이그레이션 사고 유형

먼저 자주 반복되는 사고 패턴을 정리합니다.

+---------------------+--------------------------------------------------+

| 사고 유형 | 무슨 일이 벌어지는가 |

+---------------------+--------------------------------------------------+

| 락 폭주 | ALTER가 강한 락을 잡아 모든 쿼리가 대기 |

| 복제 지연 | 대량 변경으로 복제본이 뒤처져 읽기 일관성 깨짐 |

| 데이터 손실 | 잘못된 변환/삭제로 복구 불가능한 데이터 유실 |

| 롤백 불가 | 되돌릴 수 없는 변경 후 문제 발견 |

| 타임아웃 | 마이그레이션이 너무 오래 걸려 배포/커넥션 타임아웃 |

| 중복 실행 | 마이그레이션이 동시에 두 번 실행되어 상태 깨짐 |

+---------------------+--------------------------------------------------+

각 유형은 서로 다른 근본 원인을 가지지만, 공통점이 있습니다. 모두 "작은 규모로 테스트했을 때는 멀쩡했다"는 것입니다. 운영의 데이터 양, 트래픽, 복제 토폴로지가 만들어 내는 규모의 효과가 사고를 부릅니다.

락 폭주

`ALTER TABLE`은 종류에 따라 ACCESS EXCLUSIVE 같은 강한 락을 잡습니다. 이 락은 해당 테이블의 모든 읽기와 쓰기를 막습니다. 작은 테이블에서는 순식간이지만, 수억 행 테이블에서는 수 분이 걸리고, 그동안 쌓인 쿼리들이 커넥션 풀을 가득 채워 연쇄 장애로 번집니다.

ALTER TABLE (강한 락 획득, 5분 소요)

|

[쿼리1] 대기... [쿼리2] 대기... [쿼리3] 대기...

|

커넥션 풀 고갈 -> 신규 요청 거부 -> 전면 장애

복제 지연

대량 UPDATE나 백필은 마스터에서는 빠르지만, 복제본은 그 변경을 순차로 따라잡아야 합니다. 읽기를 복제본으로 분산하는 구조라면, 복제 지연 동안 사용자는 옛 데이터를 보거나, 방금 쓴 데이터를 못 읽는(read-your-writes 깨짐) 현상을 겪습니다.

데이터 손실

가장 치명적입니다. 컬럼을 DROP했는데 그 데이터가 어디에도 백업되지 않았거나, 변환 로직의 버그로 값이 잘못 덮어써졌거나, `WHERE` 절을 빠뜨린 UPDATE가 전체 행을 망가뜨립니다. 데이터 손실은 코드 롤백으로 되돌아오지 않습니다.

가상 포스트모템 1 — 한밤의 NOT NULL

증상부터 원인, 교훈까지 따라가 봅니다.

[증상]

02:14 배포 시작 (사용자 테이블에 country 컬럼 추가)

02:15 API 응답 시간 급증, 타임아웃 폭주

02:17 커넥션 풀 고갈 알림

02:31 마이그레이션 강제 중단, 서비스 복구 시작

02:48 복구 완료 (총 34분 장애)

[원인]

마이그레이션:

ALTER TABLE users ADD COLUMN country VARCHAR(2) NOT NULL DEFAULT 'KR';

users 테이블은 8천만 행. 구버전 DB에서 DEFAULT가 있는 NOT NULL 컬럼

추가는 모든 행을 다시 쓰며, 그 동안 ACCESS EXCLUSIVE 락을 잡음.

스테이징은 5만 행이라 0.1초에 끝나 문제를 발견 못 함.

[교훈]

1. 데이터 양을 운영과 비슷하게 한 환경에서 마이그레이션을 리허설한다.

2. NOT NULL + DEFAULT는 nullable 추가 -> 백필 -> 제약 추가로 쪼갠다.

3. 마이그레이션 린트로 위험 패턴을 PR에서 잡는다.

핵심 교훈은 "스테이징의 데이터 양이 운영과 다르면, 스테이징 통과는 안전을 보장하지 않는다"입니다. 락은 데이터 양에 비례해 길어집니다.

가상 포스트모템 2 — 사라진 주소

[증상]

배포 다음 날, 고객센터에 "배송지가 사라졌다"는 문의 폭증.

일부 사용자의 address 필드가 빈 값으로 보임.

[원인]

주소를 단일 문자열에서 구조화된 객체로 바꾸는 마이그레이션:

- 새 컬럼 address_json 추가

- 옛 address 문자열을 파싱해 address_json 채움

- 옛 address 컬럼 DROP

파싱 로직이 특정 형식(쉼표 없는 주소)을 처리 못 해 약 3%가 빈 객체가 됨.

옛 컬럼을 같은 배포에서 DROP했기에 원본이 사라져 복구 불가.

[교훈]

1. 변환과 옛 데이터 삭제를 같은 배포에 넣지 않는다(expand-contract).

2. 옛 컬럼은 충분한 검증 기간 후에 삭제한다.

3. 변환 전후로 샘플·불변식 검증을 돌린다(빈 값 비율 체크).

4. 파괴적 작업 전 반드시 백업, PITR 가능 시점을 확인한다.

이 사고의 진짜 원인은 파싱 버그 자체가 아니라, 검증 없이 옛 데이터를 같은 배포에서 지웠다는 것입니다. 옛 컬럼이 남아 있었다면 30분이면 복구할 수 있었습니다.

가상 포스트모템 3 — 두 번 실행된 마이그레이션

[증상]

잔액 증감 이력 테이블에 중복 레코드 발견.

일부 사용자의 포인트가 두 배로 적립됨.

[원인]

쿠버네티스 롤아웃 중, init container 방식으로 마이그레이션을 실행.

데이터 백필 마이그레이션(과거 거래에 포인트 재계산)이

두 파드에서 거의 동시에 시작. 백필 SQL이 멱등하지 않아

(INSERT만 하고 중복 체크 없음) 같은 데이터를 두 번 삽입.

마이그레이션 버전 락은 DDL에는 걸렸지만, 백필 로직 일부가

락 밖에서 돌도록 짜여 있었음.

[교훈]

1. 백필을 포함한 모든 마이그레이션은 멱등하게 작성한다

(INSERT ... ON CONFLICT, 또는 존재 확인 후 삽입).

2. 마이그레이션은 단일 실행을 보장한다(Job 패턴, 명시적 락).

3. init container 동시 실행 패턴을 피한다.

4. 금전 관련 백필은 드라이런으로 영향 행 수를 먼저 확인한다.

멱등성은 마이그레이션의 안전벨트입니다. 한 번 실행이든 열 번 실행이든 결과가 같아야, 재시도와 동시 실행이라는 분산 환경의 현실을 견딜 수 있습니다.

가상 포스트모템 4 — 따라오지 못한 복제본

[증상]

대량 백필 배포 후, 일부 사용자가 "방금 바꾼 설정이 안 보인다"고 문의.

읽기 전용 API가 옛 데이터를 반환.

[원인]

3억 행 테이블에 대량 UPDATE(전 사용자 등급 재계산)를 한 번에 실행.

마스터에서는 8분 만에 끝났지만, 읽기 복제본 3대가 이 변경을

순차로 적용하며 최대 22분간 뒤처짐. 읽기 트래픽은 복제본으로

분산되어 있었기에, 그 동안 사용자는 옛 데이터를 봄.

[교훈]

1. 대량 변경은 배치로 쪼개 복제본이 따라올 시간을 준다.

2. 배치 사이에 복제 지연을 모니터링하고, 임계치 초과 시 대기.

3. 읽기 일관성이 중요한 경로는 마스터에서 읽거나 세션 일관성 보장.

4. 백필 중 복제 지연 알림을 켜둔다.

대량 변경의 함정은 마스터의 성공이 곧 시스템의 성공이 아니라는 것입니다. 복제본까지 수렴해야 진짜 완료입니다. 배치 사이에 복제 지연을 확인하며 진행하는 것이 핵심입니다.

-- 배치 백필 (복제 지연을 고려해 1만 행씩, 사이에 대기)

DO $$

DECLARE

rows_affected int;

BEGIN

LOOP

UPDATE users SET tier = compute_tier(score)

WHERE id IN (

SELECT id FROM users WHERE tier IS NULL LIMIT 10000

);

GET DIAGNOSTICS rows_affected = ROW_COUNT;

EXIT WHEN rows_affected = 0;

PERFORM pg_sleep(0.5); -- 복제본이 따라올 시간

END LOOP;

END $$;

가상 포스트모템 5 — 타임아웃의 연쇄

[증상]

CD 파이프라인의 마이그레이션 단계가 10분 후 타임아웃으로 실패.

하지만 마이그레이션은 DB에서 계속 실행 중. 재시도가 두 번째

마이그레이션을 시작해 락 경합 발생.

[원인]

인덱스 생성이 예상보다 오래 걸려 파이프라인 타임아웃(10분) 초과.

파이프라인은 실패로 판정했지만 DB 세션은 살아 인덱스 생성 계속.

자동 재시도가 같은 마이그레이션을 또 시작 -> 락 충돌 -> 양쪽 멈춤.

[교훈]

1. 마이그레이션 타임아웃을 실제 소요 시간보다 넉넉히 잡는다.

2. 타임아웃 시 DB 세션을 확실히 정리(취소)하고서 재시도한다.

3. 자동 재시도를 끄거나, 멱등성과 락으로 중복 실행을 막는다.

4. 오래 걸리는 작업(인덱스 생성)은 마이그레이션 밖에서 비동기로.

타임아웃은 단순히 "느리다"는 신호가 아니라, 실패 처리의 빈틈을 드러냅니다. 타임아웃 후 좀비 세션이 남아 다음 실행과 충돌하는 패턴은 흔하고 위험합니다.

안전한 온라인 DDL 도구

큰 테이블의 스키마를 무중단으로 바꾸는 전용 도구들이 있습니다. 이들은 원본 테이블을 직접 ALTER하지 않고, 새 테이블을 만들어 데이터를 점진 복사한 뒤 바꿔치기합니다.

+------------------+------------------+--------------------------------+

| 도구 | 대상 | 방식 |

+------------------+------------------+--------------------------------+

| gh-ost | MySQL | binlog 기반, 트리거 없음 |

| pt-online-schema| MySQL | 트리거 기반 섀도 테이블 |

| -change | | |

| pg_repack | PostgreSQL | bloat 제거, 테이블 재작성 |

+------------------+------------------+--------------------------------+

동작 원리는 공통적으로 다음과 같습니다.

1. 원본과 같은 구조의 새 테이블 생성 (원하는 변경 적용)

2. 원본 데이터를 새 테이블로 청크 단위 복사

3. 복사 중 발생한 변경을 추적해 새 테이블에 반영 (트리거 또는 binlog)

4. 따라잡으면 짧은 락으로 테이블 이름 교체 (원자적 스왑)

5. 옛 테이블 정리

이 방식의 장점은 긴 락 없이 큰 테이블을 바꾼다는 것입니다. 단점은 복잡하고, 디스크를 두 배 쓰며, 외래 키나 트리거가 있으면 까다롭다는 것입니다. 운영급 대형 테이블 변경에서는 이런 도구가 사실상 표준입니다.

예방 패턴 — 작게, 가역적으로, 검증하며, 관측하며

세 건의 포스트모템에서 공통으로 끌어낸 예방 원칙을 정리합니다.

작게 (Small)

큰 마이그레이션을 작은 단계로 쪼갭니다. 작은 변경은 실행이 빠르고, 락이 짧고, 문제 시 영향 범위가 좁습니다.

나쁨: ALTER TABLE ... (전체 테이블 한 번에 재작성, 락 5분)

좋음: nullable 추가(빠름) -> 배치 백필(1만 행씩) -> 제약 추가(빠름)

가역적으로 (Reversible)

expand-contract로 모든 변경을 되돌릴 수 있게 합니다. 옛 구조를 즉시 지우지 않고, 새 구조가 검증될 때까지 남겨 둡니다.

expand : 새 구조 추가 (옛것과 공존, 되돌리기 쉬움)

migrate : 양쪽 쓰기 + 백필

switch : 새 구조로 읽기 전환

contract : 옛 구조 제거 (충분히 안정된 후, 별도 배포)

검증하며 (Verified)

변환 전후로 데이터를 검증합니다. 수량, 샘플, 불변식, 빈 값 비율을 자동으로 점검합니다.

-- 변환 후 빈 값 비율 점검 (불변식 검증)

SELECT

count(*) FILTER (WHERE address_json = '{}'::jsonb) AS empty_count,

count(*) AS total,

round(100.0 * count(*) FILTER (WHERE address_json = '{}'::jsonb) / count(*), 2) AS empty_pct

FROM users;

-- empty_pct가 임계치를 넘으면 배포 중단

관측하며 (Observed)

마이그레이션 진행 상황을 실시간으로 본다. 소요 시간, 락 대기, 복제 지연을 대시보드로 지켜보며, 이상 시 즉시 중단할 수 있게 합니다.

관측해야 할 신호:

- 마이그레이션 경과 시간 (예상 대비)

- 락 대기 큐 길이 (pg_locks, SHOW PROCESSLIST)

- 복제 지연 (replica lag)

- 커넥션 풀 사용률

- 에러율, 응답 시간

대규모 마이그레이션 리허설

운영급 사고를 막는 가장 강력한 도구는 리허설입니다. 운영 데이터의 복제본(또는 운영급 규모의 합성 데이터)에서 마이그레이션을 그대로 실행해 봅니다.

1. 운영 스냅샷을 격리된 환경에 복원 (데이터 양 동일)

2. 마이그레이션을 실제로 실행하고 소요 시간 측정

3. 락 지속 시간, 복제 지연, 디스크 사용량 관찰

4. 롤백/undo 절차도 함께 리허설

5. 예상 소요 시간과 위험을 런북에 기록

리허설에서 "이 마이그레이션은 운영에서 12분 걸리고, 그동안 테이블이 락된다"는 사실을 미리 알면, 무중단 전략으로 바꾸거나 메인터넌스 윈도우를 잡는 결정을 미리 내릴 수 있습니다.

커뮤니케이션과 승인

마이그레이션 사고의 절반은 기술이 아니라 소통의 문제입니다.

- 사전 공지: 위험한 마이그레이션은 관련 팀에 미리 알립니다. "오늘 밤 사용자 테이블 마이그레이션이 있고, 약 10분간 쓰기가 느려질 수 있습니다."

- 승인 게이트: 운영 마이그레이션은 두 번째 사람의 검토를 거칩니다. 혼자 새벽에 치는 SQL이 가장 위험합니다.

- 롤백 결정권자: 누가 "중단하고 롤백" 결정을 내릴지 미리 정합니다. 장애 한가운데서 정하면 늦습니다.

- 상태 공유: 진행 중인 마이그레이션의 상태를 채널에 실시간 공유합니다.

메인터넌스 윈도우 vs 무중단

마이그레이션 전략은 크게 두 갈래입니다.

+----------------------+----------------------------------------------+

| 메인터넌스 윈도우 | 무중단 (online) |

+----------------------+----------------------------------------------+

| 서비스를 잠시 멈춤 | 서비스 유지하며 점진 적용 |

| 단순, 위험 작업도 가능 | 복잡, expand-contract 필수 |

| 계획된 다운타임 발생 | 다운타임 없음 |

| 소규모/내부 시스템에 적합| 대규모/24x7 서비스에 적합 |

+----------------------+----------------------------------------------+

무중단이 항상 옳은 것은 아닙니다. 내부 도구나 트래픽이 적은 시간대라면, 짧은 메인터넌스 윈도우가 복잡한 무중단 마이그레이션보다 안전하고 단순할 수 있습니다. 핵심은 "이 서비스에 어느 정도의 다운타임이 허용되는가"를 먼저 합의하는 것입니다.

마이그레이션 중 관측 쿼리

마이그레이션이 운영에서 도는 동안, 다음 쿼리들로 상태를 실시간 점검합니다.

-- PostgreSQL: 현재 락 대기 상황 확인

SELECT

blocked.pid AS blocked_pid,

blocking.pid AS blocking_pid,

blocked.query AS blocked_query,

blocking.query AS blocking_query

FROM pg_stat_activity blocked

JOIN pg_stat_activity blocking

ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));

-- PostgreSQL: 복제 지연 확인 (마스터에서)

SELECT

client_addr,

state,

pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes

FROM pg_stat_replication;

-- 진행 중인 긴 쿼리 확인

SELECT pid, now() - query_start AS duration, query

FROM pg_stat_activity

WHERE state = 'active' AND now() - query_start > interval '1 minute'

ORDER BY duration DESC;

이 쿼리들을 대시보드나 알림에 묶어 두면, 마이그레이션이 락을 너무 오래 잡거나 복제가 뒤처질 때 즉시 알 수 있습니다. "보이지 않으면 대응할 수 없다"는 관측의 제1원칙입니다.

종합 사전/사후 체크리스트

마이그레이션 전 (사전)

- [ ] 운영급 데이터 양에서 리허설했는가

- [ ] 예상 소요 시간과 락 지속 시간을 측정했는가

- [ ] 큰 테이블 변경에 CONCURRENTLY/배치를 적용했는가

- [ ] expand-contract로 가역적으로 설계했는가

- [ ] 파괴적 작업 전 백업과 PITR 시점을 확인했는가

- [ ] 백필이 멱등하고 단일 실행이 보장되는가

- [ ] 변환 검증 쿼리(수량/샘플/불변식)를 준비했는가

- [ ] 관련 팀에 공지하고 승인을 받았는가

- [ ] 롤백 절차와 결정권자를 정했는가

마이그레이션 중/후 (사후)

- [ ] 진행 상황(경과 시간, 락, 복제 지연)을 관측하고 있는가

- [ ] 검증 쿼리로 데이터 정합성을 확인했는가

- [ ] 복제본이 따라잡았는지(lag 회복) 확인했는가

- [ ] 에러율/응답 시간이 정상으로 돌아왔는가

- [ ] 결과를 채널에 공유하고 기록했는가

- [ ] (이상 시) 정한 절차대로 중단/롤백했는가

- [ ] 사고가 있었다면 비난 없는 포스트모템을 작성했는가

비난 없는 포스트모템 작성법

사고가 났다면, 그것을 학습 자산으로 바꾸는 것이 포스트모템입니다. 핵심은 "사람을 탓하지 않는다(blameless)"는 원칙입니다. "누가 잘못 쳤는가"가 아니라 "어떤 시스템적 빈틈이 그 실수를 가능하게 했는가"를 묻습니다.

좋은 포스트모템의 구조:

1. 요약 - 무슨 일이, 언제, 얼마나 영향을 줬는가 (한 단락)

2. 타임라인 - 분 단위로 무슨 일이 벌어졌는가

3. 근본 원인 - "5 whys"로 표면 너머의 원인까지

4. 영향 - 사용자/매출/데이터에 미친 실제 영향

5. 무엇이 잘됐나 - 빠른 감지, 좋은 대응 등도 기록

6. 액션 아이템 - 재발 방지를 위한 구체적·기한 있는 항목

비난 없는 문화가 중요한 이유는 단순합니다. 사람을 탓하면 사고가 숨겨지고, 사고가 숨겨지면 학습이 멈춥니다. 누구나 새벽 3시에 실수할 수 있다는 것을 인정하고, 그 실수가 장애로 이어지지 않도록 시스템을 고치는 것이 목표입니다. "왜 그 마이그레이션이 리뷰 없이 운영에 적용될 수 있었는가"가 "왜 네가 그걸 쳤는가"보다 훨씬 생산적인 질문입니다.

5 whys 예시

사고: NOT NULL 마이그레이션이 운영을 멈춤

왜? -> 8천만 행을 재작성하며 락을 잡아서

왜? -> NOT NULL + DEFAULT가 전체 재작성을 유발해서

왜? -> 그 패턴이 위험한지 몰랐고 검토에서 못 잡아서

왜? -> 마이그레이션 린트가 파이프라인에 없어서

왜? -> 마이그레이션을 코드처럼 검증하는 문화가 없어서

근본 원인: 도구(린트)와 문화(검증)의 부재. 개인의 실수가 아님.

액션: 린트 도입 + 운영급 리허설 + 위험 패턴 가이드 문서화

마치며

마이그레이션 사고의 교훈은 대부분 비슷한 곳으로 수렴합니다. 작게 쪼개라, 되돌릴 수 있게 하라, 운영급 규모로 리허설하라, 검증하라, 관측하라, 그리고 혼자 새벽에 치지 마라. 이 원칙들은 새롭지 않지만, 사고를 겪기 전에는 좀처럼 지켜지지 않습니다. 다행히 우리는 남의 포스트모템에서 배울 수 있습니다. 이 글의 세 가지 가상 사고가 여러분의 진짜 사고를 한 건이라도 막는다면, 가장 비싼 수업료를 가장 싸게 치른 셈입니다. 좋은 엔지니어링은 영웅적 복구가 아니라, 애초에 복구가 필요 없도록 만드는 지루한 규율에서 나옵니다.

참고 자료

- Google SRE Book — Postmortem Culture: https://sre.google/sre-book/postmortem-culture/

- PostgreSQL ALTER TABLE 락 정보: https://www.postgresql.org/docs/current/sql-altertable.html

- PostgreSQL 명시적 락 문서: https://www.postgresql.org/docs/current/explicit-locking.html

- Flyway 공식 문서: https://flywaydb.org/documentation/

- Liquibase 공식 문서: https://docs.liquibase.com/

- AWS DMS 문서: https://docs.aws.amazon.com/dms/

- GitHub — Online schema migrations(gh-ost): https://github.com/github/gh-ost

- Stripe Engineering — Online migrations at scale: https://stripe.com/blog/online-migrations

현재 단락 (1/242)

장애는 비싼 선생님입니다. 새벽 3시에 깨어 운영 DB를 복구하며 배우는 교훈은 평생 잊히지 않지만, 그 수업료는 매출 손실과 신뢰 하락과 팀의 번아웃으로 지불됩니다. 다행히 모든...

작성 글자: 0원문 글자: 8,603작성 단락: 0/242