필터#Grafana×

[DR] DR 테스트 후기 — 서울 리전 장애 유발로 도쿄 리전 활성화 하기

서비스를 서울(apnortheast2) 단일 리전으로만 운영하고 있으면, 그 리전 전체에 장애가 났을 때(AZ 장애 확산, 네트워크 단절 등) 서비스가 통째로 멈춰버린다. 이걸 대비하기 위해 도쿄(apnortheast1)에 웜 스탠바이(Warm Standby) 인프라를 미리 구축해뒀는데, "구축해뒀다"는 것과 "실제로 장애가 나도 넘어간다"는 것 사이에는 꽤 큰 간극이 있다. 그래서 실제로 서울 리전에 장애를 일으켜보고, 트래픽이 도쿄로 넘어가서 서비스가 계속되는지 눈으로 확인하는 페일오버 테스트를 진행했다. 이 글은 그 테스트 과정과 결과를 정리한 기록이다. 테스트 개요 서울 리전에 장애가 났다고 "가정"하고, 트래픽이 도쿄 리전으로 넘어가 실제로 서비스가 돌아가는지 확인하는 테스트다. 오후 2시 10분에 시작해서 2시 30분에 종료했다. 장애는 AWS FIS(Fault Injection Service)로 서울 프라이빗 서브넷의 네트워크를 인위적으로 끊어서 만들었고, 그 뒤 Route53 헬스체크가 장애를 감지해 도쿄로 전환하는 전 과정을 Grafana(AMG) 대시보드로 지켜봤다. 확인하려던 질문은 여섯 가지였다. 서울 장애를 FIS로 유도할 수 있는가? Route53이 도쿄로 전환되는가? 도쿄 EKS의 애플리케이션이 정상 응답하는가? 도쿄 RDS read replica를 승격(promote)하면 쓰기가 되는가? RTO와 RPO를 어떤 기준으로 잴 것인가? DR 중 도쿄에 쌓인 데이터가 서울에 자동으로 반영되는가? 목표 기준선은 RTO 15분, RPO 60초로 잡았다. RTO는 장애가 시작된 시점부터 서비스가 다시 정상으로 돌아오기까지 걸린 시간이고, RPO는 장애 시점에 유실될 수 있는 데이터의 시간 폭이다. RPO가 60초라는 건 최악의 경우 마지막 60초어치 데이터까지는 잃을 수 있다고 본다는 뜻이다. 왜 웜 스탠바이인가? DR 전략은 크게 복구 속도와 평시 운영 비용의 트레이드오프다. 껐다가 필요할 때 다시 세우는 백업·복원은 저렴하지만 복구가 시간 단위로 걸리고, 반대로 두 리전을 항상 동시에 운영하는 액티브액티브는 거의 즉시 전환되지만 비용이 두 배로 들고 양방향 동기화까지 신경 써야 한다. Farmily는 24시간 서비스라 시간 단위 복구는 받아들이기 어려웠지만, 그렇다고 액티브액티브를 감당할 트래픽 규모도 아니었다. 그래서 축소된 규모로 도쿄를 항상 띄워 두고 분 단위로 복구하는 웜 스탠바이를 선택했다. 대상 리소스 서울이 평상시 트래픽을 받는 active 리전이고, 도쿄가 standby다. 서울에는 prodeks 클러스터, prodalb, prodrds가 있고, 도쿄에는 dreks 클러스터, dralb, 그리고 서울 prodrds의 crossregion read replica인 drrds가 있다. 사용자 트래픽의 진입점은 api.farmily.info 도메인이고, Route53 failover 라우팅으로 PRIMARY는 서울 ALB, SECONDARY는 도쿄 ALB를 가리킨다. 헬스체크는 api.farmily.info의 /api/v1/health 경로를 443 포트로 10초마다 찌르고, 3번 연속 실패하면 장애로 판정한다. 복구 절차 자동화 이번 테스트에는 장애 감지부터 결과 검증까지 이어지는 복구 절차 자동화를 함께 도입해서 돌렸다. EventBridge가 1분마다 서울 상태를 확인하다 이상을 감지하면(DynamoDB로 중복 실행은 막는다) Step Functions가 상태 진단 → (참고용) AI 위험도 분석 → 사람 승인 대기 → 승인 후 DB 승격·트래픽 전환 → 결과 검증까지 이어가는 흐름으로 움직인다. AI는 어디까지나 판단을 돕는 조언자일 뿐이고, 실제 전환은 사람이 Slack에서 승인 버튼을 눌러야 넘어간다. 테스트에 앞서 — 도쿄를 "서비스 가능한" 상태로 인프라(VPC, EKS, RDS Replica 등) 자체는 이미 구축돼 있었지만, 페일오버 테스트에 앞서 도쿄 dreks를 단순 인프라에서 실제로 앱이 도는 환경까지 끌어올려야 했다. 오토스케일링과 운영 애드온(clusterautoscaler, metricsserver 등)을 배치하고, IAM 정책과 Helm values, 네임스페이스 템플릿 같은 앱 배포 전제조건을 준비한 뒤, farmilyapp을 도쿄 dreks에 Helm으로 실제 배포했다. 배포 자체는 성공했지만 앱 파드가 곧바로 Ready 상태가 되지 않았다. CrashLoopBackOff였다. 원인은 앱 코드가 아니라 DR 시크릿의 DB 접속 정보 두 가지였다. 첫 번째는 DB URL 누락이었다. DR 시크릿에는 DBHOST, DBPORT, DBNAME, DBUSER, DBPASSWORD는 있었지만, Spring Boot가 요구하는 jdbc:postgresql:// 형식의 DBURL 키가 빠져 있었다. 'url' must start with "jdbc" 오류로 datasource 생성이 실패한 거였고, jdbc 형식의 URL을 추가해서 해결했다. 두 번째는 DB 사용자명이 어긋난 문제였다. FATAL: password authentication failed for user "farmily" 에러가 떴는데, 처음엔 비밀번호 문제로 보였지만 실제로는 DR RDS의 마스터 계정이 farmily가 아니라 farmilyadmin이었다. 계정명을 실제 RDS 마스터 계정에 맞춰 고치고 나서야 해결됐다. 두 가지를 고친 뒤 시크릿을 강제로 재동기화하고 앱을 재시작하자 정상으로 돌아왔다. DB 커넥션 풀이 drrds에 붙었고, 마이그레이션이 정상적으로 검증됐으며, Deployment가 2/2 Available로 안정됐다. 여기서 얻은 교훈은 세 가지다. 환경변수 키를 여러 군데에 중복으로 넣으면 어디서 병합된 결과인지 추적하기 어려우니 한쪽에만 둘 것, DB 계정명은 반드시 RDS 실제 master username과 맞출 것, 시크릿을 갱신해도 이미 떠 있는 파드의 환경변수는 자동으로 바뀌지 않으니 수정 뒤에는 앱을 재시작할 것. 이렇게 도쿄 앱을 2/2로 띄워 둔 상태가 이어지는 페일오버 테스트의 출발점이 됐다. 측정 결과 요약 지표 측정값 목표 판정 읽기/헬스 RTO 약 5분 29초 RTO 15분 충족 전체 서비스 RTO (쓰기 성공) 약 9분 14초 RTO 15분 충족 보수적 RTO (백업 완료) 약 13분 5초 RTO 15분 충족 RPO (승격 직전 복제 지연) 약 126초(2.10분) RPO 60초 미달 읽기/헬스 RTO는 Route53이 도쿄로 넘어가고 도쿄 ALB가 200을 처음 돌려준 시점까지의 시간이다. 쓰기까지 포함한 전체 서비스 RTO는 도쿄 RDS를 승격해 쓰기가 처음 성공한 시점 기준이며, 백업 완료까지 보수적으로 잡으면 약 13분 5초였다. RPO는 대시보드 카드로 2.10분(126초)이 찍혔다. 목표와 견주면 RTO는 9분 14초로 15분 안에 들어와 충족했지만, RPO는 126초로 60초 목표를 두 배 넘겼다. 복구 속도는 기준을 만족했지만 복제 지연이 목표보다 컸다는 게 이번 테스트의 가장 중요한 결과다. 장애 전환 타임라인 (KST) 시각 이벤트 14:11:55 FIS 네트워크 장애 시작 — 모든 RTO 기준점(T0) 14:12:00 Route53 헬스체크 첫 실패 14:1214:15 헬스체크 0 / 부분 실패 구간 14:16:00 헬스체크 다시 1로 표시 (서울 복구 아님 — 도쿄가 200 응답한 것) 14:17:24 api.farmily.info 도쿄 ALB로 200 응답 — 읽기/헬스 RTO 종료 (5분 29초) 14:19:20 RDS promotereadreplica 호출 (전환 후 약 2분 공백) 14:20:29 도쿄 drrds 독립 primary 승격 완료 (승격 약 1분 9초) 14:21:09 farmdiaries write 성공 — 전체 서비스 RTO 종료 (9분 14초) 14:25:00 RDS 백업 완료 — 보수적 RTO (13분 5초) 14:26:56 FIS 실험 종료 단계별 분석 ① 장애 감지 — 서울 리전 FIS로 서울 네트워크를 끊자 api.farmily.info 글로벌 헬스체크 상태값이 1에서 0으로 떨어졌다. 같은 시각 서울 ALB의 정상 타겟 수가 2에서 0으로 내려갔고 ALB 5xx가 1.4천 건 가까이 치솟았다. 세 패널이 같은 시점을 가리키는 걸 봐서 장애 주입은 의도대로 먹혔다. 한 가지 눈여겨볼 부분은 FIS의 scope를 vpc가 아니라 all로 줘야 한다는 점이다. 첫 시도에서 scope를 vpc로 했더니 VPC 내부의 ALB에서 Pod, RDS로 가는 통신이 살아있어서 헬스체크가 계속 성공했고 failover가 일어나지 않았다. ② 트래픽 전환 — 서울에서 도쿄로 서울 ALB 요청 수는 14시 11분께 3.5천까지 잠깐 튀었다가 곧바로 0으로 빠졌다. 같은 시간 도쿄 ALB 요청 수가 올라오기 시작해 100에서 130 사이를 오갔고 도쿄 ALB 정상 타겟 수는 2로 안정적으로 유지됐다. 트래픽이 서울에서 도쿄로 옮겨간 게 그래프에서 분명하게 보인다. 페일오버 타임라인 패널에서도 헬스체크가 약 14시 12분부터 16분까지 빨간 구간(장애)으로 표시됐다가 다시 초록으로 돌아왔다. ③ 데이터 복제 — RPO 도쿄 drrds의 복제 지연을 RPO 관점에서 본 패널이다. 60초 임계선을 그어 뒀는데, 실제 지연은 톱니 모양으로 출렁이며 최고 4분 17초까지 올라갔다. 승격 직전 기록한 값이 약 126초였고 이게 이번 테스트의 RPO가 됐다 — 60초 목표를 넘긴 셈이다. RDS 연결 수 패널을 보면 서울 prodrds 연결은 장애 중 잠깐 30까지 줄었다가 회복했고 도쿄 drrds 연결은 60 근처를 유지하다 14시 20분께 8까지 떨어진 구간이 있다. 승격 과정에서 연결이 끊기는 순간으로 보인다. 여기서 짚을 공백이 하나 있다. 트래픽은 14시 17분에 도쿄로 넘어갔지만 promote 호출은 14시 19분 20초에야 들어갔다. 2분 남짓한 이 사이에 도착한 쓰기는 도쿄 RDS가 아직 read replica라 readonly transaction 오류로 실패했을 수 있다. 다음 테스트에서는 도쿄 전환을 확인하는 즉시 ReplicaLag를 기록하고 promote를 실행해 이 틈을 좁혀야 한다. ④ 도쿄 리전 수용 도쿄가 트래픽을 받아낼 준비가 돼 있었는지를 본다. dreks 노드 수는 1로, 실행 중인 파드 수는 약 16으로 테스트 내내 일정했다. 도쿄 쪽 컴퓨트 용량이 흔들림 없이 떠 있었다. ⑤ 페일오버 후 서비스 상태 도쿄로 넘어간 뒤 서비스 품질을 본 구간이다. 도쿄 ALB 응답시간 p99는 14시 20분께 4초까지 튀는 등 몇 차례 스파이크가 있었다. 도쿄 ALB 5xx 비율은 14시 15분께 0.78%까지 올랐다가 5% 경계선 아래에서 가라앉았다. 노드 포화도는 서울 prodeks 기준 메모리가 50%에서 70% 사이, CPU는 간헐적으로 튀는 정도였다. 앱 시그널 지연은 0.7 근처에서 평탄했고 장애(Fault) 신호는 잡히지 않았다. 응답시간이 잠깐 튄 건 승격 직후 커넥션이 다시 맺히는 구간과 겹치는 것으로 보인다. 5xx가 1% 미만에 머문 만큼 도쿄가 트래픽을 받아내는 데는 무리가 없었다. ⑥ 복구·페일백 API 헬스체크는 14시 12분부터 15분까지 0으로 떨어졌다가 다시 1로 회복했다. 도쿄 RDS 복제 지연 소진 추이는 앞서 본 것과 같은 톱니 곡선을 그리며 14시 18분 무렵 데이터가 끊긴다. 옆에는 페일백 체크리스트가 함께 정리돼 있는데, 서울 Route53 헬스체크가 계속 UP인지 확인하고, 서울 RDS를 재기동한 뒤 도쿄에서 서울로 복제를 다시 세우고, ReplicaLag가 0에 가깝게 소진되면 행/체크섬으로 데이터 정합성을 검증한 다음, Route53 우선순위를 서울로 되돌리고, 도쿄 컴퓨트를 원상태 비율로 줄이는 순서다. 승격된 도쿄가 받은 쓰기를 서울로 역동기화하는 작업이 페일백의 핵심이다. 이번 테스트에서 도쿄에만 쓰인 데이터는 farmdiaries 한 건이었다. 같은 시간대 서울 RDS에는 이 행이 없었는데, promote로 복제가 끊긴 뒤 당연한 결과다. 한 건이라 행 단위 수동 이관으로 충분하다. 다만 실제 장애에서 도쿄에 쓰기가 많이 쌓였다면 행을 하나씩 옮기는 대신 도쿄를 새 기준(source of truth)으로 두고 서울을 도쿄에 맞춰 재구성하는 편이 낫다. 상시 운영 대시보드에서 함께 본 것들 페일오버 테스트와 같은 시간대를 상시 운영 대시보드(AMP)로도 떼어 봤다. FIS가 서울 네트워크를 끊었을 때 그 여파가 다른 컴포넌트에는 어떻게 나타났는지 함께 확인할 수 있었다. 클러스터 헬스 — 스크랩 타겟 24개가 모두 정상으로 잡혔고 노드는 3개였다. 노드 CPU는 한 노드만 14시 16분께 85%까지 올라갔고 나머지는 낮게 깔렸다(argocd가 올라간 노드의 동기화 작업으로 보인다). 노드 메모리는 클러스터 전체가 50% 안팎에서 평탄했다. 결국 테스트가 prod 클러스터의 기본 체력에는 부담을 주지 않았고, 한 노드의 CPU 스파이크도 메모리까지 번지지 않고 끝났다. Istio 메시 — farmilyapp 요청률이 12.5 req/s까지 올랐다가 14시 16분 이후 0으로 빠졌다. 서울 경로가 끊기면서 prod로 들어오던 트래픽이 사라진 구간이다. 응답시간 p99는 한때 1분 가까이 치솟았는데, 막힌 경로에서 타임아웃과 재시도가 쌓이며 생긴 꼬리 지연으로 보인다. 반면 5xx 에러율은 0.18%까지만 잠깐 올랐다가 0으로 떨어졌다. 메시가 장애를 그대로 드러내면서도 에러 폭증 없이 버텨냈다. KEDA — 스케일러 활성·메트릭 값·에러율이 모두 0 근처에서 평탄했다. 부하 테스트가 아니라 장애 전환 테스트라 큐 깊이 같은 스케일 트리거가 움직일 일이 없었다. 에러가 0인 걸 보면 KEDA는 발동만 안 했을 뿐 정상 대기 상태였다. Karpenter — 파드 상태는 Running 약 50개로 안정적이었고 NodePool CPU는 4코어 근처였다. 노드 생성·종료율과 프로비저닝 지연 p90 패널은 데이터가 없었다. 테스트 내내 새 노드를 띄울 일이 없어 이벤트 자체가 안 생긴 정상 상태다. 잠깐 보인 Pending은 롤아웃으로 파드가 갈려 나가는 순간이다. 이번 부하는 기존 용량으로 다 받아냈고 오토프로비저닝까지 갈 필요는 없었다. Argo Rollouts — 14시 13분께 farmilyapp 롤아웃이 Completed에서 Progressing으로 넘어갔다. desired가 2에서 4로 뛰고 available이 잠깐 0으로 떨어졌다가 2로 돌아왔다. 새 리비전이 뜨는 동안의 surge와 교체 과정이다. 시점이 장애 전환과 겹치는 걸 보면, FIS로 prod 파드가 흔들린 구간과 맞물려 available이 0까지 내려간 듯하다. 그래도 롤아웃은 멈추지 않고 끝까지 진행됐다. 핵심 교훈 이번 테스트로 분명해진 것들을 정리하면 이렇다. 헬스체크 하나로 서비스 전체 상태를 대표할 수 없다. /api/v1/health가 성공해도 내부 기능은 실패할 수 있다. 게다가 헬스체크 대상이 failover 도메인인 api.farmily.info 자체라서 도쿄가 200을 주기 시작하면 헬스체크가 다시 Healthy로 돌아온다. 실제로 서울이 여전히 장애인데도 14시 16분에 회복으로 표시됐다. Healthy가 곧 "서울 정상"을 뜻하지 않는다는 얘기다. 서울의 실제 생사를 보려면 서울 전용 엔드포인트가 따로 있어야 한다. RDS 승격은 앱 설정을 자동으로 바꿔주지 않는다. 사용자가 어느 앱과 DB를 쓰는지는 Route53이 정한다. 승격 이후에는 서울과 도쿄가 split 상태가 되어 자동으로 동기화되지 않는다. 무엇보다 쓰기 가능 RTO와 Route53 전환 RTO는 다른 값이라 반드시 나눠서 기록해야 한다. Route53이 도쿄로 넘어가도 도쿄 RDS가 아직 replica면 쓰기는 실패하기 때문이다. 한 줄로 줄이면 이렇다. DR failover 자체는 동작한다. 다만 쓰기까지 포함한 서비스 복구는 Route53 전환만으로 끝나지 않는다. RDS promote 타이밍과 데이터 복구 전략까지 runbook에 들어가야 진짜 복구다. 모니터링 체크리스트 다음 DR 테스트에서 무엇을 봐야 하는지 미리 추려 뒀다. 필수 세 가지는 최소한으로 챙길 지표고 나머지는 여유가 되면 함께 본다. 필수 지표 (최소 3가지) 지표 확인 내용 기준 1 Route53 헬스체크 서울 ALB가 Unhealthy로 전환되는지 30초 내 실패 감지 2 RDS Replica Lag 장애 시점 복제 지연 (RPO 측정) 60초 이내 3 트래픽 전환 서울 ALB 요청 수 0으로 감소 / 도쿄 증가 시작 전환 확인 추가 지표 (권장) 지표 확인 내용 기준 4 ALB Healthy Hosts 서울 Healthy 타겟 0 / 도쿄 유지 전환 확인 5 RDS Connections 서울 연결 감소 / 도쿄 증가 전환 확인 6 EKS 파드 개수 도쿄 파드 1개→증가 Auto Scaling 동작 7 API 응답 시간 도쿄 cold cache 응답 시간 증가 일시적 허용 8 에러율 5xx 에러 비율 5% 이내 시간대별 체크 (T = 장애 시작) 시점 기대 동작 T+0 장애 시작 — 서울 ALB 타겟 죽음 T+30초 헬스체크 실패 → Slack 알림 T+1분 도쿄로 트래픽 전환 T+2분 도쿄 파드 증가 시작 T+5분 RDS Lag 기록 (RPO) T+15분 도쿄 RDS 쓰기 가능 → 서비스 완전 복구 이번 테스트 실측은 트래픽 전환 확인이 T+5분 29초, 쓰기 복구가 T+9분 14초로 아래 기대치보다는 느렸지만 15분 목표 안에는 들어왔다. 헬스체크가 도쿄 200 응답에 다시 Healthy로 돌아오는 함정과 Slack 알림이 특정 리전에서만 뜨는 문제는 핵심 교훈·후속 조치 항목과 함께 보면 된다. 후속 조치 다음 테스트 전까지 손볼 것들이 있다. 도쿄 RDS에만 남은 데이터를 처리해야 하는데, 테스트 데이터면 폐기하고 보존이 필요하면 행 단위로 서울에 옮긴다. 도쿄 RDS는 서울 prodrds의 read replica로 다시 만들어 둬야 같은 방식으로 재테스트가 된다. 헬스체크는 서울 전용, 도쿄 전용, 글로벌로 나누고, CloudWatch 알람은 헬스체크 지표가 조회되는 리전에 맞춰 만들어야 한다. 지난 테스트에서 알람을 엉뚱한 리전에 만들었다가 INSUFFICIENTDATA가 떠 알림이 가지 않았다. 마지막으로 도쿄 앱이 지금 서울 ECR 이미지를 끌어다 쓰고 있으니 도쿄 ECR로 crossregion 복제를 걸고 이미지 경로를 도쿄 ECR로 바꿔야 진짜 리전 격리가 된다.

