작년, 만으로 갓 2년이 넘은 백엔드 개발자였던 나는 DB 접속정보 한 줄을 어떻게 적어야 할지 몰라 잠깐 멈칫했다. 그 사소한 의문을 그냥 넘기지 않고 끝까지 파봤더니, 그 끝에는 "데이터베이스 고가용성(HA)"이라는 큰 주제가 있었다. 그리고 1년쯤 뒤, 운영 장애 영향도를 분석하다가 정확히 같은 주제를 다시 만났다. 이 글은 그 작은 의문에서 시작해 한 바퀴 돌아온 학습 기록이다.
Intro
- 망분리 작업 중 받은 DB 접속정보에
VIP와MMM VIP두 가지가 있었고, Spring 에 무엇을 적어야 하는지 막혔다. - MMM VIP 는 현재 writer(primary) 를 따라다니는 floating VIP, 일반 VIP 는 노드에 고정된 IP다. 쓰기 접속은 MMM VIP(writer floating) 를 적는 것이 failover 투명성 측면에서 옳다.
- 궁금해서
repmgr + HAProxy로 로컬에 같은 구조를 재현했더니, "VIP 가 writer 를 따라간다"는 게 마법이 아니라 헬스체크로 현재 primary 를 판정 + 복제 매니저의 자동 승격 조합이라는 걸 직접 확인했다. - 1년 뒤, slave DB 가 올라간 가상화 호스트 점검 협의가 들어왔다. 우리 BE 는
@Transactional(readOnly=true)면 무조건 slave 로 라우팅하는데 자동 failover 가 없어서, slave 를 내리면 읽기 요청을 master 로 우회할 수 없었다. - Hikari 설정만으로 slave 를 master 로 우회하는 방법은 없다. 라우팅은 코드 책임이고, Hikari 로는 타임아웃을 줄여 피해를 완화할 뿐이다. 진짜 무중단의 갈림길은 "접속 대상을 floating VIP 로 설계했는가"에 있었다.
- 작년에 절반쯤 만들고 바빠서 묵혀뒀던 그 PoC 를, 최근 다시 꺼내 자동 failover 가 끝까지 동작하도록 완성하고 재검증했다.
- 교훈: 애플리케이션 바깥(인프라/DB) 에서 일어나는 일도 알아야, 내가 짠 BE 설계가 단단해진다.
발단: 망분리 작업과 접속정보 한 줄
나는 어느 클라우드 백업(Backup) 서비스의 백엔드 개발을 맡고 있었다. 인프라는 우리 회사가 직접 굴리는 게 아니라, 협력하는 퍼블릭 클라우드 사업자의 환경 위에 올라가 있었다. 그 무렵 리전 내 새 망(network station) 두 개를 신규 구축하고, 기존 망 하나를 통합했다가 다시 둘로 분리하는 작업이 겹쳐 있었다.
그래서 DB 쪽 절차를 순서대로 밟고 있었다. DB VM 자산 발급 → DB 설치 → 오브젝트 추가/변경 신청(DDL) → ACL 요청 → 망분리 시 DB 마이그레이션 요청. 그 흐름 안에서 Spring Boot 애플리케이션의 DB 접속정보를 기재할 차례가 왔다.
여기서 잠깐 멈췄다. DB 운영팀에서 전달받은 접속정보에 컬럼이 두 개 있었다. 하나는 VIP, 하나는 MMM VIP.
대략 이런 모양이었다(값은 예시로 일반화했다).
| Master | db-node-01 (10.0.0.11) | 10.0.0.11 | 10.0.0.100 | 3000 |
| Slave | db-node-02 (10.0.0.12) | 10.0.0.12 | (없음) | 3000 |
두 가지가 눈에 띄었다.
VIP는 노드마다 따로 있었다. master 는 10.0.0.11, slave 는 10.0.0.12. 사실상 각 노드의 호스트 IP 와 같았다.MMM VIP는 master 행에만 10.0.0.100 하나가 있고, slave 행은 비어 있었다.
그래서 순수하게 막혔다. Spring 의 DataSource 에는 노드별 VIP 를 적어야 하나, MMM VIP 를 적어야 하나?
솔직히 말하면 그때 나는 BE 개발자였지 전문 DBA 가 아니었고, 우리 법인 내엔 전담 DBA 도 없었다. 사내 개발 인프라는 내가 한 땀 한 땀 직접 구성했고, DB 운영은 클라우드 사업자 쪽 DB 운영팀과 협업하는 구조였다. VIP 와 MMM VIP 의 차이 같은 건 모를 수밖에 없었다. 경력도 만 2년 정도였다. 운영환경 DB 서버 앞단에 LB 역할을 하는 인프라 자원이 있을 것으로 추측되는데, 별도 가이드를 받은 적은 없었으니까.
그래서 일단 DB 운영팀 DBA 께 여쭤 가이드를 받았다. 거기서 끝낼 수도 있었다. 받은 대로 적고 넘어가면 그만이었으니까. 그런데 그러기가 싫었다. 애플리케이션 바깥에서 일어나는 일도, 내가 직접 작업하지 않더라도 알아야 한다는 생각이 있었다. 그래서 가이드를 받은 뒤에도 "이게 대체 안에서 어떻게 동작하길래 이렇게 적으라는 거지?"를 직접 확인하기로 했다.
VIP와 MMM VIP는 무엇이 다른가
정리하면 둘은 층위가 다른 개념이었다.
| 노드별 VIP | 10.0.0.11 / .12 | 각 노드에 고정된 service IP (호스트와 1:1) | 고정. 죽은 노드의 VIP 는 죽은 채로 둔다 |
| MMM VIP | 10.0.0.100 | 현재 writer(primary) 를 따라다니는 floating VIP | 승격된 새 primary 노드로 이동한다 |
MMM 은 Multi-Master Replication Manager 계열에서 온 개념이다(원래 MySQL HA 도구에서 유래했다). 핵심은 writer role 에 부여되는 VIP 라는 점이다. 그래서 master 행에만 MMM VIP 가 있고 slave 에는 없다. "지금 쓰기가 가능한 노드 하나"를 가리키는 단일 VIP 인 것이다.

