diff --git a/docs/decisions/failover-app-reconnect.md b/docs/decisions/failover-app-reconnect.md new file mode 100644 index 00000000..de579e5b --- /dev/null +++ b/docs/decisions/failover-app-reconnect.md @@ -0,0 +1,92 @@ +# DB 페일오버 때 앱이 새 DB 로 넘어가는 방법 — 측정 전 후보 정리 + +작성일: 2026-11-08 +상태: 🟢 **잴 것·rig 세부 확정.** 2026-11-08 사용자 confirm — 후보 A + B, 흉내 K1 + K2. 운영 채택 문서가 아니다. +연관: [`./rpo-rto-targets.md`](./rpo-rto-targets.md) §5 구성 3 · §7 · §8 R1 · +[`../../loadtest/results/replica-promotion-2026-11-08/README.md`](../../loadtest/results/replica-promotion-2026-11-08/README.md) (DB 쪽 승격 0.25~8초) · +[`./replication-lag-and-semisync.md`](./replication-lag-and-semisync.md) + +--- + +## 0. 왜 이 문서인가 + +11-08 승격 실험은 **DB 가 다시 쓰기를 받기까지**만 쟀다(밀림 없으면 약 0.25초, 약 21초 밀리면 약 7.8초). +사용자가 겪는 시간은 그보다 길다: + +``` +원본 DB 죽음 ─▶ ① 알아챔 ─▶ ② 승격 결정 ─▶ ③ 승격 (잼: 0.25~8초) ─▶ ④ 앱 연결을 새 DB 로 ─▶ ⑤ 앱이 실제로 다시 씀 +``` + +④·⑤ 가 비어 있다. 그리고 승격 실험은 원본·리플리카가 **한 박스**라 «못 받은 데이터» 가 0 이었다 — 두 박스에서 얼마를 잃는지도 비어 있다. + +🔴 **범위 메모**: `rpo-rto-targets.md` §7 은 «자동 페일오버 — 리플리카(3) 채택 전엔 의미 없다» 로 안 하기로 했고, +리플리카는 보류 상태다. 이 문서는 **재는 것**이지 페일오버를 운영에 넣자는 게 아니다. 측정 결과는 Q5(리플리카를 둘지)의 근거로만 쓴다. + +## 1. 지금 앱이 DB 에 붙는 방식 (바꿀 대상) + +- `application.yml:43` — `jdbc:mysql://${DB_HOST:shadowfit-mysql}:3306/...` — 호스트 이름 하나. 운영 compose 는 `DB_HOST: shadowfit-mysql`(`docker-compose.prod.yml:95`) +- HikariCP `maximum-pool-size: 15` · `connection-timeout: 30000`(30초). `max-lifetime` 은 기본값 +- Connector/J `connectTimeout`·`socketTimeout` 은 **지정 안 함**(기본값) + +→ 지금은 DB 주소가 바뀌면 **설정을 고쳐 앱을 다시 띄우는 것 말고는 방법이 없다.** + +## 2. 후보 — 앱 연결을 새 DB 로 바꾸는 방법 (④) + +| # | 방법 | 앱 쪽 변경 | 전환 때 하는 일 | 예상되는 함정 (재서 확인할 것) | 운영 부담 | +|---|---|---|---|---|---| +| **A** | **주소 바꾸고 앱 재시작** | 없음 | `DB_HOST` 를 새 DB 로 바꾸고 백엔드 컨테이너 재시작 | 재시작 동안 API 전체가 멈춘다. 그 시간 = Spring 기동 시간(안 쟀다) | 없음. 사람이 해야 함 | +| **B** | **DNS 이름 바꾸기** (예: Route 53 사설 영역 `db.internal`) | `DB_HOST` 를 DNS 이름으로 | 레코드를 새 DB IP 로 | **JVM 이 DNS 결과를 캐시한다** — 레코드 TTL 과 JVM 캐시가 겹쳐 «바꿨는데 옛 주소로 붙는» 시간이 생긴다. 이 길이가 측정 대상 | Route 53 사설 영역 1개 | +| **C** | **사설 IP 옮기기** (EC2 보조 사설 IP 를 새 DB 로 재할당) | `DB_HOST` 를 그 IP 로 | `assign-private-ip-addresses --allow-reassignment` | DNS 캐시는 없다. 대신 **AWS 전용**이고 같은 서브넷(같은 AZ)이어야 한다 — 다른 AZ 로 못 넘긴다 | IAM 권한 · 같은 AZ 제약 | +| **D** | **프록시** (ProxySQL · MySQL Router) | `DB_HOST` 를 프록시로 | 프록시가 백엔드를 바꿈 (자동 가능) | 프록시 자체가 새 단일 장애점. 홉 하나 추가 | 프록시 서버·설정 상시. 🔴 `db-portfolio-roadmap.md:200` 이 «read replica/CQRS 풀구현» 을 오버엔지니어링으로 닫았다(읽기 라우팅 ProxySQL 도 같은 이유로 안 함 — `replication-lag-and-semisync.md:548`) | +| **E** | **JDBC 다중 호스트 URL** (`jdbc:mysql://원본,리플리카/...`) | URL 한 줄 | 없음 — 드라이버가 첫 호스트 실패 시 다음으로 | Connector/J 는 넘어간 연결을 **기본적으로 읽기 전용**으로 연다고 알고 있다(`failOverReadOnly`, 확인 필요). 원본이 살아나면 그쪽으로 되돌아가는 문제(갈라진 두 DB 에 쓰기) | 없음. 대신 위험이 앱 설정 안에 숨는다 | +| F | 매니지드 (RDS Multi-AZ) | — | — | — | 🚫 범위 밖 — `rpo-rto-targets.md` §7 «매니지드는 다른 판단» | + +## 3. 장애를 어떻게 흉내 내나 — 이게 결과를 가장 크게 바꾼다 + +| 흉내 | 앱이 보는 것 | 비고 | +|---|---|---| +| **K1. 프로세스 죽이기** (`docker kill`) | OS 가 살아 있어 연결이 **바로 끊겼다는 응답(RST)** 을 받는다 → 빨리 실패 | 11-08 승격 실험이 쓴 방식. 실제 호스트 상실보다 낙관적 | +| **K2. 네트워크 끊기** (보안 그룹·iptables 로 원본으로 가는 패킷 버리기) | **아무 응답도 없다** → 열린 연결의 쿼리가 타임아웃까지 매달린다. `socketTimeout` 이 기본값(무제한)이면 TCP 재전송 한도까지 갈 수 있다(추정, 확인 필요). 새 연결은 Hikari `connection-timeout` 30초에 걸린다 | 호스트 상실·AZ 장애에 더 가깝다 | + +→ **같은 후보라도 K1 에선 몇 초, K2 에선 수십 초~분일 수 있다**(추정). 그래서 K2 를 빼면 «앱 전환 N초» 를 인용할 수 없다고 본다. +K2 에서 시간이 길게 나오면, 그 다음 질문은 «`socketTimeout`·`connectTimeout` 을 얼마로 둘까» 인데 — 그 값은 이 측정이 근거가 된다(지금 정하지 않는다). + +## 4. 무엇을 재나 (후보와 무관하게 공통) + +- **무대**: EC2 3대 — 원본 DB · 리플리카 DB · 백엔드 + 부하 발생기. 원본·리플리카를 **다른 박스**로 나눠 «못 받아서 잃은 양» 을 같이 잰다 +- **부하**: 운동 기록 저장 같은 쓰기 API 를 일정 속도로 계속 부른다(문서 §8-1 가정 피크 — 초당 45행). 요청마다 «보낸 시각 · 응답 시각 · 성공 여부» 를 남긴다 +- **결과 칸**: 죽인 시각 → 첫 실패 · 마지막 실패 · **첫 성공까지 걸린 시간**, 그 사이 실패 요청 수, 매달린 요청의 최대 응답 시간, + 성공 응답을 받았는데 새 DB 에 없는 쓰기 수(= 잃은 양) +- **①② (알아채기·결정)는 고정값으로 뺀다** — 사람이 하면 사람 시간이고, 자동으로 하면 감시 주기다. 이 실험은 «승격 명령을 내린 뒤» 부터를 잰다. + 안 하면 결과가 «사람이 몇 초 만에 봤나» 에 묻힌다 +- 반복: 후보 × 흉내마다 버림판 1 + 본판 3 이상, 순서는 섞는다 + +## 5. 추천 (Claude) — 결정은 사용자 + +- **A + B, 흉내는 K1·K2 둘 다.** + - A 는 이 규모에서 **실제로 하게 될 방법**이라 기준선으로 필요하다. 남은 빈칸이 «Spring 재기동 시간» 하나라 싸다 + - B 는 업계에서 가장 흔한 방식(매니지드 DB 도 DNS 를 바꾼다)이고, **JVM DNS 캐시라는 앱 쪽 함정**이 결과에 드러난다 — 면접에서 «페일오버 어떻게 하나요» 에 «DNS 를 바꾸면 끝» 이 아니라 그 뒤 몇 초가 어디서 나오는지 말할 수 있다 + - K2 는 §3 이유로 빼면 안 된다 +- C 는 같은 AZ 제약 때문에 AZ 장애엔 못 쓴다 — 추천 안 함. D 는 로드맵이 닫은 방향과 부딪힌다. E 는 읽기 전용·되돌아감 위험을 앱 설정 안에 숨겨서, 재더라도 «이래서 안 쓴다» 를 보이는 용도다 +- 규모: 2 후보 × 2 흉내 × 4판 = 16판. EC2 3대로 한 라운드(시간은 rig 를 만든 뒤 로컬 스모크로 추정) + +## 6. 결정할 것 + +- [x] **Q1. 잴 후보** → 🟢 **A + B** (2026-11-08 사용자 confirm) +- [x] **Q2. 장애 흉내** → 🟢 **K1 + K2** (2026-11-08 사용자 confirm) +- [ ] **Q3. 범위** — §0 메모대로 «측정만, 운영 채택 아님» 으로 둘지. 명시 답은 아직 없다 — 그동안 `rpo-rto-targets.md` §7 «자동 페일오버 안 함» 은 그대로 둔다 + +### 6-1. rig 세부 — ✅ 2026-11-08 사용자 confirm (R-a 앱 박스 DNS · R-b·R-c 추천대로) + +| | 후보 | 추천 | 걸리는 것 | +|---|---|---|---| +| R-a | B 의 DNS 를 무엇으로 | Route 53 사설 영역 → 🟢 **채택은 앱 박스 DNS(dnsmasq)** | 측정용 IAM 사용자에 Route 53 권한이 **없다**(11-08 `AccessDenied` 확인) — 사용자가 콘솔에서 붙여야 한다. 대안은 앱 박스에 작은 DNS 를 띄우는 것(권한 불필요, 레코드 전파 시간은 빠져 낙관적) | +| R-b | 앱의 DB 타임아웃 | 🟢 지금 설정 그대로(기본값) | 시간 제한 넣은 팔을 더하면 판이 2배이고, 넣을 값의 근거가 아직 없다 | +| R-c | 요청 간격 | 🟢 초당 10번 | 가정 피크(세션 시작 초당 0.075번)면 13초에 한 번이라 초 단위 변화를 못 잡는다. 10번은 부하가 아니라 **시간 해상도 0.1초**에서 나온 값이다 | + +## 결정 로그 + +- 2026-11-08: 후보 초안. 11-08 승격 실험이 DB 쪽(0.25~8초)만 쟀고, 앱 전환·두 박스 유실이 비어 있어서 정리했다. 후보 A~F · 흉내 K1/K2 · 공통 측정 칸 · 추천(A + B, K1 + K2). 결정은 미결. +- 2026-11-08: 🟢 **Q1 A + B · Q2 K1 + K2 채택** (사용자 confirm «추천대로»). Q3 는 명시 답 없음. rig 세부 셋(§6-1 R-a~R-c)은 미정 — 특히 R-a 는 IAM 권한이 필요하다. +- 2026-11-08: 🟢 **rig 세부 확정** (사용자 confirm) — R-a 는 **앱 박스에 DNS 를 직접 띄운다**(Route 53 아님 → 레코드 전파 시간이 빠져 + 실제보다 낙관적이라는 것을 결과에 적는다), R-b 기본 타임아웃 그대로, R-c 초당 10번.