database-replication

3 개의 포스트

datadog

페일오버가 안전하지 않을 때: Kubernetes에서 고가용성 PostgreSQL 구축하기 (새 탭에서 열림)

Datadog은 게임데이를 통해 PostgreSQL 클러스터가 특정 가용 영역의 네트워크 장애에서 안전하게 페일오버하지 못하는 문제를 발견했다. 비동기 복제 환경에서는 장애가 발생한 리더가 계속 쓰기를 처리하는 동안 복제 지연이 커졌고, 모든 스탠바이가 안전한 승격 기준을 충족하지 못했다. 이를 해결하기 위해 Patroni가 관리하는 동기 복제 기반의 페일오버 후보를 도입해 내구성과 자동 복구 가능성을 높이려 했다. ## 게임데이로 드러난 가용 영역 장애 - 스테이징 환경에서 특정 가용 영역에 네트워크 지연을 의도적으로 유발했다. - 해당 영역에 PostgreSQL의 primary 또는 writer 노드가 위치해 있었다. - primary와 replica 간 통신이 불안정해지면서: - 복제 지연(replication lag)이 빠르게 증가 - 쓰기 작업이 멈추거나 지연 - 애플리케이션이 오래된 데이터를 조회 - 모든 replica가 primary의 최신 상태를 충분히 반영하지 못해 안전한 승격 대상이 사라졌다. - 결과적으로 지연이 해소되고 replica가 따라잡을 때까지 기다리는 것 외에는 복구 방법이 없었다. ## Kubernetes 기반 PostgreSQL 아키텍처 - 클러스터는 **leader pool**과 **read replica pool**로 분리된다. - Leader pool: - 하나의 active writer가 모든 쓰기를 처리 - 두 개의 standby 노드는 애플리케이션 읽기 트래픽에는 사용되지 않음 - leader 장애 시 standby가 승격될 수 있음 - Read replica pool: - 읽기 전용 트래픽 처리 - 읽기 확장과 쿼리 격리를 담당 - 페일오버 후보에서는 제외 - 이 구조는 읽기 용량을 독립적으로 확장하고 writer의 쓰기 지연을 안정적으로 유지하는 데 유리하다. - 그러나 장애 시 실제로 승격 가능한 노드 수가 제한되므로, 해당 후보들의 복제 상태가 중요하다. ## Patroni와 ZooKeeper의 역할 - Patroni는 PostgreSQL의 복제, 리더 선출, 페일오버를 관리한다. - ZooKeeper는 분산 구성 저장소(DCS)로 사용되며 다음 정보를 저장한다. - 현재 leader 키와 락 - 클러스터 설정 - 각 노드의 복제 상태와 최신 LSN - 새 노드는 ZooKeeper에 leader가 있는지 확인한다. - leader가 없으면 ephemeral znode를 생성해 leader 락 획득을 시도 - ZooKeeper의 단일 획득 보장으로 다중 primary(split-brain)를 방지 - leader가 이미 있으면 새 노드는 replica로 동작하며 스트리밍 복제를 시작 - 네트워크 파티션 상황에서는 상태를 확신할 수 없는 노드의 승격을 보수적으로 제한한다. - leader가 ZooKeeper와 통신하지 못하면, 적격 standby만 leader 락을 획득하도록 조정한다. - 기존 leader가 복구 후 leader 락을 다시 획득하지 못하면 스스로 강등되어 단일 leader 원칙을 유지한다. ## 비동기 복제의 한계 - 기존 환경은 PostgreSQL의 기본 복제 방식인 비동기 복제를 사용했다. - primary는 replica의 WAL 수신 확인을 기다리지 않고 트랜잭션을 커밋한다. - 장점: - 쓰기 지연이 낮음 - 높은 처리량 유지 - 단점: - primary 장애 시 아직 replica에 전달되지 않은 커밋 데이터가 유실될 수 있음 - 네트워크 지연이 커지면 replica가 primary보다 크게 뒤처질 수 있음 - 게임데이에서는 primary가 복제 지연 중에도 계속 쓰기를 수락했다. - 그 결과 모든 standby가 안전한 페일오버 기준을 초과했고, 장애 시 복구 가능한 후보가 남지 않았다. ## `maximum_lag_on_failover`와 안전한 승격 - Patroni는 standby를 승격하기 전에 복제 지연이 허용 범위 안에 있는지 검사한다. - 이 기준은 `maximum_lag_on_failover` 파라미터로 설정된다. - standby가 이 기준보다 많이 뒤처진 상태에서 승격되면 데이터 손실이나 불일치가 발생할 수 있다. - 따라서 Patroni가 승격을 거부한 것은 오작동이 아니라 데이터 일관성을 지키기 위한 정상적인 동작이었다. - 문제의 본질은 Patroni가 아니라, 장애 시 기준을 충족하는 standby가 하나도 없었다는 점이다. ## 동기 복제를 통한 개선 방향 - 동기 복제에서는 primary가 최소 한 replica의 확인 응답을 받은 뒤 클라이언트에 트랜잭션 성공을 반환한다. - 이를 통해 최소 한 replica에 커밋 데이터가 반영되었음을 보장할 수 있다. - 비동기 복제보다 쓰기 지연과 성능 비용이 발생하지만, primary 장애 시 데이터 유실 위험은 크게 줄어든다. - Datadog은 페일오버 후보에 동기 복제를 적용하고 Patroni가 이를 조정하도록 아키텍처를 재설계했다. - 목표는 성능 특성을 과도하게 훼손하지 않으면서 자동적이고 안전한 페일오버를 구현하는 것이었다. 실무적으로는 모든 읽기 replica에 동기 복제를 적용하기보다, 실제 페일오버 후보에만 동기 복제를 적용해 성능과 내구성의 균형을 맞추는 접근이 적절하다. 또한 네트워크 지연과 영역 장애를 가정한 게임데이 및 복제 지연 기반의 페일오버 테스트를 정기적으로 수행해야 한다.