즉 답은 이렇게 정리됐다.
- 쓰기(write) 접속에는 MMM VIP 를 적는다. primary 가 db-node-01 에서 db-node-02 로 바뀌어도 MMM VIP 가 새 primary 로 이동하므로, 접속정보를 바꾸지 않아도 항상 현재 writer 에 붙는다.
- 노드별 VIP 를 적으면 그 노드에 고정되어, 해당 노드가 죽으면 접속이 끊기고 수동으로 바꿔야 한다.
- 노드별 VIP 는 "이 노드를 콕 집어 봐야 한다"는 운영/디버깅 용도로 쓴다.
여기까지는 가이드로 알 수 있었다. 그런데 나를 정말 멈칫하게 한 건 그 다음이었다. "VIP 가 writer 를 따라다닌다"는 게 대체 무슨 원리지?
궁금하면 직접 만들어 본다 - 로컬 재현
머리로만 "floating VIP" 라고 외우는 건 성에 차지 않았다. 그래서 작년 여름, 비슷한 구조를 로컬에 직접 만들어 보기로 했다. PostgreSQL 의 복제 매니저인 repmgr 와, 트래픽 진입점 역할의 HAProxy 를 docker compose 로 엮었다.
핵심 아이디어는 단순했다. "writer 를 따라가는 VIP"의 본질은, 현재 누가 primary 인지 헬스체크로 판정해서 그쪽으로만 트래픽을 보내는 것 + 복제 매니저가 장애 시 자동으로 승격(promote) 하는 것의 조합이라는 가설이었다.

실제로 만들면서 매핑이 또렷해졌다.
| 실서비스 개념 | 로컬 PoC 구현 |
|---|---|
| MMM VIP (writer floating) | HAProxy :3000 + option httpchk (현재 primary 만 UP) |
| writer 자동 전환 | repmgr repmgrd 자동 promote |
| 노드별 VIP | 컨테이너 직접 접속 |
| reader 분산(있다면) | HAProxy :3001 read 분산 |
여기서 한 가지 더 파고들었다. HAProxy 의 write 포트가 "현재 primary 만 UP" 으로 보게 하려면, 단순히 "DB 가 연결되나"만 보는 헬스체크로는 부족했다. standby 도 연결은 되니까. 그래서 각 노드에 pg_is_in_recovery() 결과로 primary 면 200, standby 면 503 을 돌려주는 작은 HTTP 헬스 엔드포인트를 두고, HAProxy 가 그걸로 primary 를 판정하게 했다. 그러자 repmgr 가 standby 를 primary 로 승격하는 순간 그 노드의 헬스가 503 에서 200 으로 바뀌고, HAProxy 가 자동으로 write 트래픽을 새 primary 로 따라 보냈다.
여기까지 만들고 나니 "VIP 가 writer 를 따라간다"는 한 줄이, 헬스체크 + 자동 승격이라는 구체적인 동작으로 손에 잡히기 시작했다. 다만 그때는 딱 거기까지였다. 구조를 잡고 원리를 눈으로 확인한 정도에서, 업무가 몰아쳐 자동 승격이 끝까지 깔끔하게 도는 것을 제대로 검증하지는 못한 채 한동안 덮어뒀다.
이때만 해도 이건 "접속정보 한 줄을 이해하기 위한 공부" 정도였다. 그런데 1년쯤 뒤, 이 공부가 운영 현장에서 그대로 소환됐다.
1년 뒤, 같은 주제가 운영 장애 분석으로 돌아오다
운영 중에, slave DB 가 올라간 가상화 호스트(oVirt) 의 용량이 부족해 그 물리 서버를 점검·마이그레이션해야 한다는 협의 요청이 왔다. 인프라 팀에서 "이 작업을 하면 서비스에 영향이 있느냐"를 물어온 것이다. 답을 하려면 우리 BE 가 slave DB 에 어떻게 의존하는지 정확히 알아야 했다.
우리 서비스의 read/write 분리는 Spring 의 AbstractRoutingDataSource 기반이었다.