Sep 17, 2026AWS
[DR] DR 테스트 후기 — 서울 리전 장애 유발로 도쿄 리전 활성화 하기

동일한 부하 스크립트를 온프레미스와 AWS 환경에 각각 테스트 해 보았다.

온프레미스(AsIs) vs AWS(ToBe) 같은 k6 스크립트, 같은 부하를 온프레미스 단일 VM과 AWS ECS Fargate에 그대로 가해서, "정말 클라우드가 버티는가"를 숫자로 확인한 기록. 결론부터: 온프레미스는 평상시 부하(VU 100)에서조차 이미 마비 상태였고, 클라우드는 42만 건이 넘는 요청을 에러 0%로 처리했다. 들어가며 부하테스트를 하면 보통 "얼마나 버티는지"를 확인한다고 생각한다. 그런데 이번 테스트의 결론은 조금 달랐다. 버티고 말고를 따질 필요도 없이, 온프레미스는 평상시 트래픽 수준에서조차 이미 죽어있었다. 같은 k6 시나리오(Baseline·Stress·Spike)를 온프레미스 단일 VM과 AWS ECS Fargate에 똑같이 쐈다. 온프는 세 시나리오 전부 p95가 44초 근처로 수렴하며 마비됐고, 클라우드는 태스크를 1 → 4개로 자동 확장하며 요청을 전부 받아냈다. 그리고 이 과정에서 클라우드 쪽에 잠복해 있던 버그 2건(Redis 역직렬화, RDS ReplicaLag)까지 실제로 찾아내서 고쳤다. 이 글의 한 문장 요약은 이거다 — 느려서 무너진 게 아니라, 캐시도 없고 수평 확장도 안 되는 구조라서 무너졌다. 한눈에 보는 결론 같은 부하·같은 스크립트(k6loadv3.js)를 온프레미스 단일 VM과 AWS ECS Fargate에 그대로 가했다. 결과는 정반대였다. 시나리오 온프레미스 (AsIs) AWS 클라우드 (ToBe) Baseline (100 RPS) p95 43.99s p95 31ms (워밍업 후) Stress (→2000 RPS) p95 45.02s, 처리량 60 → 33 RPS 역행, 유실 93% p95 2.2s, 에러 0%, 태스크 1 → 4 자동 확장, 427,421건 무장애 Spike (→2500 RPS) DB CPU 100% 2회 재포화, 첫 타임아웃 3건 단일 태스크 큐잉 흡수, 에러 0%, 종료 후 1분 내 복구 공통 병목 DB CPU 포화 + HikariCP pending 169 (App CPU 1320% 유휴) 병목 없음 — RDS CPU 한 자릿수, 태스크 수평 확장 온프레미스 단일 VM은 평시 부하조차 p95 44초로 사실상 응답 불가였고, 부하를 올릴수록 처리량이 오히려 꺾이는 구조적 천장(DB CPU 포화 + 커넥션 풀 고갈)을 보였다. 동일 부하에서 AWS는 에러 0%로 흡수하며 태스크를 1 → 4로 자동 확장했다. 이 격차가 VMware → AWS 마이그레이션의 정량적 당위성이다. 1. 목적과 테스트 설계 마이그레이션의 당위성을 "느낌"이 아니라 AsIs/ToBe 수치 대비로 증명하는 것이 목적이다. 멘토 조언("부하 테스트는 AsIs/ToBe 대비가 정석, 기준은 동시 접속자 수")에 따라 ① 온프레미스 현 구성의 한계점과 병목 특정 ② AWS 구성의 탄력성(오토스케일 실작동·무장애 흡수) ③ 장애·복구 거동을 MTTR·SLO·BCP·RCA 관점으로 해석한 운영 설계 근거 확보를 목표로 잡았다. 시나리오 3종 (공통 스크립트) 시나리오 목적 부하 패턴 baseline 평상시 기준선 100 RPS 일정 (RATEMULT=0.5) stress 한계점 탐색 100 → 2000 RPS 계단식 11단계 spike 순간 폭증 내성 순간 최대 2500 RPS 급증 → 급감 공통 파라미터는 RATEMULT=0.5, MAXHANDLE=100(반복 조회 농가 100명 = 캐시 효과 측정 범위). 엔드포인트는 공개 프로필 60% + health 40%(+ 일부 calendar), 부하 기준은 동시 접속자(VU)로 현실적 동시 농부 100200명을 가정했다. 환경 비교 항목 온프레미스 (AsIs) AWS 클라우드 (ToBe) 컴퓨팅 VMware VM, App 2vCPU/2GB 고정 1대 ECS Fargate, 오토스케일 1 → N (CPU 70%, 3분 평가) DB PostgreSQL VM 2vCPU/2GB RDS (연결 한도 225, MultiAZ) 캐시 없음 ElastiCache Redis 입구 / 부하생성 WAS 직접(192.168.x) / 노트북 k6 + Tailscale ALB(HTTPS) / 배스천 EC2(t3.small) 측정 한계 (정직한 기록): 두 환경 모두 k6에서 "Insufficient VUs, reached 2000" 경고가 떴다. 부하 생성기(클라이언트) 자체가 상한이었다는 뜻이며, droppediterations의 일부는 클라이언트 도착률 한계다. 그러나 서버 거동 차이는 명확하다. 온프는 달성 처리량이 수십 RPS로 붕괴하며 p95 44초(서버 포화)인 반면, 클라우드는 같은 조건에서 427,421건을 에러 0%로 처리하고 태스크를 확장했다. 비교는 유효하며, 클라우드의 진짜 한계는 클라이언트에 막혀 미측정인 만큼 더 높다. 2. AsIs — 온프레미스 (단일 VM) Baseline — 평시 부하에서 이미 "마비" 100 RPS 평상시 부하인데도 p95 43.99초(기준 0.8초의 약 55배), 달성 처리량은 목표의 60%(max 60.5 / mean 35.3 RPS)에 그쳤다. 에러는 0%지만 응답이 44초 걸리는 것은 사실상 장애다. 온프 Baseline · 자원포화 — DB CPU max 72.1%로 위험선(70%) 돌파, App(WAS) CPU는 20.4%로 유휴. 병목은 명확히 DB 레이어. 온프 Baseline · 커넥션풀 — HikariCP pending 169(풀 30 전부 점유·idle 0). 응답 지연의 직접 원인. JVM Heap 32.6%로 앱은 여유. 총 9,515건 처리 중 dropped 5,087건(약 35% 유실). Stress — DB CPU 100% 천장에 고정 100 → 2000 RPS로 올리자 DB CPU가 100% 완전 포화(mean 75.8%)됐다. 흡수할 추가 용량이 없어 달성 처리량이 오히려 60→33 RPS로 역행하고, 목표 부하의 93%(480,047건)가 유실됐다. p95 45.02초. HikariCP pending 169는 baseline과 동일하게 고정됐다. 온프 Stress · 자원포화 — DB CPU가 100%에 평탄하게 붙어 정체. WAS CPU는 13.2%. "더 줘도 DB가 먼저 천장을 친다"의 가장 명확한 증거. 온프 Stress · 부하 — 달성 요청률 max 32.9 / mean 12.2 RPS. baseline(60.5)보다 낮다. 서버 포화로 처리량이 역행하는 구간. Spike — 폭증 흡수 불가 + 첫 타임아웃 순간 폭증(목표 2500 RPS)에서 DB CPU가 "두 개의 산" 모양으로 두 차례 100% 재포화됐다(mean 66.2%). 폭증을 흡수할 버퍼가 없어 처음으로 타임아웃 실패 3건(60초 초과)이 발생했다. p95 44.88초, max 1분 0초. 커넥션풀은 세 시나리오 모두 동일하게 pending 169에서 막혔다. 온프 Spike · 자원포화 — DB CPU "두 개의 산": 1차 상승 → 중간 저점(36%) → 2차 100% 재포화. 충격 후 회복 전에 재포화. 온프 Spike · 지연·에러 — 폭증 구간(15:4915:50)에 4xx 401 스파이크, k6 로그에 request timeout 3건. 온프에서 처음 발생한 실질 실패. 종합 — "죽지는 않지만 마비" 10분 이상 고부하에도 전 인스턴스는 UP(Healthy 4/0)을 유지했다. 문제는 "죽음"이 아니라 "마비"다 — 살아는 있으나 p95 44초로 응답 불가다. 일관된 천장은 ① p95 4445초 ② DB CPU 72 → 100% 포화 vs App CPU 1320% 유휴 ③ HikariCP pending 169 고정 ④ 유실 35% → 93% → 91% ⑤ 부하를 올려도 처리량 역행으로 나타났다. 왜 이렇게 무너졌나 — 인과관계 5단계 App CPU는 노는데 p95 44초인 현상을 인과로 풀면 이렇다. 1. 근본 원인 = 캐시 부재 + 단일 DB 2vCPU. Redis가 없어 모든 읽기가 DB로 직행한다. 프로필·캘린더 조회가 매번 복수 테이블 JOIN으로 떨어지고, DB는 2vCPU뿐이라 연산이 곧 천장이 된다. 2. App CPU가 노는데 마비인 이유 — 톰캣/HikariCP 스레드가 DB 응답을 블로킹 대기(I/O wait)로 멈춰 있다. "일하는 중"이 아니라 "기다리는 중"이라 CPU를 거의 안 쓴다. 그래서 App을 증설해도 소용없다. 3. pending 169의 이유 — 풀 30개가 전부 느린 쿼리에 묶이면 새 요청이 대기 큐에 쌓인다. 동시 처리량 = 도착률 × 응답시간(Little's law)인데 응답시간(44초)이 폭발해 풀 처리율을 초과하며 169까지 적체됐다. 풀을 키워도 근본(느린 DB)이 그대로면 pending 위치만 바뀐다. 4. 처리량 역행의 이유 — DB CPU가 100%에 닿은 뒤엔 동시 쿼리들이 같은 자원(CPU·락·버퍼)을 두고 경쟁해 개별 쿼리가 더 느려진다. 전형적 혼잡 붕괴(congestion collapse)다. 5. 세 시나리오 p95가 모두 4445초인 이유 — 부하 방식과 무관하게 같은 평형점(풀 포화 + DB 100%)에 수렴하기 때문. 한계가 부하 패턴이 아니라 구조(용량)로 정해진다는 뜻이다. 요약하면 온프의 문제는 "느려서"가 아니라 구조가 한 곳(단일 DB)에서 직렬화되기 때문이다. 캐시가 그 직렬 구간을 우회시키고 수평 확장이 용량을 늘리는데, 온프엔 그 두 레이어가 없다. 3. ToBe — AWS 클라우드 (ECS Fargate) Baseline & RCA — Redis 역직렬화 버그 발견·수정 초기 baseline에서 응답은 빨랐으나(p95 65ms) apperrors가 59.67%에 달했다. 관측 → 로그 → 코드를 추적해 원인을 규명했다. 증상은 "첫 요청 200 → 두 번째부터 500 → TTL(10분) 만료 후 다시 200"이라는 간헐 장애·자가복구 패턴이었다. 원인은 RedisConfig의 GenericJackson2JsonRedisSerializer + DefaultTyping.NONFINAL 조합이 final 타입(Java record)에는 타입 정보를 붙이지 않는 비대칭이었다. record DTO 캐시가 쓰기는 {본문}, 읽기는 [타입, {본문}](WRAPPERARRAY)을 기대해, 캐시 HIT마다 역직렬화 예외 → 500이 발생했다. nonfinal 타입(crops)이 멀쩡했던 점이 이 가설을 뒷받침했다. 조치는 단순 버그 수정에 그치지 않고 재발 방지·장애 내성까지 설계했다. 직렬화 정상화 — DefaultTyping.NONFINAL → EVERYTHING으로 record/final 루트에도 타입 정보를 부여해 쓰기·읽기 라운드트립을 복원. 캐시 키 버전화 — CacheNames.VERSION(v2) 도입으로 직렬화 포맷이 또 바뀌어도 옛 키가 자동 격리돼 롤링 배포 중 포맷 혼재를 차단. 캐시 장애 내성 — CacheErrorHandler로 역직렬화 오류를 캐시 미스로 강등 + 깨진 키 evict → DB 폴백, Redis 일시 장애에도 500 대신 서비스가 지속되도록 설계. 변경 파일은 RedisConfig.java·CacheNames.java 2개이며, 코드 수정 후 에러는 59.67% → 0%로 떨어졌다. 클라우드 Baseline(버그) — ALB Target 5xx(앱 500) 8.94K 폭증. 캐시 히트 시점마다 500. 캐시가 독이 된 상태. 클라우드 Baseline(버그) — 현재 캐시 히트율 0%(빨강). 역직렬화 실패로 캐시가 무력화됨. 클라우드 Baseline(수정 후) — ALB 5xx 전부 0, 응답은 콜드스타트 첫 1점(p99 7.9s)만 튀고 14:21부터 평탄(p50 7.56ms). 버그 해소 입증. 클라우드 Baseline(수정 후) 요약 — 에러율 0%, Running 태스크 1(100 RPS는 CPU 13%로 오토스케일 트리거 미달 — 설계대로), RDS 연결 32. 이 과정에서 부하 시험이 운영 전 잠복 버그를 드러냈고, 관측 → 로그 기반 원인 규명 → 코드 수정 → 재현 시험으로 검증 루프를 완성했다. Stress — 오토스케일 1 → 4, 2000 RPS 에러 0% 흡수 CPU 평균 70%를 3분 연속 돌파하자 CloudWatch 알람이 발동해 desired 태스크가 1 → 2(14:53)→3(14:55) → 4(14:57)로 단계 증설됐다. 전 구간 apperrors 0% / httpreqfailed 0%(0/427,421건), ALB 5xx 0이었다. 클라우드 Stress · 가용성·오토스케일 — ECS 태스크 Running/Desired 1 → 2 → 3 → 4 단계 증설. 탄력 확장의 직접 증거. 클라우드 Stress · 자원포화 — ECS CPU avg 99.8% / max 100%(mean 70.1%)로 70·80% 선 돌파 → 스케일아웃 트리거. RDS CPU는 낮음. 클라우드 Stress · 요약 — 에러율 0%, Running 태스크 4, RDS 연결 122/225(한도 내 여유). RequestCount는 peak 66.6K/분(mean 20.4K)으로 온프와 자릿수가 다른 처리량. 지연 SLO 관점에서는 p95 2.18s로 "p95 클라우드 Spike · 자원포화 — ECS CPU 15:36 29% → 15:37 avg 84.9%/max 99.9% → 15:38 20% 급락(고점 약 1분). 짧아서 오토스케일 평가창(3분) 미충족. 클라우드 Spike · 지연·에러 — 응답시간 15:36 피크(p99 9.52s) 후 15:38 0으로 감쇠, ALB 5xx 전부 0. 에러 없이 지연으로 흡수 후 즉시 회복. 클라우드 Spike · 캐시 — 현재 캐시 히트율 100%(초록). Redis가 DB를 보호. RDS CPU 피크도 35%에 그침. 에러율은 평탄 0%(max 0.0346%). 교훈: 짧은 스파이크는 반응형 오토스케일(3분 지연)로 못 막는다. 기존 용량이 큐잉으로 버티되 지연이 오르므로, 최소 태스크 버퍼 + 요청 큐/타임아웃 설계가 필요하다(개선 백로그). 4. AsIs vs ToBe 직접 비교 시나리오 / 지표 온프레미스 (AsIs) AWS 클라우드 (ToBe) Baseline p95 43.99s 31ms (워밍업 후) Stress p95 / 에러 45.02s / 0% (마비) 2.18s / 0% Stress 처리량 거동 60 → 33 RPS 역행 정상 흡수, 427,421건 무장애 Stress 확장 불가 (단일 VM 고정) 태스크 1 → 4 자동 확장 Stress 유실 93% (480,047건) 0% 실패 (도착률은 클라이언트 한계) Spike 거동 DB CPU 100% 2회 재포화, 타임아웃 3건 단일 태스크 큐잉, 에러 0% 흡수 Spike 복구(MTTR) 자력 회복 느림·재포화 종료 후 1분 내 복귀 병목 자원 DB CPU 100% + HikariCP pending 169 없음 (RDS CPU 한 자릿수, 연결 122/225) 5. 멘토링 지표로 본 해석 (MTTR·SLO·BCP·RCA) MTTR(평균 복구 시간): 클라우드 spike는 충격 종료 후 1분 내 p50 ms 복귀로 빠른 자력 회복을 보였다. 온프는 회복 전 재포화로 MTTR이 길고 불안정하다. "120초"는 일반값일 뿐, Farmily 기준 자체 복구 목표(SLO)를 정해 알람·헬스체크 기준으로 삼는다. SLO: 무결성 SLO(에러율2000 RPS) 재측정 인프라 관측 SLO 공식화, 알람 기준(5xx·DB CPU·HikariCP·ReplicaLag) 정비 모니터링 결론 부하 시험은 마이그레이션의 당위성을 숫자로 확정했다. 온프레미스 단일 VM은 평상시 부하에서 이미 p95 44초로 마비했고, 그 원인(DB CPU 포화 + HikariCP 풀 고갈, 캐시·수평확장 부재)은 튜닝이 아니라 구조의 문제였다. 동일 부하에서 AWS는 에러 0%로 흡수하며 태스크를 자동 확장했고, 시험 과정에서 잠복 버그(Redis 역직렬화)와 ReplicaLag 이상까지 사전에 제거했다. 남은 과제(지연 SLO 튜닝·스파이크 버퍼)는 클라우드 위에서 푸는 운영 최적화 영역이며, 온프에서는 애초에 도달할 수 없는 단계다. App CPU가 20%로 놀고 있다고 "여유 있다"는 뜻은 아니다. 기다리는 중인지, 일하는 중인지부터 구분해야 한다. 온프레미스가 무너진 건 느려서가 아니라, 캐시도 없고 수평으로 확장할 수도 없는 구조였기 때문이다. 그 구조를 그대로 두고 서버 스펙만 올렸다면, 아마 같은 자리에서 또 무너졌을 것이다. 그림 출처: Grafana 대시보드 스크린샷 (온프 = Prometheus exporters, 클라우드 = CloudWatch 데이터소스)

Aug 18, 2026AWS
동일한 부하 스크립트를 온프레미스와 AWS 환경에 각각 테스트 해 보았다.

Multi-AZ RDS failover 테스트 검증기 (feat.AWS FIS)

MultiAZ RDS failover 테스트 검증기 (feat.AWS FIS) AWS FIS로 운영 DB에 강제 failover를 일으켜, "MultiAZ가 정말 장애를 견디는가"를 숫자로 확인한 기록. 결론부터: DB는 15초 만에 살아났는데, 앱은 15분 동안 못 붙었다. 들어가며 RDS를 MultiAZ로 켜두면 한 가용영역(AZ)이 죽어도 다른 AZ가 받아준다고 한다. 콘솔에서 체크박스 하나 켜는 걸로 "장애 대응 완료"라고 믿기 쉽다. 그런데 정말 그런가? 켜 두는 것과 실제로 견디는 것은 다른 이야기다. 그래서 직접 깨뜨려보기로 했다. AWS FIS(Fault Injection Simulator)로 운영 중인 RDS Primary를 강제로 재부팅시켜 failover를 일으키고, 그 과정을 Grafana로 초 단위로 관측했다. 두 번에 걸쳐 진행했는데, 1차에서 예상 못 한 게 터졌고 2차에서 그 원인을 끝까지 파고들었다. 이 글의 한 문장 요약은 이거다 — DB 가용성과 서비스 가용성은 같은 숫자가 아니다. 0. 먼저 개념부터 본문에 계속 나오는 용어 다섯 개만 잡고 가자. FIS (Fault Injection Simulator) — AWS의 장애 주입 도구. "이 자원에 이런 장애를 일으켜라"를 템플릿으로 정의해 일부러 장애를 내고 시스템 반응을 관측한다. 카오스 엔지니어링의 실행 도구다. MultiAZ Failover — RDS를 두 AZ에 Primary·Standby로 이중화한 구성. Primary가 죽으면 다른 AZ의 Standby가 새 Primary로 승격된다. 평소 동기 복제라 데이터 손실이 없다. RTO (Recovery Time Objective) — 장애부터 복구까지 허용하는 목표 시간. 어느 계층을 기준으로 재느냐(DB냐 앱이냐)에 따라 값이 완전히 달라진다 — 이 글의 핵심. HikariCP (커넥션 풀) — Spring Boot 기본 DB 커넥션 풀. 요청마다 연결을 새로 만들지 않고 미리 만든 연결을 재사용한다. failover로 Primary가 바뀌면 풀에 있던 연결은 옛 Primary를 바라보는 죽은 연결이 된다. 이걸 얼마나 빨리 버리고 새로 만드냐가 앱 복구 속도를 가른다. warm pool — 방금 새 연결로 통째로 갈려서 전부 "신선한" 상태의 풀. 반대는 오래 묵은 연결로 찬 cold pool. (뒤에서 함정으로 다시 등장한다.) 1. 무엇을 테스트했나 구성 내용 대상 DB prodrds · PostgreSQL 18.3 · db.t3.small · MultiAZ = true 앱 계층 ECS · Spring Boot · HikariCP 트래픽 ALB → Target Group → ECS 안전장치 Stop Condition = CloudWatch 5xx 알람 관측 Grafana(AMG) + RDS Recent events 합격 기준은 세 가지로 잡았다. 1. DB RTO Primary(2c)가 재부팅되면 Standby(2a)가 새 Primary로 승격된다. 문제는 오른쪽 — DB는 2a로 옮겨갔는데 앱의 커넥션 풀은 여전히 옛 Primary(2c)를 붙들고 있다. 2. 1차: 일단은 평온했다, 5xx가 터지기 전까진 FIS 액션 aws:rds:rebootdbinstances에 forceFailover=true를 줘서 Primary를 강제로 재부팅했다. "Primary가 갑자기 죽는" 상황을 인위적으로 만든 것이다. 시작 직전 대시보드는 모든 게 정상이었다. DB 커넥션 10개 안정, 5xx 0건, p99 10.7ms. 그림 1. 1차 — 시작 전 정상 기준선. DB 커넥션 10 · 정상 타깃 1 · 앱 5xx 0 · p99 10.7ms · DB CPU 3.99%. 이후 그림과 비교할 'before' 상태. 그림 2. 1차 — 장애 직전 상세. 요청 수·활성 커넥션·ECS 태스크 running 1 · Failover 5xx 누적 0. 아직 모든 지표 정상. 그런데 failover를 건 직후, 5xx가 폭증하기 시작했다. 그림 3. 1차 — 장애 정점. 요청 1.5K 스파이크, Failover 5xx 누적 280건(대부분 앱 target 5xx). DB와 ECS는 멀쩡히 살아있는데(태스크 running 1) 앱이 DB에 못 붙어 5xx가 터졌다. 이 한 장이 'DB 가용 ≠ 서비스 가용'을 그대로 보여준다. 이상했다. DB CPU도, 메모리도, ECS 태스크도 전부 건강했다. 그런데 앱은 5xx를 쏟아냈다. 전환 구간을 들여다보니 DB 커넥션이 10에서 5로 빠지고 p99가 396ms까지 치솟았다. 그림 4. 1차 — 전환 중. DB 커넥션 10→5 감소, 읽기/쓰기 지연 스파이크와 함께 p99가 396ms(p50/p99 패널은 2초까지)로 상승. 앱이 옛 Primary의 죽은 커넥션을 붙들고 새 Primary로 못 옮겨가는 구간. 그리고 한참 뒤에야 앱이 회복됐다. 그림 5. 1차 — 종료 후 회복. DB 커넥션 0→10 복귀, p99가 30초(커넥션 대기 천장)를 찍었다가 5.02ms로 정상화. 5xx 막대는 16:0316:04에 잡힌다. DB는 진작(15:50) 복구됐는데 앱 재연결은 16:05. 체감 RTO ≈ 15분의 끝. 정리하면 1차의 숫자는 이렇게 갈렸다. 지표 값 DB RTO (인프라) 약 1분 — 왜 1차가 실패했나? failover 직후 RDS가 Standby를 다시 동기화(재동기화)하는 시간을 기다리지 않고 곧바로 다시 실행했기 때문이다. 이게 2차에서 검증할 가설이 됐다. 3. 2차: 이번엔 기다렸다, 그리고 회복을 관측했다 2차는 단 하나만 바꿨다 — failover 후 RDS 동기화가 완료될 때까지 기다렸다가 다음 회차를 실행했다. 설정은 한 줄도 안 건드렸다. 그렇게 18:45 / 18:56 / 19:16, 세 번 깨끗하게 때렸다. 구분 1차 2차 실행 방식 동기화 완료 전 곧바로 재실행 동기화 완료까지 대기 후 실행 실행 횟수 2회 3회 결과 실패 — 전환 도중 앱 오류 폭증 정상 — 매 회차 깨끗하게 전환·관측 매 회차 깨끗하게 전환됐다. DB 연결 수 그래프를 보면 failover 시점에 커넥션이 잠깐 푹 꺼졌다가 다시 차오르는 게 그대로 보인다. 그림 6. 2차 1회차(18:45) — p99 2.21ms · DB CPU 5.02% · 정상 타깃 1. DB 연결 30 → failover 시점 일시 감소 → 30 복귀. 동기화 완료를 기다린 뒤 실행하니 깨끗이 전환됐다. 그림 7. 2차 2회차(18:56) — p99 2.39ms · DB CPU 5.41%. 같은 패턴이지만 앱 커넥션 풀 회복이 약 2분으로 빨라졌다. 그림 8. 2차 3회차(19:16) — p99 2.62ms. 읽기/쓰기 지연 스파이크가 짧게 여러 번 났다 회복. 요청 700 스파이크도 잠깐 보인다 — 요청 스레드가 죽은 커넥션을 즉시 일괄 폐기해 앱 회복이 약 20초까지 빨라졌다. DB 자체 복구는 RDS 이벤트 로그로 실측했다. 그림 9. 2차 — RDS Recent events. 18:45·18:56·19:16 세 번 모두 MultiAZ failover started → DB instance restarted → failover completed. DB 자체 복구 15초·전체 3045초의 실측 근거. (위 6/11 23:03·23:07은 정기 백업.) [2차 — RDS MultiAZ failover events] 1회차 18:45:02 started → 18:45:18 restarted → 18:45:31 completed (AZ 2c→2a) 2회차 18:56:17 started → 18:56:32 restarted → 18:57:01 completed (AZ 2a→2c) 3회차 19:16:27 started → 19:16:42 restarted → 19:17:01 completed (AZ 2c→2a) 3회 측정을 표로 모으면 이렇다. 지표 1회차 2회차 3회차 평균 DB 자체 복구 16초 15초 15초 15.3초 전체 완료 30초 45초 35초 36.7초 앱 커넥션 풀 복구 5분 2분 20초 — ECS 태스크 교체 없음 없음 없음 — 여기서 눈에 띄는 줄이 앱 커넥션 풀 복구: 5분 → 2분 → 20초다. 설정은 동일했는데 왜 점점 빨라졌을까? 4. 핵심 분석 — DB는 15초, 앱은 15분, 그 사이 14분의 사각 같은 failover 한 번을 두 계층이 전혀 다른 시간으로 겪었다. 그림으로 보면 이렇다. 시간축 0 15초 15분 │ │ │ DB 계층 ──────● (복구 15초) │ 앱 계층 ──────○━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━● failover ↑ DB는 살아있으나 앱이 못 붙는 구간 ≈ 14분 ↑ 앱 복구 '가용성'을 DB 한 층으로만 재면 저 가운데 14분이 통째로 가려진다. 사용자가 실제로 겪는 시간은 가장 느린 계층, 즉 앱이 결정한다. 왜 앱이 늦게 붙었나 — HikariCP의 죽은 커넥션 failover로 Primary가 바뀌면, 풀 안의 연결들은 전부 옛 Primary를 향하는 죽은 연결이 된다. 문제는 이 죽은 연결을 언제 감지해서 새 연결로 갈아끼우느냐다. 1회차 (5분) — 죽은 커넥션 감지 후, housekeeper 스레드가 10초에 1개씩 검증·폐기했다. 풀 20개를 다 갈아끼우는 데 5분. 로그엔 'Thread starvation or clock leap detected'가 동반됐다. 2회차 (2분) — 같은 패턴이지만, 직전에 한 번 갈린 풀이라 수명 짧은 연결이 섞여 더 빨리 교체됐다. 3회차 (20초) — 요청 스레드가 즉시 감지해 0.5초 안에 풀 전체를 일괄 폐기했다(housekeeper 10초 대기 없이). 단, 직후 새 연결 생성이 몰리며 'request timed out after 30000ms'가 짧게 났다 정상화됐다. 회차가 빨라진 건 '개선'이 아니다 — warm pool 함정 여기가 제일 중요하다. 5분 → 20초는 내가 뭘 고쳐서가 아니다. 한 번 failover를 겪으면 풀이 통째로 새 연결로 갈린다. 그 직후 회차는 "방금 만들어 수명이 거의 0인" 신선한 연결만 남은 상태(warm pool)가 되고, 그 덕에 우연히 빨라진 것뿐이다. 문제는 실제 운영 failover는 며칠몇 주 멀쩡히 돌던 cold pool에서 딱 한 번 터진다는 것. 즉 현실은 늘 '1회차(5분)' 상황이지 2·3회차(빠름)가 아니다. 첫 회차가 5분이나 걸린 직접 원인은 maxLifetime 기본값이 30분이라 housekeeper가 죽은 연결을 천천히 교체했기 때문이다. 참고로 "warm pool"은 EC2 Auto Scaling의 Warm Pools와는 완전히 다른 개념이다. 여기선 "커넥션 풀이 한 번 갈려서 신선해진 상태"를 가리킨다. 5. 판정과 개선 과제 AWS 기대치 대비로 보면 인프라는 전부 통과, 앱만 숙제로 남았다. 지표 결과 AWS 기대치 판정 :: DB 자체 복구 1516초 30초 이내 양호 전체 Failover 완료 3045초 60초 이내 양호 데이터 손실 없음 (동기복제) 무손실 양호 ECS 태스크 생존 전 회차 교체 없음 — 양호 앱 에러 구간 최대 5분 · 체감 15분(1차) — 개선 필요 개선 1순위는 HikariCP 튜닝이다. 조치 기대 효과 :: 1 maxlifetime = 60000 (60초) 커넥션 수명 단축 → failover 시 빠른 교체 2 connectiontimeout = 5000 (5초) 새 연결 실패 시 빠른 에러 반환 3 keepalivetime = 30000 (30초) 주기적 alive 체크 → 죽은 연결 조기 발견 4 validationtimeout = 3000 (3초) validate 빠른 실패 처리 5 ECS min 태스크 2개 이상 1개가 죽어도 다른 태스크가 커버 그 외에 1차에서 도출한 개선들: Deep health check — ALB 헬스체크가 DB 연결까지 점검하게. Healthy=1 맹점을 없애 죽은 경로로 트래픽 보내는 걸 차단. 알람 기준 보강 — Healthy 수가 아니라 5xx·DB 활성 커넥션 수 기준으로. FIS 템플릿에 aws:fis:wait 추가 — reboot는 13초 fireandexit라 정작 장애 구간엔 실험이 끝나 Stop Condition이 무력화된다. wait로 실험 수명을 복구 구간까지 늘려야 한다. 마치며 이 실험의 목적은 "MultiAZ가 장애를 견디는가"를 추측이 아니라 숫자로 확인하는 것이었고, 결과는 두 계층으로 뚜렷이 갈렸다. 검증된 것 — MultiAZ failover 메커니즘 자체는 정상이다. DB 복구 1516초, 데이터 무손실, AZ 자동 전환, ECS 태스크 생존. 인프라 회복력은 합격. 발견한 것 — 서비스 RTO를 좌우하는 건 DB가 아니라 앱이었다. HikariCP가 죽은 연결을 늦게 교체해 1차 체감 복구가 15분, 5xx 280건. 그 와중에 ALB 헬스체크는 DB 단절을 못 봤다. 운영 교훈 — failover 직후 RDS 재동기화가 끝나기 전에 재실행하면 안 된다(1차 실패 원인). 그리고 풀 회복이 빨라 보인 건 warm pool 효과일 뿐, 실제 운영은 늘 '5분' 상황이라 튜닝이 전제다. 가장 크게 남은 한 줄은 이거다. 서비스 가용성은 가장 빠른 계층이 아니라 가장 느린 계층이 결정한다. DB가 15초 만에 살아나도 앱이 14분을 못 붙으면, 사용자에게 그 서비스는 14분간 죽어 있던 것이다. 'MultiAZ를 켜 두는 것'과 '장애를 실제로 견디는 것'의 차이가 바로 여기에 있다. 이게 카오스 엔지니어링을 직접 해보기 전엔 안 보이던 사각지대였다. 체크박스를 켜는 것과, 깨뜨려서 숫자를 보는 것은 정말로 달랐다.

Jun 18, 2026AWS
Multi-AZ RDS failover 테스트 검증기 (feat.AWS FIS)