단일 리전에서 안정적으로 돌아가던 서비스도 리전 전체 장애 앞에서는 무력합니다. 가용 영역(AZ) 하나가 죽는 상황은 멀티 AZ 배치로 흡수할 수 있지만, 특정 리전 전체가 네트워크·전원·컨트롤 플레인 수준에서 마비되는 사건은 주기적으로 발생합니다. 이때 서비스의 생사를 가르는 것은 “다른 리전에서 얼마나 빨리 데이터 손실 없이 트래픽을 받아낼 수 있는가”입니다.
멀티 리전 아키텍처는 단순히 서버를 두 곳에 복사하는 일이 아닙니다. 핵심은 상태(state)를 어떻게 복제하고, 장애 시 어느 시점의 데이터로 어느 리전이 주도권을 넘겨받느냐입니다. 이 글에서는 RPO·RTO 산정, 복제 토폴로지, DB 복제, 트래픽 라우팅과 페일오버 자동화, 그리고 가장 어려운 스플릿 브레인 방지까지 실무 관점에서 정리합니다.
먼저 RPO와 RTO를 숫자로 정의한다
멀티 리전 설계는 다이어그램이 아니라 두 개의 숫자에서 출발합니다. RPO(Recovery Point Objective)는 “얼마만큼의 데이터 손실을 허용하는가”, RTO(Recovery Time Objective)는 “얼마나 빨리 복구되어야 하는가”입니다. 이 두 값이 복제 방식과 비용을 결정합니다.
RPO가 0에 가까울수록 동기 복제가 필요하고, 동기 복제는 리전 간 왕복 지연(RTT)을 쓰기 경로에 그대로 얹습니다. RTT가 30~40ms라면 모든 커밋이 그만큼 느려집니다. 반대로 RPO를 수 초~수십 초로 완화하면 비동기 복제로 쓰기 지연 없이 갈 수 있지만, 페일오버 시 복제되지 않은 마지막 트랜잭션은 사라집니다.
- RPO 0 / RTO 수 분: 동기 복제 + 자동 페일오버. 금융 원장, 결제 확정.
- RPO 수 초 / RTO 수 분: 비동기 복제 + 자동 승격. 일반 트랜잭션 서비스 대다수의 현실적 타협.
- RPO 수 분 / RTO 수십 분: 스냅샷/로그 백업 복구. 분석계, 콜드 스탠바이.
“무조건 RPO 0″을 요구하기 전에, 그 데이터가 정말로 단 한 건의 손실도 허용하지 않는지 도메인별로 나눠야 합니다. 대부분의 서비스는 결제 확정 같은 소수 테이블만 강한 보장이 필요하고 나머지는 초 단위 RPO로 충분한데, 이 구분이 비용과 지연을 크게 줄입니다.
복제 토폴로지: Active-Passive vs Active-Active
멀티 리전 토폴로지는 크게 세 가지이며, 각각 운영 난이도와 정합성 특성이 다릅니다.
- Active-Passive (웜 스탠바이): 한 리전만 쓰기를 받고, 다른 리전은 읽기 복제본을 유지하다 장애 시 승격. 구현이 단순하고 정합성 문제가 적어 가장 널리 쓰입니다.
- Active-Active (전 리전 쓰기): 모든 리전이 쓰기를 받음. 지연이 낮고 리전 하나가 죽어도 나머지가 흡수하지만, 쓰기 충돌 해소가 난제입니다.
- Read-local Write-global: 읽기는 가까운 리전, 쓰기는 홈 리전으로 라우팅. 지역성과 단순성의 절충.
처음 멀티 리전을 도입한다면 Active-Passive에서 시작하세요. Active-Active는 “데이터가 두 리전에서 동시에 수정될 수 있다”는 전제를 감당할 수 있을 때만 의미가 있고, 그 전제는 충돌 해소(CRDT, last-write-wins)와 멱등성을 모두 요구합니다. 지연 몇 밀리초를 아끼려다 정합성 지옥에 빠지곤 합니다.
데이터베이스 복제 전략
관계형 DB의 리전 간 복제는 대개 물리 복제(WAL/binlog 스트리밍)로 구현합니다. PostgreSQL에서 동기·비동기 선택은 synchronous_commit과 synchronous_standby_names로 정합니다.
# postgresql.conf (프라이머리) — 커밋을 어느 시점까지 확인하고 반환할지 결정
synchronous_commit = remote_write # remote_write: 스탠바이 수신+OS버퍼까지
# on(=remote_flush): fsync 까지 (강함/느림)
# local: 로컬만 확인 (비동기, RPO>0)
# 원격 리전 스탠바이는 동기 대상에서 빼고, 강한 보장은 리전 내 AZ 로
synchronous_standby_names = 'FIRST 1 (local_az_standby)'
wal_level = replica
max_wal_senders = 10
wal_keep_size = 4GB # 스탠바이가 밀렸을 때 재동기화
흔한 함정은 완전 동기 복제(remote_flush)를 리전 간에 그대로 켜는 것입니다. 동기 스탠바이가 파티션으로 응답하지 않으면 프라이머리 쓰기가 무한 대기에 빠져, 원격 리전 장애가 오히려 주 리전을 멈춥니다. 그래서 리전 간에는 비동기 또는 remote_write를 쓰고, 강한 보장은 리전 내 다중 AZ 동기 복제로 확보하는 계층 구조가 안전합니다. 승격 자체보다 중요한 것은 승격 이전에 옛 프라이머리가 확실히 격리됐음을 보장하는 펜싱(fencing)입니다.
# 스탠바이 승격 — 반드시 옛 프라이머리 펜싱(격리) 완료 후 실행
pg_ctl promote -D /var/lib/postgresql/data
# 승격 후 뒤처진 WAL 확인 — 손실 트랜잭션 파악
psql -c "SELECT pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn();"
DB만 복제한다고 서비스가 사는 것은 아닙니다. 사용자 업로드 파일·이미지 같은 객체 스토리지도 리전 간 복제(CRR) 대상이며 대부분 비동기·최종 일관성입니다. 주의할 점은 객체 복제와 DB 복제의 지연이 서로 다르다는 것입니다. DB에는 “업로드 완료” 레코드가 복제됐는데 실제 객체는 아직 대상 리전에 도착하지 않아, 페일오버 직후 참조 불일치가 404를 유발합니다. 애플리케이션은 객체 부재를 일시적 상태로 간주해 재시도하거나 대체해야 합니다.
트래픽 라우팅과 헬스 기반 페일오버
리전을 전환하는 실제 스위치는 DNS 또는 글로벌 로드 밸런서입니다. DNS 페일오버는 쉽지만 TTL과 리졸버 캐싱 때문에 즉각적이지 않습니다. TTL을 60초로 낮춰도 일부 리졸버는 이를 무시하므로, RTO가 초 단위라면 애니캐스트 기반 글로벌 로드 밸런서가 더 적합합니다.
구현 방식과 무관하게 헬스체크는 얕은 핑이 아니라 깊은 의존성 검사여야 합니다. TCP 200 응답만 보는 헬스체크는 DB 커넥션이 끊긴 좀비 리전을 건강하다고 오판합니다. 엔드포인트는 DB·캐시·핵심 다운스트림에 가벼운 쿼리를 던져 종합 판정을 내려야 합니다.
# 깊은 헬스체크 (python / FastAPI)
@app.get("/health/deep")
async def deep_health():
checks = {}
# DB: 이 리전이 실제로 쓰기 가능한 프라이머리인지 확인
try:
row = await db.fetchrow("SELECT pg_is_in_recovery() AS ro")
checks["db_writable"] = not row["ro"]
except Exception:
checks["db_writable"] = False
try:
checks["cache"] = (await redis.ping()) is True # 캐시 확인
except Exception:
checks["cache"] = False
healthy = checks["db_writable"] and checks["cache"]
# unhealthy 면 503 → DNS/LB 가 이 리전을 트래픽에서 제외
return JSONResponse(checks, 200 if healthy else 503)
스플릿 브레인과 펜싱
페일오버에서 가장 위험한 실패는 리전 장애 자체가 아니라 스플릿 브레인입니다. 네트워크 파티션으로 프라이머리가 죽은 것처럼 보여 대기 리전을 승격했는데, 사실 옛 프라이머리는 살아 있어 여전히 쓰기를 받는 경우입니다. 두 리전이 각자 프라이머리라고 믿으며 서로 다른 데이터를 쓰고, 병합 불가능한 충돌이 남습니다.
이를 막는 유일한 방법은 합의(consensus)와 펜싱입니다. 승격 결정은 다수결 쿼럼을 확보한 오케스트레이터만 내릴 수 있어야 하고, 승격 전에 옛 프라이머리를 확실히 차단해야 합니다. 홀수 개(보통 3개) 노드에 합의 그룹을 두는 이유로, 2개면 파티션 시 어느 쪽도 다수가 안 됩니다.
# etcd 리더 락으로 승격 권한을 직렬화 — 쿼럼(3노드 중 2)을 잡은 쪽만 성공
LEASE=$(etcdctl lease grant 15 | awk '{print $2}')
if etcdctl put --lease="$LEASE" /cluster/primary "region-b" --prev-kv 2>/dev/null; then
echo "승격 권한 획득 — 이 락을 잡은 프로세스만 pg_ctl promote 수행"
else
echo "락 실패 — 다른 리전이 이미 프라이머리. 승격 중단"; exit 1
fi
펜싱은 여러 계층에서 동시에 적용합니다. 네트워크 레벨에서 옛 프라이머리로 향하는 트래픽을 끊고, 스토리지 레벨에서 쓰기 권한을 회수하며, 애플리케이션 레벨에서는 승격 시 에포크(epoch) 번호를 증가시켜 낮은 에포크의 쓰기를 거부합니다. 이 토큰이 뒤늦게 부활한 옛 프라이머리의 쓰기를 막는 마지막 방어선입니다.
애플리케이션 계층의 리전 인식 설계
인프라만 멀티 리전이어도 애플리케이션이 리전을 의식하지 못하면 반쪽짜리입니다. 세션·캐시·분산 락은 리전 로컬이어야 합니다. 페일오버 후 대기 리전이 옛 프라이머리의 캐시를 원격 참조하면, 옛 리전이 죽는 순간 새 리전도 멈춥니다.
- 세션: 리전 로컬 캐시에 두되, JWT 같은 무상태 토큰이면 전환에 강합니다.
- 분산 락: 전역 락은 리전 간 합의를 요구해 느립니다. 락 범위를 리전 로컬로 좁히거나 파티션 키를 홈 리전에 고정합니다.
- ID 발급: 리전 비트를 포함한 ID(예: Snowflake 변형)로 리전 간 ID 충돌을 막습니다.
또한 쓰기 요청은 항상 “지금 이 리전이 프라이머리가 맞는지”를 확인하고, 아니라면 프라이머리로 프록시하거나 거절해야 합니다. 앞의 pg_is_in_recovery() 결과를 진입점에서 짧게 캐시하면, 승격 지연 구간의 잘못된 쓰기를 막을 수 있습니다.
페일오버는 반드시 정기적으로 훈련한다
가장 자주 실패하는 부분은 기술이 아니라 검증되지 않은 절차입니다. 한 번도 전환해 본 적 없는 페일오버는 장애 당일 예상 못한 곳에서 멈춥니다. 복제 슬롯이 밀렸거나, DNS TTL이 무시되거나, 승격 스크립트 권한이 만료된 식입니다. 그래서 성숙한 팀은 계획된 페일오버 훈련(game day)을 정기적으로 수행해, 트래픽을 대기 리전으로 넘기고 되돌리는(failback) 사이클로 RPO·RTO를 실측하고 목표치와의 격차를 다음 과제로 만듭니다.
# 페일오버 훈련 사전 검증 (자동화 스크립트)
# 1) 복제 지연이 임계치 이하인가 (승격해도 손실 최소)
LAG=$(psql -tAc "SELECT COALESCE(EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())),0)")
[ "${LAG%.*}" -lt 5 ] || { echo "복제 지연 ${LAG}s > 5s, 중단"; exit 1; }
# 2) 대상 리전 헬스 딥체크 통과 확인
curl -fsS https://region-b.internal/health/deep || { echo "헬스 실패"; exit 1; }
# 이후: 펜싱 → 승격 → DNS 전환 → 스모크 테스트 → failback 리허설
echo "훈련 준비 완료"
마무리: 무엇을 먼저 결정할 것인가
멀티 리전 아키텍처는 세 가지 결정의 연쇄입니다. 첫째, 도메인별 RPO·RTO를 숫자로 못박는다. 둘째, 그 숫자가 허용하는 가장 단순한 토폴로지(대개 Active-Passive + 비동기 복제)를 고른다. 셋째, 스플릿 브레인을 막을 합의·펜싱·에포크 방어선을 갖춘다. 가장 흔한 실패는 정합성 요구를 과대평가해 불필요하게 동기·Active-Active로 가면서 정작 스플릿 브레인 방지는 소홀히 하는 것입니다. 복잡도는 필요한 만큼만 지불하고, 그 대신 페일오버 절차를 정기적으로 훈련해 “실제로 작동하는” 상태로 유지하는 편이 견고합니다.
자주 묻는 질문
Q. 리전 간 완전 동기 복제로 RPO를 0으로 만들면 되지 않나요?
A. 대가가 큽니다. 모든 커밋이 리전 간 왕복 지연을 감수해야 하고, 더 위험하게는 원격 스탠바이가 파티션으로 응답하지 못할 때 프라이머리 쓰기가 멈춰 원격 장애가 주 리전을 다운시킵니다. 강한 보장이 필요한 소수 테이블만 리전 내 다중 AZ 동기로 처리하고 리전 간에는 비동기를 쓰는 계층 구조를 권장합니다.
Q. DNS 페일오버만으로 충분한가요, 아니면 글로벌 로드 밸런서가 필요한가요?
A. RTO 요구치에 달렸습니다. RTO가 수 분이면 TTL 60초의 DNS 페일오버로 대개 충분합니다. 다만 리졸버 캐싱을 무시하는 클라이언트 탓에 실제 전환은 TTL보다 오래 걸릴 수 있으므로, RTO가 초 단위라면 애니캐스트 기반 글로벌 로드 밸런서가 더 적합합니다.