콴다팀이 AWS Aurora/RDS for MySQL과 DynamoDB를 GCP Cloud SQL for MySQL로 이관하며 겪은 검토, 준비, 실행 과정을 정리한 글이다. service downtime 10분 이내와 이전 비용 최소화를 목표로, 유료 마이그레이션 솔루션(DMS, Datastream) 없이 mysqldump와 netcat, binlog 복제로 진행했다.
핵심 포인트- 목표는 DB 이전, DEV 환경을 {1 DB:n App}에서 {1 DB:1 App}로 전환, query/schema/index 최적화, MySQL 버전 업그레이드 4가지이며 모두 다운타임과 비용 최소화가 전제였다.
- 이전 전에 grafana + VictoriaMetrics + mysql/rds exporter로 모니터링을 세우고 general.log와 pt-query-digest로 실행 쿼리를 분석했다.
- single index 남발, 불필요한 OR, transaction 남용, 과도한 longtext/varchar 등 부하 요인을 찾아 복합 인덱스와 default 값, 타입 축소로 개선했다.
- Aurora 5.7과 Cloud SQL 8.0의 옵티마이저/parameter 차이를 검토했고, DynamoDB는 data copy 애플리케이션으로 이전해 비용을 약 1/8로 절감했다.
- binlog 복제(failover) 방식으로 이전하며 innodb_flush_log_at_trx_commit과 replica_type_conversions 설정으로 복제 오류를 방지했다.
- GCP는 source writer 직접 복제가 불가해 External meta virtual instance를 만들어 API로 demote와 복제를 구성했다.
상세 정리- 사전 검토 5단계: 실행 쿼리(general.log + pt-query-digest), schema/parameter, replica 구축 시간, 버전 차이, DB 종류 단일화를 순서대로 점검했다.
- 쿼리 문제: SELECT 1 같은 저부하 쿼리도 양이 많으면 부하가 되고, join 회피로 개별 실행하면 오히려 부하가 커지며, single index 과다로 scan과 정렬이 늘어난다.
- 인덱스 예: (user_id, expired_time) 복합 인덱스가 필요한데 각각 single index로 만들어져 있었고, admin 필터 대응으로 컬럼마다 single index를 만든 경우도 있었다.
- OR와 옵티마이저 함정: expired_time에 NULL 대신 default(9999-12-31)를 주면 OR를 제거할 수 있고, ORDER BY single-index-column DESC LIMIT 조합이 잘못된 인덱스로 full scan을 유발하는 이슈를 확인했다.
- schema 부채: flag성 컬럼까지 longtext를 쓰고, auto increment int PK가 한계에 임박했으며, varchar size 255 남발, sql_mode 미적용, 복제용 binlog_format 미설정 상태였다.
- 미사용 인덱스: performance_schema.table_io_waits_summary_by_index_usage에서 COUNT_STAR=0인 인덱스를 찾아 제거 대상을 선별했다.
- export 병렬화: information_schema.files로 테이블별 크기를 뽑아 평균에 맞춰 mysqldump를 병렬 실행하고, utf8mb4와 set-gtid-purged=OFF 등 옵션으로 문자 깨짐을 막았다.
- 전송과 import: mysqldump를 tar와 nc(netcat) 파이프라인으로 전송하고, import도 크기 짝을 맞춰 병렬 실행하며 대형 단일 테이블은 INSERT 라인을 awk로 나눠 병렬 적재했다.
- 복제 시간 단축: innodb_flush_log_at_trx_commit이 기본 1이면 DML 많은 서비스에서 sync가 끝나지 않아 0이나 2로 바꿔야 하며, 인덱스는 데이터 적재 후 ALTER 한 번으로 생성해 scan을 1회로 줄였다.
- schema 수정: 이전 시 부적절한 longtext 제거, AI PK를 int에서 bigint로, varchar 축소, enum 제거, 불필요 인덱스 제거, characterSet과 collation 제거를 함께 적용했다.
- GCP 복제 특성: source writer 직접 연결이 불가해 onPremisesConfiguration으로 External source instance를 sqladmin REST API로 생성하고, demote는 콘솔 미지원이라 golang SDK로 SkipReplicationSetup 등을 지정해 수행했다.
- 복제 오류 방지: binlog_format=ROW에서 writer와 reader 컬럼 타입이 다르면 복제가 실패해 replica_type_conversions를 ALL_LOSSY, ALL_NON_LOSSY로 설정했다.
- 실행 절차: 점검 시작, 구 writer read_only, Cloud SQL reader promote, 도메인 IP 교체, 구 writer session 정리와 stop, service open 순으로 2분 내외 다운타임을 목표했다.
- 장애와 대응: STRICT_TRANS_TABLES로 varchar 초과가 warning에서 error가 된 건 정상이었고, prefer_ordering_index=off 전역 적용이 SELECT ORDER BY id DESC LIMIT을 full scan으로 만들어 롤백 후 쿼리별 SET_VAR 힌트로 대응했다.
- 이후 평가: Cloud SQL은 query insight로 전체 쿼리 확인이 쉽고 core 대비 memory 지정이 유연한 장점이 있으나, 대용량 DDL 복제 지연(gh-ost 사용)과 HA 추가 인스턴스 비용, 클러스터 간 복제의 비직관성은 단점이었다.
왜 읽나public cloud 간 MySQL 이전을 유료 솔루션 없이 다운타임과 비용 최소로 수행하려는 DBA/백엔드 엔지니어에게 실전 절차와 함정 레퍼런스.