DMS 의 구조와 CDC 동작 원리
이번 포스팅부터는 AWS DMS(Database Migration Service) 를 정리해보려 합니다. 이름은 마이그레이션 서비스지만, 실제로는 운영 데이터베이스의 변경을 분석 환경으로 계속 흘려보내는 용도로도 널리 쓰입니다. 데이터를 어디에 쌓을지 정하고 나면 반드시 따라오는 질문이 "그럼 그것을 어떻게 실어 나를 것인가" 인데, 그 자리를 맡는 도구입니다.
첫 편에서는 DMS 가 어떤 구조이고 어떻게 동작하는지를 다루겠습니다.
1. 무엇을 푸는 도구인가
1.1. 두 가지 요구
DMS 가 다루는 상황은 크게 둘입니다.
- 마이그레이션 : 온프레미스 Oracle 을 AWS 의 Aurora 로 옮기는 것처럼, 한 번 옮기고 끝나는 작업입니다. 다만 서비스를 오래 세울 수 없으니, 옮기는 동안에도 원본은 계속 돌아야 합니다.
- 지속 복제 : 운영 데이터베이스의 변경을 계속해서 다른 곳으로 보냅니다. Redshift 나 S3 로 보내 분석에 쓰는 경우가 대표적입니다.
Redshift 시리즈 1편에서 "왜 운영 DB 에서 분석하면 안 되는가" 를 이야기했는데 그럼 어떻게 옮길 것인가에 대한 답 중 하나가 DMS 입니다.
1.2. 직접 만들면 무엇이 어려운가
"운영 DB 에서 읽어 웨어하우스에 넣는다" 는 말은 간단해 보입니다. 직접 만들어보면 문제가 드러납니다.
- 전체를 매번 복사할 수는 없습니다. 수억 행짜리 테이블을 매일 통째로 읽으면 운영 DB 가 버티지 못합니다.
- 변경분만 가져오려면 기준이 필요합니다.
updated_at컬럼을 쓰는 방법이 흔한데 삭제된 행은 잡을 수 없습니다. 그 컬럼이 없는 테이블도 많습니다. - 처음 한 번은 전체를 옮겨야 하는데 그 사이에도 원본은 계속 바뀝니다. 전체 복사가 끝난 시점과 변경 추적을 시작하는 시점이 어긋나면 데이터가 빠지거나 중복됩니다.
- 소스와 타깃의 타입이 다릅니다. Oracle 의
NUMBER를 Redshift 의 무엇으로 받을지 정해야 합니다.
DMS 는 이것들을 관리형으로 처리합니다.
2. 구성 요소
DMS 는 세 가지로 이루어집니다.
2.1. 복제 인스턴스
실제로 일을 하는 서버입니다. 소스에서 읽어 변환한 뒤 타깃에 씁니다. EC2 인스턴스 형태로 뜨며 사용자의 VPC 안에 배치됩니다. 여기서 중요한 성질이 있습니다. DMS 는 데이터를 저장하지 않습니다. 흘려보낼 뿐입니다. 다만 처리 중인 변경분은 인스턴스의 메모리와 디스크에 잠시 머뭅니다. 그래서 타깃이 느리면 여기에 쌓이고 스토리지가 가득 차면 작업이 실패합니다.
DMS Serverless 를 쓰면 인스턴스 크기를 직접 정하지 않고 용량 단위로 자동 조정하게 할 수 있습니다.
2.2. 엔드포인트
소스와 타깃의 접속 정보입니다. 어떤 엔진인지, 어디에 있는지, 자격 증명은 무엇인지를 담습니다. 지원하는 소스와 타깃이 넓습니다.
- 소스 : Oracle, SQL Server, MySQL, PostgreSQL, MongoDB, SAP ASE, IBM Db2 등
- 타깃 : 위의 것들에 더해 Redshift, S3, Kinesis, OpenSearch, DynamoDB, Kafka 등
소스와 타깃의 엔진이 달라도 됩니다. 이것을 이기종(heterogeneous) 마이그레이션이라고 하며 DMS 의 큰 장점입니다.
2.3. 복제 작업
무엇을 어떻게 옮길지를 정의합니다. 소스 엔드포인트, 타깃 엔드포인트, 마이그레이션 유형, 테이블 매핑, 그리고 상세 설정을 담습니다. 작업 하나가 여러 테이블을 다룰 수 있고 반대로 큰 테이블을 여러 작업으로 나눌 수도 있습니다.
3. 세 가지 마이그레이션 유형
3.1. Full Load
지금 있는 데이터를 통째로 한 번 옮깁니다. 끝나면 작업이 완료됩니다. 소스 테이블을 조회해 읽고 타깃에 넣습니다. 여러 테이블을 병렬로 처리하며 기본값은 8개입니다.
3.2. CDC (Change Data Capture)
변경만 계속 따라갑니다. 이것이 DMS 의 핵심 기능입니다.
3.3. Full Load + CDC
둘을 이어 붙인 것입니다. 실무에서 가장 많이 쓰는 형태입니다.
- 전체 적재를 시작하면서 그 시점의 로그 위치를 기억해둡니다.
- 전체 적재가 도는 동안 발생한 변경은 캐시에 쌓아둡니다.
- 전체 적재가 끝나면 쌓아둔 변경을 적용합니다.
- 그 뒤로는 계속 CDC 로 따라갑니다.
1.2 에서 말한 "전체 복사 시점과 변경 추적 시점의 어긋남" 을 이렇게 해결합니다. 사용자가 신경 쓸 필요 없이 DMS 가 이어 붙여줍니다.
4. CDC 는 어떻게 동작하는가
DMS 를 이해하는 데 가장 중요한 부분입니다.
4.1. 트랜잭션 로그를 읽는다
DMS 는 SELECT 로 변경을 찾지 않습니다. 데이터베이스의 트랜잭션 로그를 읽습니다. 관계형 데이터베이스는 장애 복구를 위해 모든 변경을 로그에 먼저 기록합니다. 그 로그에는 어떤 행이 어떻게 바뀌었는지가 순서대로 들어 있습니다. DMS 는 이것을 읽어 타깃에 재현합니다.
이 방식의 장점이 분명합니다.
- 삭제도 잡힙니다.
updated_at방식으로는 불가능했던 부분입니다. - 원본 테이블에 부하를 거의 주지 않습니다. 테이블을 스캔하지 않기 때문입니다.
- 순서가 보장됩니다. 트랜잭션 순서대로 적용됩니다.
4.2. 엔진마다 방식이 다르다
- MySQL :
바이너리 로그(binlog) 를 읽습니다.
binlog_format이 ROW 여야 합니다. STATEMENT 로 두면 실행된 SQL 문만 기록되어 어느 행이 바뀌었는지 알 수 없습니다. - PostgreSQL :
논리적 복제(logical replication) 를 씁니다.
wal_level을 logical 로 설정해야 합니다. - Oracle : 두 가지 방법이 있습니다. LogMiner 는 Oracle 이 제공하는 기능을 쓰는 방식이라 설정이 간단하지만 느립니다. Binary Reader 는 DMS 가 redo 로그를 직접 읽어 훨씬 빠르지만 설정이 복잡합니다. 대량 변경을 다룬다면 후자를 고려합니다. 그리고 보충 로깅(supplemental logging) 을 켜야 합니다. 켜지 않으면 변경된 컬럼만 기록되어 행을 식별할 수 없습니다.
- SQL Server : MS-CDC 또는 트랜잭션 로그를 씁니다.
소스 쪽 설정을 먼저 맞춰야 한다는 것이 실무에서 첫 번째 관문입니다. DMS 만 만들어두고 왜 안 되는지 찾다 보면 대개 여기입니다.
4.3. 로그가 지워지면 안 된다
주의할 점입니다. DMS 가 아직 읽지 못한 로그를 데이터베이스가 정리해버리면 그 구간의 변경을 영영 잃습니다. 그래서 소스 쪽에서 로그 보존 기간을 충분히 길게 잡아야 합니다. DMS 작업이 며칠 멈춰 있어도 복구할 수 있을 만큼이면 좋습니다. 보존이 짧으면 작업을 처음부터 다시 해야 합니다.
5. 테이블 매핑
어떤 테이블을 옮기고, 옮기면서 무엇을 바꿀지 정합니다. JSON 으로 작성합니다.
5.1. 선택 규칙
무엇을 포함하고 무엇을 뺄지 정합니다.
{
"rule-type": "selection",
"rule-id": "1",
"rule-name": "include-sales",
"object-locator": {
"schema-name": "sales",
"table-name": "%"
},
"rule-action": "include"
}
% 로 와일드카드를 쓸 수 있어 스키마 전체를 포함한 뒤 일부만 제외하는 식의 구성이 가능합니다.
5.2. 변환 규칙
옮기면서 이름이나 구조를 바꿉니다.
- 스키마 이름 바꾸기
- 테이블·컬럼 이름 바꾸기, 접두사·접미사 붙이기
- 대소문자 변환 : Oracle 은 대문자, PostgreSQL 은 소문자를 선호하므로 이기종 마이그레이션에서 자주 씁니다
- 컬럼 제거하기
- 컬럼 타입 바꾸기
5.3. 병렬 로드 규칙
큰 테이블 하나를 여러 스레드로 나눠 읽습니다. 파티션 단위나 지정한 경계값 범위로 나눌 수 있습니다. 전체 적재 시간을 줄이는 가장 효과적인 수단 중 하나입니다.
6. 타입 변환
6.1. 두 단계를 거친다
이기종 마이그레이션에서 DMS 는 타입을 직접 매핑하지 않습니다. 중간 타입을 거칩니다.
소스 타입 → DMS 데이터 타입 → 타깃 타입
이렇게 하면 소스와 타깃 조합마다 매핑을 만들지 않아도 됩니다. 엔진이 N개면 조합은 N×N 인데 중간 타입을 두면 2N 으로 줄어듭니다. 대신 두 번 변환되면서 손실이 생길 수 있습니다. 정밀도가 다른 숫자 타입이나 시간대를 다루는 방식이 다른 경우가 그렇습니다. 중요한 컬럼은 변환 후 값을 직접 확인해야 합니다.
6.2. LOB 처리
가장 까다로운 부분입니다. CLOB, BLOB 같은 큰 객체는 크기를 미리 알 수 없어 특별히 다룹니다.
- Limited LOB 모드 : 최대 크기를 지정하고 그 크기로 메모리를 할당합니다. 빠르지만 지정한 크기를 넘는 데이터는 잘립니다. 이 잘림이 조용히 일어나므로 위험합니다.
- Full LOB 모드 : 크기 제한 없이 조각내어 옮깁니다. 안전하지만 매우 느립니다. LOB 하나마다 별도 조회가 발생합니다.
- Inline LOB 모드 : 작은 것은 인라인으로 빠르게, 큰 것만 따로 처리합니다. 둘의 절충입니다.
실제 LOB 크기 분포를 먼저 확인하고 정해야 합니다. 99%가 1KB 미만인데 1%가 10MB 라면, Inline 모드로 경계를 잡는 것이 맞습니다. 그리고 LOB 컬럼을 옮기려면 그 테이블에 기본 키가 있어야 합니다. 다음 편에서 볼 제약 중 하나입니다.
7. DMS 가 하지 않는 것
자주 오해하는 부분이라 미리 짚어두겠습니다.
- 스키마를 만들어주지 않습니다. 정확히는 기본적인 테이블 구조는 만들어주지만 인덱스, 외래 키, 트리거, 저장 프로시저, 시퀀스는 옮기지 않습니다. 이것들은 AWS SCT(Schema Conversion Tool) 나 직접 작성으로 준비해야 합니다.
- DDL 을 완전히 따라가지 않습니다. 운영 중에 컬럼을 추가하면 일부 엔진 조합에서는 반영되지만 지원되지 않는 조합도 많습니다. 스키마 변경은 계획하고 조율해야 합니다.
- 데이터를 가공하지 않습니다. 이름 바꾸기와 컬럼 제거 정도는 하지만 집계나 조인 같은 변환은 못 합니다. 그런 것은 타깃에 넣은 뒤 처리합니다.
- 성능을 알아서 맞춰주지 않습니다. 인스턴스 크기, 병렬도, LOB 설정을 잘못 두면 느립니다.
8. 조금 더 들어가보기 : 트랜잭션 로그의 실제
CDC 가 트랜잭션 로그를 읽는다고 했는데 그 로그가 무엇이고 왜 거기에 모든 변경이 순서대로 들어 있는지까지 내려가 보겠습니다. CDC 운영에서 사고가 나는 지점이 대부분 이 층에 있습니다.
8.1. 로그를 먼저 쓰는 이유
관계형 데이터베이스는 WAL(Write-Ahead Logging) 이라는 원칙을 지킵니다. 데이터 페이지를 디스크에 쓰기 전에, 무엇을 바꿀 것인지를 로그에 먼저 기록하고 그 로그를 디스크에 확정합니다.
이렇게 하는 이유는 장애 복구 때문입니다. 커밋 직후 전원이 나가도, 재시작할 때 로그를 다시 읽어 적용하면 커밋된 것을 되살릴 수 있습니다. 여기에 부수적인 성능 이점도 있습니다. 데이터 페이지는 디스크 곳곳에 흩어져 있어 임의 접근이지만 로그는 끝에 덧붙이기만 하므로 순차 쓰기입니다. 디스크에게 훨씬 싼 작업입니다.
CDC 가 기대는 지점이 바로 이것입니다. 커밋된 모든 변경이, 일어난 순서 그대로, 한 줄로 기록되어 있습니다. 별도의 추적 장치를 만들 필요가 없습니다.
8.2. 위치를 가리키는 번호
로그를 이어서 읽으려면 "어디까지 읽었는지" 를 표시할 수단이 필요합니다. 엔진마다 이름이 다릅니다.
- PostgreSQL 의 LSN(Log Sequence Number) :
WAL 전체를 하나의 긴 바이트 열로 보고, 그 안에서의 위치를 가리킵니다.
0/1A2B3C4D같은 형태입니다. - Oracle 의 SCN(System Change Number) : 데이터베이스 전체에서 단조 증가하는 논리적 시각입니다.
- MySQL 의 binlog 파일명과 오프셋 : 또는 GTID 라는 전역 트랜잭션 식별자를 씁니다.
DMS 가 작업을 이어서 재개할 수 있는 것은 이 값을 기억해두기 때문입니다. 앞서 Full Load 와 CDC 를 이어 붙일 때 "그 시점의 로그 위치를 기억해둔다" 고 한 것도 이 값입니다.
8.3. 물리 로그와 논리 로그
여기서 엔진별 차이가 갈립니다. 원래 WAL 은 복구를 위한 것이라 "몇 번 블록의 몇 번째 바이트를 이렇게 바꿔라" 처럼 물리적으로 적혀 있습니다. 이대로는 다른 엔진이 이해할 수 없습니다.
- PostgreSQL 은 논리적 디코딩(logical decoding) 이라는 층을 둡니다. 물리 WAL 을 읽어 "이 테이블의 이 행이 이렇게 바뀌었다" 는 형태로 번역해 내보냅니다.
wal_level을logical로 올려야 하는 이유가 이것입니다. 번역에 필요한 정보를 WAL 에 더 담아두게 하는 설정입니다. - MySQL 의 binlog 는 애초에 복제를 위한 별도 로그입니다. 그래서
binlog_format을 고를 수 있는데ROW로 두면 바뀌기 전과 후의 행 이미지가 통째로 들어갑니다.STATEMENT로 두면 실행된 SQL 문만 남아 어느 행이 바뀌었는지 알 수 없습니다. - Oracle 은 redo 로그가 물리에 가까워서, LogMiner 로 해석하거나 Binary Reader 로 직접 파싱합니다. 보충 로깅을 켜야 하는 이유도 같습니다. 기본적으로는 바뀐 컬럼만 기록되어 그 행을 식별할 키 컬럼이 로그에 없기 때문입니다.
"어느 행이 어떻게 바뀌었는지" 를 로그만 보고 알 수 있게 만드는 설정이 앞서 말한 소스 쪽 준비 작업의 정체입니다.
8.4. 복제 슬롯과 디스크가 차는 사고
PostgreSQL 을 소스로 쓸 때 가장 자주 터지는 사고라 따로 적어둡니다. 논리적 디코딩을 쓰려면 복제 슬롯(replication slot) 을 만듭니다. 이 슬롯은 "이 구독자가 여기까지 읽었다" 를 데이터베이스 서버 쪽에 기록합니다.
그리고 PostgreSQL 은 모든 슬롯이 읽어간 지점보다 앞선 WAL 만 지웁니다. 아직 아무도 안 읽은 구간을 지우면 그 변경을 영영 전달할 수 없기 때문입니다. 문제는 여기서 시작됩니다. DMS 작업을 멈추거나 지운 뒤 슬롯을 정리하지 않으면, 그 슬롯은 영원히 옛 위치에 머뭅니다. 서버는 그 지점 이후의 WAL 을 계속 보관하고 WAL 이 무한정 쌓여 디스크가 가득 찹니다. 그러면 운영 데이터베이스 전체가 멈춥니다.
분석용으로 걸어둔 복제가 운영 DB 를 멈추게 하는 셈이라, 영향이 큽니다. 그래서 이렇게 챙겨야 합니다.
- 작업을 지울 때 슬롯도 함께 지웁니다.
pg_replication_slots에서 쓰이지 않는 슬롯이 남아 있는지 주기적으로 확인합니다.- WAL 디렉토리 크기에 경보를 걸어둡니다.
MySQL 이나 Oracle 은 이 구조가 아니라 보존 기간이 지나면 그냥 지웁니다. 그래서 반대 방향의 문제가 생깁니다. 디스크는 안전한 대신, DMS 가 멈춰 있는 동안 로그가 지워지면 이어서 재개할 수 없습니다. 디스크를 지킬 것인가 이어 읽기를 지킬 것인가의 차이입니다.
8.5. 트랜잭션 경계와 메모리
로그에는 커밋되지 않은 변경도 들어옵니다. 트랜잭션이 길게 열려 있으면 그 사이의 변경이 계속 로그에 쌓이고 커밋이 나와야 비로소 타깃에 적용할 수 있습니다. 그래서 DMS 는 아직 커밋을 못 본 변경을 메모리에 들고 있어야 합니다. 앞에서 CDC 단계가 메모리를 많이 쓴다고 한 이유가 이것입니다.
여기서 실무적인 결론이 나옵니다. 소스에서 아주 긴 트랜잭션을 돌리면 DMS 의 메모리가 그만큼 부풀고 지연도 함께 커집니다. 수천만 행을 한 트랜잭션으로 갱신하는 배치가 있다면, 나눠서 커밋하도록 바꾸는 것이 복제 안정성에 도움이 됩니다.
8.6. 그래서 지연이 두 갈래인 것이다
이제 앞에서 본 두 지표가 왜 나뉘어 있는지가 분명해집니다.
- 읽는 쪽은 로그를 따라가는 속도입니다. 소스의 변경량, 로그 해석 방식, 트랜잭션 길이에 좌우됩니다.
- 쓰는 쪽은 타깃에 적용하는 속도입니다. 타깃의 인덱스, 저장 구조, 배치 설정에 좌우됩니다.
두 구간은 완전히 다른 자원을 쓰고 다른 이유로 느려집니다. 하나의 "지연" 으로 묶어두면 원인을 좁힐 수 없기 때문에 처음부터 나누어 재는 것입니다.
결론
DMS 를 공부하면서 가장 인상 깊었던 것은 트랜잭션 로그를 읽는다는 부분이었습니다. 그전까지 저는 변경분을 가져오는 방법으로 updated_at 컬럼을 비교하는 것밖에 떠올리지 못했습니다. 그런데 그 방식은 삭제를 놓치고, 컬럼이 없으면 쓸 수 없고, 원본에 스캔 부하를 줍니다. 데이터베이스가 이미 남기고 있는 로그를 활용한다는 발상이 훨씬 깔끔했습니다.
두 번째로 배운 것은 Full Load 와 CDC 를 이어 붙이는 방식이었습니다. 전체를 복사하는 동안에도 원본은 계속 바뀌므로 어딘가에서 반드시 틈이 생깁니다. 시작 시점의 로그 위치를 기억해두고 복사가 끝난 뒤 그 사이의 변경을 이어서 적용하는 것으로 그 틈을 메웁니다. "어디서부터 이어서 할지" 를 기록해두는 것이 분산 시스템에서 반복해 나오는 해법이라는 생각이 들었습니다. 하둡 시리즈의 체크포인트, 쿠버네티스의 resourceVersion 과 같은 계열입니다.
세 번째는 중간 타입을 두는 설계였습니다. 엔진 조합마다 매핑을 만들면 제곱으로 늘어나는 문제를, 중간 단계를 하나 끼워 선형으로 줄였습니다. 대신 두 번 변환되면서 정밀도가 손실될 수 있다는 대가가 따라옵니다. 추상화가 조합의 폭발을 막아주지만 그만큼 정보를 잃는다는 것을, 구체적인 예로 확인한 셈입니다.