DB 무중단 마이그레이션(Henry) - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki
본 문서는 devths 서비스의 데이터베이스를 V1 환경(EC2 내 Self-managed PostgreSQL)에서 V2 환경(AWS RDS PostgreSQL)으로 무중단 마이그레이션하기 위한 전체 계획을 기술한다. 마이그레이션 진행 시 본 문서를 참고하여 사전 준비부터 컷오버, 롤백, 사후 정리까지 전 과정을 일관되게 수행할 수 있도록 하는 것을 목적으로 작성한다.
본 마이그레이션은 단번에 모든 인프라(애플리케이션+DB)를 전환할 때 발생하는 리스크를 완화하기 위해 2단계 컷오버(2-Phase Cutover) 방식을 채택한다.
- 1차 컷오버 (서비스 분리): V2 애플리케이션(EC2)을 배포하되 DB는 여전히 V1 PostgreSQL을 바라보게 한다. 이를 통해 애플리케이션 분리 배포의 안정성을 실트래픽으로 먼저 검증한다.
- 2차 컷오버 (DB 전환): V2 애플리케이션 안정성 검증 후, 최종적으로 DB 엔드포인트를 V1에서 V2 RDS로 전환한다.
1차 컷오버(V2 서비스 전환) 이후부터 2차 컷오버(RDS 전환) 완료 시점까지 스키마 변경(DDL)을 수반하는 배포를 전면 금지한다. Logical Replication은 DML만 복제하므로, 이 기간 중 V1 DB의 스키마가 변경되면 복제(Subscription)가 중단된다.
현재 V1 환경의 한계
V1 환경은 MVP 개발 단계에서 빠른 구축을 위해 애플리케이션 서버와 동일 EC2 인스턴스 내에 PostgreSQL을 설치하여 운영하고있다. 해당 인프라 구성 방식은 다음과 같은 문제를 야기한다.
- 애플리케이션과 DB간의 리소스 경합 발생
- 인스턴스 장애 시 애플리케이션과 DB가 동시에 중단되는 단일 장애점(SPOF) 존재
- 수동 백업 및 복구 구성으로 운영 부담 증가
- 스케일 아웃 불가로 추후 V2 목표 트래픽 대응 불가
V2 RDS 전환 필요성
AWS RDS로 전환하면 다음과 같은 이점 확보가 가능하다.
- 자동 백업 및 Point-in-Time-Recovery(PITR) 지원
- 고가용성 확보
- 애플리케이션 서버와 DB 서버의 분리로, 독립적 스케일링 가능
- 모니터링, 패치, 유지보수 자동화로 운영 부담 감소
| 용어 | 설명 |
|---|---|
| V1 DB | 현재 운영 중인 EC2 내 Self-managed PostgreSQL 14.20 |
| V2 DB / RDS | 마이그레이션 목표인 AWS RDS PostgreSQL 14.x |
| Shadow EC2 | EBS 스냅샷으로 V1 DB를 복제한 임시 EC2 인스턴스. DMS의 Source로 활용 |
| Full Load | DMS가 Shadow EC2의 전체 데이터를 RDS로 초기 복사하는 단계 |
| CDC | Change Data Capture. Full Load 이후 V1 DB의 변경분을 실시간으로 RDS에 동기화하는 방식 |
| Lag | CDC 동기화 지연시간. 컷오버 조건 판단 기준 |
| 컷오버 | 애플리케이션의 DB 연결을 V1에서 V2 RDS로 전환하는 시점 |
| 롤백 | 컷오버 후 이상 감지 시 V1 DB로 되돌아가는 절차 |
| WAL | Write-Ahead Log. PostgreSQL의 변경 로그. CDC의 기반이 되는 데이터 |
| DMS | AWS Database Migration Service. Full Load 및 CDC를 관리하는 AWS 관리형 서비스 |
| PITR | Point-in-Time Recovery. 특정 시점으로 DB를 복구하는 기능 |
| RPO | Recovery Point Objective. 데이터 유실 허용 범위. 본 마이그레이션에서는 0으로 설정 |
| RTO | Recovery Time Objective. 서비스 복구 목표 시간. 롤백 기준 30초 이내 |
-
단일 EC2 인스턴스에 애플리케이션(BE, FE, AI) 서버와 DB가 함께 구성되어있다.
-
DB: PostgreSQL 14.20 (Ubuntu 22.04, x86_64)
-
익스텐션: plpgsql 1.0 만 사용 (RDS 완전 호환, 추가 작업 필요 없음)
-
데이터 용량: 약 10MB(DB에 작성된 사용자 데이터)
-
트래픽: 실제 운영 중 (사용자 존재)
-
커넥션 풀: HikariCP (max 20, min-idle 5, connection-timeout: 30,000ms, idle-timeout: 600,000ms)
-
현재 환경 분석을 위한 스크립트
#콘솔 psql로 접속 후 확인 #==========# #postgreSQL 버전 확인 SELECT version(); #==========# #현재 설치, 사용중인 익스텐션 확인 SELECT extname, extversion FROM pg_extension; #==========# #현재 WAL 설정 확인 -- 현재 WAL 레벨 확인 (마이그레이션 전 반드시 확인) SHOW wal_level; -- 예상 결과: replica (기본값) -- 필요 설정: logical (DMS CDC를 위해 변경 필요) SHOW max_replication_slots; -- 기본값: 10 (충분) SHOW max_wal_senders; -- 기본값: 10 (충분)
마이그레이션 완료 후 V2 환경은 애플리케이션(EC2 인스턴스)과 DB(RDS)가 분리된 구조로 전환된다.
- DB: AWS RDS PostgreSQL
- 데이터 10MB로 매우 작은편 (빠른 데이터 이전 가능)
- 운영 트래픽 있음 (데이터 누락 미 허용)
- 다운타임 미허용 (Graceful Shutdown으로 기존 트랜잭션 유지 및 데이터 유실 방지)
| ID | 요구사항 | 설명 |
|---|---|---|
| FR-01 | 전체 데이터 이전 | V1 DB의 모든 테이블, 인덱스, 시퀀스, 제약조건을 V2 RDS로 완전 이전 |
| FR-02 | 데이터 정합성 보장 | 마이그레이션 완료 후 V1과 V2의 데이터가 완전히 일치함을 검증 |
| FR-03 | 서비스 연속성 보장 | 마이그레이션 전 과정에서 사용자 서비스 중단 최소화 |
| FR-04 | 롤백 가능성 보장 | 컷오버 후 이상 감지 시 V1으로 즉시 복구 가능(30초 이내) |
| FR-05 | V1 DB 독립 운영 | 마이그레이션 완료 후 V2 RDS만으로 서비스 운영 |
| FR-06 | 시퀀스 동기화 | 컷오버 시점의 모든 시퀀스 값을 V2에 정확히 반영 |
-
서비스 다운타임 0초를 목표로 진행
단계 다운타임 비고 WAL 레벨 변경 (사전 작업) 30초 이내 최초 생성 시 해당 옵션 활성화 상태로, 재시작 불필요 데이터 동기화 기간 0초 운영 병행 컷오버 (실제 전환) 0초 목표 HikariCP 재연결로 처리 -
데이터 정합성 조건 확인
항목 기준 검증 방법 Row Count V1 = V2 (100% 일치) 테이블별 COUNT 비교 Checksum V1 = V2 (100% 일치) MD5 해시 비교 시퀀스 V2 >= V1 (컷오버 시점 기준) pg_sequences 비교 인덱스 V1과 동일한 인덱스 구성 pg_indexes 비교 제약조건 V1과 동일한 제약조건 구성 information_schema 비교 -
성능 요구사항
항목 기준 비고 Full Load 완료 시간 1시간 이내 데이터 1GB 미만 기준 CDC Lag 컷오버 시점 0ms 목표 1초 이내 허용 컷오버 후 응답시간 V1 대비 110% 이내 성능 저하 허용 범위 롤백 완료 시간 30초 이내 RTO 기준 -
가용성 요구사항
항목 기준 RPO (데이터 유실 허용 범위) 0 (데이터 유실 없음) RTO (서비스 복구 목표 시간) 30초 이내 (롤백 기준) PITR 백업 보존 기간 최소 7일
-
PostgreSQL → RDS 전환 시 동일 메이저 버전 유지로 호환성 유지
-
HikariCP max-pool-size 20인 현재 상태로 DMS로 인한 부하 고려
-
기술적 제약
ID 제약 영향 대응 방안 TC-01 PostgreSQL 14.x 버전 유지 메이저 버전 업그레이드 불가 RDS 14.x로 동일 버전 생성 TC-02 WAL 레벨 변경 시 재시작 필요 30초 다운타임 발생 새벽 시간대 진행, 사전 공지 TC-03 Subscriber가 V1에 직접 연결 복제 전용 커넥션 1개 추가 사용 max-pool-size 20 기준 영향 없음 TC-04 RDS 슈퍼유저 권한 제한 CREATE SUBSCRIPTION 실행 제한 rds_superuser 역할 부여로 해결 가능 TC-05 DMS는 시퀀스 자동 복제 미지원 컷오버 시 시퀀스 불일치 가능 컷오버 직전 수동 동기화 스크립트 실행 TC-06 Logical Replication DDL 미지원 복제 중 스키마 변경 시 복제 중단 가능 마이그레이션 기간 중 스키마 변경 배포 동결 TC-07 복제 전용 유저 권한 설정 필요 V1에 REPLICATION 권한 유저 없으면 불가 마이그레이션 전 복제 전용 유저 사전 생성 -
운영적 제약
ID 제약 영향 대응 방안 OC-01 실제 운영 트래픽 존재 마이그레이션 중 서비스 영향 최소화 복제 전용 커넥션 분리로 운영 트래픽과 경합 방지 OC-02 비용 최소화 필요 임시 리소스 장기 운영 불가 Logical Replication은 추가 비용 없음 OC-03 단독 진행 (소규모 팀) 실시간 모니터링 인력 제한 자동화 스크립트 및 체크리스트로 보완 OC-04 마이그레이션 기간 배포 동결 신규 기능 배포 일시 중단 마이그레이션 기간 최소화 (목표 D-3 ~ D+1)
| 일정 | 단계 | 작업 내용 | 다운타임 | 진행 시간대 |
|---|---|---|---|---|
| D-3일 | 사전 작업 | WAL 레벨 변경 + PostgreSQL 재시작 | 30초 이내 | 새벽 2~4시 |
| D-2일 | 환경 구성 | RDS 생성 + 스키마 적용 + 복제 전용 유저 생성 | 0초 | 상시 |
| D-1일 | 복제 시작 | Publication 생성(V1) + Subscription 생성(RDS) | 0초 | 상시 |
| 초기 데이터 자동 동기화 시작 (copy_data = true) | 0초 | 상시 | ||
| D-Day (1차) | 앱 컷오버 | V2 애플리케이션 배포 시작 (DB 연결은 V1 유지). 실트래픽 검증. | 0초 목표 | 상시 |
| D-Day (중간) | 운영/검증 | V2 서비스 + V1 DB 구조로 운영. (DDL 변경 배포 절대 금지) | 0초 | 최소 수 시간 |
| D-Day (2차) | DB 컷오버 | RDS 초기화 후 재동기화 → Lag 0 확인 → 시퀀스 동기화 → HikariCP DB 연결(V1→RDS) 전환 | 0초 목표 | 새벽 시간대 |
| D+1 ~ D+3 | 사후 관리 | 모니터링 + Publication/Subscription 삭제 | 0초 | 상시 |
-
트리거 플로우 차트 스크립트
Loadingflowchart TD A[컷오버 완료] --> B{모니터링 이상 감지} B --> |정상| C[D+1 ~ D+3 모니터링 유지] C --> D[V1 DB / Shadow EC2 / DMS 삭제] D --> E[마이그레이션 완료 ✅] B --> |이상 감지| F{트리거 조건 확인} F --> G[에러율 5% 초과] F --> H[응답시간 V1 대비 200% 초과] F --> I[DB 연결 실패] F --> J[데이터 불일치 감지] G --> K[롤백 결정] H --> K I --> K J --> K K --> L[V1 DB read-only 해제] L --> M[Spring Boot → V1 재연결] M --> N[헬스체크 확인] N --> |성공| O[롤백 완료 ✅\n소요시간 30초 이내] N --> |실패| P[긴급 대응\n온콜 알림] O --> Q[원인 분석 후 재마이그레이션 계획] style A fill:#4CAF50,color:#fff style E fill:#4CAF50,color:#fff style O fill:#2196F3,color:#fff style K fill:#FF5722,color:#fff style P fill:#B71C1C,color:#fff style G fill:#FFF3E0,color:#333 style H fill:#FFF3E0,color:#333 style I fill:#FFF3E0,color:#333 style J fill:#FFF3E0,color:#333
*(참고: 1차 애플리케이션 컷오버 후 앱 단의 롤백은, 로드밸런서(ALB) 타겟을 다시 V1 EC2로 원복하는 방식으로 처리함.)*
# 5. 리스크 분석
## 5.1. 리스크 목록 및 영향도
| ID | 리스크 | 발생 가능성 | 영향도 | 위험도 |
| --- | --- | --- | --- | --- |
| R-01 | WAL 재시작 중 데이터 유실 | 낮음 | 매우 높음 | 🔴 높음 |
| R-02 | Logical Replication Lag 증가 | 중간 | 높음 | 🔴 높음 |
| R-03 | 시퀀스 불일치로 PK 충돌 | 높음 | 높음 | 🔴 높음 |
| R-04 | 컷오버 후 데이터 불일치 감지 | 중간 | 매우 높음 | 🔴 높음 |
| R-05 | HikariCP 재연결 실패 | 낮음 | 높음 | 🟠 중간 |
| R-06 | 복제 중 DDL 변경으로 복제 중단 | 낮음 | 높음 | 🟠 중간 |
| R-07 | V1 DB 직접 부하로 운영 영향 | 낮음 | 중간 | 🟡 낮음 |
```mermaid
quadrantChart
title Risk Matrix
x-axis Low Probability --> High Probability
y-axis Low Impact --> High Impact
quadrant-1 Immediate Action
quadrant-2 Monitor Continuously
quadrant-3 Low Priority
quadrant-4 Preventive Action
R-01 WAL Restart: [0.2, 0.95]
R-02 Replication Lag: [0.5, 0.80]
R-03 Sequence Mismatch: [0.75, 0.80]
R-04 Data Inconsistency: [0.5, 0.90]
R-05 HikariCP Reconnect: [0.2, 0.70]
R-06 DDL Change: [0.2, 0.70]
R-07 V1 Direct Load: [0.2, 0.45]
R-01. WAL 재시작 중 데이터 유실 🔴
-- [사전 조치 1] 재시작 전 활성 트랜잭션 확인
SELECT pid, usename, application_name,
state, wait_event_type, query_start,
now() - query_start AS duration
FROM pg_stat_activity
WHERE state != 'idle'
AND query_start IS NOT NULL;# [사전 조치 2] 재시작 전 pg_dump로 백업 (복구 포인트 확보)
pg_dump -U postgres \
-h localhost \
-d devths \
-F c \
-f "/backup/devths_pre_migration_$(date +%Y%m%d%H%M%S).dump"
# [사전 조치 3] postgresql.conf WAL 레벨 변경
sudo sed -i "s/#wal_level = replica/wal_level = logical/" \
/etc/postgresql/14/main/postgresql.conf
# [사전 조치 4] PostgreSQL 재시작 (30초 이내)
sudo systemctl restart postgresql
# [사후 확인] WAL 레벨 변경 확인
psql -U postgres -c "SHOW wal_level;"
# 결과: logicalR-02. DMS CDC 지연 증가 🔴
-- [모니터링] V1에서 복제 Lag 실시간 확인
SELECT
application_name,
client_addr,
state,
pg_size_pretty(pg_wal_lsn_diff(
pg_current_wal_lsn(), sent_lsn
)) AS send_lag,
pg_size_pretty(pg_wal_lsn_diff(
sent_lsn, write_lsn
)) AS write_lag,
pg_size_pretty(pg_wal_lsn_diff(
write_lsn, replay_lsn
)) AS replay_lag,
pg_wal_lsn_diff(
pg_current_wal_lsn(), replay_lsn
) AS total_lag_bytes
FROM pg_stat_replication;
-- [컷오버 조건] total_lag_bytes = 0 확인 후 진행# [자동화] Lag 모니터링 스크립트 (5초 간격)
while true; do
LAG=$(psql -U postgres -t -c "
SELECT pg_wal_lsn_diff(
pg_current_wal_lsn(), replay_lsn
)
FROM pg_stat_replication
WHERE application_name = 'devths_sub';
")
echo "$(date '+%H:%M:%S') Replication Lag: ${LAG} bytes"
if [ "${LAG}" -eq 0 ]; then
echo "✅ Lag 0 확인 - 컷오버 진행 가능"
break
fi
sleep 5
doneR-03. 시퀀스 불일치로 PK 충돌 🔴
-- [컷오버 직전] V2 RDS에서 실행
DO $$
DECLARE
seq_record RECORD;
max_val BIGINT;
seq_query TEXT;
BEGIN
FOR seq_record IN
SELECT
n.nspname AS schema_name,
s.relname AS seq_name,
t.relname AS table_name,
a.attname AS col_name
FROM pg_class s
JOIN pg_namespace n ON n.oid = s.relnamespace
JOIN pg_depend d ON d.objid = s.oid
JOIN pg_class t ON t.oid = d.refobjid
JOIN pg_attribute a ON a.attrelid = t.oid
AND a.attnum = d.refobjsubid
WHERE s.relkind = 'S'
LOOP
seq_query := format(
'SELECT COALESCE(MAX(%I), 0) FROM %I.%I',
seq_record.col_name,
seq_record.schema_name,
seq_record.table_name
);
EXECUTE seq_query INTO max_val;
PERFORM setval(
format('%I.%I',
seq_record.schema_name,
seq_record.seq_name),
max_val + 1000
);
RAISE NOTICE '✅ 시퀀스 동기화 완료: %.% → %',
seq_record.table_name,
seq_record.col_name,
max_val + 1000;
END LOOP;
END $$;-- [검증] 동기화 후 시퀀스 현황 확인
SELECT
sequencename,
last_value,
increment_by,
max_value
FROM pg_sequences
ORDER BY sequencename;R-04. 컷오버 후 데이터 불일치 감지 🔴
-- [컷오버 전 검증 1] Row Count 비교
-- V1과 V2 양쪽에서 실행 후 결과 대조
SELECT
schemaname,
tablename,
n_live_tup AS row_count
FROM pg_stat_user_tables
ORDER BY tablename;-- [컷오버 전 검증 2] Checksum 비교
-- V1과 V2 양쪽에서 실행 후 결과 대조
SELECT
tablename,
md5(string_agg(t::text, ',' ORDER BY ctid)) AS checksum
FROM (
SELECT tablename, tableoid::regclass t
FROM pg_tables
WHERE schemaname = 'public'
) sub
GROUP BY tablename;# [컷오버 후] 애플리케이션 헬스체크 모니터링
while true; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
http://localhost:8080/actuator/health)
echo "$(date '+%H:%M:%S') Health: $STATUS"
if [ "$STATUS" != "200" ]; then
echo "🚨 이상 감지 - 롤백 트리거"
break
fi
sleep 5
doneR-05. HikariCP 재연결 실패 🟠
# [컷오버 전] RDS 연결 사전 테스트
psql -h RDS_ENDPOINT \
-U devths_user \
-d devths \
-c "SELECT version();"
# [컷오버 전] Security Group 포트 확인
aws ec2 describe-security-groups \
--group-ids sg-xxxxxxxxx \
--query 'SecurityGroups[*].IpPermissions' \
--region ap-northeast-2# [Spring Boot] 컷오버 시 적용할 RDS 연결 설정
spring:
datasource:
url: jdbc:postgresql://RDS_ENDPOINT:5432/devths
username: devths_user
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
initialization-fail-timeout: 0
keepalive-time: 30000R-06. 복제 중 DDL 변경으로 복제 중단 🟠
-- [사전 조치] 복제 상태 모니터링
-- DDL 변경 발생 시 복제가 중단되고 아래 쿼리에서 감지 가능
SELECT
subname,
subenabled,
subslotname
FROM pg_subscription;
-- 복제 중단 감지 시 Subscription 재생성 필요
DROP SUBSCRIPTION devths_sub;
CREATE SUBSCRIPTION devths_sub
CONNECTION 'host=V1_IP port=5432 dbname=devths user=repl_user password=<repl-password>'
PUBLICATION devths_pub
WITH (copy_data = false); -- 데이터 재복사 없이 재연결[운영 프로세스] 마이그레이션 기간 배포 동결 절차
- 마이그레이션 시작 전 팀 전체 공지
- D-1일 ~ D+1일 스키마 변경 배포 금지
- 긴급 배포가 필요한 경우 마이그레이션 일정 재조정
R-07. V1 DB 직접 부하로 운영 영향 🟡
-- [모니터링] V1 복제 커넥션 확인
SELECT
pid,
usename,
application_name,
client_addr,
state,
backend_type
FROM pg_stat_activity
WHERE backend_type = 'walsender';
-- 복제 전용 커넥션 1개만 존재해야 정상
-- 운영 트래픽 커넥션과 분리되어 있음을 확인즉시 대응 필요 (마이그레이션 시작 전 반드시 해소)
├── R-01: WAL 변경 전 pg_dump 백업 완료
├── R-02: Lag 모니터링 스크립트 사전 검증 완료
├── R-03: 시퀀스 동기화 스크립트 사전 검증 완료
└── R-06: 팀 전체 배포 동결 공지 완료
컷오버 시점 실시간 모니터링
├── R-02: Lag 0 확인 후 컷오버 진행
├── R-04: 데이터 검증 2단계 완료 후 진행
└── R-05: RDS 연결 테스트 완료 후 진행
컷오버 후 72시간 모니터링
├── R-04: 데이터 불일치 감지 시 즉시 롤백
└── R-07: 응답시간 이상 감지 시 롤백 트리거| 항목 | DMS 단독 | Shadow EC2 + DMS | Logical Replication 단독 (선택) |
|---|---|---|---|
| 무중단 여부 | ✅ | ✅ | ✅ |
| 데이터 유실 없음 | ✅ | ✅ | ✅ |
| V1 운영 DB 부하 | 🔴 직접 부하 | ✅ 없음 (Shadow 분리) | 🟡 복제 커넥션 1개 추가 |
| RDS 호환성 | ✅ | ✅ | ✅ (rds_superuser로 해결) |
| 시퀀스 자동 복제 | ❌ | ❌ | ❌ (수동 동기화) |
| 데이터 검증 도구 | ✅ DMS 내장 | ✅ DMS 내장 | 🟠 직접 구현 필요 |
| 롤백 용이성 | 🟠 중간 | ✅ 높음 | 🟠 중간 |
| 구현 복잡도 | 낮음 | 높음 | 중간 |
| 비용 | 🟠 DMS 유료 | 🔴 DMS + Shadow EC2 | ✅ 무료 |
장점
- 구성이 단순하고 AWS GUI로 진행 가능
- DMS 내장 데이터 검증 기능 활용 가능
단점
- DMS가 V1 운영 DB를 직접 읽어 운영 트래픽과 부하 경합
- HikariCP max-pool-size 20 기준 커넥션 경합 가능성
- Shadow EC2 없으므로 롤백 포인트 부족
제외 근거
- 운영 트래픽이 존재하는 상황에서 V1 DB 직접 부하는 서비스 안정성 리스크가 있음
- 추가 비용 대비 Logical Replication 대비 이점 없음
장점
- Shadow EC2가 V1 운영 DB를 완전히 보호
- 단계별 롤백 포인트 명확히 확보
- DMS 내장 검증 기능 활용 가능
단점
- 구성 단계가 많아 복잡도 높음
- DMS + Shadow EC2 비용 이중 발생
- 시퀀스 수동 동기화 필요
제외 근거
- 데이터 1GB 미만, MAU 200명 수준의 트래픽에서 Shadow EC2까지 구성하는 것은 과도한 복잡도
- 비용 대비 효율이 낮음
장점
- 추가 AWS 비용 없이 PostgreSQL 네이티브 기능만으로 구현
- 복제 커넥션 1개만 추가되어 V1 운영 트래픽 영향 최소
- WAL, LSN, Publication/Subscription 등 DB 내부 원리를 직접 다루며 제어 가능.
- 구성 단계가 적어 실수 포인트 최소화
단점
- DMS 대비 데이터 검증 도구 부재로 직접 구현 필요
- 복제 중 DDL 변경 시 복제 중단 가능 (배포 동결로 대응)
- 시퀀스 수동 동기화 필요 (DMS도 동일한 제약)
flowchart TD
A[마이그레이션 방식 선정] --> B{운영 트래픽 존재?}
B --> |Yes| C{비용 최소화 필요?}
B --> |No| D[DMS 단독 가능]
C --> |Yes| E{RDS 권한 제약 해결 가능?}
C --> |No| F[Shadow EC2 + DMS]
E --> |Yes - rds_superuser 활용| G{데이터 검증 직접 구현 가능?}
E --> |No| F
G --> |Yes| H[✅ Logical Replication 단독 선택]
G --> |No| F
style H fill:#4CAF50,color:#fff
style F fill:#9E9E9E,color:#fff
style D fill:#9E9E9E,color:#fff
- 비용 최소화 DMS Replication Instance + Shadow EC2 비용 없이 PostgreSQL 네이티브 기능만으로 동일한 목표 달성 가능. 데이터 1GB 미만 규모에서 DMS는 과도한 투자임.
- V1 운영 트래픽 영향 최소화 복제 전용 커넥션 1개만 추가되며 HikariCP max-pool-size 20 기준으로 운영 트래픽에 실질적 영향 없음.
- RDS 권한 제약 해결 가능 rds_superuser 역할 부여로 CREATE SUBSCRIPTION 실행 가능. 실제 운영에서 검증된 방식임.
- 데이터 검증 직접 구현 가능 Row Count + Checksum 검증 스크립트를 직접 구현하여 DMS 내장 검증 도구 부재를 보완.
검토했으나 제외한 방식
- DMS 단독: V1 운영 DB 직접 부하 + 불필요한 비용 발생
- Shadow EC2 + DMS: 소규모 환경 대비 과도한 복잡도와 비용
[사용자]
↓ HTTPS (443)
↓ HTTP → HTTPS 리다이렉트 (80)
[devths.com / api.devths.com / ai.devths.com]
↓ DNS
[devths-v1-prod (<v1-ec2-instance-id>)]
[<vpc-id> / <subnet-id> / <v1-ec2-private-ip> / <v1-ec2-public-ip>]
└── Nginx (Local Proxy)
├── devths.com / www.devths.com → Next.js (localhost:3000)
├── api.devths.com → Spring Boot (localhost:8080)
└── ai.devths.com → FastAPI (localhost:8000)
│
↓ localhost:5432
PostgreSQL 14.20 (Self-managed)
devths DB (10MB)[devths-V1-prod-vpc]
<vpc-id> / <vpc-cidr>
│
│ devths-v1-prod (<v1-ec2-instance-id> / <v1-ec2-private-ip>)
│ ├── Nginx
│ │ ├── api.devths.com → Spring Boot (V1 DB 연결 유지)
│ │ ├── devths.com → Next.js
│ │ └── ai.devths.com → FastAPI
│ └── PostgreSQL 14.20
│ └── Publication: devths_pub (FOR ALL TABLES)
│ │
│ │ WAL 변경분 실시간 전송 (5432)
│ │ VPC Peering (<pcx-id>)
│ │ <vpc-cidr> ↔ <v2-vpc-cidr>
│ ↓
[devths-V2-prod-vpc]
vpc-0b4e73006f0c0b7e2 / <v2-vpc-cidr>
│
│ devths-v2-prod-rds
│ devths-v2-prod-rds.<rds-endpoint>
│ ├── Subscription: devths_sub (변경분 수신 및 적용)
│ ├── Primary (ap-northeast-2a / <subnet-id> / 172.16.20.0/24)
│ └── Standby (ap-northeast-2c / <subnet-id> / 172.16.21.0/24)[사용자]
↓ HTTPS (443)
[api.devths.com]
↓
[devths-V2-prod-vpc / vpc-0b4e73006f0c0b7e2 / <v2-vpc-cidr>]
│
│ Spring Boot (devths-v2-prod-be-sg / sg-0846764a14cd4ba66)
│ ↓ Private Subnet 내부 통신 (5432)
│
│ devths-v2-prod-rds (PostgreSQL 14.17)
│ devths-v2-prod-rds.<rds-endpoint>
│ ├── Primary (ap-northeast-2a / <subnet-id>)
│ └── Standby (ap-northeast-2c / <subnet-id>)
│ ↓
│ 자동 백업 + PITR (보존 7일)| 컴포넌트 | 역할 | 운영 기간 |
|---|---|---|
| devths-v1-prod ( / ) | 운영 서버 + Publisher (복제 송신) | ~ D+3일 |
| PostgreSQL 14.20 (V1 내부 / localhost:5432) | 복제 원본 DB | ~ D+3일 |
| Nginx (V1) | 단일 EC2 내부 리버스 프록시 | ~ 컷오버 |
| devths-v2-prod-rds (PostgreSQL 14.17) | Subscriber (복제 수신) → 컷오버 후 운영 DB | D-2일 ~ 영구 |
| Publication: devths_pub | V1 전체 테이블 복제 정의 | D-1일 ~ D+3일 |
| Subscription: devths_sub | RDS에서 V1 변경분 수신 및 적용 | D-1일 ~ D+3일 |
| VPC Peering () | V1 VPC ↔ V2 prod VPC 통신 | 마이그레이션 기간 |
| devths-prod-rds-sg () | RDS 인바운드 5432 허용 () | 마이그레이션 기간 |
| devths-V1-prod-ec2 () | V1 인바운드 5432 허용 () | 마이그레이션 기간 |
| VPC | VPC ID | CIDR | 용도 |
|---|---|---|---|
| devths-V1-prod-vpc | V1 운영 환경 | ||
| devths-V2-prod-vpc | vpc-0b4e73006f0c0b7e2 | V2 운영 환경 |
VPC Peering: (active)
| 라우팅 테이블 | VPC | 목적지 | 타겟 |
|---|---|---|---|
| devths-V1-prod-vpc | local | ||
| devths-V1-prod-vpc | ✅ | ||
| devths-V2-prod-vpc | ✅ | ||
| devths-V2-prod-vpc | local | ||
| devths-V2-prod-vpc | 0.0.0.0/0 |
| Security Group | 방향 | 포트 | 소스 | 목적 |
|---|---|---|---|---|
| devths-prod-rds-sg () | Inbound | 5432 | V1 EC2 → RDS 연결 ✅ | |
| devths-V1-prod-ec2 () | Inbound | 5432 | RDS Subscriber → V1 Publisher ✅ | |
| devths-V1-prod-ec2 () | Inbound | 80 | 0.0.0.0/0 | HTTP (HTTPS 리다이렉트) |
| devths-V1-prod-ec2 () | Inbound | 443 | 0.0.0.0/0 | HTTPS |
| devths-V1-prod-ec2 () | Inbound | 9100 | 192.168.0.0/16 | Node Exporter 모니터링 |
devths-v1-prod (<v1-ec2-private-ip>:5432)
│
│ ① RDS Subscriber가 V1 Publisher에 연결 요청
│ 172.16.x.x → <v1-ec2-private-ip>:5432
│ <v1-sg-id> Inbound 5432 허용 ✅
│
│ ② V1 WAL 변경분 전송
│ <v1-ec2-private-ip> → devths-v2-prod-rds:5432
│ VPC Peering <pcx-id> 경유
│ <rds-sg-id> Inbound 5432 허용 ✅
↓
devths-v2-prod-rds
(devths-v2-prod-rds.<rds-endpoint>:5432)
[V1 devths-v1-prod / <v1-ec2-private-ip>]
│
│ wal_level = logical 설정 후
│ pgoutput 플러그인이 WAL을 논리적 이벤트로 디코딩
│ INSERT / UPDATE / DELETE 단위로 변환
↓
[Publication: devths_pub]
CREATE PUBLICATION devths_pub FOR ALL TABLES
│
│ PostgreSQL Replication Protocol
│ VPC Peering (<pcx-id>)
│ <v1-ec2-private-ip>:5432 → devths-v2-prod-rds:5432
↓
[Subscription: devths_sub]
devths-v2-prod-rds에서 수신 및 적용
Logical Replication Worker가 변경분 처리
│
↓
[LSN 기반 동기화 위치 추적]
-- V1에서 현재 WAL 위치 확인
SELECT pg_current_wal_lsn();
-- RDS에서 복제 수신 위치 확인
SELECT
subname,
received_lsn,
latest_end_lsn,
latest_end_time
FROM pg_stat_subscription;
-- received_lsn = V1의 pg_current_wal_lsn() 이면 Lag = 0
-- V1에서 복제 지연 모니터링
SELECT
application_name,
client_addr,
state,
pg_size_pretty(pg_wal_lsn_diff(
pg_current_wal_lsn(), sent_lsn
)) AS send_lag,
pg_size_pretty(pg_wal_lsn_diff(
sent_lsn, write_lsn
)) AS write_lag,
pg_size_pretty(pg_wal_lsn_diff(
write_lsn, replay_lsn
)) AS replay_lag
FROM pg_stat_replication;복제 완료 된 정보가 저장 될 타켓 RDS를 생성
복제 전용 유저는 V1 PostgreSQL에 생성한다. 운영 유저와 분리하여 복제 권한만 부여한다.
-- V1 EC2에서 실행
-- sudo -u postgres psql
-- 복제 전용 유저 생성
CREATE USER repl_user WITH REPLICATION LOGIN PASSWORD '<repl-password>';
-- devths DB 접근 권한 부여
GRANT CONNECT ON DATABASE devths TO repl_user;
-- 스키마 내 모든 테이블 SELECT 권한 부여 (초기 데이터 복사용)
GRANT USAGE ON SCHEMA public TO repl_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO repl_user;
-- 이후 생성되는 테이블에도 자동 권한 부여
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO repl_user;-- 생성 확인
SELECT usename, userepl FROM pg_user WHERE usename = 'repl_user';
-- usename | userepl
-- -----------+---------
-- repl_user | tRDS Subscriber가 V1 Publisher에 접속할 수 있도록 pg_hba.conf에 허용 규칙을 추가.
# V1 EC2에서 실행
sudo vi /etc/postgresql/14/main/pg_hba.conf
#아래 내용을 파일 하단에 추가
# Logical Replication - RDS Subscriber 허용 (devths-V2-prod-vpc CIDR)
host replication repl_user <v2-vpc-cidr> scram-sha-256
host devths repl_user <v2-vpc-cidr> scram-sha-256# 설정 반영 (재시작 없이 적용)
sudo -u postgres psql -c "SELECT pg_reload_conf();"
# 반영 확인
sudo -u postgres psql -c "SELECT type, database, user_name, address, auth_method FROM pg_hba_file_rules WHERE user_name && ARRAY['repl_user'];"WAL 레벨을 replica에서 logical로 변경. PostgreSQL 재시작이 필요하므로 새벽 시간대에 진행.
# [사전 확인 1] 활성 트랜잭션 확인 (없어야 진행 가능)
sudo -u postgres psql -c "
SELECT pid, usename, state, query_start,
now() - query_start AS duration
FROM pg_stat_activity
WHERE state != 'idle'
AND query_start IS NOT NULL;"# [사전 확인 2] 현재 WAL 레벨 확인
sudo -u postgres psql -c "SHOW wal_level;"
# 결과: replica# [사전 조치] pg_dump로 백업 (복구 포인트 확보)
sudo -u postgres pg_dump \
-d devths \
-F c \
-f "/tmp/devths_pre_migration_$(date +%Y%m%d%H%M%S).dump"
# 백업 파일 확인
ls -lh /tmp/devths_pre_migration_*.dump# [WAL 레벨 변경] postgresql.conf 수정
sudo sed -i "s/#wal_level = replica/wal_level = logical/" \
/etc/postgresql/14/main/postgresql.conf
# 혹시 주석이 없는 경우
sudo sed -i "s/wal_level = replica/wal_level = logical/" \
/etc/postgresql/14/main/postgresql.conf
# 변경 내용 확인
grep "wal_level" /etc/postgresql/14/main/postgresql.conf# [PostgreSQL 재시작] (30초 이내)
sudo systemctl restart postgresql
# [재시작 후 확인]
sudo -u postgres psql -c "SHOW wal_level;"
# 결과: logical ✅V1 DB의 스키마를 RDS에 적용. 데이터는 복제하지 않고 스키마(DDL)만 먼저 적용
# V1 EC2에서 실행
# 스키마만 추출 (데이터 제외)
sudo -u postgres pg_dump \
-d devths \
--schema-only \
-F p \
-f "/tmp/devths_schema_$(date +%Y%m%d%H%M%S).sql"
# 추출 확인
ls -lh /tmp/devths_schema_*.sql# RDS에 스키마 적용
psql \
-h devths-v2-prod-rds.<rds-endpoint> \
-U devths \
-d devths \
-f /tmp/devths_schema_*.sql-- 스키마 적용 확인 (RDS에서 실행)
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;파라미터 그룹의 rds.logical_replication = 1이 pending-reboot 상태이므로 재시작이 필요
# RDS 재시작
aws rds reboot-db-instance \
--db-instance-identifier devths-v2-prod-rds \
--region ap-northeast-2# 재시작 완료 확인
aws rds describe-db-instances \
--db-instance-identifier devths-v2-prod-rds \
--query 'DBInstances[*].{Status:DBInstanceStatus,ParameterApplyStatus:DBParameterGroups[0].ParameterApplyStatus}' \
--output table \
--region ap-northeast-2
# ParameterApplyStatus: in-sync 확인-- RDS에서 logical_replication 설정 확인
SHOW rds.logical_replication;
-- 결과: on ✅- devths-v2-prod-rds 생성 완료 (PostgreSQL 14.17, available)
- rds.logical_replication = 1 적용 완료 (in-sync)
- VPC Peering 라우팅 설정 완료 ()
- Security Group 설정 완료
- devths-prod-rds-sg ← 5432
- devths-V1-prod-ec2 ← 5432
- pg_dump 백업 완료
- wal_level = logical 변경 완료
- PostgreSQL 재시작 완료 (30초 이내)
- repl_user 생성 완료 (REPLICATION 권한)
- pg_hba.conf 설정 완료 ( 허용)
- pg_reload_conf() 반영 완료
- 스키마 적용 완료 (테이블 목록 V1과 일치)
- RDS 재시작 완료 (rds.logical_replication in-sync)
- RDS 연결 테스트 완료
- V1 → RDS 연결 테스트
- RDS → V1 연결 테스트 (복제 유저)
# V1 EC2에서 RDS 연결 테스트
psql \
-h devths-v2-prod-rds.<rds-endpoint> \
-U devths \
-d devths \
-c "SELECT version();"# RDS에서 V1 연결 테스트는 아래처럼 확인 가능
# (Subscription 생성 시 자동으로 연결 시도하므로 사전 확인 용도)
# V1 EC2에서 repl_user 로컬 접속 테스트
sudo -u postgres psql \
-U repl_user \
-d devths \
-c "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'public';"| 일정 | Phase | 작업 | 소요시간 | 다운타임 |
|---|---|---|---|---|
| D-3일 02:00 | Phase 0 | WAL 레벨 변경 + PostgreSQL 재시작 | ~5분 | 30초 이내 |
| D-2일 | Phase 1 | 복제 전용 유저 생성 + pg_hba.conf + 스키마 적용 | ~1시간 | 0초 |
| D-1일 | Phase 2 | Publication 생성 + RDS 재시작 + Subscription 생성 | ~30분 | 0초 |
| 초기 데이터 자동 동기화 시작 (copy_data = true) | ~10분 | 0초 |
# [Step 1] 활성 트랜잭션 확인 (없어야 진행)
sudo -u postgres psql -d devths -c "
SELECT pid, usename, state,
now() - query_start AS duration
FROM pg_stat_activity
WHERE state != 'idle'
AND query_start IS NOT NULL;"# [Step 2] pg_dump 백업 (복구 포인트 확보)
sudo -u postgres pg_dump \
-d devths \
-F c \
-f "/tmp/devths_pre_migration_$(date +%Y%m%d%H%M%S).dump"
ls -lh /tmp/devths_pre_migration_*.dump# [Step 3] wal_level 변경
sudo sed -i "s/wal_level = replica/wal_level = logical/" \
/etc/postgresql/14/main/postgresql.conf
# 변경 확인
grep "wal_level" /etc/postgresql/14/main/postgresql.conf
# 결과: wal_level = logical# [Step 4] PostgreSQL 재시작 (30초 이내)
sudo systemctl restart postgresql
# [Step 5] 변경 확인
sudo -u postgres psql -c "SHOW wal_level;"
# 결과: logical ✅# [Step 6] 서비스 정상 확인
curl -s -o /dev/null -w "%{http_code}" https://api.devths.com/actuator/health
# 결과: 200 ✅# [Step 1] 복제 전용 유저 생성
sudo -u postgres psql -d devths -c "
CREATE USER repl_user WITH REPLICATION LOGIN PASSWORD '<repl-password>';
GRANT CONNECT ON DATABASE devths TO repl_user;
GRANT USAGE ON SCHEMA public TO repl_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO repl_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO repl_user;"# [Step 2] 유저 생성 확인
sudo -u postgres psql -c "
SELECT usename, userepl
FROM pg_user
WHERE usename = 'repl_user';"
# userepl: t ✅# [Step 3] pg_hba.conf 설정
sudo tee -a /etc/postgresql/14/main/pg_hba.conf << 'EOF'
# Logical Replication - RDS Subscriber 허용 (devths-V2-prod-vpc)
host replication repl_user <v2-vpc-cidr> scram-sha-256
host devths repl_user <v2-vpc-cidr> scram-sha-256
EOF# [Step 4] pg_hba.conf 반영
sudo -u postgres psql -c "SELECT pg_reload_conf();"
# 반영 확인
sudo -u postgres psql -c "
SELECT type, database, user_name, address, auth_method
FROM pg_hba_file_rules
WHERE user_name && ARRAY['repl_user'];"# [Step 5] 스키마 추출 (V1)
sudo -u postgres pg_dump \
-d devths \
--schema-only \
-F p \
-f "/tmp/devths_schema_$(date +%Y%m%d%H%M%S).sql"
ls -lh /tmp/devths_schema_*.sql# [Step 6] RDS에 스키마 적용
psql \
-h devths-v2-prod-rds.<rds-endpoint> \
-U devths \
-d devths \
-f /tmp/devths_schema_*.sql-- [Step 7] 스키마 적용 확인 (RDS에서 실행)
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
-- V1과 동일한 테이블 목록 확인 ✅# [Step 8] V1 → RDS 연결 테스트
psql \
-h devths-v2-prod-rds.<rds-endpoint> \
-U devths \
-d devths \
-c "SELECT version();"
# PostgreSQL 14.17 확인 ✅# [Step 1] RDS 재시작 (rds.logical_replication 적용)
aws rds reboot-db-instance \
--db-instance-identifier devths-v2-prod-rds \
--region ap-northeast-2# [Step 2] RDS 재시작 완료 확인
aws rds describe-db-instances \
--db-instance-identifier devths-v2-prod-rds \
--query 'DBInstances[*].{Status:DBInstanceStatus,ParameterApplyStatus:DBParameterGroups[0].ParameterApplyStatus}' \
--output table \
--region ap-northeast-2
# Status: available, ParameterApplyStatus: in-sync ✅-- [Step 3] RDS logical_replication 설정 확인
SHOW rds.logical_replication;
-- 결과: on ✅-- [Step 4] Publication 생성 (V1에서 실행)
-- sudo -u postgres psql -d devths
CREATE PUBLICATION devths_pub FOR ALL TABLES;
-- 확인
SELECT pubname, puballtables, pubinsert, pubupdate, pubdelete
FROM pg_publication;
-- pubname | puballtables | pubinsert | pubupdate | pubdelete
-- -------------+--------------+-----------+-----------+-----------
-- devths_pub | t | t | t | t ✅-- [Step 5] Subscription 생성 (RDS에서 실행)
GRANT rds_superuser TO devths;
CREATE SUBSCRIPTION devths_sub
CONNECTION 'host=<v1-ec2-private-ip> port=5432 dbname=devths user=repl_user password=<repl-password>'
PUBLICATION devths_pub
WITH (copy_data = true);
-- copy_data = true: 초기 데이터 자동 복사 시작-- [Step 6] Subscription 생성 확인
SELECT subname, subenabled, subslotname
FROM pg_subscription;
-- subname | subenabled | subslotname
-- -------------+------------+-------------
-- devths_sub | t | devths_sub ✅-- [Step 7] 초기 동기화 상태 확인 (RDS에서 실행)
SELECT subname,
relid::regclass AS table_name,
srsubstate
FROM pg_subscription_rel
JOIN pg_subscription ON pg_subscription.oid = pg_subscription_rel.srsubid
ORDER BY table_name;
-- srsubstate 상태값
-- 'i': 초기화 중
-- 'd': 데이터 복사 중
-- 'r': 동기화 완료 ✅-- [Step 8] 복제 슬롯 생성 확인 (V1에서 실행)
SELECT slot_name, plugin, slot_type, active
FROM pg_replication_slots;
-- slot_name | plugin | slot_type | active
-- -------------+----------+-----------+--------
-- devths_sub | pgoutput | logical | t ✅- V2 환경(BE, AI, FE EC2) 인프라 배포를 완료한다. (이때, Spring Boot HikariCP 설정은 V1 DB IP를 유지)
- ALB 타겟 그룹 스위칭: 타겟 그룹을 V1에서 V2 EC2로 스위칭(무중단 트래픽 전환)한다. 헬스체크 정상 여부를 확인한다.
- 실트래픽 환경하에서 V2 신규 애플리케이션 환경의 안정성을 검증하며 운영한다.
⚠️ 주의 (DDL 동결): 이 시점(1차 컷오버 완료)부터 2차 컷오버(DB 전환)가 끝날 때까지, V1 DB에 스키마 변경(ADD COLUMN 등 구조 변경)을 발생시키는 릴리즈/배포는 절대 금지된다.
이 Phase 시작 시점에 k6 부하를 ON합니다.
최초 migration_load.js 작성 시 POST 요청의 title 에 VU(Virtual User)와 Iteration 번호를 삽입하여 부하 트래픽임을 명확히 식별할 수 있도록 구성했습니다.
// migration_load.js 내 POST payload 예시
const payload = JSON.stringify({
title: `마이그레이션 부하 테스트 VU${__VU}-#${__ITER}`,
content: "마이그레이션 성능 테스트를 위한 게시글입니다.",
});터미널을 2개로 분리해서 병렬 진행합니다.
터미널 1 (부하 주입) k6 run -e TOKEN="<BEER_TOKEN>" migration_load.js → stages: 0→5VU(2m) → 20VU(5m) → 20VU(999m, 컷오버까지 유지)
터미널 2 (Lag 모니터링) bash lag_monitor.sh → 5초 간격으로 Lag 실시간 출력
🚨 트러블슈팅 (Rate Limit 이슈)
- 현상: 부하 주입 중 API 환경의 Redis 기반 Rate Limiting(ex. 1분당 100건)에 도달하여 HTTP 429 에러가 다량 발생했습니다.
- 조치: V2 Redis 인스턴스의 Rate Limit Key(
rate-limit:board:*)를 주기적으로 날려주는 쉘 스크립트(reset_rl.sh)를 작성하여 별도의 터미널(터미널 3)에서 1초 주기로 백그라운드 실행하도록 하여 해결했습니다.- 재시도: 이전 부하 데이터를 일괄
DELETE쿼리로 정리한 뒤, Rate Limit 우회 스크립트와 함께 k6 부하를 재개하여 안정적인 0% 에러율을 유지한 채 컷오버를 진행했습니다.
# reset_rl.sh
#!/bin/bash
while true; do
redis-cli -h <redis-endpoint> -p 6379 KEYS "rate-limit:board:*" | xargs -r redis-cli -h <redis-endpoint> -p 6379 DEL
sleep 1
donek6 확인 포인트:
- 트래픽이 있는 상태에서 CDC Lag이 0으로 수렴하는지
- k6 에러율 0% 유지 여부 (thresholds: http_req_failed < 1%)
- V1 DB 커넥션 수 max 20 미만 유지 여부
-- [검증 1] 테이블별 Row Count 비교
-- V1과 RDS 양쪽에서 실행 후 결과 대조
SELECT schemaname, tablename, n_live_tup AS row_count
FROM pg_stat_user_tables
ORDER BY tablename;-- [검증 2] Checksum 비교
-- V1과 RDS 양쪽에서 실행 후 결과 대조
SELECT
tablename,
md5(string_agg(t::text, ',' ORDER BY ctid)) AS checksum
FROM (
SELECT tablename, tableoid::regclass t
FROM pg_tables
WHERE schemaname = 'public'
) sub
GROUP BY tablename;-- [검증 3] 시퀀스 목록 비교
SELECT sequencename, last_value
FROM pg_sequences
ORDER BY sequencename;# [검증 4] Lag 확인 (V1에서 실행)
# 컷오버 진입 전 반드시 0 확인
sudo -u postgres psql -d devths -c "
SELECT
application_name,
client_addr,
state,
pg_size_pretty(pg_wal_lsn_diff(
pg_current_wal_lsn(), replay_lsn
)) AS total_lag
FROM pg_stat_replication;"
# total_lag: 0 bytes ✅2차 컷오버 직전, 확실한 정합성과 그간의 DDL 반영 여부를 깔끔하게 정리하기 위해 약 10MB 크기의 RDS 데이터를 완전히 비우고 최신 상태로 재구독한다.
-- [Step 1] 기존 Subscription 삭제 (RDS에서 실행)
DROP SUBSCRIPTION IF EXISTS devths_sub;
-- RDS 스키마 완전 초기화
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO devths;# [Step 2] V1 EC2에서 최신 스키마 재추출 및 바로 반영
sudo -u postgres pg_dump -d devths --schema-only -F p -f "/tmp/devths_schema_final.sql"
psql -h devths-v2-prod-rds.<rds-endpoint> -U devths -d devths -f /tmp/devths_schema_final.sql-- [Step 3] 새 Subscription 구성 및 데이터 완전 재동기화 (RDS에서 실행)
CREATE SUBSCRIPTION devths_sub
CONNECTION 'host=<v1-ec2-private-ip> port=5432 dbname=devths user=repl_user password=<repl-password>'
PUBLICATION devths_pub
WITH (copy_data = true);# [Step 1] 재동기화 후 Lag 0 수렴 확인 (Phase 5 컷오버 진입 조건)
while true; do
LAG=$(sudo -u postgres psql -t -d devths -c "
SELECT pg_wal_lsn_diff(
pg_current_wal_lsn(), replay_lsn
)
FROM pg_stat_replication
WHERE application_name = 'devths_sub';
")
echo "$(date '+%H:%M:%S') Lag: ${LAG} bytes"
if [ "${LAG// /}" -eq 0 ]; then
echo "✅ Lag 0 확인 - 컷오버 진행"
break
fi
sleep 5
done-- [k6 확인] Nginx 스위칭 시 에러율 스파이크 없이 정상 전환 (예상된 동작)
-- [Step 2] Lock-Free 마이그레이션이므로 V1 DB 쓰기 차단(Read-Only)을 수행하지 않음-- [Step 3] 시퀀스 동기화 (RDS에서 실행)
DO $$
DECLARE
seq_record RECORD;
max_val BIGINT;
seq_query TEXT;
BEGIN
FOR seq_record IN
SELECT
n.nspname AS schema_name,
s.relname AS seq_name,
t.relname AS table_name,
a.attname AS col_name
FROM pg_class s
JOIN pg_namespace n ON n.oid = s.relnamespace
JOIN pg_depend d ON d.objid = s.oid
JOIN pg_class t ON t.oid = d.refobjid
JOIN pg_attribute a ON a.attrelid = t.oid
AND a.attnum = d.refobjsubid
WHERE s.relkind = 'S'
LOOP
seq_query := format(
'SELECT COALESCE(MAX(%I), 0) FROM %I.%I',
seq_record.col_name,
seq_record.schema_name,
seq_record.table_name
);
EXECUTE seq_query INTO max_val;
PERFORM setval(
format('%I.%I',
seq_record.schema_name,
seq_record.seq_name),
max_val + 1000
);
RAISE NOTICE '✅ 시퀀스 동기화: %.% → %',
seq_record.table_name,
seq_record.col_name,
max_val + 1000;
END LOOP;
END $$;🚨 트러블슈팅 및 실제 진행 결과 (시퀀스 버퍼 확대)
- 현상/원인: CodeDeploy 배포 소요 시간(약 5분) 및 잠재적인 부하 트래픽 중 인서트 충돌을 더 여유 있게 방지하기 위해 버퍼를 늘릴 필요가 있었습니다.
- 조치: 계획된
+1000대신+20000의 넉넉한 버퍼를 부여하는 최적화된 스크립트로 1차 시퀀스 동기화를 수행했습니다.DO $$ DECLARE seq_record RECORD; max_val BIGINT; seq_query TEXT; BEGIN FOR seq_record IN SELECT n.nspname AS schema_name, s.relname AS seq_name, t.relname AS table_name, a.attname AS col_name FROM pg_class s JOIN pg_namespace n ON n.oid = s.relnamespace JOIN pg_depend d ON d.objid = s.oid JOIN pg_class t ON t.oid = d.refobjid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid WHERE s.relkind = 'S' LOOP seq_query := format('SELECT COALESCE(MAX(%I), 0) FROM %I.%I', seq_record.col_name, seq_record.schema_name, seq_record.table_name); EXECUTE seq_query INTO max_val; PERFORM setval(format('%I.%I', seq_record.schema_name, seq_record.seq_name), max_val + 20000); RAISE NOTICE '✅ 시퀀스 1차 버퍼링: %.% → %', seq_record.table_name, seq_record.col_name, max_val + 20000; END LOOP; END $$;
-- [Step 4] 최종 Lag 0 재확인 (V1에서 실행)
SELECT pg_wal_lsn_diff(
pg_current_wal_lsn(), replay_lsn
) AS final_lag_bytes
FROM pg_stat_replication
WHERE application_name = 'devths_sub';
-- final_lag_bytes: 0 ✅# [핵심 검증] ALB 타겟 전환 순간 k6 에러율 스파이크 여부 주시
# [Step 5] ALB Listener Rule 변경을 통한 트래픽 스위칭 (콘솔/CLI)
# 컷오버 진행 시 타겟 그룹 V1 -> V2 로 즉시 전환# [k6 에러율 동시 확인] 헬스체크 200 + k6 에러율 < 1% = 컷오버 성공
# [Step 6] 헬스체크 확인
curl -s https://api.devths.com/actuator/health
# {"status":"UP"} ✅-- [Step 7] V1 DB 최종 Lag 0 확인 스크립트 대기 (롤백 대기 상태 유지)
🚨 트러블슈팅 및 실제 진행 결과 (배포 방식 변경 및 헬스체크 에러 처리)
- 원본 계획은 애플리케이션 선 전환 후 ALB 라우팅 변경 및 V1 DB 락 설정 방식이었으나, 애플리케이션 내의
DB_URL_V2파라미터 스토어 값을 V1에서 RDS로 스위칭하고 CodeDeploy로 애플리케이션을 무중단(Rolling) 재시작하는 방식으로 컷오버를 진행했습니다.- 롤백 및 재배포: 최초 CodeDeploy 배포 시, V2 BE의
scripts/health.sh내 응답 체크 로직이grep -c를 사용하면서 빈 응답에 대해integer expression expectedbash 에러가 발생하여 배포가 중단/롤백되었습니다.
- 조치:
health.sh의 비교문을 정규식 매칭grep -qE로 수정하여 에러 저항성을 높인 후,fix/cicd브랜치에 푸시 &main병합하여 최종 배포에 성공했습니다.
🚨 트러블슈팅 및 실제 진행 결과 (시퀀스 2차 갭 최적화)
+20000버퍼 부여 후 데이터에 갭이 생기는 것을 해결하기 위해 배포 직후 2차 동기화를 진행했습니다.- 초기 스크립트는 데이터가 없는 빈 테이블에서 MAX값이 0이 되어
value 0 is out of bounds에러가 발생했습니다.
- 조치:
max_val = 0일 경우 예외적으로 시퀀스를setval(..., 1, false)로 초기화하는 스크립트로 보완하여 모든 테이블의 시퀀스를 완벽히 일치시켰습니다.DO $$ DECLARE seq_record RECORD; max_val BIGINT; seq_query TEXT; BEGIN FOR seq_record IN SELECT n.nspname AS schema_name, s.relname AS seq_name, t.relname AS table_name, a.attname AS col_name FROM pg_class s JOIN pg_namespace n ON n.oid = s.relnamespace JOIN pg_depend d ON d.objid = s.oid JOIN pg_class t ON t.oid = d.refobjid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid WHERE s.relkind = 'S' LOOP seq_query := format('SELECT COALESCE(MAX(%I), 0) FROM %I.%I', seq_record.col_name, seq_record.schema_name, seq_record.table_name); EXECUTE seq_query INTO max_val; IF max_val = 0 THEN PERFORM setval(format('%I.%I', seq_record.schema_name, seq_record.seq_name), 1, false); ELSE PERFORM setval(format('%I.%I', seq_record.schema_name, seq_record.seq_name), max_val); END IF; END LOOP; END $$;
최종 컷오버 직후 k6를 10분 추가 유지하며 RDS 마이그레이션이 성공적인지 검증. 정상 처리 검증 완료 후 k6를 종료하고 이후 정리를 진행.
컷오버 완료 후 k6를 10분 추가 유지합니다.
확인 포인트:
- 컷오버 전/후 p95 응답시간 비교 (목표: V1 대비 110% 이내)
- WRITE 요청에서 PK 충돌 발생 여부 (목표: 0건)
- 컷오버 순간 에러율 스파이크 발생 여부
10분 후 k6 종료 (터미널 1에서 Ctrl+C 또는 ramp-down 자동 진행)
k6 결과 리포트 저장: k6 run --out json=/tmp/migration_k6_result.json migration_load.js → 저장 항목: 에러율, p50/p95 응답시간, PK 충돌 건수
-- [Step 1] Subscription 삭제 (RDS에서 실행)
DROP SUBSCRIPTION devths_sub;
-- 확인
SELECT subname FROM pg_subscription;
-- 0 rows ✅-- [Step 2] Publication 삭제 (V1에서 실행)
DROP PUBLICATION devths_pub;
-- 확인
SELECT pubname FROM pg_publication;
-- 0 rows ✅# [Step 3] V1 Security Group 5432 규칙 삭제
aws ec2 revoke-security-group-ingress \
--group-id <v1-sg-id> \
--protocol tcp \
--port 5432 \
--cidr <v2-vpc-cidr> \
--region ap-northeast-2# [Step 4] repl_user 삭제 (V1에서 실행)
sudo -u postgres psql -c "DROP USER repl_user;"🚨 트러블슈팅 및 실제 진행 결과 (권한 종속성으로 인한 계정 삭제 불가)
- 현상: 단순
DROP USER repl_user;명령 실행 시, 해당 계정이 가지고 있는 테이블 권한 및 기본 소유권으로 인해 오류(role "repl_user" cannot be dropped because some objects depend on it)가 발생하여 삭제가 거부되었습니다.- 조치: 아래 스크립트를 통해 스키마 전체에서
repl_user의 모든 권한을 강제 회수하고 잔여 소유권을postgres로 이관한 뒤 계정을 삭제했습니다.sudo -u postgres psql -d devths << 'EOF' REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM repl_user; REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM repl_user; REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public FROM repl_user; REVOKE ALL PRIVILEGES ON SCHEMA public FROM repl_user; REVOKE ALL PRIVILEGES ON DATABASE devths FROM repl_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM repl_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON SEQUENCES FROM repl_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM repl_user; REASSIGN OWNED BY repl_user TO postgres; DROP OWNED BY repl_user; DROP USER repl_user; EOF
# [Step 5] 백업 파일 정리
rm /tmp/devths_pre_migration_*.dump
rm /tmp/devths_schema_*.sql- 72시간 모니터링 이상 없음 확인
- Subscription 삭제 완료 (RDS)
- Publication 삭제 완료 (V1)
- V1 Security Group 5432 규칙 삭제 완료
- repl_user 삭제 완료
- 백업 파일 정리 완료
- V1 PostgreSQL 종료 여부 결정
-- V1과 RDS 양쪽에서 실행 후 결과 대조
SELECT
schemaname,
tablename,
n_live_tup AS row_count
FROM pg_stat_user_tables
ORDER BY tablename;pg_stat_user_tables는 통계 기반이라 실시간 정확도가 낮을 수 있음. 정확한 검증이 필요한 테이블은 아래 쿼리로 추가 확인 진행.
-- 테이블별 정확한 COUNT 확인
SELECT
'users' AS tablename, COUNT(*) AS row_count FROM users
UNION ALL
SELECT 'posts', COUNT(*) FROM posts
UNION ALL
SELECT 'comments', COUNT(*) FROM comments;
-- 테이블명은 실제 devths 테이블 목록으로 교체| 결과 | 판단 | 조치 |
|---|---|---|
| V1 = RDS | ✅ 정상 | 다음 검증 진행 |
| V1 ≠ RDS | ❌ 이상 | 복제 상태 확인 후 원인 파악 및 재동기화 |
-- V1과 RDS 양쪽에서 실행 후 checksum 대조
SELECT
tablename,
md5(string_agg(t::text, ',' ORDER BY ctid)) AS checksum
FROM (
SELECT tablename, tableoid::regclass t
FROM pg_tables
WHERE schemaname = 'public'
) sub
GROUP BY tablename
ORDER BY tablename;주의사항
- Logical Replication 진행 중에는 V1에 변경이 계속 발생하므로 Checksum은 Lock-Free 상태에서도 Lag가 0에 수렴하는 한 동기화된 것으로 간주.
- 컷오버 직전 Checksum 비교 → 일치 확인 → ALB(로드밸런서) 타겟 그룹 스위칭 순서로 진행.
| 결과 | 판단 | 조치 |
|---|---|---|
| V1 = RDS (전 테이블) | ✅ 정상 | 컷오버 진행 |
| 일부 테이블 불일치 | ❌ 이상 | 해당 테이블 Row Count 재확인 및 원인 파악 |
Logical Replication은 시퀀스를 자동으로 복제하지 않음. 컷오버 직전 수동 동기화 이후 검증 진행
-- [검증 전] V1과 RDS 시퀀스 현재값 비교
-- V1과 RDS 양쪽에서 실행
SELECT
sequencename,
last_value,
increment_by
FROM pg_sequences
ORDER BY sequencename;-- [동기화] RDS에서 실행 (컷오버 직전)
DO $$
DECLARE
seq_record RECORD;
max_val BIGINT;
seq_query TEXT;
BEGIN
FOR seq_record IN
SELECT
n.nspname AS schema_name,
s.relname AS seq_name,
t.relname AS table_name,
a.attname AS col_name
FROM pg_class s
JOIN pg_namespace n ON n.oid = s.relnamespace
JOIN pg_depend d ON d.objid = s.oid
JOIN pg_class t ON t.oid = d.refobjid
JOIN pg_attribute a ON a.attrelid = t.oid
AND a.attnum = d.refobjsubid
WHERE s.relkind = 'S'
LOOP
seq_query := format(
'SELECT COALESCE(MAX(%I), 0) FROM %I.%I',
seq_record.col_name,
seq_record.schema_name,
seq_record.table_name
);
EXECUTE seq_query INTO max_val;
PERFORM setval(
format('%I.%I',
seq_record.schema_name,
seq_record.seq_name),
max_val + 1000
);
RAISE NOTICE '✅ 시퀀스 동기화: %.% → %',
seq_record.table_name,
seq_record.col_name,
max_val + 1000;
END LOOP;
END $$;-- [검증 후] 동기화 완료 확인
SELECT
sequencename,
last_value
FROM pg_sequences
ORDER BY sequencename;
-- RDS의 last_value >= V1의 last_value + 1000 ✅| 항목 | 기준 | 판단 |
|---|---|---|
| RDS 시퀀스 last_value | V1 MAX(PK) + 1000 이상 | ✅ 정상 |
| 컷오버 후 INSERT 시 PK 충돌 | 발생하지 않아야 함 | ✅ 정상 |
DB 레벨 검증 외에 실제 API 응답으로 데이터 정합성을 확인.
# [검증 1] 주요 API 응답 확인
# 컷오버 전 V1 기준 응답 저장
curl -s https://api.devths.com/api/v1/posts?page=0&size=10 \
-H "Authorization: Bearer ${TOKEN}" \
| jq '.' > /tmp/v1_posts_response.json
# 컷오버 후 RDS 기준 응답 저장
curl -s https://api.devths.com/api/v1/posts?page=0&size=10 \
-H "Authorization: Bearer ${TOKEN}" \
| jq '.' > /tmp/v2_posts_response.json
# 응답 비교
diff /tmp/v1_posts_response.json /tmp/v2_posts_response.json
# 차이 없으면 정상 ✅# [검증 2] 헬스체크 확인
curl -s https://api.devths.com/actuator/health
# {"status":"UP"} ✅# [검증 3] 컷오버 후 신규 데이터 쓰기 테스트
# INSERT 후 SELECT로 정상 확인
curl -s -X POST https://api.devths.com/api/v1/posts \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"title":"마이그레이션 테스트","content":"정상 동작 확인"}' \
| jq '.id'
# id 정상 반환 확인 (시퀀스 충돌 없음) ✅| 검증 항목 | 기준 | 판단 |
|---|---|---|
| 주요 API 응답 | V1 응답과 동일 | ✅ 정상 |
| 헬스체크 | {"status":"UP"} | ✅ 정상 |
| 신규 데이터 INSERT | PK 충돌 없이 정상 저장 | ✅ 정상 |
| 에러율 | 5% 미만 | ✅ 정상 |
| 응답시간 | V1 대비 200% 이내 | ✅ 정상 |
컷오버는 아래 조건이 모두 충족된 경우에만 진행.
| 조건 | 확인 방법 | 기준 |
|---|---|---|
| Lag 0 확인 | pg_stat_replication.replay_lsn | 0 bytes |
| 데이터 검증 완료 | Row Count + Checksum 일치 | 100% 일치 |
| RDS 연결 테스트 완료 | psql -h RDS_ENDPOINT | 연결 성공 |
| 시퀀스 동기화 완료 | pg_sequences last_value 확인 | MAX + 1000 |
| 서비스 헬스체크 정상 | /actuator/health | {"status":"UP"} |
| 새벽 시간대 확인 | 트래픽 최저 구간 | 02:00 ~ 04:00 |
하나라도 충족되지 않으면 컷오버 중단 후 원인 파악 및 일정 재조정.
컷오버 시 Spring Boot의 HikariCP가 기존 V1 연결을 끊고 RDS로 재연결하는 과정.
[컷오버 시점]
│
▼
Spring Boot 재시작 (RDS Endpoint로 변경된 설정 적용)
│
▼
HikariCP 커넥션 풀 초기화
- minimum-idle: 5개 즉시 연결 시도
- connection-timeout: 30,000ms 이내 연결 성공 필요
│
├── 연결 성공 → 서비스 정상 복구 ✅
└── connection-timeout 초과 → 재시도
└── 30초 이내 미복구 시 롤백 트리거
현재 HikariCP 설정 기준 재연결 타임라인
| 항목 | 값 | 컷오버 영향 |
|---|---|---|
| connection-timeout | 30,000ms | 30초 이내 RDS 연결 성공 필요 |
| minimum-idle | 5 | 재시작 시 5개 커넥션 즉시 생성 시도 |
| maximum-pool-size | 20 | 최대 20개까지 확장 |
| keepalive-time | 30,000ms | 유휴 커넥션 유지로 재연결 지연 방지 |
| initialization-fail-timeout | 0 | 초기 연결 실패 시 즉시 종료하지 않음 |
RDS 연결 설정 (컷오버 시 적용)
spring:
datasource:
url: jdbc:postgresql://devths-v2-prod-rds.<rds-endpoint>:5432/devths
username: devths
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
keepalive-time: 30000
initialization-fail-timeout: 0V1(단일 EC2)에서 V2(신규 EC2 + RDS)로 트래픽을 넘기기 위해 Nginx 설정 대신 AWS ALB(Application Load Balancer) 규칙 또는 타겟 그룹 가정을 조정하는 진정한 인프라 레벨의 Blue-Green 전환을 사용함.
현재 상태 (V1)
Route53 → ALB → V1 EC2 타겟 그룹 (Nginx 80 → Spring Boot 8080 : V1 DB)
전환 후 (V2)
Route53 → ALB → V2 EC2 타겟 그룹 (Nginx 80 → Spring Boot 8000 : RDS DB)
# 1. 컷오버 전 V2 전용 EC2 인스턴스들(ASG 또는 직접 생성) 배포 완료
# 2. V2 인스턴스의 Spring Boot는 내부적으로 RDS를 바라보도록 환경변수 설정 적용 완료
# 3. ALB의 V2 타겟 그룹(Target Group)에 V2 EC2들이 Healthy 상태로 등록되어 있음# 외부에서 V2 타겟 그룹에 직접 바인딩된 임시 포트나 ALB 룰로 헬스체크 확인
curl -s http://V2_EC2_IP:8000/actuator/health
# {"status":"UP"} ✅# AWS Console 또는 CLI를 통해 ALB Listener Rule을 변경
# api.devths.com 접근 트래픽을 V1 타겟 그룹(100%)에서 V2 타겟 그룹(100%)으로 변경
aws elbv2 modify-listener \
--listener-arn arn:aws:elasticloadbalancing:... \
--default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:...V2-TG...# 외부 헬스체크 및 실제 유저 API 데이터 정상 요청 응답 확인
curl -s https://api.devths.com/actuator/health
# {"status":"UP"} ✅
# V2 EC2 인스턴스 내부의 로그에서 정상 트래픽 인입 확인
docker logs -f ai-service# ALB 스위칭 직후 기존 V1 EC2에 묶여있던 진행 중인 트랜잭션이 종료될 때까지 대기
# ALB Target Group의 최소 Deregistration Delay 시간(기본 300초 또는 60초) 대기 권장
echo "⏳ 기존 V1 연결 상태 Draining 대기 (60s 이상 권장)..."
# 작업자는 즉시 V1을 종료하지 않고, 아래 2가지를 명확히 수동 검증한 후 명시적으로 종료(Kill)함.
# 1. V1 EC2의 애플리케이션 로그에 잔여 트랜잭션이 없는지 확인
# 2. V1 DB -> RDS 로의 논리적 복제 Lag가 완벽히 0 Bytes 인지 `lag_monitor.sh` 로 3회 이상 확인-- V1 EC2에서 psql 접속 후 실행
-- 컷오버가 완벽히 스위칭되고 잔류 트랜잭션이 전혀 없는 것을 확인한 직후 적용
-- 롤백 대기 2~3일간 외부 배치/스크립트 등 오염된 쓰기 작업(Ghost Write)이 진입하는 것을 원천 차단
ALTER DATABASE devths SET default_transaction_read_only = on;
-- 확인
SHOW default_transaction_read_only;
-- 결과: on ✅-- V2 RDS에서 psql 접속 후 실행
-- V1 DB에 락을 걸었다 하더라도, 롤백 발생 시 V1에 쓰인 데이터가
-- 엉망이 된 채로 V2로 복제되는 것을 방지하기 위해 파이프라인 단절
ALTER SUBSCRIPTION devths_sub DISABLE;
-- 확인
SELECT subname, subenabled FROM pg_subscription;
-- subenabled: f ✅DB 연결 확인
- Spring Boot → RDS 연결 정상 (HikariCP 커넥션 풀 active)
- RDS 커넥션 수 정상 (최대 20개 이내)
- V1 DB 커넥션 0 (더 이상 연결 없음)
서비스 확인
- /actuator/health → {"status":"UP"}
- 주요 API 정상 응답 (GET /api/v1/posts 등)
- 신규 데이터 INSERT 정상 (PK 충돌 없음)
- V2 EC2 컨테이너 로그 정상 트래픽 유입 확인
데이터 확인
- 컷오버 후 Row Count V1 = RDS 일치
- 시퀀스 충돌 없음 확인
- 에러율 5% 미만
모니터링 확인
- CloudWatch RDS CPU 정상 (80% 미만)
- CloudWatch RDS 커넥션 수 정상
- Grafana 응답시간 V1 대비 200% 이내
- V1 DB 전원 휴면 대기 완료 및 쓰기 락(Read-Only) 활성화 완료
컷오버 후 아래 조건 중 하나라도 감지되면 즉시 롤백 실행.
즉시 롤백 (감지 즉시 실행)
| 조건 | 기준 | 확인 방법 |
|---|---|---|
| 헬스체크 실패 | /actuator/health 비정상 | curl 응답 확인 |
| DB 연결 실패 | HikariCP 커넥션 풀 고갈 | 애플리케이션 로그 |
| 에러율 급증 | 5% 초과 | Grafana 대시보드 |
| 데이터 불일치 감지 | Row Count / Checksum 불일치 | 검증 스크립트 |
| PK 충돌 발생 | INSERT 시 시퀀스 충돌 | 애플리케이션 로그 |
경고 후 롤백 (10분 내 미해소 시 실행)
| 조건 | 기준 | 확인 방법 |
|---|---|---|
| 응답시간 저하 | V1 대비 200% 초과 | Grafana 대시보드 |
| RDS CPU 과부하 | 80% 초과 5분 지속 | CloudWatch |
| RDS 커넥션 수 이상 | 20개 초과 지속 | CloudWatch |
롤백 가능 시간은 컷오버 후 V1 DB가 유지되는 D+3일까지.
롤백 플로우
flowchart TD
A[이상 감지] --> B{즉시 롤백 조건?}
B --> |Yes| C[즉시 롤백 실행]
B --> |No - 경고 조건| D[10분 모니터링]
D --> |해소됨| E[정상 운영 유지]
D --> |미해소| C
C --> F[ALB Listener V1 타겟 그룹으로 원복]
F --> G[ALB 라우팅 즉시 전환]
G --> H[기존 V1 EC2 트래픽 유입 재개 확인]
H --> I[외부 헬스체크 확인]
I --> |성공| J[롤백 완료 ✅\n소요시간 30초 이내]
I --> |실패| K[긴급 대응\n온콜 알림]
J --> L[원인 분석 후\n재마이그레이션 계획]
style A fill:#FF5722,color:#fff
style C fill:#FF5722,color:#fff
style J fill:#4CAF50,color:#fff
style E fill:#4CAF50,color:#fff
style K fill:#B71C1C,color:#fff
# ALB Listener Rule을 다시 원래 V1 EC2가 묶여있는 대상 그룹으로 복구 (즉각적 트래픽 복원)
# V1 EC2는 컷오버 중에도 인스턴스와 Spring Boot가 계속 런닝 상태였으므로 재부팅 불필요
aws elbv2 modify-listener \
--listener-arn arn:aws:elasticloadbalancing:... \
--default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:...V1-TG...# 외부 헬스체크 정상화 대기
curl -s https://api.devths.com/actuator/health
# {"status":"UP"} ✅
# 구 V1 인스턴스(기존 V1 DB 연결)의 로그 모니터링
tail -f /home/ubuntu/ai/logs/api.log
# 트래픽이 다시 V1으로 유입되는 것 확인# 롤백 후 트래픽이 완전히 V1으로 우회되었음을 확인하면, 더 이상 쓰이지 않는 V2 인스턴스 종료
# (Auto Scaling Group에 물려있다면 Desired Capacity를 0으로 조절)-- RDS에서 실행
-- V1 DB로 롤백 후 RDS로 계속 복제되면 데이터 혼입 위험
ALTER SUBSCRIPTION devths_sub DISABLE;
-- 확인
SELECT subname, subenabled FROM pg_subscription;
-- subenabled: f ✅서비스 복구 확인
- /actuator/health → {"status":"UP"}
- V1 EC2 로그 트래픽 정상 확인
- 주요 API 정상 응답 확인
- 에러율 5% 미만 복구
DB 연결 확인
- Spring Boot → V1 DB 연결 정상
- V1 DB default_transaction_read_only = off 확인
- RDS Subscription 비활성화 완료 (devths_sub disabled)
- V1 DB 커넥션 정상 (HikariCP active)
롤백 후 조치
- 롤백 시점 및 원인 기록
- 롤백 중 V1 DB 변경분 확인 (Subscription 비활성화 구간)
- 재마이그레이션 일정 재조정
- 원인 분석 완료 후 재시도 여부 결정
재마이그레이션 준비
롤백 후 재마이그레이션 시 아래 항목 재확인 후 진행.
| 항목 | 확인 내용 |
|---|---|
| 롤백 원인 해소 | 동일 문제 재발 방지 조치 완료 |
| RDS Subscription 재활성화 | ALTER SUBSCRIPTION devths_sub ENABLE |
| 데이터 재동기화 | Subscription 비활성화 구간 변경분 반영 확인 |
| 검증 스크립트 재실행 | Row Count + Checksum 재확인 |
| 컷오버 조건 재충족 | 10.1 체크리스트 전체 재확인 |
마이그레이션 전 과정에서 아래 지표를 지속 모니터링.
| 지표 | 정상 기준 | 경고 기준 | 즉시 중단 기준 |
|---|---|---|---|
| 에러율 | 1% 미만 | 5% 초과 | 20% 초과 |
| p95 응답시간 | 3초 이내 | V1 대비 200% 초과 | V1 대비 300% 초과 |
| 헬스체크 | {"status":"UP"} | - | 비정상 응답 |
| 지표 | 정상 기준 | 경고 기준 | 즉시 중단 기준 |
|---|---|---|---|
| CPU 사용률 | 60% 미만 | 80% 초과 | 95% 초과 5분 지속 |
| 활성 커넥션 수 | 15개 이하 | 18개 초과 | 20개 (풀 고갈) |
| Replication Lag | 0 bytes | 1MB 초과 | 컷오버 진입 불가 |
| WAL 생성 속도 | 정상 범위 | 급증 감지 | - |
| 지표 | 정상 기준 | 경고 기준 | 즉시 중단 기준 |
|---|---|---|---|
| CPU 사용률 | 60% 미만 | 80% 초과 | 95% 초과 5분 지속 |
| DatabaseConnections | 15개 이하 | 18개 초과 | 20개 초과 |
| FreeStorageSpace | 10GB 이상 | 5GB 미만 | 1GB 미만 |
| ReadLatency / WriteLatency | 10ms 이내 | 50ms 초과 | 100ms 초과 |
| ReplicaLag | 0 | 1초 초과 | 컷오버 진입 불가 |
-- 복제 전송 상태 전체 확인
SELECT
application_name,
client_addr,
state,
pg_size_pretty(pg_wal_lsn_diff(
pg_current_wal_lsn(), sent_lsn
)) AS send_lag,
pg_size_pretty(pg_wal_lsn_diff(
sent_lsn, write_lsn
)) AS write_lag,
pg_size_pretty(pg_wal_lsn_diff(
write_lsn, replay_lsn
)) AS replay_lag,
pg_wal_lsn_diff(
pg_current_wal_lsn(), replay_lsn
) AS total_lag_bytes
FROM pg_stat_replication;-- Subscription 수신 위치 확인
SELECT
subname,
received_lsn,
latest_end_lsn,
latest_end_time,
now() - latest_end_time AS lag_time
FROM pg_stat_subscription;#!/bin/bash
# lag_monitor.sh
# 실행: bash lag_monitor.sh
LOG_FILE="/tmp/replication_lag_$(date +%Y%m%d).log"
ALERT_THRESHOLD=1048576 # 1MB
echo "Replication Lag 모니터링 시작: $(date)" | tee -a $LOG_FILE
while true; do
LAG=$(sudo -u postgres psql -t -d devths -c "
SELECT pg_wal_lsn_diff(
pg_current_wal_lsn(), replay_lsn
)
FROM pg_stat_replication
WHERE application_name = 'devths_sub';
" | tr -d ' ')
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
if [ -z "$LAG" ]; then
echo "$TIMESTAMP ⚠️ 복제 연결 없음 - Subscriber 확인 필요" | tee -a $LOG_FILE
elif [ "$LAG" -eq 0 ]; then
echo "$TIMESTAMP ✅ Lag: 0 bytes" | tee -a $LOG_FILE
elif [ "$LAG" -gt "$ALERT_THRESHOLD" ]; then
echo "$TIMESTAMP 🚨 Lag 경고: ${LAG} bytes (1MB 초과)" | tee -a $LOG_FILE
else
echo "$TIMESTAMP ℹ️ Lag: ${LAG} bytes" | tee -a $LOG_FILE
fi
sleep 5
done-- 복제 슬롯 WAL 누적 확인
-- inactive 상태 지속 시 WAL 파일 무한 누적 위험
SELECT
slot_name,
plugin,
slot_type,
active,
pg_size_pretty(pg_wal_lsn_diff(
pg_current_wal_lsn(),
confirmed_flush_lsn
)) AS retained_wal
FROM pg_replication_slots;복제 슬롯이 inactive 상태로 장시간 유지되면 V1 디스크 고갈 위험. 즉시 원인 파악 후 조치.
# 디스크 사용량 확인
df -h /var/lib/postgresql/14/main/pg_wal/컷오버 완료 후 72시간 동안 아래 기준으로 이상 감지.
#!/bin/bash
# post_cutover_monitor.sh
# 실행: bash post_cutover_monitor.sh
HEALTH_URL="https://api.devths.com/actuator/health"
LOG_FILE="/tmp/post_cutover_$(date +%Y%m%d).log"
CHECK_INTERVAL=10
echo "컷오버 후 모니터링 시작: $(date)" | tee -a $LOG_FILE
while true; do
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
# 헬스체크
STATUS=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL)
if [ "$STATUS" != "200" ]; then
echo "$TIMESTAMP 🚨 헬스체크 실패: HTTP $STATUS → 롤백 트리거" | tee -a $LOG_FILE
break
fi
echo "$TIMESTAMP ✅ 헬스체크 정상: HTTP $STATUS" | tee -a $LOG_FILE
sleep $CHECK_INTERVAL
done| 시간 | 모니터링 강도 | 주요 확인 항목 |
|---|---|---|
| 컷오버 후 1시간 | 집중 (10초 간격) | 헬스체크, 에러율, DB 커넥션 |
| 컷오버 후 24시간 | 일반 (1분 간격) | 응답시간, RDS CPU, 에러율 |
| 컷오버 후 72시간 | 유지 (5분 간격) | RDS 전반 지표, 이상 징후 |
| 72시간 이후 | 정상 운영 | CloudWatch 알람 기반 |
# RDS CPU 80% 초과 알람
aws cloudwatch put-metric-alarm \
--alarm-name devths-prod-rds-cpu-high \
--metric-name CPUUtilization \
--namespace AWS/RDS \
--dimensions Name=DBInstanceIdentifier,Value=devths-v2-prod-rds \
--period 300 \
--evaluation-periods 2 \
--threshold 80 \
--comparison-operator GreaterThanThreshold \
--statistic Average \
--alarm-actions arn:aws:sns:ap-northeast-2:ACCOUNT_ID:devths-alert \
--region ap-northeast-2# RDS 커넥션 수 18개 초과 알람
aws cloudwatch put-metric-alarm \
--alarm-name devths-prod-rds-connections-high \
--metric-name DatabaseConnections \
--namespace AWS/RDS \
--dimensions Name=DBInstanceIdentifier,Value=devths-v2-prod-rds \
--period 60 \
--evaluation-periods 3 \
--threshold 18 \
--comparison-operator GreaterThanThreshold \
--statistic Average \
--alarm-actions arn:aws:sns:ap-northeast-2:ACCOUNT_ID:devths-alert \
--region ap-northeast-2# RDS FreeStorageSpace 5GB 미만 알람
aws cloudwatch put-metric-alarm \
--alarm-name devths-prod-rds-storage-low \
--metric-name FreeStorageSpace \
--namespace AWS/RDS \
--dimensions Name=DBInstanceIdentifier,Value=devths-v2-prod-rds \
--period 300 \
--evaluation-periods 1 \
--threshold 5368709120 \
--comparison-operator LessThanThreshold \
--statistic Average \
--alarm-actions arn:aws:sns:ap-northeast-2:ACCOUNT_ID:devths-alert \
--region ap-northeast-2| 패널 | 지표 | 경고 임계값 |
|---|---|---|
| API 에러율 | HTTP 5xx 비율 | 5% 초과 |
| p95 응답시간 | Spring Boot 응답시간 | 3초 초과 |
| DB 커넥션 수 | HikariCP active connections | 18개 초과 |
| RDS CPU | CloudWatch CPUUtilization | 80% 초과 |
| JVM Heap | Spring Boot Heap 사용률 | 85% 초과 |
| GC 시간 | GC pause time | 500ms 초과 |
Logical Replication은 PostgreSQL 네이티브 기능을 사용하므로 마이그레이션 과정에서 추가 AWS 비용 없음.
| 항목 | DMS + Shadow EC2 방식 | Logical Replication 방식 |
|---|---|---|
| DMS Replication Instance | dms.t3.medium 약 $70/월 | 없음 ✅ |
| Shadow EC2 | t3.medium 약 $30/월 | 없음 ✅ |
| 마이그레이션 기간 비용 | 약 $100 (1~2일 기준) | $0 |
| 추가 네트워크 비용 | VPC 간 데이터 전송 | VPC Peering 데이터 전송만 발생 |
VPC Peering을 통한 데이터 전송은 동일 리전 내 AZ 간 요금 적용.
| 구간 | 요금 | 예상 전송량 | 예상 비용 |
|---|---|---|---|
| V1 VPC → V2 VPC (초기 동기화) | $0.01/GB | 10MB | $0.0001 미만 |
| V1 VPC → V2 VPC (CDC 기간) | $0.01/GB | 수십 MB | $0.001 미만 |
| 합계 | 무시 가능 수준 |
devths DB 용량이 10MB 수준이므로 데이터 전송 비용은 사실상 무시 가능.
| 항목 | 스펙 | 단가 | 월 비용 |
|---|---|---|---|
| RDS 인스턴스 | db.t3.medium, Multi-AZ | $0.156/시간 | 약 $113 |
| 스토리지 | 20GB gp3 | $0.138/GB/월 | 약 $2.8 |
| 백업 스토리지 | 자동 백업 20GB (보존 7일) | $0.095/GB/월 | 약 $1.9 |
| 합계 | 약 $117/월 |
| 항목 | V1 (EC2 내 PostgreSQL) | V2 (RDS) |
|---|---|---|
| DB 비용 | EC2 비용에 포함 (별도 없음) | 약 $117/월 |
| 운영 부담 | 높음 (직접 관리) | 낮음 (AWS 관리형) |
| HA 구성 | 없음 (SPOF) | Multi-AZ (자동 Failover) |
| 백업 | 수동 | 자동 + PITR 7일 |
| 비용 효율 | 단기적으로 저렴 | 장기적으로 운영 안정성 확보 |
비용 최적화 옵션 (향후 검토)
| 옵션 | 절감 효과 | 조건 |
|---|---|---|
| Reserved Instance 1년 | 약 40% 절감 → 약 $70/월 | 1년 약정 |
| Reserved Instance 3년 | 약 60% 절감 → 약 $47/월 | 3년 약정 |
| Single-AZ 전환 | 약 50% 절감 → 약 $58/월 | HA 포기 |
V2 MAU 30만명 목표 달성 시 인스턴스 스펙 업 필요. 트래픽 증가에 따라 db.t3.large 또는 db.r6g.large로 전환 검토.
마이그레이션 완료 후 불필요한 리소스 정리로 비용 최소화.
| 리소스 | 정리 시점 | 정리 방법 | 예상 절감 |
|---|---|---|---|
| V1 PostgreSQL (프로세스) | D+3일 | systemctl stop postgresql | EC2 리소스 확보 |
| V1 Security Group 5432 규칙 | D+3일 | aws ec2 revoke-security-group-ingress | 보안 강화 |
| VPC Peering 라우팅 | D+3일 | aws ec2 delete-route | 불필요 라우팅 제거 |
| pg_dump 백업 파일 | D+3일 | rm /tmp/devths_*.dump | 디스크 확보 |
| repl_user | D+3일 | DROP USER repl_user | 보안 강화 |
| devths-prod-postgres-params (구 16버전) | 즉시 | aws rds delete-db-parameter-group | 리소스 정리 |
# [D+3일] V1 PostgreSQL 중지
sudo systemctl stop postgresql
sudo systemctl disable postgresql
# 중지 확인
sudo systemctl status postgresql# [D+3일] Security Group 5432 규칙 삭제
aws ec2 revoke-security-group-ingress \
--group-id <v1-sg-id> \
--protocol tcp \
--port 5432 \
--cidr <v2-vpc-cidr> \
--region ap-northeast-2# [D+3일] V1 VPC 라우팅 테이블에서 V2 경로 삭제
aws ec2 delete-route \
--route-table-id <rtb-id> \
--destination-cidr-block <v2-vpc-cidr> \
--region ap-northeast-2# [D+3일] 백업 파일 정리
rm /tmp/devths_pre_migration_*.dump
rm /tmp/devths_schema_*.sql-- [D+3일] repl_user 삭제 (V1에서 실행)
DROP USER repl_user;# [즉시] 구 파라미터 그룹 삭제 (16버전)
aws rds delete-db-parameter-group \
--db-parameter-group-name devths-prod-postgres-params \
--region ap-northeast-2정리 완료 체크리스트
- V1 PostgreSQL 프로세스 중지 완료
- V1 PostgreSQL 자동 시작 비활성화 완료
- Security Group 5432 규칙 삭제 완료
- VPC Peering 라우팅 삭제 완료
- pg_dump 백업 파일 삭제 완료
- repl_user 삭제 완료
- 구 파라미터 그룹 삭제 완료
- CloudWatch 알람 정상 동작 확인
- Grafana 대시보드 RDS 기준으로 업데이트 완료
사전 체크리스트 (D-3일 이전)
- devths-v2-prod-rds 생성 완료 (PostgreSQL 14.17)
- rds.logical_replication = 1 파라미터 적용 완료
- VPC Peering 라우팅 설정 완료 ()
- Security Group 설정 완료
- devths-prod-rds-sg ← 5432
- devths-V1-prod-ec2 ← 5432
- 팀 전체 배포 동결 공지 완료 (D-1일 ~ D+1일)
- 롤백 스크립트 사전 검증 완료
- 컷오버 담당자 온콜 일정 확인
- migration_load.js k6 스크립트 작성 완료
- 테스트 유저 20명분 AccessToken 사전 발급 완료
- k6 실행 환경 확인 (k6 설치, 실행 터미널 2개 준비)
실행 체크리스트 (D-3일 ~ D-Day)
- Phase 0: WAL 레벨 변경 완료 (wal_level = logical)
- Phase 0: PostgreSQL 재시작 30초 이내 완료
- Phase 0: 서비스 헬스체크 정상 확인
- Phase 1: repl_user 생성 완료 (userepl = t)
- Phase 1: pg_hba.conf 설정 완료 ( 허용)
- Phase 1: RDS 스키마 적용 완료 (테이블 목록 V1과 일치)
- Phase 1: RDS 연결 테스트 완료
- Phase 2: RDS 재시작 완료 (ParameterApplyStatus: in-sync)
- Phase 2: Publication devths_pub 생성 완료
- Phase 2: Subscription devths_sub 생성 완료
- Phase 2: 복제 슬롯 active 확인
- Phase 2: 초기 동기화 완료 (srsubstate = r)
- Phase 3: V2 환경(BE, AI, FE) 배포 완료
- Phase 3: ALB Listener 타겟 그룹 전환 완료 (1차 컷오버: V2 EC2)
- Phase 3: 외부 헬스체크 및 트래픽 정상 확인
- Phase 3: 중간 운영 중 DDL 동결 배포 정책 준수 확인
- Phase 3: k6 부하 주입 시작 (Lag 0 수렴 및 에러율 0% 모니터링)
- Phase 3: Row Count 및 Checksum 등 데이터 정합성 사전 검증 완료
- Phase 4: D-Day 심야 컷오버 직전, 기존 Subscription 및 Schema 완전 초기화
- Phase 4: V1 최신 스키마 추출 및 RDS 즉시 재반영 완료
- Phase 4: 복제 Subscription 재구성 및 데이터 재동기화(copy_data=true) 완료
- Phase 5: 최종 DB 컷오버 선행조건(재동기화 후 Lag 0 수렴) 확인
- Phase 5: V1→RDS 시퀀스 강제 동기화 스크립트 실행 완료
- Phase 5: 서비스 DB 커넥션(HikariCP 설정) V1 → RDS 전환 배포
- Phase 5: 전환 순간 애플리케이션 에러율 스파이크 및 헬스체크 확인
- Phase 5: 컷오버 후 새로운 데이터 INSERT 시 PK 충돌 0건 확인
- Phase 6: DB 컷오버 완료 후 k6 10분 추가 유지 검증
- Phase 6: p95 응답시간 목표치 이내(V1 대비 110% 이하) 확인
- Phase 6: k6 결과 리포트 저장 및 부하 종료 완료 (/tmp/migration_k6_result.json)
사후 체크리스트 (D+1 ~ D+3)
- 72시간 모니터링 이상 없음 확인
- Subscription 삭제 완료 (RDS)
- Publication 삭제 완료 (V1)
- V1 PostgreSQL 중지 완료
- V1 Security Group 5432 규칙 삭제 완료
- VPC Peering 라우팅 삭제 완료
- repl_user 삭제 완료
- 백업 파일 정리 완료
- 구 파라미터 그룹 삭제 완료
- CloudWatch 알람 정상 동작 확인
- Grafana 대시보드 RDS 기준 업데이트 완료
PostgreSQL 공식 문서
| 항목 | URL |
|---|---|
| Logical Replication | https://www.postgresql.org/docs/14/logical-replication.html |
| Publication | https://www.postgresql.org/docs/14/sql-createpublication.html |
| Subscription | https://www.postgresql.org/docs/14/sql-createsubscription.html |
| pg_stat_replication | https://www.postgresql.org/docs/14/monitoring-stats.html |
| pg_hba.conf | https://www.postgresql.org/docs/14/auth-pg-hba-conf.html |
AWS 공식 문서