figma

Figma 데이터베이스 팀이 대 (새 탭에서 열림)

Figma는 데이터베이스 규모가 2020년 이후 약 100배 성장하면서 단일 Postgres와 수직 분할만으로는 한계에 도달했다. 먼저 캐시, 읽기 복제본, 수직 파티셔닝으로 확장 여유를 확보했지만, 수 테라바이트 규모의 테이블과 급격히 증가하는 쓰기량 때문에 수평 샤딩이 필요해졌다. Figma는 새로운 데이터베이스로 전면 이전하기보다 기존 RDS Postgres 전문성과 데이터 일관성을 유지하는 점진적 수평 샤딩을 선택해 약 9개월 만에 확장 기반을 마련했다. ## 급격한 성장과 수직 파티셔닝의 한계 - Figma는 2020년 AWS의 가장 큰 물리 인스턴스에서 단일 Postgres를 운영했다. - 2022년 말에는 다음과 같은 분산 구조로 발전했다. - 캐시 - 읽기 복제본 - 약 12개의 수직 분할 데이터베이스 - “Figma 파일”, “조직”처럼 연관된 테이블 그룹을 별도 데이터베이스로 분리해 점진적으로 확장했다. - 수직 파티셔닝은 CPU 사용량을 낮추고 빠르게 확장 여유를 확보하는 데 효과적이었다. - 그러나 수직 분할의 최소 단위는 테이블 하나이므로, 하나의 테이블 자체가 지나치게 커지는 문제는 해결하지 못했다. ## 데이터베이스 병목을 정량적으로 측정 - Figma는 CPU뿐 아니라 다음 지표를 함께 모니터링했다. - 디스크 I/O - 테이블 크기 - 기록되는 행 수 - 데이터베이스별 처리 한계 - 과거 운영 데이터와 부하 테스트를 조합해 각 데이터베이스와 샤드의 “남은 확장 여유”를 예측했다. - 수 테라바이트와 수십억 개의 행을 가진 테이블에서는 Postgres의 `VACUUM` 작업이 안정성에 영향을 주기 시작했다. - `VACUUM`은 트랜잭션 ID 고갈을 방지하는 필수 백그라운드 작업이지만, 대형 테이블에서는 처리 부담이 커진다. - 쓰기량이 가장 많은 테이블은 AWS RDS가 제공하는 최대 IOPS에 곧 도달할 상황이었다. - 이 문제는 테이블을 다른 데이터베이스로 옮기는 수직 분할만으로는 해결할 수 없어 수평 샤딩이 요구됐다. ## 확장 설계의 목표 Figma는 단순히 데이터를 여러 데이터베이스에 나누는 것보다, 운영과 애플리케이션 변경 위험을 최소화하는 것을 중요하게 봤다. - **개발자 영향 최소화** - 기존의 복잡한 관계형 데이터 모델을 최대한 유지한다. - 애플리케이션 개발자가 대규모 데이터 접근 코드를 전면 수정하지 않도록 한다. - **투명한 확장** - 최초에 샤딩 호환성을 확보한 뒤에는, 향후 샤드를 추가할 때 애플리케이션 변경을 최소화한다. - **대규모 백필 회피** - 수개월이 걸릴 수 있는 전체 테이블 백필이나 전체 데이터 마이그레이션을 피한다. - **점진적 적용** - 빠르게 증가하는 테이블부터 단계적으로 적용한다. - 각 단계에서 위험을 검증해 대규모 장애 가능성을 낮춘다. - **롤백 가능성 확보** - 물리적 샤딩 이후에도 문제가 생기면 이전 상태로 되돌릴 수 있어야 한다. - **강한 일관성 유지** - 다운타임과 일관성 위험을 유발할 수 있는 이중 쓰기 방식을 피한다. - 거의 무중단에 가까운 확장을 목표로 한다. - **기존 역량 활용** - 이미 축적한 RDS Postgres 운영 경험과 도구를 활용해 새로운 저장소의 불확실성을 줄인다. ## 대체 데이터베이스 검토 - Figma는 수평 확장을 지원하는 여러 기술을 검토했다. - CockroachDB - TiDB - Spanner - Vitess - 그러나 새 데이터베이스로 전환하려면 기존 Postgres와 새 저장소 사이의 복잡한 데이터 마이그레이션이 필요했다. - 데이터 일관성과 안정성을 확보하면서 핵심 기능을 모두 이전하려면 상당한 시간이 필요했다. - Figma는 이미 RDS Postgres를 안정적이고 성능 좋게 운영하는 전문성을 갖고 있었으므로, 새로운 데이터베이스로 바꾸면 이 역량을 다시 구축해야 했다. - 성장 속도가 매우 빨라 남은 확장 여유가 수개월뿐이었기 때문에, 새로운 저장소를 도입하는 것은 기술적으로 가능하더라도 일정과 위험 측면에서 적합하지 않았다. ## NoSQL을 선택하지 않은 이유 - NoSQL은 기본적으로 수평 확장을 제공하지만, Figma의 데이터 모델과는 잘 맞지 않았다. - Figma는 복잡한 관계형 데이터와 여러 테이블 간 관계를 Postgres 위에서 활용하고 있었다. - NoSQL API만으로는 이러한 관계형 질의와 데이터 모델의 유연성을 동일하게 제공하기 어려웠다. - 따라서 애플리케이션 구조를 대규모로 재설계하는 대신, 기존 관계형 모델을 유지하면서 Postgres를 수평 확장하는 방향을 택했다. ## 실용적인 결론 데이터베이스 확장은 처음부터 수평 샤딩으로 시작하기보다, 캐시·읽기 복제본·수직 파티셔닝으로 단기 여유를 확보한 뒤 실제 병목을 측정하며 단계적으로 진행하는 것이 현실적이다. 특히 기존 데이터베이스에 대한 운영 역량과 강한 일관성이 중요하다면, 새로운 저장소로 전면 이전하기보다 현재 시스템을 유지한 채 점진적으로 샤딩하는 전략이 위험과 개발 비용을 줄일 수 있다.