여기서 구조적 약점이 드러났다. readOnly=true 트랜잭션은 무조건 slave 키로 라우팅되고, slave 키는 항상 DataSource 맵에 존재하므로 defaultTargetDataSource(master) 같은 fallback 이 동작하지 않았다. 즉 slave 가 죽어도 master 로 자동 전환되는 로직이 없었다. slave 를 내리는 순간
- 모든 조회 API 가 slave 커넥션 획득을 시도하다 타임아웃 후 실패하고,
- 심지어 일부 쓰기 API 도, 진입 시점에
readOnly=true로 활성 프로젝트 여부를 검증하는 AOP 가 slave 를 조회하기 때문에 같이 막혔다. - 게다가 헬스체크는 master 만 확인해서, Pod 는 멀쩡히 "정상" 으로 보이는데 실제 서비스는 장애인 위험한 불일치까지 있었다.
여기서 내가 던진 질문이 1년 전 그 주제와 정확히 맞닿았다. VIP 와 라우팅이 다 갖춰져 있는데도, slave 를 내리면 왜 무중단이 안 되는가? 그리고 새로 배포하지 않고 BE 의 Hikari 설정만으로 막을 방법은 없는가?
Hikari 설정만으로 막을 수 있을까 - 결론은 "없다"
결론부터. 순수 Hikari 설정만으로 slave 장애를 master 로 우회(failover)하는 방법은 없다.
이유는 책임의 경계에 있었다. 어느 노드로 보낼지(라우팅)는 ReplicationRoutingDataSource, 즉 코드가 정한다. Hikari 는 각 DataSource(커넥션 풀)의 수명과 타임아웃만 관리할 뿐이다. slave 풀이 죽은 노드를 가리키는 한, Hikari 는 그 노드로만 연결을 시도한다. 라우팅 자체를 바꿀 수는 없다.
Hikari 로 할 수 있는 건 "우회" 가 아니라 "빠른 실패로 피해를 줄이는 것" 뿐이었다.
| 설정 | 효과 | 한계 |
|---|---|---|
connection-timeout 30s → 2~5s |
slave 다운 시 30초 블로킹을 수 초로 단축, 스레드 풀 고갈 완화 | 읽기 전면 장애 자체는 그대로 |
keepalive-time / max-lifetime 단축 |
끊긴 커넥션 조기 폐기 | 라우팅 전환이 아님 |
minimum-idle 축소 |
죽은 풀 유지 비용 감소 | 근본 해결 아님 |
게다가 "재배포 없이" 라는 조건도 걸림돌이었다. 위 설정들도 대부분 변경하려면 결국 Pod 재시작이 필요하다(Spring/Hikari 는 부팅 시 DataSource 구성을 고정한다). JMX(HikariConfigMXBean)로 connectionTimeout 같은 일부 값은 런타임 변경이 가능하지만, 접속 대상인 jdbc-url 은 런타임에 못 바꾼다. 그래서 JMX 로도 slave 를 master 로 돌릴 수는 없었다.
즉 BE 단독으로, 배포도 재시작도 없이 막는 방법은 없었다. 그러면 진짜 무중단의 갈림길은 어디였을까. 여기서 1년 전 공부가 빛을 발했다.
갈림길은 "floating VIP 로 설계했는가"에 있었다
실효적인 무중단 우회는 BE 의 Hikari 가 아니라, 인프라 레벨에서 slave 의 접속 대상 VIP 를 살아있는 노드로 임시 전환하는 데서 나온다. 그리고 이게 정확히 1년 전 "노드 고정 VIP vs floating VIP" 의 차이였다.
- slave 의 접속 대상이 노드 고정 VIP 라면, 그 노드를 내리는 순간 BE 는 갈 곳이 없다. 무중단이 구조적으로 어렵다.
- 만약 reader 역할의 floating VIP(현재 읽기 노드를 따라다니는 VIP) 였다면, 인프라 레벨에서 그 VIP 를 살아있는 노드로 옮겨 BE 재배포 없이 우회할 여지가 생긴다(다만 standby 가 1대뿐이면 결국 master 가 읽기까지 떠안게 된다).
정리하면, 무중단 가능성은 BE Hikari 설정이 아니라 "접속 대상을 floating VIP 로 설계했는가" 에서 갈린다. 1년 전엔 write 접속을 MMM VIP(writer floating) 로 적는 게 왜 옳은지를 배웠고, 1년 뒤엔 그 반대편, 즉 read 접속이 노드 고정 IP 에 묶여 있을 때 운영이 얼마나 경직되는지를 몸으로 확인한 셈이다.
물론 BE 가 손 놓고 있어야 한다는 뜻은 아니다. 근본 해결은 결국 코드/구성 변경을 동반한다.
ReplicationRoutingDataSource에 slave 헬스 실패 시 master 로 fallback 하는 로직 추가.connection-timeout합리화 + slave 까지 포함한 Custom HealthIndicator 로, K8s probe 와 실제 서비스 상태를 일치시키기.- readOnly 검증 AOP 가 slave 에 의존하지 않게 분리하거나, Circuit Breaker 도입.
다만 이건 "재배포 없이 막을 수 있나" 와는 다른, 다음 스프린트의 숙제다.
다시 꺼내기: 1년 넘게 묵힌 PoC를 끝까지 완성하다
그리고 최근에야, 1년 넘게 덮어뒀던 그 PoC 를 다시 꺼냈다. 운영에서 같은 주제를 한 번 더 마주치고 나니, 작년에 절반쯤 그려두고 멈춘 그림을 끝까지 완성해 보고 싶었다.
이번에는 자동 failover 가 실제로 끝까지 동작하도록 제대로 손봤다. 복제 매니저 데몬을 상시 기동하게 하고, primary 를 판정하는 헬스체크로 write 진입점이 현재 primary 만 바라보게 하고, 복구된 구 master 가 새 primary 의 standby 로 자동 재합류하도록 만들었다. 그러고 실측해 보니 명확했다. master 를 내리면 약 20초 뒤 standby 가 자동 승격되어 쓰기가 다시 성공했고, 내렸던 노드를 다시 올리면 약 12초 만에 새 primary 의 standby 로 합류했다.
과정이 마냥 매끄럽지는 않았다. 복제 사용자 권한, replication slot 미생성, 컨테이너 환경 특유의 헬스체크 문제까지 잠복해 있던 버그들을 하나씩 들춰내고서야 자동 failover 가 끝까지 돌았다. 작년의 나는 거기까지 갈 시간이 없었던 것이고, 이번에야 그 빚을 갚은 셈이다. (PoC 와 실측 로그는 Github에 정리해 뒀다.)
GitHub - alanhakhyeonsong/postgresql-haproxy-test: 개발자를 위한 PostgreSQL HA 클러스터 테스트 및 가이드 - Mast
개발자를 위한 PostgreSQL HA 클러스터 테스트 및 가이드 - Master/Slave 복제 + HAProxy + Keepalived (Docker Compose) - alanhakhyeonsong/postgresql-haproxy-test
github.com
회고: 접속정보 한 줄이 데려다준 곳
돌아보면 출발점은 정말 사소했다. DB 접속정보에서 VIP 와 MMM VIP 중 무엇을 적느냐, 그 한 줄이었다. 가이드만 받고 적었어도 그날의 일은 끝났을 것이다.
그런데 "바깥에서 일어나는 일도 알아야 한다" 는 작은 고집 때문에, 나는 floating VIP 의 동작을 로컬에서 직접 재현했고, 헬스체크와 자동 승격이라는 구체적인 메커니즘까지 손에 쥐었다. 그리고 1년 뒤, 운영 장애 영향도를 분석해야 하는 순간에 그 학습이 그대로 무기가 됐다. "왜 무중단이 안 되는지", "Hikari 로는 왜 안 되는지", "진짜 갈림길은 어디인지" 를 막힘없이 설명할 수 있었던 건, 그날 한 줄을 그냥 넘기지 않았기 때문이다.
백엔드 개발자라고 해서 코드 안쪽만 알면 된다고 생각하지 않는다. 애플리케이션은 결코 혼자 돌지 않는다. 그 바깥의 VIP, 복제, 라우팅, 가상화 호스트가 어떻게 맞물리는지를 알아야, 내가 짠 @Transactional(readOnly = true) 한 줄이 운영에서 어떤 의미가 되는지를 비로소 이해할 수 있다. 사소한 의문을 끝까지 따라가는 일은, 생각보다 멀리 데려다 준다.