10 분 소요

지난 글에서 풀백업/복원 + 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.

원인

두 가지가 흔해요.

  1. 타깃에 DEFAULT 가 빠짐 — 원본에는 DEFAULT GETDATE() 가 걸려있어서 NULL 이 안 들어오는데, 타깃 스키마를 새로 만들면서 DEFAULT 가 누락됨. CDC 가 NULL 을 그대로 INSERT 함.
  2. 컬럼 추가/변경이 원본에만 적용됨 — 마이그레이션 도중 원본 스키마가 바뀜. 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 마이그레이션 마스터 체크리스트 를 한 장으로 묶어볼게요.


← 이전 글: (4/5) 변경분 계속 쌓기 — NORECOVERY 체인과 CDC-only 결합