“마이그레이션이 잘 됐다”는 말은 증명되어야 합니다. 핵심 세 축 — 숫자로 일치한다 + 샘플로 일치한다 + 실패가 없다 — 이 셋이 보고서/메시지에 담기면 “잘 됐다”는 자연스럽게 성립합니다.
왜 세 축인가
Row 수가 맞아도 데이터가 깨질 수 있고, 데이터가 맞아도 실패 건이 있으면 재처리가 남은 것이고, 둘 다 맞아도 운영 영향(다운타임, 롤백 불가)이 있으면 “잘 됐다”고 말하기 어렵습니다. 따라서 (A) 정합성-Row Count + (B) 정합성-Checksum + (C) 경계 케이스 + (D) 실패·스킵 + (E) 운영 영향, 다섯 항목이 완성된 마이그레이션 보고의 최소 단위입니다.
| 축 | 핵심 질문 | 한 줄 요약 |
|---|---|---|
| (A) Row Count | 원본과 신규의 건수가 같은가? | 숫자가 일치한다 |
| (B) Checksum / 샘플 | 내용도 같은가? | 샘플이 일치한다 |
| (C) 경계·엣지 케이스 | 까다로운 row도 정상인가? | 극단값도 OK |
| (D) 실패·스킵 | 처리 못 한 건이 있는가? | 실패가 없다(또는 사유 명시) |
| (E) 운영 영향 | 서비스에 영향이 있었는가? | 다운타임·롤백 상태 명시 |
(A) 정합성 — Row Count
가장 먼저, 가장 쉽게 확인할 수 있는 지표입니다. 전체 건수는 물론 주요 파티션·상태 별로도 나눠서 보여줘야 신뢰도가 올라갑니다.
보고 형식 (표 한 줄이면 충분)
테이블 src dst diff 비고
----------- ----------- ----------- ---- ----------------------------
messages 1,234,567 1,234,567 0
attachments 312,089 312,089 0
user_events 4,001,200 3,998,700 -2,500 만료(90일) 메시지 제외 — 의도된 차이
diff가 있을 때는 반드시 사유를 적습니다. “의도된 차이”임을 명시하지 않으면 의심을 삽니다.
SQL 예시
-- 원본
SELECT COUNT(*) AS src_count FROM src_db.messages;
-- 신규 (의도적 제외 조건이 있으면 동일 조건 적용)
SELECT COUNT(*) AS dst_count FROM dst_db.messages
WHERE created_at > NOW() - INTERVAL '90 days'; -- 만료 제외 시
-- 파티션별 (status 기준)
SELECT status, COUNT(*) FROM src_db.messages GROUP BY status;
SELECT status, COUNT(*) FROM dst_db.messages GROUP BY status;
(B) 정합성 — Checksum / 샘플 비교
건수가 맞아도 내용이 깨질 수 있습니다. Encoding 문제, NULL 처리 차이, 타임존 변환 오류가 대표적입니다. 전수 비교는 비싸므로 두 가지 전략 중 하나를 씁니다.
전략 1: 집계 해시 (빠름, 간단)
-- 핵심 컬럼을 이어 붙여 MD5 집계 — 한 쿼리로 전체 비교
-- (Postgres 기준)
SELECT MD5(STRING_AGG(
CONCAT(id::text, '|', content, '|', created_at::text),
',' ORDER BY id
)) AS checksum
FROM messages
WHERE id BETWEEN 1 AND 1000000;
원본과 신규에서 같은 쿼리를 실행해 해시가 일치하면 OK. 불일치 시 범위를 절반씩 좁혀 이진 탐색으로 문제 행을 찾습니다.
전략 2: 무작위 N건 샘플링 (직관적)
-- 무작위 1,000건 샘플 추출
SELECT id, content, status, created_at
FROM messages
ORDER BY RANDOM()
LIMIT 1000;
# Python으로 src/dst 비교
import hashlib
def row_hash(row):
key = f"{row['id']}|{row['content']}|{row['status']}|{row['created_at']}"
return hashlib.md5(key.encode()).hexdigest()
src_hashes = {r['id']: row_hash(r) for r in src_sample}
dst_hashes = {r['id']: row_hash(r) for r in dst_sample}
mismatches = [id for id in src_hashes if src_hashes[id] != dst_hashes.get(id)]
print(f"샘플 {len(src_sample)}건 중 불일치: {len(mismatches)}건")
# → 샘플 1,000건 중 불일치: 0건
보고 형식:
무작위 샘플 1,000건 검증: id, content, status, created_at 4개 컬럼 hash 비교 → 불일치 0건
(C) 경계·엣지 케이스
무작위 샘플은 “평범한 row”에만 걸릴 확률이 높습니다. 실제 마이그레이션 사고는 대부분 엣지 케이스에서 납니다. 아래 항목을 명시적으로 확인해 보고에 포함하면 신뢰도가 크게 올라갑니다.
| 케이스 | 확인 쿼리 힌트 | 왜 중요한가 |
|---|---|---|
| 가장 오래된 row | ORDER BY id ASC LIMIT 1 | 초기 데이터, 스키마 변경 이전 레거시 포맷 |
| 가장 최근 row | ORDER BY id DESC LIMIT 1 | 마이그레이션 cut-off 직전 데이터 |
| 가장 큰 페이로드 | ORDER BY LENGTH(content) DESC LIMIT 5 | BLOB/TEXT 크기 제한, 버퍼 overflow |
| NULL 많은 row | WHERE col IS NULL 각 컬럼별 | NULL 처리 방식 차이(빈 문자열 vs NULL) |
| 한글·이모지·특수문자 | WHERE content ~ '[가-힣🎉<>&]' | Encoding(utf8mb4 vs utf8), collation 차이 |
| 타임존 경계값 | DST 전환일 전후 created_at | 타임존 변환 오류로 1~2시간 오차 발생 가능 |
-- 한글/이모지 포함 row 샘플 확인 (Postgres)
SELECT id, LEFT(content, 80) AS preview, LENGTH(content) AS len
FROM messages
WHERE content ~ '[가-힣]' OR content ~ '[^ -]'
ORDER BY RANDOM()
LIMIT 20;
보고 형식:
엣지 케이스 확인: 최고령(2019-03-01) / 최신(2026-05-19) / 최대 페이로드(128KB) / NULL 다수 row / 한글+이모지 포함 20건 → 전부 정상 확인
(D) 실패·스킵 처리
0건이면 “0건”이라고 명시해야 합니다. 말하지 않으면 “확인 안 했거나 숨기는 것”으로 의심받습니다.
실패가 있을 때
| 항목 | 내용 |
|---|---|
| 실패 건수 | 23건 |
| 사유 | FK 참조 무결성 위반 (삭제된 user_id 참조) |
| 후속 조치 | 별도 테이블(migration_failures)에 적재 후 수동 검토 예정 / 또는 NULL로 대체 후 재처리 |
| 서비스 영향 | 해당 메시지 23건은 이미 서비스에서 노출 불가 상태였으므로 무영향 |
실패가 없을 때 (꼭 명시)
마이그레이션 실패·스킵: 0건. 전 row 정상 처리 완료.
실패 row 추적 패턴
-- 마이그레이션 실패 테이블 (사전 준비)
CREATE TABLE migration_failures (
id BIGSERIAL PRIMARY KEY,
src_id BIGINT NOT NULL,
error_code TEXT,
error_msg TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 마이그레이션 코드에서 실패 시 INSERT
INSERT INTO migration_failures (src_id, error_code, error_msg)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING;
-- 마이그레이션 완료 후 집계
SELECT error_code, COUNT(*) AS cnt, MIN(src_id), MAX(src_id)
FROM migration_failures
GROUP BY error_code
ORDER BY cnt DESC;
(E) 운영 영향
| 항목 | 확인 포인트 | 보고 예시 |
|---|---|---|
| 소요 시간 | 시작~완료 timestamp | 2026-05-19 02:00 ~ 03:47 (107분) |
| 다운타임 | 서비스 중단 여부 | 다운타임 없음 (읽기는 원본에서 계속 서비스) |
| 트래픽 영향 | p99 latency, DB CPU 변화 | 마이그레이션 중 원본 DB CPU 피크 67% (평소 대비 +20%p), p99 응답시간 +40ms — 허용 범위 내 |
| 롤백 준비 | 원본 보존 기간, 롤백 절차 | 원본 테이블 30일 보존 후 DROP (2026-06-18 이후). 롤백 시 애플리케이션 config만 변경으로 원본 복귀 가능. |
완성된 보고 템플릿
Slack 메시지나 노션 문서에 아래 형식을 그대로 써도 됩니다.
## 마이그레이션 완료 보고 — messages 테이블 (2026-05-19)
### (A) Row Count
| 테이블 | src | dst | diff | 비고 |
|------------|---------------|---------------|------|---------------|
| messages | 1,234,567 | 1,234,567 | 0 | |
| attachments| 312,089 | 312,089 | 0 | |
### (B) 샘플 검증
- 무작위 1,000건: id / content / status / created_at hash 비교 → **불일치 0건**
- 집계 해시(MD5): src == dst ✅
### (C) 엣지 케이스
- 최고령 row (2019-03-01) ✅ / 최신 row (2026-05-19) ✅
- 최대 페이로드 128KB ✅ / NULL 다수 row ✅
- 한글·이모지·특수문자 포함 20건 ✅
### (D) 실패·스킵
- **0건** — 전 row 정상 처리 완료
### (E) 운영 영향
- 소요: 02:00~03:47 (107분) / 다운타임: 없음
- DB CPU 피크 +20%p, p99 latency +40ms — 허용 범위 내
- 원본 테이블 30일 보존 (2026-06-18 이후 DROP 예정)
- 롤백: config 변경만으로 원본 복귀 가능
자주 빠뜨리는 것들
| 흔한 실수 | 결과 | 해결 |
|---|---|---|
| “건수 맞음” 만 보고 | 내용 깨진 것을 뒤늦게 발견 | 반드시 샘플 hash 비교 추가 |
| 실패 0건을 명시 안 함 | “혹시 숨기는 거 아니야?” 의심 | “0건”을 명시적으로 적는다 |
| 전체 건수만, 파티션 없음 | 특정 상태·기간 데이터 누락 못 잡음 | status / date range 별로 나눠서 확인 |
| 의도된 diff 설명 없음 | diff가 오류인지 정책인지 모름 | diff > 0이면 사유를 반드시 기재 |
| 엣지 케이스 생략 | 이모지·한글·대용량 row에서 나중에 사고 | 명시적 항목 체크리스트 만들기 |
| 롤백 계획 미언급 | 문제 발생 시 대응이 느림, 불안감 | 원본 보존 기간·롤백 방법을 미리 합의 |
요약: “숫자로 일치한다 + 샘플로 일치한다 + 실패가 없다” 세 축이 보고서에 담기면 “마이그레이션이 잘 됐다”는 증명됩니다. (C) 엣지 케이스와 (E) 운영 영향은 신뢰도를 높이는 보완 축입니다.
답글 남기기