유성

Kubernetes 기반 PostgreSQL 고가용성(HA) 아키텍처 구축 및 카오스 검증 본문

DevOps

Kubernetes 기반 PostgreSQL 고가용성(HA) 아키텍처 구축 및 카오스 검증

백엔드 유성 2026. 2. 1. 22:07

과거 LifeKeeper 같은 고가의 솔루션으로 수일씩 걸려 구축했던 DB 고가용성(HA) 환경, 이제는 쿠버네티스와 Helm 명령어 단 몇 줄로 끝낼 수 있다.

이번 글에서는 오픈소스를 활용해 비용 없이 무적의 DB 환경을 구축하고, 실제 마스터 노드를 죽여가며 그 복구 과정을 검증해 본다.

 

1. 왜 고가용성(HA)인가?

현대 서비스에서 데이터베이스는 시스템의 심장과 같다. 하지만 단일 DBMS 구성은 여러 가지 태생적 위험을 안고 있다.

고가용성이 왜 필수적인지, 기존 구조에서 발생하는 주요 문제점들을 통해 살펴보자.

 

A. 단일 DBMS의 다운 (SPOF)

DB를 단 한 대만 운용할 경우, 해당 서버에 장애가 발생하는 즉시 서비스 전체가 마비되는 '단일 장애점(Single Point of Failure)' 문제가 발생한다.

서버 하드웨어 결함이나 단순한 프로세스 오류만으로도 비즈니스가 완전히 멈출 수 있는 위험한 상태이다.

 

B. Connection Pool 고갈과 자원 병목

서비스 트래픽이 급증하는 피크 타임에 Connection Pool이 고갈되면 시스템은 연쇄적인 위기에 빠진다.

Connection Pool 고갈만 되면 단순 지연으로 끝날 수 있으나, DB 지연으로 전파되면 이후 처리가 모두 밀려 DB가 마비될 수 있다.

 

C. Master-Slave 구조에서 Master 다운의 한계

성능 분산을 위해 Master-Slave 구조를 갖췄더라도 Master가 다운되면 치명적이다.

Slave가 살아있어 조회는 가능할지 몰라도, 데이터의 생성, 수정, 삭제가 불가능해지기 때문에 사실상 서비스 반쪽이 마비된 상태가 된다.

 

2. 과거의 해결책: 고비용과 복잡한 설정

과거에는 이러한 문제들을 해결하기 위해 최소 두 대 이상의 DB를 배치하고, 장애 발생 시 복잡한 복구 과정을 자동화하기 위해 LifeKeeper와 같은 고가의 유로 솔루션을 주로 이용했다.

 

과거에 주로 사용되던 고가용성 구조는 다음과 같은 것들이 있다.

  1. 공유 디스크: Active DB와 예비(Standby) DB가 하나의 물리적인 저장소(Disk)를 바라보는 구조로, Active가 죽으면 Standby가 소유권을 이어받아 재개한다.
  2. Active-Passive 구조: 두 개의 DB 노드를 하나로 묶어 관리하며, 평소에는 Active만 사용하다 Active가 죽으면 Passive 노드를 승격시킨다.
  3. Master-Slave 분산 구조: Master는 쓰기전용, Slave는 읽기 전용으로 역할을 나누어 부하를 분산한다. Master 장애 시 Slave를 Master로 전환하는 과정이 몹시 까다롭고 위험 부담이 크다.

노드를 동적으로 전환하는 VIP나 jdbc를 (수동으로) 나눠 실행하는 등, 개발로도 인프라로도 매우 복잡한 과정이었다.

 

3. 현대의 해결책: 명령어 두 줄로 끝내는 고가용성 환경

과거에 LifeKeeper 설정을 위해 며칠 밤새워야 했다면, 이제는 쿠버네티스 오퍼레이터를 통해 단 몇 줄의 선언적인 코드만으로 완벽한 HA 클러스터를 구축할 수 있다.

 

우리가 구축한 현대적 아키텍처 (Cloud-Native PG)

이번 구현에서는 오퍼레이터 기반의 아키텍처를 활용했다. 이 구조를 지탱하는 핵심 엔진은 다음과 같다.

  • Patroni (The Heartbeat): 각 DB 포드의 상태를 감시하고, Master 장애 시 투표를 통해 새로운 리더를 선출하는 관리자 역할을 한다. 과거의 복잡한 Failover 스크립트를 대체한다.
  • PgBounder (The Shield): 앞서 언급한 'Connection Pool 고갈' 문제를 방지하는 완충지대이다. 수천 개의 연결을 소수의 DB 커넥션으로 농축하여 DB의 물리적 자원을 보호한다.
  • Kubernetes Services: primary와 replicas라는 이름의 정문을 각각 만들어, 앱이 IP 변경 없이도 항상 올바른 노드에 접속하게 해 준다.

단 3줄로 완성되는 DB 클러스터 인프라

# DB 서비스 오픈
helm repo add percona https://percona.github.io/percona-helm-charts/
helm install pg-operator percona/pg-operator --namespace pg --create-namespace
helm install pg percona/pg-db --namespace pg

 

4. 실전 검증: Master를 죽여보자 (Failover 재현)