figma

LiveGraph: Figma의 실시간 (새 탭에서 열림)

Figma는 실시간 협업 제품에 필요한 데이터를 안정적으로 제공하기 위해 Postgres 위에 GraphQL 기반의 실시간 데이터 계층인 LiveGraph를 구축했다. LiveGraph는 프론트엔드가 선언적으로 데이터를 구독하면 데이터베이스 복제 스트림을 읽어 밀리초 단위로 변경 사항을 반영한다. 이를 통해 수동 이벤트 메시지와 클라이언트 상태 동기화의 복잡성을 줄이고, 대규모 실시간 데이터 구독을 지원한다. ## Figma에서 실시간 데이터가 필요한 이유 - 협업자가 파일을 추가하거나 권한을 변경하면 다른 사용자 화면에도 새로고침 없이 즉시 반영되어야 한다. - 따라서 서버에서 데이터를 한 번 가져오는 것만으로는 부족하며, 클라이언트가 현재 관심 있는 데이터의 변경 사항을 계속 받아야 한다. - 인프라 팀의 목표는 제품 개발자가 데이터 전파 방식이나 WebSocket 세부 구현을 직접 관리하지 않고도 실시간 뷰를 만들 수 있게 하는 것이었다. ## 기존 방식의 한계 - 초기에는 React 프론트엔드가 Ruby HTTP 엔드포인트에서 필요한 데이터를 한 번에 받아 Redux 전역 상태에 저장했다. - 데이터 변경 시 백엔드 코드에서 관련 클라이언트에 보낼 이벤트 메시지를 직접 작성하고, 프론트엔드는 WebSocket으로 이벤트를 받아 상태를 갱신했다. - 사용자와 데이터 규모가 커지면서 모든 데이터를 한 번에 로드하기 어려워졌고, 데이터를 점진적으로 불러오면서 다음 문제가 발생했다. - 특정 데이터가 메모리에 항상 존재한다는 보장이 없어짐 - 여러 제품 영역이 같은 데이터를 사용할 때 데이터 로딩 책임이 불분명해짐 - 이벤트를 어느 시점에 보내고 받아야 하는지 관리하기 어려워짐 - 단순한 “새 파일 생성” 이벤트와 달리 권한 변경은 다른 리소스의 가시성까지 연쇄적으로 바꿀 수 있어 이벤트 설계가 복잡했다. - 데이터베이스 쓰기 순서와 실시간 메시지의 송수신 순서가 항상 일치한다는 보장도 없었다. - 그 결과 클라이언트 상태가 서버 상태의 올바른 부분집합을 반영하지 못하는 일관성 버그가 발생했다. ## GraphQL 기반 Live Query 선택 - Figma는 개발자가 실시간 데이터 구독을 선언적으로 정의할 수 있는 일반적인 프레임워크가 필요하다고 판단했다. - GraphQL을 인터페이스로 사용하면 필요한 데이터와 관계를 쿼리로 표현하고, 시스템이 해당 데이터를 자동으로 가져오고 최신 상태로 유지할 수 있다. - 여기서 말하는 GraphQL 구독은 일반적인 이벤트 스트림 구독과 다르다. - GraphQL의 전통적인 `subscription`은 이벤트 메시지를 전달하는 방식에 가깝다. - LiveGraph가 목표로 한 것은 쿼리 결과 자체를 계속 갱신하는 “Live Query” 방식이다. - 프론트엔드는 GraphQL과 유사한 쿼리를 보내고, 서버는 결과를 JSON 트리로 반환한다. - 서버에는 엔터티와 관계를 정의하는 스키마 및 그래프의 일부를 조회할 수 있는 뷰가 존재한다. ## 기존 실시간 데이터베이스 대신 자체 구축한 이유 - Figma는 이미 Postgres를 대규모로 운영하고 있었기 때문에 Firebase나 RethinkDB 같은 별도의 실시간 데이터베이스로 이전할 수 없었다. - LiveGraph는 새로운 저장소가 아니라 기존 Postgres 위에 동작하는 쿼리 엔진이 되어야 했다. - Figma의 Multiplayer 시스템은 파일 단위의 쓰기와 충돌 해결을 담당하지만, LiveGraph는 여러 데이터의 조회와 실시간 동기화를 담당한다. - Hasura, Prisma, PostGraphile 등 GraphQL 기술도 검토했지만, 대규모 동시 구독을 핵심 요구사항으로 설계된 것은 아니었다. - Figma는 실시간 구독 수가 많아질수록 데이터베이스 부하가 커지는 폴링 방식도 피하고자 했다. - 폴링은 쿼리마다 주기를 정해야 한다. - 구독 수가 늘어나면 동일한 쿼리가 반복 실행되어 데이터베이스 부하가 증가한다. - 폴링보다 변경 발생 시점에 가까운 낮은 지연 시간을 확보하기 어렵다. ## 데이터베이스 복제 스트림 기반 설계 - LiveGraph는 주기적으로 데이터를 다시 조회하는 대신 Postgres의 데이터베이스 복제 로그를 추적한다. - 복제 스트림에서 변경 사항을 읽으면 실제 데이터베이스 변경을 감지한 뒤 구독 중인 쿼리 결과를 갱신할 수 있다. - 이 방식은 폴링보다 빠른 업데이트 지연 시간을 제공한다. - 다만 LiveGraph가 데이터베이스의 전체 변경량을 읽어야 하므로, 대규모 환경에서는 확장성이 중요하다. - Figma는 여러 데이터베이스 샤드의 변경 사항을 여러 머신에 분산 처리할 수 있는 구조를 고려했다. - 최종적으로 LiveGraph를 자체 구축한 이유는 Figma의 협업 기능에서 실시간 데이터가 핵심 기능이며, 이러한 요구사항이 경쟁력으로 이어질 수 있다고 판단했기 때문이다. ## 실용적인 결론 실시간 UI를 구축할 때 클라이언트별 수동 이벤트와 전역 상태 갱신에 의존하면 데이터 규모와 기능 복잡도가 커질수록 일관성 문제가 발생하기 쉽다. 기존 Postgres를 유지해야 하고 대규모 구독이 필요하다면, GraphQL 기반 선언적 쿼리와 데이터베이스 변경 스트림을 결합하는 방식이 폴링이나 수동 이벤트보다 확장성과 유지보수성 측면에서 유리하다.