(5/5) MSSQL 마이그레이션 정합성 트러블슈팅 — 케이스별로 풀어보는 8가지 함정
📚 MSSQL → AWS RDS 마이그레이션 시리즈 (전체 5편)
- 방법 비교 — 6가지 중에서 고르기
- Native Backup/Restore 실전 — 옵션 그룹부터 컷오버까지
- DMS + CDC 무중단 컷오버 — 풀로드 후 변경분 따라잡기
- 변경분 계속 쌓기 — NORECOVERY 체인과 CDC-only 결합
- 정합성 트러블슈팅 — 케이스별 8가지 함정 ← 지금 글
지난 글에서 풀백업/복원 + DMS CDC-only 결합 패턴을 정리했어요. 흐름 자체는 깔끔한데, 실제로 운영해보면 데이터가 100% 1:1 로 떨어지지 않는 케이스를 자주 만나요. 이번 글은 그 결합 패턴(또는 DMS 단독)에서 실제로 자주 만나는 정합성 문제들 을 케이스별로 정리한 글이에요. 원인 → 증상 → 대응 순서로 풀게요.
💡 이 글에서 다루는 것
- 중복 키 (Duplicate PK) — 풀로드와 CDC 시작 시점 겹침
- NOT NULL / DEFAULT 컬럼 — 풀로드는 OK, CDC 에서 깨지는 케이스
- IDENTITY 시드 어긋남 — 컷오버 후 첫 INSERT 가 PK 충돌
- 외래키 적용 순서 — 풀로드 중/후의 cascade 문제
- Computed column / rowversion — 옮기지 않아야 하는 컬럼
- Collation / 데이터 타입 미스매치 — 비교 결과는 같아 보이는데 정합성 검증에서 다름
- 트리거 fire-on-replication — 적용 측에서 데이터가 두 번 변형
- 검증 체크리스트
1. 큰 그림 — 정합성 문제가 어디서 생기나
DMS 든 백업/복원이든, 데이터가 한 시점에 딱 고정되지 않는 한 정합성 문제는 어디서든 끼어들 수 있어요. 결합 패턴에서 정합성 문제가 잘 끼는 지점은 세 군데.
[원본] [타깃]
│
├── A. 풀백업 시작 시점 (T1)
│ 풀백업이 도는 동안의 변경분이 CDC 와 겹침
│
├── B. CDC 시작 시점 (T5)
│ cdc-start-time 의 미세한 오차로 일부 변경 중복/누락
│
└── C. 컷오버 시점
트리거/제약/IDENTITY 가 양쪽에서 동시에 발동
대부분의 함정은 이 세 지점에서 발생해요. 케이스별로 풀게요.
2. 케이스 1 — 중복 키 (Duplicate PK)
증상
DMS Task 의 Table statistics 에 Apply errors 가 쌓이고, CloudWatch Logs 에 이런 메시지가 떨어져요.
Violation of PRIMARY KEY constraint 'PK_Orders'.
Cannot insert duplicate key in object 'dbo.Orders'.
The duplicate key value is (12345).
원인
풀백업 시점 ~ CDC 시작 시점 사이에 들어온 row 가 풀로드로 한 번, CDC INSERT 로 한 번 적용되면서 충돌해요. CDC start time 을 풀백업 시작 시각으로 잡으면 이게 흔히 일어나요(의도된 안전마진).
대응
DMS Task settings 에서 INSERT 충돌을 흡수하도록 설정.
{
"ErrorBehavior": {
"ApplyErrorInsertPolicy": "INSERT_OR_UPDATE",
"ApplyErrorUpdatePolicy": "LOG_ERROR",
"ApplyErrorDeletePolicy": "IGNORE_RECORD",
"ApplyErrorEscalationPolicy": "LOG_ERROR",
"FullLoadIgnoreConflicts": true
}
}
| 옵션 | 의미 |
|---|---|
INSERT_OR_UPDATE |
PK 충돌 시 INSERT 대신 UPDATE 로 적용 (UPSERT) |
FullLoadIgnoreConflicts |
풀로드 중 충돌은 무시 |
⚠️
INSERT_OR_UPDATE는 PK 가 있어야 동작해요. PK 없는 테이블엔 적용 안 됨. PK 없는 테이블은 별도 처리가 필요.
검증
컷오버 후 양쪽 row count + CHECKSUM_AGG() 비교.
SELECT COUNT(*) AS cnt, CHECKSUM_AGG(BINARY_CHECKSUM(*)) AS chk
FROM dbo.Orders;
같은 값이 나와야 정합 OK.
3. 케이스 2 — NOT NULL / DEFAULT 컬럼
증상
풀로드는 정상인데, CDC 에서 특정 컬럼만 NULL 로 들어오면서 NOT NULL 제약 위반.
Cannot insert the value NULL into column 'created_at', table 'dbo.Orders'.
Column does not allow nulls.
원인
두 가지가 흔해요.
- 타깃에 DEFAULT 가 빠짐 — 원본에는
DEFAULT GETDATE()가 걸려있어서 NULL 이 안 들어오는데, 타깃 스키마를 새로 만들면서 DEFAULT 가 누락됨. CDC 가NULL을 그대로 INSERT 함. - 컬럼 추가/변경이 원본에만 적용됨 — 마이그레이션 도중 원본 스키마가 바뀜. DMS 는 기본적으로 DDL 을 안 따라가요.
대응
- DEFAULT 누락 점검 — 풀로드 끝난 직후 스키마 비교 한 사이클 돌리기.
-- 양쪽에서 동일 쿼리로 DEFAULT 비교
SELECT t.name AS table_name, c.name AS column_name, dc.definition AS default_value
FROM sys.tables t
JOIN sys.columns c ON t.object_id = c.object_id
LEFT JOIN sys.default_constraints dc ON c.default_object_id = dc.object_id
WHERE t.name = 'Orders'
ORDER BY c.column_id;
- 마이그레이션 기간 동안 원본 스키마 freeze 가 가장 안전. 강제 못 하면 DMS Task settings 의
HandleSourceTableAltered,HandleSourceTableDropped옵션으로 동작 명시.
4. 케이스 3 — IDENTITY 시드 어긋남
증상
컷오버 후 앱이 첫 INSERT 를 치자마자 PK 충돌.
Violation of PRIMARY KEY constraint 'PK_Orders'.
Cannot insert duplicate key in object 'dbo.Orders'.
The duplicate key value is (10001).
원인
복원/CDC 는 row 의 PK 값을 그대로 옮기지만, IDENTITY 의 “다음에 발급할 시드” 는 같이 안 따라와요. 타깃의 IDENTITY 다음 값이 1 부터 시작하는 채로 남아있다가, 앱이 INSERT 하면서 이미 존재하는 키를 다시 만들어요.
대응
컷오버 직후 모든 IDENTITY 테이블에 대해 DBCC CHECKIDENT(..., RESEED, ...) 로 시드 보정.
-- 1. 원본의 현재 MAX 값 확인
SELECT MAX(id) FROM dbo.Orders;
-- => 10000
-- 2. 타깃에서 RESEED
DBCC CHECKIDENT('dbo.Orders', RESEED, 10000);
-- 3. 검증
DBCC CHECKIDENT('dbo.Orders', NORESEED);
-- 'Checking identity information: current identity value '10000'.'
자동화 팁
IDENTITY 컬럼이 많으면 동적 SQL 로 한 번에.
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql = @sql +
N'DECLARE @m BIGINT; SELECT @m = ISNULL(MAX(' + c.name + N'),0) FROM '
+ QUOTENAME(s.name) + N'.' + QUOTENAME(t.name) + N'; '
+ N'DBCC CHECKIDENT(''' + s.name + N'.' + t.name + N''', RESEED, @m);' + CHAR(13)
FROM sys.tables t
JOIN sys.schemas s ON t.schema_id = s.schema_id
JOIN sys.columns c ON t.object_id = c.object_id
WHERE c.is_identity = 1;
EXEC sp_executesql @sql;
💡 IDENTITY 시드 보정은 컷오버 절차의 필수 단계 예요. 이전 글 컷오버 체크리스트에도 들어있어요. 한 번이라도 빼먹으면 운영 첫 분에 사고.
5. 케이스 4 — 외래키 적용 순서
증상
풀로드 중에 자식 테이블이 부모 테이블보다 먼저 들어가면서 외래키 위반.
The INSERT statement conflicted with the FOREIGN KEY constraint 'FK_OrderItems_Orders'.
The conflict occurred in database 'MyDB', table 'dbo.Orders', column 'id'.
CDC 단계에서도 트랜잭션 경계가 어긋나면 비슷한 에러가 떨어져요.
원인
DMS 의 풀로드는 테이블을 병렬로 로드해요. 부모/자식 의존성을 모르고 동시에 진행하니까, 자식이 먼저 끝나는 시점이 생겨요.
CDC 도 한 트랜잭션 안에 있던 변경을 항상 같은 트랜잭션 단위로 보장하진 않아요(BatchApplyPreserveTransaction 옵션이 켜져야).
대응
풀로드 전에 타깃의 외래키를 비활성화, 풀로드/CDC 끝나면 다시 활성화 + 검증.
-- 1. 타깃의 모든 FK 비활성화
DECLARE @sql NVARCHAR(MAX) = N'';
SELECT @sql = @sql +
N'ALTER TABLE ' + QUOTENAME(s.name) + N'.' + QUOTENAME(t.name)
+ N' NOCHECK CONSTRAINT ' + QUOTENAME(fk.name) + N';' + CHAR(13)
FROM sys.foreign_keys fk
JOIN sys.tables t ON fk.parent_object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id;
EXEC sp_executesql @sql;
-- 2. 풀로드/CDC 진행
-- 3. 컷오버 후 FK 재활성화 + 검증 (WITH CHECK 가 핵심)
SELECT @sql = N'';
SELECT @sql = @sql +
N'ALTER TABLE ' + QUOTENAME(s.name) + N'.' + QUOTENAME(t.name)
+ N' WITH CHECK CHECK CONSTRAINT ' + QUOTENAME(fk.name) + N';' + CHAR(13)
FROM sys.foreign_keys fk
JOIN sys.tables t ON fk.parent_object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id;
EXEC sp_executesql @sql;
WITH CHECK CHECK CONSTRAINT 의 두 번째 CHECK 가 중요. 기존 데이터까지 다시 검증 하라는 옵션이에요. 그냥 CHECK CONSTRAINT 만 쓰면 제약은 다시 켜지지만 기존 위반은 안 잡혀요(is_not_trusted = 1 로 남음).
검증
SELECT s.name + '.' + t.name AS table_name, fk.name AS fk_name, fk.is_not_trusted
FROM sys.foreign_keys fk
JOIN sys.tables t ON fk.parent_object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id
WHERE fk.is_not_trusted = 1;
위 쿼리 결과가 비어야 정합성 OK.
6. 케이스 5 — Computed column / rowversion
증상
DMS 가 computed column 또는 rowversion(timestamp) 컬럼에 직접 값을 쓰려다 실패.
The column 'total_amount' cannot be modified because it is either a computed column
or is the result of a UNION operator.
Cannot insert an explicit value into a timestamp column.
원인
이 두 종류 컬럼은 DB 가 자동 계산/발급 해요. 값을 직접 INSERT 하면 안 됨.
computed column(예:total_amount AS price * quantity)rowversion/timestamp(자동 증가 8-byte 바이너리)
대응
DMS Table mapping 에서 transformation rule 로 해당 컬럼을 제외.
{
"rule-type": "transformation",
"rule-id": "100",
"rule-name": "exclude-computed",
"rule-target": "column",
"object-locator": {
"schema-name": "dbo",
"table-name": "Orders",
"column-name": "total_amount"
},
"rule-action": "remove-column"
}
rowversion 도 같은 식으로 remove-column. 타깃 테이블에는 동일 정의가 그대로 있으므로, INSERT 시점에 자동 계산됨.
사전 검출
-- computed column 목록
SELECT s.name + '.' + t.name AS table_name, c.name AS column_name
FROM sys.computed_columns c
JOIN sys.tables t ON c.object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id;
-- rowversion/timestamp 컬럼 목록
SELECT s.name + '.' + t.name AS table_name, c.name AS column_name
FROM sys.columns c
JOIN sys.tables t ON c.object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id
JOIN sys.types ty ON c.user_type_id = ty.user_type_id
WHERE ty.name IN ('timestamp', 'rowversion');
이 두 쿼리 결과를 사전에 다 뽑아서 transformation rule 로 다 빼두면 깔끔.
7. 케이스 6 — Collation / 데이터 타입 미스매치
증상
row count 는 같은데 CHECKSUM_AGG 가 안 맞음. 특정 문자열 컬럼을 비교하면 같은 글자처럼 보이는데 다름.
SELECT * FROM source.dbo.Orders WHERE customer_name = N'홍길동'; -- 1건
SELECT * FROM target.dbo.Orders WHERE customer_name = N'홍길동'; -- 0건
원인
- Collation 차이 — 원본 DB 가
Korean_Wansung_CI_AS, 타깃이SQL_Latin1_General_CP1_CI_AS같은 경우. 한글이 깨지거나, 비교가 case-insensitive 가 아니거나, trailing space 처리 다름. - 타입 변경 — DMS 가 자동으로
VARCHAR(MAX) → NVARCHAR(MAX)같은 변환을 할 때 옵션에 따라 trailing space 가 잘림.
대응
- 타깃 DB 의 collation 을 원본과 동일하게 만들어두는 게 정공법.
-- 새 DB 생성 시 collation 명시
CREATE DATABASE [MyDB] COLLATE Korean_Wansung_CI_AS;
-- 기존 DB collation 변경 (다소 무거운 작업)
ALTER DATABASE [MyDB] COLLATE Korean_Wansung_CI_AS;
- 이미 다른 collation 으로 만들어버렸으면 컬럼 단위 collation 변경 으로 보정.
ALTER TABLE dbo.Orders
ALTER COLUMN customer_name NVARCHAR(100) COLLATE Korean_Wansung_CI_AS NOT NULL;
검증
-- 의심되는 문자열 컬럼은 LEN, DATALENGTH 둘 다 비교
SELECT TOP 10 id, customer_name, LEN(customer_name), DATALENGTH(customer_name)
FROM dbo.Orders ORDER BY id;
LEN 은 같지만 DATALENGTH 가 다르면 trailing space 차이.
8. 케이스 7 — 트리거 fire-on-replication
증상
CDC 가 INSERT 한 row 에 대해 타깃의 트리거가 발동되면서, 데이터가 한 번 더 변형되거나 다른 테이블에까지 영향이 번짐.
원인
기본적으로 SQL Server 트리거는 모든 INSERT/UPDATE/DELETE 에 발동 해요. CDC 가 흘리는 INSERT 도 예외 없음. 원본에선 트랜잭션 한 번에 트리거 + 본 데이터가 같이 처리됐을 텐데, CDC 는 본 데이터만 흘려요 → 타깃에서 트리거가 또 발동 → 중복 처리.
대응
트리거를 NOT FOR REPLICATION 으로 정의 하면, replication/CDC 로 들어온 변경에는 발동 안 해요.
CREATE TRIGGER trg_Orders_AfterInsert
ON dbo.Orders
AFTER INSERT
NOT FOR REPLICATION
AS
BEGIN
-- 트리거 로직
END;
기존 트리거 변경은 ALTER TRIGGER 로 똑같이.
⚠️ 이 옵션은 SQL Server 의 replication / CDC 기반 변경을 인식해서 skip 해요. DMS 가 일반 SQL INSERT 로 적용하는 경우엔 안 통할 수 있어요. 안 통하면 마이그레이션 기간 동안 트리거 자체를 DISABLE 해두는 게 가장 확실해요.
-- 컷오버 전: 트리거 다 끄기
DISABLE TRIGGER ALL ON dbo.Orders;
-- 컷오버 후: 다시 켜기
ENABLE TRIGGER ALL ON dbo.Orders;
9. 케이스 8 — LOB / 큰 컬럼
증상
VARBINARY(MAX), NVARCHAR(MAX), XML, IMAGE 같은 LOB 컬럼이 잘려서 들어오거나, 풀로드 자체가 느려짐.
원인
DMS 의 기본 LOB 처리 모드가 Limited LOB mode 인 경우, LOB 컬럼을 일정 크기(기본 32KB) 로 잘라요. 큰 BLOB 가 있는 테이블에선 데이터 손실.
대응
DMS Task settings 의 LOB 모드를 명시.
{
"TargetMetadata": {
"SupportLobs": true,
"FullLobMode": false,
"LobChunkSize": 64,
"LimitedSizeLobMode": true,
"LobMaxSize": 1024
}
}
| 모드 | 의미 | 용도 |
|---|---|---|
| Don’t include LOB | LOB 컬럼 자체를 skip | LOB 따로 마이그레이션할 때 |
| Limited LOB mode | LobMaxSize 까지만 옮김 (빠름) |
LOB 크기가 작고 일정한 경우 |
| Full LOB mode | 청크 단위로 전체 옮김 (느림) | LOB 크기가 크고 가변인 경우 |
💡 Full LOB mode 는 속도가 크게 떨어져요. LOB 가 큰 테이블은 별도 task 로 분리해서 옮기는 게 일반적.
사전 검출
-- 테이블별 LOB 컬럼 + 평균/최대 크기
SELECT s.name + '.' + t.name AS table_name, c.name AS column_name,
AVG(DATALENGTH(c.name)) AS avg_size,
MAX(DATALENGTH(c.name)) AS max_size
FROM sys.columns c
JOIN sys.tables t ON c.object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id
JOIN sys.types ty ON c.user_type_id = ty.user_type_id
WHERE ty.name IN ('varbinary', 'nvarchar', 'varchar', 'xml', 'image', 'text', 'ntext')
AND c.max_length IN (-1, 8000) -- MAX 타입
GROUP BY s.name, t.name, c.name
ORDER BY max_size DESC;
10. 검증 체크리스트 — 컷오버 전후
마이그레이션 마지막 단계에서 한 번에 돌리는 검증 모음이에요. 이 정도면 정합성에 대해 안심하고 컷오버할 수 있어요.
- DBCC CHECKDB — 무결성 (타깃)
- 테이블별 row count 비교 — 양쪽 동일
CHECKSUM_AGG(BINARY_CHECKSUM(*))비교 — 핵심 테이블 전수- MAX(updated_at) 비교 — CDC 따라잡힘 확인
- DEFAULT 제약 비교 — 누락된 DEFAULT 가 있는지
- IDENTITY 시드 보정 — 모든 IDENTITY 테이블
- 외래키
is_not_trusted = 0— 전부 신뢰 상태 - 트리거 enable 상태 — 컷오버용으로 disable 했던 것 복원
- 인덱스/통계 재구성 — 풀로드 중 비활성화했다면 재생성 +
UPDATE STATISTICS - 앱 권한/로그인 매핑 — 고아 사용자 0
11. 정리
데이터 정합성 문제는 사전 점검 → DMS 옵션 → 컷오버 후 보정 의 3단계로 잡히는 게 대부분이에요. 케이스별 핵심만 다시.
| 케이스 | 핵심 조치 |
|---|---|
| 중복 키 | DMS INSERT_OR_UPDATE + FullLoadIgnoreConflicts |
| NOT NULL/DEFAULT | 타깃 DEFAULT 누락 사전 검출 |
| IDENTITY 시드 | 컷오버 직후 DBCC CHECKIDENT ... RESEED |
| 외래키 순서 | 풀로드 전 disable → 후 WITH CHECK CHECK CONSTRAINT |
| Computed/rowversion | DMS transformation remove-column |
| Collation | 타깃 DB/컬럼 collation 원본과 일치 |
| 트리거 fire | NOT FOR REPLICATION 또는 마이그레이션 동안 disable |
| LOB | LOB 모드 명시, 큰 LOB 는 별도 task |
저희 사내 마이그레이션에선 위 8가지 중 3 (IDENTITY) + 4 (외래키) + 7 (트리거) 가 가장 자주 발목을 잡았어요. 컷오버 절차의 표준 체크리스트에 넣어두면 사고가 거의 안 납니다.
일단 오늘은 여기까지…..
다음 글에서는 이 시리즈를 한 페이지로 정리하는 MSSQL → AWS RDS 마이그레이션 마스터 체크리스트 를 한 장으로 묶어볼게요.