수년간 PostgreSQL은 ChatGPT와 OpenAI API 같은 핵심 제품을 뒷단에서 구동하는 가장 중요한 데이터 시스템 중 하나였습니다. 사용자 기반이 빠르게 성장함에 따라 데이터베이스에 대한 요구도 기하급수적으로 증가했습니다. 지난 1년 동안 PostgreSQL 부하는 10배 이상 증가했으며, 지금도 빠르게 늘어나고 있습니다.
이러한 성장을 지속하기 위해 프로덕션 인프라를 고도화하는 과정에서, PostgreSQL이 기존에 생각했던 것보다 훨씬 큰 규모의 읽기 중심 워크로드를 안정적으로 지원할 수 있다는 새로운 인사이트를 얻었습니다. 이 시스템은(캘리포니아대학교 버클리 캠퍼스의 과학자 팀이 처음 개발한) 단일 Azure PostgreSQL 플렉시블 서버 프라이머리 인스턴스(새 창에서 열기)와 전 세계 여러 리전에 분산된 약 50개의 읽기 복제본만으로 대규모 글로벌 트래픽을 지원할 수 있게 해주었습니다. 이 글은 엄격한 최적화와 탄탄한 엔지니어링을 통해 OpenAI에서 PostgreSQL을 확장해 8억 명 사용자의 초당 수백만 쿼리를 지원하게 된 과정과, 그 과정에서 얻은 핵심 교훈을 다룹니다.
ChatGPT 출시 이후 트래픽은 전례 없는 속도로 증가했습니다. 이를 지원하기 위해 애플리케이션과 PostgreSQL 데이터베이스 계층 모두에서 광범위한 최적화를 신속히 적용했고, 인스턴스 크기를 키워 스케일 업하고 읽기 복제본을 추가해 스케일 아웃했습니다. 이 아키텍처는 오랜 기간 동안 효과적으로 작동해 왔습니다. 지속적인 개선을 통해 향후 성장에도 충분한 여지를 제공하고 있습니다.
단일 프라이머리 아키텍처가 OpenAI 규모의 요구를 충족할 수 있다는 점은 놀랍게 들릴 수 있지만, 이를 실제로 구현하는 것은 결코 간단하지 않습니다. Postgres 과부하로 인해 여러 차례 심각한 장애(SEV)를 겪었으며, 대부분 비슷한 패턴을 보였습니다. 캐시 계층 장애로 인한 대규모 캐시 미스, CPU를 포화시키는 고비용 다중 조인 급증, 신규 기능 출시로 인한 쓰기 폭증 등이 그 예입니다. 리소스 사용률이 높아지면 쿼리 지연 시간이 증가하고 요청이 타임아웃되기 시작합니다. 이후 재시도가 부하를 더욱 증폭시키며 악순환을 일으켜 ChatGPT와 API 서비스 전체의 성능을 저하시킬 수 있습니다.
PostgreSQL은 읽기 중심 워크로드에서는 잘 확장되지만, 쓰기 트래픽이 많은 시기에는 여전히 어려움이 존재합니다. 이는 주로 PostgreSQL의 다중 버전 동시성 제어(MVCC) 구현 방식이 쓰기 중심 워크로드에 덜 효율적이기 때문입니다. 예를 들어 쿼리가 튜플이나 단일 필드를 업데이트할 때도 전체 행이 복사되어 새로운 버전이 생성됩니다. 쓰기가 많을 경우 이는 상당한 쓰기 증폭을 초래합니다. 또한 최신 데이터를 가져오기 위해 여러 튜플 버전(죽은 튜플)을 스캔해야 하므로 읽기 증폭도 증가합니다. MVCC는 테이블과 인덱스 팽창, 인덱스 유지 관리 오버헤드 증가, 복잡한 autovacuum 튜닝과 같은 추가적인 문제도 야기합니다. (이러한 문제에 대한 심층 분석은 카네기멜론대학교의 Andy Pavlo 교수와 함께 작성한 블로그 글 The Part of PostgreSQL We Hate the Most(새 창에서 열기)에서 확인할 수 있으며, 해당 글은 PostgreSQL 위키백과 페이지에도 인용(새 창에서 열기)되어 있습니다.)
이러한 한계를 완화하고 쓰기 압력을 줄이기 위해, 수평 분할이 가능한 쓰기 중심 워크로드를 Azure Cosmos DB와 같은 샤딩 시스템으로 이전했으며, 불필요한 쓰기를 최소화하도록 애플리케이션 로직을 최적화했고 현재도 이전을 계속하고 있습니다. 또한 현재 PostgreSQL 배포 환경에는 더 이상 새 테이블을 추가하지 않습니다. 새로운 워크로드는 기본적으로 샤딩 시스템을 사용합니다.
인프라가 발전해 왔음에도 PostgreSQL은 여전히 샤딩되지 않은 상태로, 단일 프라이머리 인스턴스가 모든 쓰기를 처리하고 있습니다. 그 주된 이유는 기존 애플리케이션 워크로드를 샤딩하는 작업이 매우 복잡하고 시간이 많이 들며, 수백 개의 애플리케이션 엔드포인트 변경이 필요해 수개월 또는 수년이 걸릴 수 있기 때문입니다. 워크로드가 주로 읽기 중심이고 광범위한 최적화를 적용했기 때문에, 현재 아키텍처는 트래픽 증가를 계속 지원할 수 있는 충분한 여유를 제공합니다. 향후 PostgreSQL 샤딩을 배제하는 것은 아니지만, 현재와 미래의 성장을 감당할 충분한 여지가 있기 때문에 단기적인 우선순위는 아닙니다.
다음 섹션에서는 우리가 직면한 과제와 이를 해결하고 향후 장애를 방지하기 위해 적용한 광범위한 최적화, 그리고 PostgreSQL을 한계까지 끌어올려 초당 수백만 쿼리(QPS)로 확장한 과정을 살펴봅니다.
과제: 단일 프라이머리 구성에서는 쓰기 노드가 하나뿐이므로 쓰기 확장이 어렵습니다. 대규모 쓰기 스파이크는 프라이머리를 빠르게 과부하 상태로 만들고 ChatGPT와 API 같은 서비스에 영향을 줄 수 있습니다.
해결책: 프라이머리가 쓰기 스파이크를 감당할 수 있도록 읽기와 쓰기 모두에서 프라이머리 부하를 최대한 줄입니다. 읽기 트래픽은 가능한 한 복제본으로 오프로드합니다. 다만 일부 읽기 쿼리는 쓰기 트랜잭션의 일부이기 때문에 프라이머리에 남아 있어야 합니다. 이 경우 효율성을 높이고 느린 쿼리를 피하는 데 집중합니다. 쓰기 트래픽의 경우, 샤딩이 가능한 쓰기 중심 워크로드를 Azure CosmosDB와 같은 샤딩 시스템으로 이전했습니다. 샤딩이 어렵지만 쓰기량이 많은 워크로드는 이전에 더 많은 시간이 필요하며, 해당 작업은 현재도 진행 중입니다. 또한 쓰기 부하를 줄이기 위해 애플리케이션을 적극적으로 최적화했습니다. 예를 들어, 중복 쓰기를 유발하던 애플리케이션 버그를 수정하고, 트래픽 급증을 완화하기 위해 필요한 경우 지연 쓰기(lazy writes)를 도입했습니다. 추가로 테이블 필드 백필 작업 시에는 과도한 쓰기 압력을 방지하기 위해 엄격한 속도 제한을 적용합니다.
과제: PostgreSQL에서 여러 고비용 쿼리를 발견했습니다. 과거에는 이러한 쿼리의 급격한 트래픽 증가가 CPU를 대량으로 소모해 ChatGPT와 API 요청 모두를 느리게 만들었습니다.
해결책: 다수의 테이블을 조인하는 일부 고비용 쿼리는 전체 서비스를 심각하게 저하시키거나 중단시킬 수 있습니다. 따라서 PostgreSQL 쿼리를 지속적으로 최적화해 효율성을 확보하고 일반적인 OLTP 안티패턴을 피해야 합니다. 예를 들어, 과거에 12개의 테이블을 조인하는 매우 비용이 큰 쿼리를 발견한 적이 있는데, 이 쿼리의 급증이 이전의 고심각도 SEV 발생의 원인이었습니다. 가능한 한 복잡한 다중 테이블 조인을 피해야 합니다. 조인이 불가피한 경우에는 쿼리를 분해하고 복잡한 조인 로직을 애플리케이션 계층으로 옮기는 방식을 고려하게 되었습니다. 이러한 문제성 쿼리 중 상당수는 ORM에서 생성되므로, 생성되는 SQL을 면밀히 검토해 예상대로 동작하는지 확인하는 것이 중요합니다. PostgreSQL에서는 장시간 유휴 상태로 실행 중인 쿼리를 발견하는 경우도 흔합니다. idle_in_transaction_session_timeout과 같은 타임아웃 설정은 이러한 쿼리가 autovacuum을 차단하지 않도록 하는 데 필수적입니다.
과제: 읽기 복제본 하나가 다운되더라도 트래픽은 다른 복제본으로 라우팅할 수 있습니다. 하지만 단일 쓰기 노드에 의존하면 단일 장애 지점이 발생하며, 해당 노드가 다운되면 서비스 전체가 영향을 받습니다.
해결책: 대부분의 핵심 요청은 읽기 쿼리만 포함합니다. 프라이머리의 단일 장애 지점을 완화하기 위해 이러한 읽기 요청을 프라이머리에서 복제본으로 오프로드해, 프라이머리가 다운되더라도 요청을 계속 처리할 수 있도록 했습니다. 쓰기 작업은 여전히 실패하지만, 읽기가 유지되기 때문에 영향은 줄어들며 더 이상 SEV0 수준의 장애는 아닙니다.
프라이머리 장애를 완화하기 위해, 항상 트래픽을 인계받을 준비가 된 지속 동기화 복제본인 핫 스탠바이를 포함한 고가용성(HA) 모드로 프라이머리를 운영합니다. 프라이머리가 다운되거나 유지보수를 위해 오프라인이 필요할 경우, 스탠바이를 신속히 승격시켜 다운타임을 최소화할 수 있습니다. Azure PostgreSQL 팀은 매우 높은 부하 상황에서도 이러한 페일오버가 안전하고 안정적으로 이루어지도록 상당한 작업을 수행했습니다. 읽기 복제본 장애에 대비해 각 리전에 충분한 여유 용량을 가진 여러 복제본을 배치함으로써, 단일 복제본 장애가 리전 전체 장애로 이어지지 않도록 하고 있습니다.
과제: 특정 요청이 PostgreSQL 인스턴스에서 과도한 리소스를 사용하는 상황이 자주 발생합니다. 이로 인해 동일한 인스턴스에서 실행 중인 다른 워크로드의 성능이 저하될 수 있습니다. 예를 들어, 새로운 기능 출시로 비효율적인 쿼리가 유입되면 PostgreSQL CPU를 과도하게 소모해 다른 핵심 기능의 요청 속도가 느려질 수 있습니다.
Solution: To mitigate the “noisy neighbor” problem, we isolate workloads onto dedicated instances to ensure that sudden spikes in resource-intensive requests don’t impact other traffic. Specifically, we split requests into low-priority and high-priority tiers and route them to separate instances. This way, even if a low-priority workload becomes resource-intensive, it won’t degrade the performance of high-priority requests. We apply the same strategy across different products and services as well, so that activity from one product does not affect the performance or reliability of another.
Challenge: Each instance has a maximum connection limit (5,000 in Azure PostgreSQL). It’s easy to run out of connections or accumulate too many idle ones. We’ve previously had incidents caused by connection storms that exhausted all available connections.
Solution: We deployed PgBouncer as a proxy layer to pool database connections. Running it in statement or transaction pooling mode allows us to efficiently reuse connections, greatly reducing the number of active client connections. This also cuts connection setup latency: in our benchmarks, the average connection time dropped from 50 milliseconds (ms) to 5 ms. Inter-region connections and requests can be expensive, so we co-locate the proxy, clients, and replicas in the same region to minimize network overhead and connection use time. Moreover, PgBouncer must be configured carefully. Settings like idle timeouts are critical to prevent connection exhaustion.
각 읽기 복제본에는 여러 PgBouncer 파드를 실행하는 전용 Kubernetes 배포가 있습니다. 동일한 Kubernetes Service 뒤에서 여러 배포를 운영해 파드 간 트래픽을 로드 밸런싱합니다.
Challenge: A sudden spike in cache misses can trigger a surge of reads on the PostgreSQL database, saturating CPU and slowing user requests.
Solution: To reduce read pressure on PostgreSQL, we use a caching layer to serve most of the read traffic. However, when cache hit rates drop unexpectedly, the burst of cache misses can push a large volume of requests directly to PostgreSQL. This sudden increase in database reads consumes significant resources, slowing down the service. To prevent overload during cache-miss storms, we implement a cache locking (and leasing) mechanism so that only a single reader that misses on a particular key fetches the data from PostgreSQL. When multiple requests miss on the same cache key, only one request acquires the lock and proceeds to retrieve the data and repopulate the cache. All other requests wait for the cache to be updated rather than all hitting PostgreSQL at once. This significantly reduces redundant database reads and protects the system from cascading load spikes.
Challenge: The primary streams Write Ahead Log (WAL) data to every read replica. As the number of replicas increases, the primary must ship WAL to more instances, increasing pressure on both network bandwidth and CPU. This causes higher and more unstable replica lag, which makes the system harder to scale reliably.
Solution: We operate nearly 50 read replicas across multiple geographic regions to minimize latency. However, with the current architecture, the primary must stream WAL to every replica. Although it currently scales well with very large instance types and high-network bandwidth, we can’t keep adding replicas indefinitely without eventually overloading the primary. To address this, we’re collaborating with the Azure PostgreSQL team on cascading replication(새 창에서 열기), where intermediate replicas relay WAL to downstream replicas. This approach allows us to scale to potentially over a hundred replicas without overwhelming the primary. However, it also introduces additional operational complexity, particularly around failover management. The feature is still in testing; we’ll ensure it’s robust and can fail over safely before rolling it out to production.
Challenge: A sudden traffic spike on specific endpoints, a surge of expensive queries, or a retry storm can quickly exhaust critical resources such as CPU, I/O, and connections, which causes widespread service degradation.
Solution: We implemented rate-limiting across multiple layers—application, connection pooler, proxy, and query—to prevent sudden traffic spikes from overwhelming database instances and triggering cascading failures. It’s also crucial to avoid overly short retry intervals, which can trigger retry storms. We also enhanced the ORM layer to support rate limiting and when necessary, fully block specific query digests. This targeted form of load shedding enables rapid recovery from sudden surges of expensive queries.
Challenge: Even a small schema change, such as altering a column type, can trigger a full table rewrite(새 창에서 열기). We therefore apply schema changes cautiously—limiting them to lightweight operations and avoiding any that rewrite entire tables.
Solution: Only lightweight schema changes are permitted, such as adding or removing certain columns that do not trigger a full table rewrite. We enforce a strict 5-second timeout on schema changes. Creating and dropping indexes concurrently is allowed. Schema changes are restricted to existing tables. If a new feature requires additional tables, they must be in alternative sharded systems such as Azure CosmosDB rather than PostgreSQL. When backfilling a table field, we apply strict rate limits to prevent write spikes. Although this process can sometimes take over a week, it ensures stability and avoids any production impact.
This effort demonstrates that with the right design and optimizations, Azure PostgreSQL can be scaled to handle the largest production workloads. PostgreSQL handles millions of QPS for read-heavy workloads, powering OpenAI’s most critical products like ChatGPT and the API platform. We added nearly 50 read replicas, while keeping replication lag near zero, maintained low-latency reads across geo-distributed regions, and built sufficient capacity headroom to support future growth.
This scaling works while still minimizing latency and improving reliability. We consistently deliver low double-digit millisecond p99 client-side latency and five-nines availability in production. And over the past 12 months, we’ve had only one SEV-0 PostgreSQL incident (it occurred during the viral launch(새 창에서 열기) of ChatGPT ImageGen, when write traffic suddenly surged by more than 10x as over 100 million new users signed up within a week.)
While we’re happy with how far PostgreSQL has taken us, we continue to push its limits to ensure we have sufficient runway for future growth. We’ve already migrated the shardable write-heavy workloads to our sharded systems like CosmosDB. The remaining write-heavy workloads are more challenging to shard—we’re actively migrating those as well to further offload writes from the PostgreSQL primary. We’re also working with Azure to enable cascading replication so we can safely scale to significantly more read replicas.
Looking ahead, we’ll continue to explore additional approaches to further scale, including sharded PostgreSQL or alternative distributed systems, as our infrastructure demands continue to grow.
작성자
감사의 말
이 글에 참여해 주신 Jon Lee, Sicheng Liu, Chaomin Yu, Chenglong Hao, 그리고 PostgreSQL 확장에 도움을 준 모든 팀원에게 특별한 감사를 전합니다. 또한 긴밀한 파트너십을 제공해 준 Azure PostgreSQL 팀에도 감사드립니다.