설정이 끝났다면 실제로 잘 작동하는지 장애 테스트를 해볼 차례이다.

여기서는 PgBouncer를 사용하여 요청을 보내는 중간에 Master를 다운시킨다.

 

Step 1: 현재 상태 확인

먼저 Primary와 Replica의 상태, 애플리케이션 상태를 보자.

# Running pod
pg-pg-db-instance1-8bhr-0  4/4  Running 17h   primary
pg-pg-db-instance1-kcf5-0  4/4  Running 17h   replica
pg-pg-db-instance1-pglc-0  4/4  Running 17h   replica

# 연결중인 boucer domain name
HOST: pg-pg-db-pgbouncer.pg.svc.cluster.local

# 애플리케이션 로그
9:46:20.089Z  [ReadTransaction] request: 2160, success: 2160, fail: 0
9:46:20.090Z  [WriteTransaction] request: 2160, success: 2160, fail: 0
  • 포드 상태: 8bhr-0 포드가 Master 역할을 수행 중이다.
  • 접속 정보: 앱은 PgBouncer 도메인을 통해 DB에 접근한다.
  • 정상 로그: 읽기/쓰기 트랜잭션 모두 실패 없이(fail: 0) 안정적으로 처리되고 있다.

Step 2: Master DB 강제 다운 후 Master 교체와 자동 복구 관찰

Primary 포드를 삭제하고 어떻게 올라오는지 확인한다.

# 수행 명령어
kubectl delete pod pg-pg-db-instance1-8bhr-0 -n pg

# 명령어 수행 결과 확인
pg-pg-db-instance1-8bhr-0 Terminating 17h

# 이후 Slave -> Master 격상과 함께 Slave 기동 준비
pg-pg-db-instance1-8bhr-0 PodInitializing  8s
pg-pg-db-instance1-kcf5-0 Running          17h   replica
pg-pg-db-instance1-pglc-0 Running          17h   primary

# Slave 기동 완료
pg-pg-db-instance1-8bhr-0 Running 2m54s   replica
pg-pg-db-instance1-kcf5-0 Running 17h     replica
pg-pg-db-instance1-pglc-0 Running 17h     primary

# 애플리케이션 로그
9:46:21.232Z  WARN : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@2527f497 (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
9:46:21.242Z  WARN : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@6250af93 (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
9:46:36.530Z  [ReadTransaction] request: 2170, success: 2170, fail: 0
9:46:36.531Z  [WriteTransaction] request: 2170, success: 2170, fail: 0
  • Master DB 재기동: 8bhr-0 pod가 재기동을 시작하며 primary의 역할을 상실하고 replica로 올라온다.
  • Slave DB의 Master 격상: Slave 중 하나인 pglc-0 pod를 primary로 즉시 격상시킨다.
  • 애플리케이션단 재요청: connection 닫힘으로 판단하고, 가장 최근에 생성된 connection을 찾아 재요청한다.

 

여기서 흥미로운 부분은 PgBouncer의 역할이다.

  • 예상: PgBouncer가 요청을 큐에 담아두었다가 앱에 에러 없이 전달할 것으로 예상함
  • 실제: 앱 측에서 "연결 닫힘"을 감지하고 Connection을 갈아 끼우는 방식으로 재시도가 진행됨
  • 결과: 리더 선출이 매우 빠르게 진행되어, 새롭게 격상된 Master의 Connection을 바로 확보해, 단 하나의 실패도 없이 모두 정상 처리됨

 

5. 실전 검증 2: Master-Slave 환경에서 Master를 죽여보자

이번에는 쓰기는 Master로, 읽기는 Slave로 PgBouncer 없이 요청을 처리한다.

# 연결중인 master/slave domain name
MASTER_HOST: pg-pg-db-primary.pg.svc.cluster.local
SLAVE_HOST: pg-pg-db-replicas.pg.svc.cluster.local

# Master-Slave 구조에서의 Master 다운
10:28:33.736Z WARN HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@3599dc9d (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
10:28:33.736Z WARN HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@6145c667 (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
10:28:34.756Z [ReadTransaction] request: 580, success: 580, fail: 0
10:28:34.756Z [WriteTransaction] request: 570, success: 560, fail: 0

 

결과는 동일했고, 애플리케이션에서는 동일하게 재시도 후 문제없이 성공 처리가 되었다.

 

6. 마치며: 장애는 피할 수 없지만, 중단은 막을 수 있다

이번 글을 통해 고가용성 아키텍처를 직접 구축하고 카오스 테스트를 수행해 보았다.

필자가 생각하는 고가용성이란 로그 한 줄 없는 완벽한 무결성이 아니라,

"시스템 스스로가 장애를 인지하고 얼마나 빠르게 정상화하는 것"이다.

 

실제로 직접 마스터를 죽여보며 관찰한 결과, PgBouncer가 모든 에러를 마법처럼 숨겨주지는 않았다.

하지만 Promotion이 매우 신속하게 이루어지다 보니, 애플리케이션 레벨의 재시도만으로 사용자는 단 하나의 실패도 경험하지 않는 '무중단 서비스'를 구현할 수 있었다.