OpenAI의 PostgreSQL 8억 사용자 스케일링: 심층 아키텍처 설계 가이드
관련글
Scaling PostgreSQL to power 800 million ChatGPT users
OpenAI의 PostgreSQL 8억 사용자 스케일링: 포괄적 설계 가이드
서문: 불가능해 보였던 도전
2022년 11월, ChatGPT가 세상에 공개되었을 때 아무도 예상하지 못했던 일이 벌어졌습니다. 출시 5일 만에 100만 사용자, 2개월 만에 1억 사용자를 돌파하며 역사상 가장 빠르게 성장하는 소비자 애플리케이션이 되었습니다. 그리고 2026년 1월 현재, ChatGPT는 8억 명의 월간 활성 사용자를 보유하고 있으며, 전 세계 인구의 약 10%가 사용하는 서비스가 되었습니다.
이러한 폭발적 성장 뒤에는 보이지 않는 영웅이 있습니다. 바로 PostgreSQL입니다. 1986년 UC 버클리의 Michael Stonebraker 교수가 시작한 이 오픈소스 관계형 데이터베이스는 40년 가까운 역사를 가지고 있습니다. 많은 이들이 “PostgreSQL은 대규모 서비스에 적합하지 않다”, “NoSQL이나 NewSQL이 필요하다”, “샤딩 없이는 불가능하다”라고 말했습니다. 하지만 OpenAI는 이러한 통념을 깨뜨렸습니다.
단일 프라이머리 인스턴스와 약 50개의 읽기 복제본. 특별한 커스터마이징 없는 표준 PostgreSQL. 복잡한 샤딩 시스템 없이 수백만 QPS를 처리하고, P99 레이턴시 10ms 이하를 유지하며, 99.999%의 가용성을 달성했습니다. 어떻게 이것이 가능했을까요?
이 문서는 OpenAI의 PostgreSQL 스케일링 여정을 기술적 깊이와 실무적 통찰력을 모두 갖춘 포괄적인 가이드로 제공합니다. 단순한 성공 사례가 아닌, 수많은 시행착오, 심각한 장애, 그리고 그로부터 배운 교훈을 담았습니다.
제1부: 위기의 시작 - 초기 아키텍처의 한계
1장: ChatGPT 출시와 예상치 못한 성장
OpenAI의 데이터베이스 팀은 ChatGPT 출시 전까지는 비교적 평온한 시기를 보내고 있었습니다. GPT-3 API는 안정적으로 운영되고 있었고, 트래픽 증가는 예측 가능한 수준이었습니다. 데이터베이스 아키텍처는 전형적인 프라이머리-복제본 구조였으며, Azure Database for PostgreSQL을 사용하여 관리 부담을 줄였습니다.
초기 설계는 다음과 같았습니다:
- 프라이머리 인스턴스: 모든 쓰기 작업 처리
- 읽기 복제본: 3-5개, 읽기 트래픽 분산
- 캐싱 계층: Redis를 사용한 기본적인 캐싱
- 모니터링: 기본적인 메트릭 수집
이 아키텍처는 수만 명의 사용자를 지원하는 데는 충분했습니다. CPU 사용률은 30-40% 수준이었고, 복제 지연은 거의 없었으며, 쿼리 레이턴시는 만족스러웠습니다.
하지만 ChatGPT가 출시되자 모든 것이 변했습니다. 첫 주에 트래픽이 10배 증가했고, 첫 달에 100배가 증가했습니다. 데이터베이스 팀은 불이 난 집에 물을 끼얹듯 긴급 대응에 나섰습니다. 인스턴스 크기를 늘리고, 복제본을 추가하고, 캐시 용량을 확대했습니다. 하지만 이는 근본적인 해결책이 아니었습니다.
2장: 악순환의 발견 - Vicious Cycle
시스템이 부하를 받기 시작하면서 팀은 반복되는 패턴을 발견했습니다. 이를 “악순환(Vicious Cycle)”이라고 부르기 시작했습니다.
1단계: 트리거 이벤트
악순환은 항상 어떤 트리거로 시작됩니다:
캐시 레이어 실패: Redis 클러스터의 노드 하나가 실패하거나 네트워크 파티션이 발생하면, 갑자기 수많은 캐시 미스가 발생합니다. 평소에는 캐시에서 99%의 읽기 요청을 처리하지만, 캐시가 실패하면 이 모든 트래픽이 PostgreSQL로 향합니다.
예를 들어, 초당 100만 개의 읽기 요청 중 99만 개가 캐시에서 처리되고 1만 개만 데이터베이스에 도달한다고 가정해봅시다. 캐시가 실패하면 갑자기 100만 개의 요청이 모두 데이터베이스로 향합니다. 이는 100배의 부하 증가입니다.
비싼 쿼리의 급증: 새로운 기능이 배포되거나 특정 사용 패턴이 증가하면서 비싼 쿼리가 갑자기 많이 실행될 수 있습니다. 예를 들어, 12개 테이블을 조인하는 쿼리가 초당 10번 실행되던 것이 갑자기 1000번 실행되면, 단일 쿼리 타입만으로 CPU를 포화시킬 수 있습니다.
쓰기 폭풍: 새로운 기능 출시나 이벤트로 인해 갑자기 대량의 쓰기가 발생할 수 있습니다. ChatGPT ImageGen이 출시되었을 때, 일주일 만에 1억 명의 신규 사용자가 가입하면서 사용자 테이블에 대한 쓰기가 10배 이상 증가했습니다.
2단계: PostgreSQL 과부하
트리거 이벤트가 발생하면 PostgreSQL은 갑자기 증가한 부하를 처리하려고 합니다. 하지만 리소스는 한정되어 있습니다:
- CPU 포화: CPU 사용률이 90-100%에 도달하면 쿼리 처리 속도가 급격히 느려집니다.
- I/O 병목: 디스크 I/O가 한계에 도달하면 모든 작업이 디스크 대기로 느려집니다.
- 메모리 압박: 버퍼 캐시가 부족하면 디스크 읽기가 증가하고, 작업 메모리가 부족하면 정렬과 조인이 디스크를 사용합니다.
- 연결 고갈: 연결 수가 제한(Azure PostgreSQL의 경우 5,000개)에 도달하면 새로운 연결을 받을 수 없습니다.
3단계: 요청 지연 및 타임아웃
데이터베이스가 과부하 상태에 빠지면 쿼리 처리 시간이 급격히 증가합니다. 평소 5ms에 완료되던 쿼리가 100ms, 1초, 심지어 10초 이상 걸릴 수 있습니다.
애플리케이션은 일반적으로 타임아웃을 설정합니다. 예를 들어, API 요청은 30초 타임아웃을 가질 수 있습니다. 쿼리가 이 시간 내에 완료되지 않으면 애플리케이션은 타임아웃 에러를 반환하고 연결을 닫습니다.
하지만 여기에 문제가 있습니다. 애플리케이션이 타임아웃으로 연결을 닫아도, PostgreSQL 백엔드 프로세스는 여전히 쿼리를 실행 중입니다. 쿼리가 완료될 때까지 리소스를 소비합니다. 이는 “좀비 쿼리” 문제를 야기합니다.
4단계: 재시도 폭풍
사용자나 클라이언트는 실패한 요청을 재시도합니다. 이는 자연스러운 행동입니다. 하지만 시스템이 과부하 상태에서 재시도는 문제를 악화시킵니다.
예를 들어, 원래 초당 10만 개의 요청이 있었는데, 이 중 30%가 실패했다고 가정해봅시다. 각 실패한 요청이 평균 2번 재시도하면, 갑자기 초당 6만 개의 추가 요청이 발생합니다. 총 부하는 16만 개로 60% 증가합니다.
더 나쁜 것은, 재시도가 즉시 발생하는 경우입니다. 지수 백오프(exponential backoff) 없이 즉시 재시도하면, 시스템이 회복할 시간을 주지 않습니다.
5단계: 완전한 붕괴
악순환이 계속되면 시스템은 점점 더 악화됩니다. CPU는 계속 100%를 유지하고, 쿼리 큐는 계속 길어지고, 타임아웃과 재시도는 계속 증가합니다. 이는 “죽음의 소용돌이(death spiral)”로 이어질 수 있습니다.
최악의 경우, 데이터베이스는 완전히 응답을 멈출 수 있습니다. 모든 연결이 사용 중이고, 모든 CPU가 쿼리 처리에 소진되고, 새로운 요청은 받을 수 없습니다. 이는 Sev0 장애 - 전체 서비스 중단 - 입니다.
3장: 첫 번째 Sev0 장애: Redis 장애와 연쇄 실패
2023년 초, 팀은 첫 번째 주요 장애를 경험했습니다. 원인은 Redis 클러스터의 마스터 노드 장애였습니다.
타임라인:
00:00 - 정상 운영
- PostgreSQL CPU: 40%
- 초당 쿼리: 500,000 QPS
- 평균 레이턴시: 5ms
- 캐시 히트율: 99.5%
00:05 - Redis 마스터 노드 장애
- Redis 클러스터의 한 마스터 노드가 OOM(Out of Memory)으로 크래시
- Sentinel이 장애 조치를 시작하지만 30초 소요
00:06 - 캐시 미스 폭증
- 장애난 샤드의 모든 키에 대한 요청이 PostgreSQL로 향함
- PostgreSQL QPS가 갑자기 2배 증가: 1,000,000 QPS
- CPU 사용률: 40% → 75%
00:08 - 쿼리 레이턴시 증가
- 평균 레이턴시: 5ms → 20ms
- P99 레이턴시: 15ms → 100ms
- 일부 쿼리 타임아웃 시작
00:10 - 재시도 시작
- 타임아웃된 요청의 재시도가 시작됨
- 실제 사용자 요청 + 재시도로 부하 추가 증가
- PostgreSQL CPU: 75% → 95%
00:12 - 연결 고갈
- 재시도로 인한 연결 폭증
- 활성 연결: 2000 → 4500 (제한 5000에 근접)
- 새로운 연결 생성 실패 시작
00:15 - 완전 장애
- PostgreSQL CPU: 100%
- 평균 레이턴시: 5초+
- 대부분의 요청 실패
- ChatGPT 완전 다운
00:20 - 긴급 대응
- 온콜 엔지니어 호출
- PostgreSQL 프라이머리 재시작 결정
00:30 - 부분 복구
- PostgreSQL 재시작 완료
- Redis 장애 조치 완료
- 서비스 점진적 복구
00:45 - 완전 복구
- 모든 시스템 정상 복귀
- 사후 분석 시작
근본 원인 분석:
이 장애는 여러 시스템의 문제가 연쇄적으로 발생한 결과였습니다:
- Redis의 취약성: 단일 노드 장애가 전체 서비스에 영향
- 느린 장애 조치: Sentinel이 30초 걸림
- 과도한 캐시 의존: 캐시 없이는 PostgreSQL이 부하를 감당 못함
- 재시도 로직 문제: 지수 백오프 없이 즉시 재시도
- 연결 관리 부족: 연결 풀링 없이 각 요청이 새 연결 생성
- 모니터링 공백: 문제를 조기에 감지하지 못함
학습한 교훈:
- 캐시는 단일 장애점이 되어서는 안 된다: PostgreSQL은 캐시 없이도 기본 수준의 트래픽을 처리할 수 있어야 함
- 재시도에는 지수 백오프가 필수: 즉시 재시도는 시스템을 더 악화시킴
- 연결 풀링이 필수: PgBouncer 같은 연결 풀러를 도입해야 함
- 다층 방어가 필요: 단일 시스템 장애가 전체 장애로 이어지지 않도록 여러 방어선 구축
- 모니터링과 알람 강화: 문제를 조기에 감지하고 자동으로 대응
이 장애 이후, 팀은 근본적인 아키텍처 재설계에 착수했습니다.
제2부: MVCC의 이해 - PostgreSQL의 양날의 검
4장: MVCC 동작 원리와 장단점
PostgreSQL의 MVCC(Multi-Version Concurrency Control)는 동시성 제어의 우아한 해결책입니다. 하지만 대규모 쓰기 워크로드에서는 심각한 문제를 야기합니다.
MVCC의 기본 원리:
전통적인 잠금 기반 시스템에서는 하나의 트랜잭션이 행을 수정할 때 다른 트랜잭션은 대기해야 합니다. 읽기는 쓰기를 차단하고, 쓰기는 읽기를 차단합니다. 이는 동시성을 크게 제한합니다.
MVCC는 다른 접근을 택합니다. 각 행의 여러 버전을 유지하여, 읽기와 쓰기가 서로를 차단하지 않도록 합니다. 트랜잭션은 시작 시점의 데이터베이스 스냅샷을 봅니다.
예를 들어:
1
2
3
4
5
6
7
8
시간 T0: Row A = {id: 1, value: 100}
시간 T1: Transaction 1 시작 (읽기)
시간 T2: Transaction 2 시작 (쓰기)
시간 T3: Transaction 2가 Row A를 업데이트 → {id: 1, value: 200}
시간 T4: Transaction 1이 Row A를 읽음 → 여전히 {id: 1, value: 100}을 봄
시간 T5: Transaction 2 커밋
시간 T6: Transaction 1 커밋
Transaction 1은 Transaction 2의 변경을 보지 않습니다. 이는 격리(Isolation)를 제공합니다.
내부 구현:
PostgreSQL은 각 행에 메타데이터를 저장합니다:
xmin: 이 행을 생성한 트랜잭션 IDxmax: 이 행을 삭제하거나 업데이트한 트랜잭션 ID (없으면 0)ctid: 행의 물리적 위치
행을 업데이트할 때:
- 기존 행의
xmax를 현재 트랜잭션 ID로 설정 - 새 행을 생성하고
xmin을 현재 트랜잭션 ID로 설정 - 인덱스를 업데이트하여 새 행을 가리킴
읽기 쿼리는:
- 각 행의
xmin과xmax를 확인 - 자신의 스냅샷 기준으로 보이는 버전을 선택
장점:
- 높은 동시성: 읽기와 쓰기가 서로를 차단하지 않음
- 일관된 읽기: 트랜잭션은 일관된 스냅샷을 봄
- 단순한 롤백: 롤백은 메타데이터 변경만으로 가능
단점 - 쓰기 증폭:
하지만 MVCC는 쓰기 중심 워크로드에서 심각한 문제를 일으킵니다.
전체 행 복사: 단일 컬럼만 변경해도 전체 행이 복사됩니다.
예를 들어, 다음과 같은 테이블이 있다고 가정합시다:
1
2
3
4
5
6
7
8
CREATE TABLE users (
id BIGINT,
email VARCHAR(255),
name VARCHAR(100),
bio TEXT, -- 평균 1KB
settings JSONB, -- 평균 2KB
last_login TIMESTAMP
);
last_login만 업데이트하려고 합니다:
1
UPDATE users SET last_login = NOW() WHERE id = 12345;
이 쿼리는 8바이트(TIMESTAMP)만 변경하지만, 전체 행(약 3KB)이 복사됩니다. 이는 375배의 쓰기 증폭입니다.
초당 10,000번의 이러한 업데이트가 발생하면:
- 실제 변경 데이터: 10,000 * 8바이트 = 78KB/s
- 실제 쓰기량: 10,000 * 3KB = 29MB/s
이는 디스크 I/O와 WAL 생성을 크게 증가시킵니다.
단점 - 읽기 증폭:
삭제되거나 업데이트된 행(dead tuples)은 즉시 제거되지 않습니다. 아직 이 행을 볼 수 있는 트랜잭션이 있을 수 있기 때문입니다.
테이블을 스캔할 때, PostgreSQL은 모든 행 버전을 읽고 각각의 가시성을 확인해야 합니다.
예를 들어, 1억 행 테이블에서 각 행이 평균 3번 업데이트되었다면:
- 논리적 행 수: 1억
- 물리적 행 버전: 3억 (현재 버전 1억 + dead tuples 2억)
전체 테이블 스캔은 3억 행을 읽어야 하며, 각각의 가시성을 확인해야 합니다. 이는 순수한 데이터 읽기보다 3배 느립니다.
단점 - 블로트:
Dead tuples는 공간을 차지합니다. 시간이 지남에 따라 테이블과 인덱스가 “부풀어 오릅니다(bloat)”.
예를 들어, 처음에 10GB였던 테이블이 블로트로 인해 50GB가 될 수 있습니다. 이는:
- 디스크 공간 낭비
- 느린 스캔 (50GB를 읽어야 10GB의 유효 데이터를 얻음)
- 더 큰 인덱스 (더 많은 메모리 필요)
단점 - Autovacuum의 복잡성:
PostgreSQL은 Autovacuum 프로세스를 통해 dead tuples를 정리합니다. 하지만 Autovacuum은 복잡하고 튜닝하기 어렵습니다.
기본 설정으로는, 테이블의 20%가 변경되어야 Autovacuum이 실행됩니다. 대형 테이블에서는 이는 너무 늦습니다.
1억 행 테이블이라면 2000만 행이 변경되어야 Autovacuum이 시작됩니다. 이미 엄청난 블로트가 발생한 상태입니다.
Autovacuum은 또한 리소스를 소비합니다:
- CPU: Dead tuples 찾기 및 제거
- I/O: 테이블과 인덱스 스캔
- Locks: AccessShare 잠금 (대부분의 작업과 호환되지만 일부 DDL 차단)
5장: OpenAI의 MVCC 문제와 해결 전략
OpenAI는 MVCC의 모든 문제를 경험했습니다.
문제 1: 대화 테이블의 블로트
ChatGPT의 핵심은 대화 데이터입니다. 각 대화는 여러 메시지로 구성됩니다:
1
2
3
4
5
6
7
8
9
10
CREATE TABLE conversations (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(500),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
last_message_at TIMESTAMP,
message_count INT DEFAULT 0,
metadata JSONB
);
사용자가 대화를 계속할 때마다 last_message_at와 message_count가 업데이트됩니다. 인기 있는 대화는 수백 번 업데이트될 수 있습니다.
초기에는 이를 모든 메시지마다 업데이트했습니다:
1
2
3
4
5
# 메시지가 추가될 때마다
await db.execute(
"UPDATE conversations SET last_message_at = $1, message_count = message_count + 1 WHERE id = $2",
message_timestamp, conversation_id
)
이는 엄청난 블로트를 야기했습니다. 1000만 개의 활성 대화가 있고 각각 평균 100개의 메시지를 가진다면, 10억 개의 업데이트가 발생합니다.
해결책 - Lazy Write:
모든 메시지마다 업데이트하는 대신, 배치로 업데이트하도록 변경했습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 메모리 버퍼에 저장
conversation_updates[conversation_id] = {
'last_message_at': message_timestamp,
'message_count_delta': 1
}
# 주기적으로 플러시 (예: 1분마다)
async def flush_conversation_updates():
for conv_id, updates in conversation_updates.items():
await db.execute(
"UPDATE conversations SET last_message_at = $1, message_count = message_count + $2 WHERE id = $3",
updates['last_message_at'], updates['message_count_delta'], conv_id
)
conversation_updates.clear()
이렇게 하면 100개의 메시지가 있어도 업데이트는 1-2번만 발생합니다. 쓰기량이 50-100배 감소했습니다.
문제 2: 사용자 테이블의 last_seen
사용자 활동 추적을 위해 last_seen 컬럼을 업데이트했습니다:
1
2
3
4
5
6
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
-- ... 많은 다른 컬럼들 ...
last_seen TIMESTAMP
);
초기 구현:
1
2
3
4
5
# 모든 API 요청마다
await db.execute(
"UPDATE users SET last_seen = NOW() WHERE id = $1",
user_id
)
8억 사용자가 있고 각각 하루에 평균 50번 요청을 보낸다면:
- 일일 업데이트: 400억 번
- 초당 업데이트: 약 50만 번
이는 엄청난 쓰기 부하입니다.
해결책 - 의미 있는 변경만 기록:
1
2
3
4
5
6
7
8
# 1시간 이상 차이가 날 때만 업데이트
last_update = cache.get(f"user:{user_id}:last_seen_update")
if not last_update or (now() - last_update) > timedelta(hours=1):
await db.execute(
"UPDATE users SET last_seen = NOW() WHERE id = $1",
user_id
)
cache.set(f"user:{user_id}:last_seen_update", now(), ex=3600)
이렇게 하면 일일 업데이트가 400억에서 약 20억으로 95% 감소합니다. 1시간 단위 정밀도면 대부분의 사용 사례에 충분합니다.
문제 3: Autovacuum이 따라잡지 못함
쓰기 감소 후에도 Autovacuum이 블로트를 따라잡지 못하는 경우가 있었습니다.
모니터링 결과:
1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT
schemaname, tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size,
n_dead_tup,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
-- 결과
schemaname | tablename | size | n_dead_tup | last_autovacuum
------------+----------------+--------+------------+------------------
public | conversations | 500 GB | 50000000 | 3 hours ago
public | messages | 2 TB | 200000000 | 5 hours ago
Autovacuum이 실행되고 있지만 dead tuples가 계속 증가합니다.
해결책 - 공격적인 Autovacuum 튜닝:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 대형 테이블에 대해 매우 공격적인 설정
ALTER TABLE conversations SET (
autovacuum_vacuum_scale_factor = 0.01, -- 기본값 0.2에서 변경
autovacuum_vacuum_threshold = 10000,
autovacuum_vacuum_cost_delay = 2, -- 기본값 20ms에서 변경
autovacuum_vacuum_cost_limit = 1000 -- 기본값 200에서 변경
);
ALTER TABLE messages SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 10000,
autovacuum_vacuum_cost_delay = 2,
autovacuum_vacuum_cost_limit = 1000
);
이 설정은 Autovacuum이:
- 더 자주 실행됨 (1% 변경 후, 20% 대신)
- 더 빠르게 실행됨 (비용 제한 완화)
결과적으로 블로트가 크게 감소했습니다.
문제 4: HOT 업데이트 최적화
PostgreSQL은 HOT(Heap-Only Tuple) 업데이트라는 최적화를 제공합니다. 업데이트된 행이 같은 페이지에 저장될 수 있으면, 인덱스 업데이트를 건너뛸 수 있습니다.
하지만 이는 페이지에 빈 공간이 있어야 합니다.
해결책 - Fillfactor 조정:
1
2
3
-- 자주 업데이트되는 테이블
ALTER TABLE conversations SET (fillfactor = 70);
ALTER TABLE users SET (fillfactor = 70);
이렇게 하면 각 페이지의 30%를 빈 공간으로 남겨둡니다. 업데이트 시 새 행 버전이 같은 페이지에 저장될 가능성이 높아집니다.
트레이드오프:
- 장점: HOT 업데이트 증가, 인덱스 유지 비용 감소
- 단점: 테이블 크기 약 30% 증가, 스캔 성능 약간 감소
벤치마크 결과, 업데이트 중심 테이블에서는 HOT 업데이트 증가로 인한 이점이 크기 증가로 인한 단점을 크게 상회했습니다.
제3부: 쓰기 워크로드 최적화 - 프라이머리 보호
6장: Azure Cosmos DB로의 전략적 마이그레이션
PostgreSQL의 단일 프라이머리는 쓰기 확장의 병목입니다. OpenAI는 쓰기 중심 워크로드를 Azure Cosmos DB로 선별적으로 마이그레이션하기로 결정했습니다.
Cosmos DB를 선택한 이유:
Azure Cosmos DB는 글로벌 분산 NoSQL 데이터베이스입니다:
- 수평 확장: 파티션 추가로 쓰기를 선형적으로 확장
- 글로벌 분산: 여러 지역에 자동 복제
- 유연한 일관성: 5가지 일관성 모델 선택
- 관리형: Azure가 운영 관리
마이그레이션 후보 선정:
모든 워크로드를 마이그레이션할 수는 없습니다. 다음 기준으로 후보를 선정했습니다:
- 쓰기 비중 > 30%: 읽기 중심은 PostgreSQL이 더 적합
- 명확한 파티션 키: Cosmos DB는 파티션 키가 필수
- 복잡한 조인 불필요: Cosmos DB는 조인이 제한적
- 최종 일관성 허용: Cosmos DB는 기본적으로 최종 일관성
사례 연구: 세션 데이터 마이그레이션
ChatGPT는 사용자 세션을 추적합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
-- PostgreSQL 스키마
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip_address INET,
user_agent TEXT,
metadata JSONB
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
워크로드 분석:
- 쓰기: 70% (세션 생성, 갱신, 삭제)
- 읽기: 30% (세션 조회)
- 파티션 키 후보:
user_id - 조인: 없음 (세션 테이블만 조회)
이는 완벽한 마이그레이션 후보입니다.
1단계: Cosmos DB 스키마 설계
Cosmos DB는 문서 기반이므로 스키마를 변환했습니다:
1
2
3
4
5
6
7
8
9
10
11
12
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": 12345,
"createdAt": "2026-01-24T10:00:00Z",
"expiresAt": "2026-01-25T10:00:00Z",
"ipAddress": "192.168.1.1",
"userAgent": "Mozilla/5.0...",
"metadata": {
"deviceType": "desktop",
"location": "Seoul"
}
}
파티션 키: /userId
2단계: Dual-Write 구현
새로운 세션은 PostgreSQL과 Cosmos DB 모두에 쓰기:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async def create_session(user_id: int, session_data: dict):
session_id = uuid.uuid4()
# PostgreSQL에 쓰기
await pg_db.execute(
"""INSERT INTO sessions (id, user_id, created_at, expires_at, ip_address, user_agent, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
session_id, user_id, now(), expires_at, ip_address, user_agent, metadata
)
# Cosmos DB에도 쓰기
await cosmos_client.create_item(
database='chatgpt',
container='sessions',
body={
'id': str(session_id),
'userId': user_id,
'createdAt': now().isoformat(),
'expiresAt': expires_at.isoformat(),
'ipAddress': ip_address,
'userAgent': user_agent,
'metadata': metadata
}
)
return session_id
3단계: 데이터 마이그레이션
기존 데이터를 배치로 마이그레이션:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
async def migrate_sessions():
offset = 0
batch_size = 1000
while True:
# PostgreSQL에서 배치 가져오기
sessions = await pg_db.fetch(
"""SELECT * FROM sessions
WHERE expires_at > NOW()
ORDER BY id
LIMIT $1 OFFSET $2""",
batch_size, offset
)
if not sessions:
break
# Cosmos DB로 쓰기
tasks = []
for session in sessions:
tasks.append(
cosmos_client.upsert_item(
database='chatgpt',
container='sessions',
body=convert_session_to_document(session)
)
)
await asyncio.gather(*tasks)
offset += batch_size
# 속도 제한
await asyncio.sleep(1)
4단계: Dual-Read 및 검증
일정 기간 동안 양쪽에서 읽고 결과를 비교:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async def get_session(session_id: UUID):
# 양쪽에서 읽기
pg_session = await pg_db.fetchrow(
"SELECT * FROM sessions WHERE id = $1", session_id
)
cosmos_session = await cosmos_client.read_item(
database='chatgpt',
container='sessions',
item=str(session_id),
partition_key=pg_session['user_id']
)
# 검증
if not sessions_match(pg_session, cosmos_session):
logger.error(f"Session mismatch for {session_id}")
metrics.incr('session_mismatch')
# PostgreSQL 결과 반환 (아직 전환 전)
return pg_session
5단계: 읽기 전환
검증 결과 불일치가 0.01% 미만으로 떨어지면 읽기를 Cosmos DB로 전환:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async def get_session(session_id: UUID, user_id: int):
# Cosmos DB에서 읽기
try:
session = await cosmos_client.read_item(
database='chatgpt',
container='sessions',
item=str(session_id),
partition_key=user_id
)
return session
except CosmosDBNotFoundError:
# 폴백: PostgreSQL에서 읽기
return await pg_db.fetchrow(
"SELECT * FROM sessions WHERE id = $1", session_id
)
6단계: 쓰기 전환 및 PostgreSQL 정리
몇 주 후 Cosmos DB가 안정적으로 운영되면 PostgreSQL 쓰기를 중단:
1
2
3
4
5
6
7
8
9
10
11
12
13
async def create_session(user_id: int, session_data: dict):
# Cosmos DB에만 쓰기
session_id = uuid.uuid4()
await cosmos_client.create_item(
database='chatgpt',
container='sessions',
body={
'id': str(session_id),
'userId': user_id,
# ...
}
)
return session_id
PostgreSQL의 sessions 테이블은 보관 기간 후 삭제:
1
2
-- 몇 달 후
DROP TABLE sessions;
결과:
세션 데이터 마이그레이션 결과:
- PostgreSQL 쓰기 감소: 일일 5억 writes 감소
- Cosmos DB 성능: P99 레이턴시 < 10ms
- 비용: Cosmos DB RU(Request Units) 비용 증가, 하지만 PostgreSQL 부하 감소로 상쇄
- 안정성: 세션 관련 장애 0건 (마이그레이션 후 6개월)
마이그레이션된 다른 워크로드:
- 로그 데이터: 시간순 쓰기 중심, 파티션 키 = timestamp
- 이벤트 추적: 분석용 데이터, 파티션 키 = user_id
- 임시 데이터: TTL 활용, 자동 만료
마이그레이션하지 않은 워크로드:
- 사용자 데이터: 복잡한 관계, 트랜잭션 필요
- 대화 메타데이터: 복잡한 쿼리, 조인 필요
- 청구 데이터: 강력한 일관성 필요
7장: 백필 및 대량 업데이트 전략
새로운 기능을 추가할 때 기존 데이터에 새 컬럼을 채워야 하는 경우가 있습니다.
문제: 1억 행 테이블에 새 컬럼을 추가하고 기본값으로 채우기
나이브한 접근:
1
ALTER TABLE conversations ADD COLUMN priority INT DEFAULT 1;
PostgreSQL 11 이상에서는 이것이 메타데이터만 변경하므로 빠릅니다. 하지만 실제로 컬럼을 채우려면:
1
UPDATE conversations SET priority = calculate_priority(id);
이 쿼리는 1억 행을 모두 업데이트합니다. 단순 실행하면:
- 시간: 수 시간
- WAL 생성: 수백 GB
- 프라이머리 과부하
- 복제 지연 증가
- 프로덕션 영향
해결책: Controlled Backfill
작은 배치로 나누고 각 배치 사이에 지연을 둡니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
async def backfill_priority():
batch_size = 1000
delay_ms = 100 # 배치 간 100ms 대기
while True:
# 우선순위가 NULL인 행 찾기
result = await db.execute(
"""UPDATE conversations
SET priority = calculate_priority(id)
WHERE id IN (
SELECT id FROM conversations
WHERE priority IS NULL
ORDER BY id
LIMIT $1
)
RETURNING id""",
batch_size
)
updated_count = len(result)
logger.info(f"Updated {updated_count} rows")
if updated_count == 0:
break
# 지연
await asyncio.sleep(delay_ms / 1000)
logger.info("Backfill complete")
이 방식은:
- 프라이머리에 가해지는 부하를 분산
- WAL 생성을 시간에 걸쳐 분산
- 복제본이 따라잡을 시간 제공
- 필요시 중단하고 재시작 가능
1억 행을 1000개씩 처리하면:
- 총 배치: 100,000
- 배치당 100ms 대기 → 총 대기 시간: 10,000초 = 약 2.8시간
- 실제 업데이트 시간 포함하면 약 4-6시간
프로덕션에 영향을 주지 않으면서 안전하게 완료할 수 있습니다.
고급: 우선순위 기반 백필
모든 행이 동일한 우선순위를 가지지 않습니다. 중요한 행을 먼저 백필:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async def backfill_priority_smart():
# 1단계: 활성 사용자의 대화 먼저
await backfill_batch(
"""UPDATE conversations c
SET priority = calculate_priority(c.id)
WHERE c.id IN (
SELECT c2.id FROM conversations c2
JOIN users u ON c2.user_id = u.id
WHERE c2.priority IS NULL
AND u.last_seen > NOW() - INTERVAL '7 days'
ORDER BY c2.id
LIMIT $1
)""",
batch_size=1000
)
# 2단계: 나머지
await backfill_batch(
"""UPDATE conversations
SET priority = calculate_priority(id)
WHERE priority IS NULL
ORDER BY id
LIMIT $1""",
batch_size=5000 # 더 큰 배치 사용 가능
)
제4부: 읽기 확장 - 복제본 아키텍처
8장: 읽기 복제본 전략과 배치
OpenAI는 약 50개의 읽기 복제본을 운영합니다. 하지만 단순히 복제본을 추가하는 것만으로는 충분하지 않습니다. 전략적 배치와 워크로드 격리가 필수입니다.
지리적 분산:
ChatGPT는 글로벌 서비스입니다. 한국의 사용자가 미국의 데이터센터에 접속하면 네트워크 레이턴시만 100-200ms 추가됩니다.
복제본 배치:
- 미국 동부 (US East): 15개 복제본
- 프라이머리와 같은 리전
- 가장 낮은 복제 지연
- 미국 동부 사용자 서비스
- 미국 서부 (US West): 10개 복제본
- 미국 서부 사용자 서비스
- 크로스 리전 복제
- 유럽 (West Europe): 10개 복제본
- 유럽 사용자 서비스
- GDPR 준수를 위한 데이터 로컬리티
- 아시아 태평양 (Asia Pacific): 15개 복제본
- 한국, 일본, 동남아시아 사용자 서비스
- 높은 사용자 밀도로 더 많은 복제본 필요
각 리전의 복제본은 해당 리전의 애플리케이션 서버에서 사용됩니다. 사용자 요청은 가장 가까운 리전으로 라우팅되고, 해당 리전의 복제본에서 읽기를 수행합니다.
우선순위 기반 격리:
모든 읽기가 동등하지 않습니다. ChatGPT의 실시간 대화 응답은 높은 우선순위를 가지지만, 내부 분석 쿼리는 낮은 우선순위입니다.
복제본을 우선순위별로 분리:
Tier 1 - Critical (고우선순위):
- 용도: ChatGPT 실시간 대화
- 복제본: 리전당 5개
- SLA: P99 < 10ms
- 리소스: 최고 사양 인스턴스
Tier 2 - Important (중간우선순위):
- 용도: API 요청, 일반 서비스
- 복제본: 리전당 3개
- SLA: P99 < 50ms
- 리소스: 표준 사양 인스턴스
Tier 3 - Best Effort (저우선순위):
- 용도: 분석, 리포팅, 배치 작업
- 복제본: 리전당 2개
- SLA: 없음
- 리소스: 비용 효율적 인스턴스
애플리케이션은 요청의 우선순위에 따라 적절한 티어의 복제본에 연결:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class ReplicaRouter:
def __init__(self):
self.tier1_replicas = ['replica-us-east-1a', 'replica-us-east-1b', ...]
self.tier2_replicas = ['replica-us-east-2a', 'replica-us-east-2b', ...]
self.tier3_replicas = ['replica-us-east-3a', 'replica-us-east-3b', ...]
def get_connection(self, priority: str):
if priority == 'critical':
replica = random.choice(self.tier1_replicas)
elif priority == 'important':
replica = random.choice(self.tier2_replicas)
else:
replica = random.choice(self.tier3_replicas)
return psycopg2.connect(host=replica, ...)
# 사용
router = ReplicaRouter()
# ChatGPT 대화
conn = router.get_connection('critical')
messages = conn.execute("SELECT * FROM messages WHERE conversation_id = %s", (conv_id,))
# 분석 쿼리
conn = router.get_connection('best_effort')
stats = conn.execute("SELECT user_id, COUNT(*) FROM conversations GROUP BY user_id")
이렇게 함으로써 저우선순위 쿼리가 고우선순위 서비스에 영향을 주지 않습니다.
복제 지연 관리:
복제는 비동기입니다. 프라이머리에서 쓴 데이터가 복제본에 도달하기까지 지연이 있습니다.
목표: P99 복제 지연 < 100ms
모니터링:
1
2
3
4
5
6
7
-- 복제본에서 실행
SELECT
CASE
WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()
THEN 0
ELSE EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))
END AS lag_seconds;
복제 지연이 증가하는 원인:
- 프라이머리의 높은 쓰기 부하: 많은 WAL 생성
- 네트워크 대역폭 부족: WAL 전송 병목
- 복제본의 느린 재생: CPU/IO 부족
- 장기 실행 쿼리: 복제본에서 실행 중인 쿼리가 WAL 재생 차단
해결책:
- 쓰기 최적화 (Cosmos DB 마이그레이션 등)
- 네트워크 대역폭 증대
- 복제본 인스턴스 크기 증가
- 장기 실행 쿼리 kill 또는 전용 복제본 사용
9장: 쿼리 최적화 - ORM의 함정
많은 애플리케이션이 ORM(Object-Relational Mapping)을 사용합니다. ORM은 편리하지만 비효율적인 SQL을 생성할 수 있습니다.
문제: N+1 쿼리
가장 흔한 안티패턴입니다:
1
2
3
4
5
6
# SQLAlchemy 사용
conversations = session.query(Conversation).filter_by(user_id=12345).all()
for conv in conversations:
print(f"Conversation: {conv.title}")
print(f"Creator: {conv.user.name}") # 여기서 추가 쿼리!
실제 SQL:
1
2
3
4
5
6
7
8
-- 1개 쿼리: 대화 목록
SELECT * FROM conversations WHERE user_id = 12345;
-- N개 쿼리: 각 대화마다 사용자 정보
SELECT * FROM users WHERE id = 12345;
SELECT * FROM users WHERE id = 12345;
SELECT * FROM users WHERE id = 12345;
...
사용자가 100개의 대화를 가지고 있다면, 101개의 쿼리가 실행됩니다.
해결책: Eager Loading
1
2
3
4
5
6
7
8
9
# JOIN으로 한 번에 가져오기
conversations = session.query(Conversation)\
.filter_by(user_id=12345)\
.options(joinedload(Conversation.user))\
.all()
for conv in conversations:
print(f"Conversation: {conv.title}")
print(f"Creator: {conv.user.name}") # 추가 쿼리 없음!
실제 SQL:
1
2
3
4
SELECT conversations.*, users.*
FROM conversations
LEFT OUTER JOIN users ON conversations.user_id = users.id
WHERE conversations.user_id = 12345;
단 1개의 쿼리로 모든 데이터를 가져옵니다.
문제: 12-way JOIN의 악몽
OpenAI는 12개 테이블을 조인하는 쿼리를 발견했습니다. 이는 ORM이 자동으로 생성한 것이었습니다:
1
2
3
4
5
6
7
8
9
10
# 코드는 간단해 보임
conversation = session.query(Conversation)\
.options(
joinedload(Conversation.user),
joinedload(Conversation.messages).joinedload(Message.author),
joinedload(Conversation.shared_with),
# ... 더 많은 관계들
)\
.filter_by(id=conv_id)\
.first()
생성된 SQL은 12개 테이블을 조인했고, EXPLAIN ANALYZE 결과:
- 실행 시간: 2-5초
- 읽은 행: 수백만 행
- CPU 사용: 매우 높음
이 쿼리가 초당 100번 실행되면:
- 총 실행 시간: 200-500 CPU-초
- 일반적인 서버의 CPU를 완전히 포화
해결책: 조인 분해
1
2
3
4
5
6
7
# 여러 단순 쿼리로 분할
conversation = session.query(Conversation).filter_by(id=conv_id).first()
user = session.query(User).filter_by(id=conversation.user_id).first()
messages = session.query(Message).filter_by(conversation_id=conv_id).all()
# 각각의 메시지 작성자는 별도로 가져오되 배치로
author_ids = [m.author_id for m in messages]
authors = session.query(User).filter(User.id.in_(author_ids)).all()
각 쿼리는 단순하고 빠릅니다 (< 10ms). 총 시간은 여전히 빠르며 (< 50ms), CPU 사용은 크게 감소합니다.
추가 이점:
- 캐싱 가능: 각 쿼리 결과를 개별적으로 캐시
- 부분 실패 허용: 한 쿼리 실패가 전체 실패로 이어지지 않음
- 선택적 로딩: 필요한 데이터만 가져오기
*문제: SELECT **
ORM은 종종 SELECT *를 생성합니다:
1
user = session.query(User).filter_by(id=12345).first()
1
SELECT * FROM users WHERE id = 12345;
users 테이블에 50개의 컬럼이 있고 그 중 bio 컬럼이 평균 10KB라면, 필요 없는 데이터를 가져오는 것입니다.
해결책: 명시적 컬럼 선택
1
user = session.query(User.id, User.email, User.name).filter_by(id=12345).first()
1
SELECT id, email, name FROM users WHERE id = 12345;
데이터 전송량이 10KB에서 100바이트로 감소합니다.
10장: 캐싱 전략과 Cache Stampede 방지
캐싱은 데이터베이스 부하를 줄이는 가장 효과적인 방법 중 하나입니다. 하지만 잘못 구현하면 오히려 문제를 야기할 수 있습니다.
기본 캐싱 패턴:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
async def get_user(user_id: int):
# 캐시 확인
cache_key = f"user:{user_id}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# 캐시 미스: 데이터베이스에서 가져오기
user = await db.fetchrow("SELECT * FROM users WHERE id = $1", user_id)
# 캐시에 저장
await redis.setex(cache_key, 3600, json.dumps(user))
return user
문제: Cache Stampede
인기 있는 데이터(예: 유명인의 사용자 정보)의 캐시가 만료되면, 동시에 수천 개의 요청이 캐시 미스를 경험하고 모두 데이터베이스에 접근합니다.
시나리오:
- 10:00:00 - 캐시 만료 (TTL 1시간)
- 10:00:00 - 동시에 1000개의 요청이 이 데이터 필요
- 10:00:00 - 1000개 요청 모두 캐시 미스
- 10:00:00 - 1000개 요청 모두 데이터베이스 쿼리
- 데이터베이스 과부하
해결책: Cache Locking
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
async def get_user(user_id: int):
cache_key = f"user:{user_id}"
lock_key = f"lock:user:{user_id}"
# 캐시 확인
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# 락 획득 시도
acquired = await redis.set(lock_key, "1", nx=True, ex=10)
if acquired:
# 이 요청만 데이터베이스에서 가져옴
try:
user = await db.fetchrow("SELECT * FROM users WHERE id = $1", user_id)
await redis.setex(cache_key, 3600, json.dumps(user))
return user
finally:
await redis.delete(lock_key)
else:
# 다른 요청이 이미 가져오는 중
# 짧은 지연 후 캐시 재확인
for _ in range(10):
await asyncio.sleep(0.1)
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# 타임아웃: 폴백
user = await db.fetchrow("SELECT * FROM users WHERE id = $1", user_id)
return user
이렇게 하면 동시에 1000개의 요청이 와도 단 1개만 데이터베이스에 접근합니다.
고급: Probabilistic Early Expiration
캐시가 정확히 만료 시점에 stampede가 발생하는 것을 방지하기 위해, 만료 전에 확률적으로 갱신:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import random
async def get_user(user_id: int):
cache_key = f"user:{user_id}"
cached_data = await redis.get(cache_key)
if cached_data:
data = json.loads(cached_data)
# TTL 확인
ttl = await redis.ttl(cache_key)
# 만료가 가까워지면 확률적으로 갱신
# TTL이 짧을수록 갱신 확률 증가
if ttl > 0:
refresh_probability = 1.0 - (ttl / 3600.0)
if random.random() < refresh_probability:
# 백그라운드에서 갱신
asyncio.create_task(refresh_user_cache(user_id))
return data
# 캐시 미스: 락과 함께 가져오기
return await fetch_and_cache_user(user_id)
async def refresh_user_cache(user_id: int):
user = await db.fetchrow("SELECT * FROM users WHERE id = $1", user_id)
await redis.setex(f"user:{user_id}", 3600, json.dumps(user))
이 방법은:
- Stampede 완전 방지
- 캐시 히트율 향상
- 지연시간 일정 유지
제5부: 연결 관리의 혁명 - PgBouncer
11장: PostgreSQL 연결 모델의 한계
PostgreSQL의 연결 모델은 높은 동시성 환경에서 문제가 됩니다.
Process-per-Connection 모델:
PostgreSQL은 각 클라이언트 연결에 대해 별도의 백엔드 프로세스를 fork합니다. 이는:
- 프로세스 생성 비용:
- fork() 시스템 콜
- 메모리 할당
- 연결 초기화
- 총 약 1-5ms
- 메모리 오버헤드:
- 각 백엔드: 5-10MB
- 1000개 연결: 5-10GB 메모리
- 컨텍스트 스위칭:
- 수천 개의 프로세스 → OS 스케줄링 오버헤드
- CPU 캐시 효율성 저하
연결 제한:
Azure PostgreSQL Flexible Server는 인스턴스 크기에 따라 연결 수를 제한합니다:
- 2 vCores: 859 connections
- 4 vCores: 1719 connections
- 8 vCores: 3438 connections
- 32 vCores: 5000 connections (최대)
OpenAI는 32 vCore 인스턴스를 사용하므로 최대 5000개 연결입니다.
문제 시나리오:
OpenAI는 수백 개의 애플리케이션 서버를 운영합니다. 각 서버가 10개의 연결 풀을 유지한다면:
- 100 서버 * 10 연결 = 1000 연결 (OK)
- 200 서버 * 10 연결 = 2000 연결 (OK)
- 500 서버 * 10 연결 = 5000 연결 (한계!)
트래픽이 증가하여 서버를 더 추가해야 하는데, 연결 제한 때문에 불가능합니다.
또한 연결 풀링 없이 각 요청이 새 연결을 만든다면:
- 초당 10,000 요청
- 각 요청 1개 연결
- 평균 요청 시간 100ms
- 동시 연결 수: 10,000 * 0.1 = 1000개
하지만 피크 시간에는:
- 초당 100,000 요청
- 동시 연결 수: 10,000개 (한계 초과!)
12장: PgBouncer - 연결 풀링의 마법
PgBouncer는 경량 연결 풀러로, PostgreSQL과 애플리케이션 사이에 위치합니다.
작동 원리:
애플리케이션은 PgBouncer에 연결하고, PgBouncer는 PostgreSQL에 대한 연결 풀을 유지합니다.
1
2
3
4
5
[App Server 1] ---\
[App Server 2] ----\
[App Server 3] -----+---> [PgBouncer] ---> [PostgreSQL]
[App Server 4] ----/ (50 connections) (20 connections)
[App Server 5] ---/
수백 개의 애플리케이션 연결이 수십 개의 데이터베이스 연결로 집약됩니다.
풀링 모드:
PgBouncer는 3가지 풀링 모드를 제공합니다:
1. Session Pooling:
- 클라이언트 연결이 끊어질 때까지 서버 연결 유지
- 가장 안전하지만 효율성 낮음
- PostgreSQL의 모든 기능 사용 가능
2. Transaction Pooling (OpenAI 사용):
- 트랜잭션이 끝나면 서버 연결을 풀로 반환
- 매우 효율적
- 일부 제약: Prepared statements, LISTEN/NOTIFY 제한적
3. Statement Pooling:
- 각 쿼리 후 서버 연결 반환
- 가장 효율적이지만 제약 많음
- 트랜잭션 사용 불가
OpenAI는 Transaction Pooling을 선택했습니다. 대부분의 트랜잭션이 짧기 때문에(< 10ms) 매우 효율적입니다.
설정 예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[databases]
chatgpt_primary = host=primary.postgres.database.azure.com port=5432 dbname=chatgpt
chatgpt_replica1 = host=replica1.postgres.database.azure.com port=5432 dbname=chatgpt
chatgpt_replica2 = host=replica2.postgres.database.azure.com port=5432 dbname=chatgpt
[pgbouncer]
listen_addr = *
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 20
min_pool_size = 10
reserve_pool_size = 5
reserve_pool_timeout = 5
server_idle_timeout = 600
server_lifetime = 3600
server_connect_timeout = 15
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
핵심 파라미터:
pool_mode = transaction: 트랜잭션 풀링default_pool_size = 20: 각 데이터베이스당 20개 서버 연결 유지max_client_conn = 10000: 최대 10,000개 클라이언트 연결 허용reserve_pool_size = 5: 예비 연결 풀
성능 개선:
도입 전:
- 평균 연결 시간: 50ms
- P99 쿼리 레이턴시: 100ms
- 활성 연결: 4000-5000 (한계 근접)
도입 후:
- 평균 연결 시간: 5ms (10배 개선!)
- P99 쿼리 레이턴시: 20ms (5배 개선!)
- 활성 PostgreSQL 연결: 100-200 (20배 감소!)
- 클라이언트 연결: 무제한 (PgBouncer가 관리)
왜 이렇게 빨라졌나?:
- 연결 재사용: 새 연결 생성 제거
- 로컬 연결: PgBouncer를 같은 리전에 배치하여 네트워크 지연 최소화
- 오버헤드 제거: 연결 초기화, 인증, 설정 로드 등 반복 작업 제거
13장: PgBouncer 운영 노하우
Kubernetes 배치:
OpenAI는 PgBouncer를 Kubernetes에서 운영합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
apiVersion: apps/v1
kind: Deployment
metadata:
name: pgbouncer-replica1
namespace: database
spec:
replicas: 4
selector:
matchLabels:
app: pgbouncer
database: replica1
template:
metadata:
labels:
app: pgbouncer
database: replica1
spec:
containers:
- name: pgbouncer
image: pgbouncer/pgbouncer:1.21.0
ports:
- containerPort: 6432
name: pgbouncer
resources:
requests:
cpu: 500m
memory: 256Mi
limits:
cpu: 2000m
memory: 1Gi
volumeMounts:
- name: config
mountPath: /etc/pgbouncer
livenessProbe:
tcpSocket:
port: 6432
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
tcpSocket:
port: 6432
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: config
configMap:
name: pgbouncer-config
---
apiVersion: v1
kind: Service
metadata:
name: pgbouncer-replica1
namespace: database
spec:
selector:
app: pgbouncer
database: replica1
ports:
- port: 6432
targetPort: 6432
type: ClusterIP
각 복제본마다 별도의 PgBouncer 배포가 있습니다. 이렇게 하면:
- 복제본별 연결 관리
- 복제본 장애 시 격리
- 독립적인 확장
모니터링:
PgBouncer는 SHOW STATS 명령으로 통계를 제공합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- PgBouncer 통계
SHOW STATS;
-- 결과 예시
database | total_xact_count | total_query_count | total_received | total_sent | total_xact_time | total_query_time
-----------------+--------------------+---------------------+-----------------+------------------+-----------------+-----------------
chatgpt_replica1| 45000000 | 50000000 | 450000000000 | 500000000000 | 90000000 | 85000000
-- 풀 상태
SHOW POOLS;
database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | sv_tested | sv_login | maxwait
-----------------+----------+------------+------------+-----------+---------+---------+-----------+----------+--------
chatgpt_replica1| chatgpt | 150 | 0 | 18 | 2 | 0 | 0 | 0 | 0
주요 메트릭:
cl_active: 활성 클라이언트 연결cl_waiting: 대기 중인 클라이언트 연결sv_active: 활성 서버 연결sv_idle: 유휴 서버 연결maxwait: 최대 대기 시간
Prometheus exporter를 사용하여 이러한 메트릭을 수집하고 Grafana로 시각화합니다.
주의사항 - Transaction Pooling의 제약:
Transaction Pooling은 일부 PostgreSQL 기능을 제한합니다:
1. Prepared Statements:
Session-level prepared statements는 작동하지 않습니다:
1
2
3
4
5
6
# 작동하지 않음
cursor.execute("PREPARE stmt AS SELECT * FROM users WHERE id = $1")
cursor.execute("EXECUTE stmt", (12345,))
# 대안: 직접 쿼리
cursor.execute("SELECT * FROM users WHERE id = %s", (12345,))
PgBouncer 1.21+는 제한적인 prepared statement 지원을 제공하지만, 기본적으로는 비활성화됩니다.
2. LISTEN/NOTIFY:
실시간 알림은 작동하지 않습니다:
1
2
# 작동하지 않음
cursor.execute("LISTEN channel")
대안: Redis Pub/Sub 사용
3. 임시 테이블:
세션-레벨 임시 테이블은 트랜잭션 종료 시 사라집니다:
1
2
3
-- 작동하지 않음
CREATE TEMP TABLE temp_data AS SELECT * FROM ...;
-- 트랜잭션 종료 후 사라짐
대안: 일반 테이블 사용 후 삭제, 또는 CTE 사용
4. SET 명령:
트랜잭션 내 SET은 트랜잭션 종료 후 리셋됩니다:
1
2
3
4
5
-- 다음 트랜잭션에 영향 없음
BEGIN;
SET work_mem = '256MB';
SELECT ...;
COMMIT;
대안: 쿼리 레벨 힌트 사용 또는 사용자/데이터베이스 레벨 설정
대부분의 OLTP 애플리케이션은 이러한 기능을 사용하지 않으므로, Transaction Pooling의 제약이 문제가 되지 않습니다.
(문서 계속…)
작성 일자: 2026-01-24
제6부: 스키마 거버넌스와 안전한 변경
14장: 스키마 변경이 프로덕션을 무너뜨린 날
2023년 여름, OpenAI는 스키마 변경으로 인한 심각한 장애를 경험했습니다.
사건 경과:
엔지니어가 messages 테이블에 새 컬럼을 추가하려고 했습니다:
1
ALTER TABLE messages ADD COLUMN embedding_version INT;
테스트 환경에서는 즉시 완료되었습니다(테스트 데이터 1만 행). 프로덕션에 배포했습니다.
프로덕션 messages 테이블: 100억 행
타임라인:
14:00 - ALTER TABLE 실행 14:01 - 명령이 반환되지 않음, 계속 실행 중 14:05 - 모든 INSERT, UPDATE가 차단됨 (ACCESS EXCLUSIVE lock) 14:10 - 새 메시지를 저장할 수 없음, ChatGPT 응답 실패 시작 14:15 - 완전 서비스 중단, Sev0 선언 14:20 - ALTER TABLE 강제 종료 결정 14:25 - 서비스 복구
근본 원인:
PostgreSQL 11 이전 버전에서 DEFAULT 값이 있는 컬럼 추가는 전체 테이블 재작성을 트리거합니다.
100억 행 테이블을 재작성하려면 수 시간이 걸립니다. 그동안 테이블은 ACCESS EXCLUSIVE lock으로 잠겨 모든 읽기/쓰기가 차단됩니다.
교훈 및 정책 수립:
이 사건 후 OpenAI는 엄격한 스키마 거버넌스 정책을 수립했습니다.
15장: 안전한 스키마 변경 가이드라인
허용되는 변경:
- NULL 허용 컬럼 추가 (DEFAULT 없이): ```sql – 안전: 즉시 완료 ALTER TABLE messages ADD COLUMN embedding_version INT;
– 나중에 backfill UPDATE messages SET embedding_version = 1 WHERE embedding_version IS NULL LIMIT 1000;
1
2
3
4
5
6
7
2. **컬럼 삭제**:
```sql
-- 2단계 프로세스
-- 1단계: 애플리케이션 코드에서 컬럼 사용 중단
-- 2단계: 컬럼 삭제
ALTER TABLE messages DROP COLUMN old_field;
- 인덱스 생성/삭제 (CONCURRENTLY): ```sql – 안전: 다른 작업 차단 안 함 CREATE INDEX CONCURRENTLY idx_messages_conversation_id ON messages(conversation_id);
– 삭제도 CONCURRENTLY DROP INDEX CONCURRENTLY idx_old_index;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
**금지되는 변경**:
1. **새 테이블 생성**:
- PostgreSQL에 새 테이블 추가 금지
- 새 기능은 Cosmos DB 등 다른 저장소 사용
2. **테이블 재작성 트리거 변경**:
- 컬럼 타입 변경
- DEFAULT 값 있는 컬럼 추가 (PostgreSQL 10 이하)
- NOT NULL 제약 추가
3. **외래 키 추가**:
- 대형 테이블에 대한 외래 키 추가는 전체 스캔 필요
- 애플리케이션 레벨에서 무결성 검증
**5초 룰**:
모든 스키마 변경은 5초 이내에 완료되어야 합니다:
```python
# 자동화된 검증
def execute_ddl(sql: str):
conn = get_db_connection()
conn.execute(f"SET statement_timeout = '5s'")
try:
conn.execute(sql)
logger.info(f"DDL executed successfully: {sql}")
except OperationalError as e:
if 'statement timeout' in str(e):
logger.error(f"DDL exceeded 5s timeout: {sql}")
raise ValueError("DDL too slow, rejected")
raise
5초 이내에 완료되지 않으면 거부됩니다. 이는 장기 실행 변경이 프로덕션에 영향을 주는 것을 방지합니다.
16장: 안전한 마이그레이션 패턴
패턴 1: 컬럼 타입 변경
직접 변경 대신 새 컬럼 생성:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 1단계: 새 컬럼 추가
ALTER TABLE users ADD COLUMN age_new INT;
-- 2단계: 애플리케이션 배포 (dual write)
-- 기존 코드: UPDATE users SET age = '25' WHERE id = 1;
-- 새 코드: UPDATE users SET age = '25', age_new = 25 WHERE id = 1;
-- 3단계: 배치로 기존 데이터 마이그레이션
UPDATE users SET age_new = age::int WHERE age_new IS NULL LIMIT 1000;
-- 반복...
-- 4단계: 애플리케이션 배포 (age_new 읽기)
-- 5단계: 구 컬럼 삭제
ALTER TABLE users DROP COLUMN age;
-- 6단계: 리네임
ALTER TABLE users RENAME COLUMN age_new TO age;
패턴 2: NOT NULL 제약 추가
직접 추가하면 전체 스캔:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 위험: 전체 테이블 스캔
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- 안전: 체크 제약 사용
-- 1단계: 체크 제약 추가 (NOT VALID)
ALTER TABLE users ADD CONSTRAINT users_email_not_null
CHECK (email IS NOT NULL) NOT VALID;
-- 2단계: 기존 데이터 검증 (백그라운드)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;
-- 3단계: NOT NULL 변경
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- 4단계: 체크 제약 삭제
ALTER TABLE users DROP CONSTRAINT users_email_not_null;
NOT VALID 옵션은 기존 데이터를 검증하지 않고 제약을 추가합니다. VALIDATE CONSTRAINT는 백그라운드에서 검증하며, 다른 작업을 차단하지 않습니다.
제7부: 고급 확장 - Cascading Replication
17장: 복제본 확장의 물리적 한계
프라이머리는 각 복제본에 WAL을 스트리밍합니다. 복제본이 50개라면, 프라이머리는 50개의 WAL sender 프로세스를 실행합니다.
리소스 소비:
- 네트워크 대역폭: WAL 크기 * 복제본 수
- WAL 생성: 1GB/분
- 복제본: 50개
- 총 대역폭: 50GB/분 = 약 7Gbps
- CPU: 각 WAL sender 프로세스
- 50개 프로세스 * 각 5% CPU = 250% CPU (2.5 코어)
- 메모리: 각 WAL sender
- 50개 * 10MB = 500MB
확장 한계:
이론적으로는 계속 복제본을 추가할 수 있지만, 실제로는:
- 네트워크 대역폭 포화
- CPU 오버헤드 증가
- 복제 지연 증가
OpenAI는 약 50개에서 이러한 징후를 발견하기 시작했습니다.
18장: Cascading Replication 구조
기본 개념:
Cascading Replication은 복제본이 다른 복제본의 소스가 되는 계층적 구조입니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
프라이머리
├── 중간 복제본 A (Intermediate)
│ ├── 최종 복제본 A1
│ ├── 최종 복제본 A2
│ ├── 최종 복제본 A3
│ ├── 최종 복제본 A4
│ └── 최종 복제본 A5
├── 중간 복제본 B
│ ├── 최종 복제본 B1
│ ├── 최종 복제본 B2
│ ├── 최종 복제본 B3
│ ├── 최종 복제본 B4
│ └── 최종 복제본 B5
...
프라이머리는 5개의 중간 복제본에만 WAL을 전송하고, 각 중간 복제본은 5개의 최종 복제본에 WAL을 전송합니다.
총 복제본: 5 + (5 * 5) = 30개
프라이머리 부담은 5개만큼만 증가하지만, 총 30개의 복제본을 운영할 수 있습니다.
리소스 비교:
직접 복제 (50개):
- 프라이머리 네트워크: 50GB/분
- 프라이머리 CPU: 250% (2.5 코어)
Cascading (5 + 25):
- 프라이머리 네트워크: 5GB/분
- 프라이머리 CPU: 25% (0.25 코어)
- 중간 복제본 네트워크 (각): 5GB/분
- 중간 복제본 CPU (각): 25%
프라이머리 부담이 10배 감소합니다.
Azure PostgreSQL Cascading Replication:
Azure는 최근 Cascading Replication을 프리뷰로 제공하기 시작했습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
# 1단계: 프라이머리에서 중간 복제본 생성
az postgres flexible-server replica create \
--resource-group mygroup \
--name intermediate-replica-us-east-1 \
--source-server primary-server \
--location eastus
# 2단계: 중간 복제본에서 최종 복제본 생성
az postgres flexible-server replica create \
--resource-group mygroup \
--name final-replica-us-east-1a \
--source-server intermediate-replica-us-east-1 \
--location eastus
제약 사항:
- 계층 제한: 최대 2단계 (프라이머리 → 중간 → 최종)
- 분기 제한: 각 노드는 최대 5개의 하위 노드
- 버전 요구: PostgreSQL 14+
- 복제 지연 누적: 각 단계마다 지연 추가
19장: Cascading Replication 운영
복제 지연 관리:
Cascading에서는 지연이 누적됩니다:
- 프라이머리 → 중간 복제본: 50ms
- 중간 복제본 → 최종 복제본: 50ms
- 총 지연: 100ms
목표를 달성하려면 각 단계의 지연을 절반으로 줄여야 합니다.
모니터링:
각 단계의 지연을 개별적으로 모니터링:
1
2
3
4
5
6
7
8
9
-- 중간 복제본에서
SELECT
'intermediate' AS level,
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) AS lag_seconds
UNION ALL
-- 최종 복제본에서
SELECT
'final' AS level,
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) AS lag_seconds;
장애 조치 복잡성:
중간 복제본이 실패하면 하위 복제본도 영향을 받습니다:
시나리오: intermediate-replica-A 실패
- 영향받는 복제본: final-replica-A1~A5 (5개)
- 조치: 하위 복제본을 다른 중간 복제본 또는 프라이머리에 재연결
자동화된 장애 조치:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async def handle_intermediate_replica_failure(failed_replica: str):
# 영향받는 최종 복제본 찾기
affected = get_downstream_replicas(failed_replica)
# 대체 중간 복제본 선택
alternative = select_alternative_intermediate()
# 각 최종 복제본을 새 소스로 재연결
for replica in affected:
await reconnect_replica(
replica=replica,
new_source=alternative
)
# 복제 지연이 정상화될 때까지 대기
await wait_for_replication_sync(replica, max_lag_ms=100)
OpenAI의 롤아웃 계획:
OpenAI는 단계적으로 Cascading Replication을 도입하고 있습니다:
- Phase 1: 비중요 복제본에 테스트 (분석 워크로드)
- Phase 2: 일부 리전에 제한적 배포
- Phase 3: 장애 조치 시나리오 테스트
- Phase 4: 전체 배포
현재 Phase 2 진행 중이며, 안정성이 검증되면 Phase 3/4로 진행할 예정입니다.
제8부: 모니터링과 알람 - 문제의 조기 발견
20장: 핵심 메트릭 체계
효과적인 모니터링은 문제를 조기에 발견하고 빠르게 대응하는 데 필수적입니다.
1. 성능 메트릭:
QPS (Queries Per Second):
1
2
3
4
SELECT
datname,
xact_commit + xact_rollback as total_queries
FROM pg_stat_database;
트렌드 모니터링이 중요합니다. 갑작스러운 증가나 감소는 문제를 나타냅니다.
TPS (Transactions Per Second):
1
2
3
4
5
SELECT
datname,
xact_commit as commits,
xact_rollback as rollbacks
FROM pg_stat_database;
롤백 비율이 높으면 애플리케이션 문제를 나타냅니다.
쿼리 레이턴시 분포:
Prometheus + pg_stat_statements:
1
2
3
4
5
6
7
8
9
SELECT
query,
calls,
mean_exec_time,
stddev_exec_time,
max_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
P50, P95, P99를 추적합니다. P99가 급증하면 일부 쿼리가 느려진 것입니다.
연결 수:
1
2
3
4
5
6
SELECT
count(*) as total,
count(*) FILTER (WHERE state = 'active') as active,
count(*) FILTER (WHERE state = 'idle') as idle,
count(*) FILTER (WHERE state = 'idle in transaction') as idle_in_transaction
FROM pg_stat_activity;
idle in transaction이 많으면 애플리케이션이 트랜잭션을 제대로 닫지 않는 것입니다.
2. 데이터베이스 건강 메트릭:
캐시 히트율:
1
2
3
SELECT
sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) as cache_hit_ratio
FROM pg_statio_user_tables;
목표: >95%. 낮으면 메모리 부족 또는 워킹셋 증가를 나타냅니다.
Dead Tuples:
1
2
3
4
5
6
7
8
9
SELECT
schemaname,
tablename,
n_dead_tup,
n_live_tup,
n_dead_tup::float / NULLIF(n_live_tup, 0) as dead_ratio
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC;
dead_ratio > 0.2이면 Autovacuum이 따라잡지 못하는 것입니다.
블로트 측정:
복잡한 쿼리가 필요합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SELECT
tablename,
pg_size_pretty(table_bytes) AS table_size,
pg_size_pretty(bloat_bytes) AS bloat_size,
round(bloat_bytes::numeric / table_bytes::numeric * 100, 2) AS bloat_pct
FROM (
SELECT
tablename,
pg_total_relation_size(tablename::regclass) AS table_bytes,
(pg_total_relation_size(tablename::regclass) -
pg_relation_size(tablename::regclass)) AS bloat_bytes
FROM pg_tables
WHERE schemaname = 'public'
) AS bloat_data
WHERE bloat_bytes > 0
ORDER BY bloat_pct DESC;
Autovacuum 활동:
1
2
3
4
5
6
7
8
9
SELECT
schemaname,
tablename,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY last_autovacuum DESC NULLS LAST;
3. 복제 메트릭:
복제 지연:
1
2
3
4
5
6
7
8
-- 프라이머리에서
SELECT
client_addr,
state,
pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) as pending_bytes,
pg_wal_lsn_diff(pg_current_wal_lsn(), write_lsn) as write_lag_bytes,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) as replay_lag_bytes
FROM pg_stat_replication;
WAL 생성 속도:
1
2
3
4
SELECT
pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') /
EXTRACT(EPOCH FROM (now() - pg_postmaster_start_time())) as wal_bytes_per_second
FROM pg_stat_replication;
21장: Prometheus와 Grafana 구성
Postgres Exporter 설정:
1
2
3
4
5
6
7
8
9
# docker-compose.yml
version: '3'
services:
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
environment:
DATA_SOURCE_NAME: "postgresql://monitoring:password@postgres:5432/chatgpt?sslmode=require"
ports:
- "9187:9187"
Prometheus 설정:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'postgres-primary'
static_configs:
- targets: ['postgres-exporter-primary:9187']
labels:
instance: 'primary'
region: 'us-east-1'
- job_name: 'postgres-replicas'
static_configs:
- targets:
- 'postgres-exporter-replica1:9187'
- 'postgres-exporter-replica2:9187'
# ... 모든 복제본
labels:
instance: 'replica'
주요 쿼리:
# QPS
rate(pg_stat_database_xact_commit[1m]) + rate(pg_stat_database_xact_rollback[1m])
# 캐시 히트율
sum(rate(pg_stat_database_blks_hit[5m])) /
(sum(rate(pg_stat_database_blks_hit[5m])) + sum(rate(pg_stat_database_blks_read[5m])))
# 복제 지연 (초)
pg_replication_lag_seconds
# 활성 연결
pg_stat_activity_count{state="active"}
# Dead tuples (상위 10 테이블)
topk(10, pg_stat_user_tables_n_dead_tup)
Grafana 대시보드:
주요 패널:
- Overview: QPS, TPS, 연결 수, CPU/메모리
- Performance: P50/P95/P99 레이턴시, 느린 쿼리 Top 10
- Replication: 모든 복제본의 지연, WAL 생성 속도
- Health: 캐시 히트율, 블로트, Dead tuples, Autovacuum 활동
- Alerts: 현재 활성 알람
22장: 알람 정책과 온콜 대응
알람 레벨:
Critical (즉시 대응 필요):
- 프라이머리 다운
- 복제 지연 > 5초
- 캐시 히트율 < 80%
- 활성 연결 > 4500 (90% of 5000)
- 디스크 사용률 > 90%
- CPU > 90% (10분 이상)
Warning (곧 조치 필요):
- 복제 지연 > 1초
- 캐시 히트율 < 90%
- 활성 연결 > 4000
- 디스크 사용률 > 80%
- CPU > 70% (30분 이상)
- 블로트 > 30%
Info (모니터링):
- 복제 지연 > 500ms
- 장기 실행 쿼리 > 10초
- Autovacuum 실행 시간 > 1시간
Prometheus Alertmanager 규칙:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# alerts.yml
groups:
- name: postgres_critical
interval: 30s
rules:
- alert: PostgreSQLDown
expr: up{job="postgres-primary"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "PostgreSQL primary is down"
description: "Primary instance has been down for more than 1 minute"
- alert: HighReplicationLag
expr: pg_replication_lag_seconds > 5
for: 2m
labels:
severity: critical
annotations:
summary: "High replication lag"
description: "Replica has lag s"
- alert: LowCacheHitRatio
expr: |
sum(rate(pg_stat_database_blks_hit[5m])) /
(sum(rate(pg_stat_database_blks_hit[5m])) + sum(rate(pg_stat_database_blks_read[5m]))) < 0.8
for: 10m
labels:
severity: critical
annotations:
summary: "Low cache hit ratio"
description: "Cache hit ratio is , below 80%"
- alert: ConnectionPoolExhaustion
expr: pg_stat_activity_count > 4500
for: 5m
labels:
severity: critical
annotations:
summary: "Connection pool near exhaustion"
description: " connections active, limit is 5000"
온콜 런북(Runbook):
각 알람에 대한 표준 대응 절차:
HighReplicationLag:
- 영향 확인: 어느 복제본인가? 지연이 얼마나 되는가?
- 프라이머리 확인: WAL 생성 속도가 비정상적으로 높은가?
- 복제본 확인: CPU/IO가 포화 상태인가?
- 네트워크 확인: 대역폭이 부족한가?
- 임시 조치:
- 복제본에서 장기 실행 쿼리 종료
- 필요시 복제본 재시작
- 근본 원인 해결:
- 쓰기 부하 감소
- 복제본 인스턴스 크기 증가
- 네트워크 업그레이드
ConnectionPoolExhaustion:
- 현재 연결 확인:
1
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
- idle in transaction 확인 및 종료:
1 2 3 4
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction' AND state_change < now() - interval '5 minutes';
- 애플리케이션 확인: 어떤 서비스가 연결을 많이 사용하는가?
- PgBouncer 확인: 제대로 작동하는가?
- 근본 원인 해결:
- 애플리케이션 연결 누수 수정
- PgBouncer 풀 크기 조정
제9부: 재해 복구와 고가용성
23장: Azure HA 모드와 자동 장애 조치
HA 아키텍처:
Azure Database for PostgreSQL의 HA 모드는 Zone-Redundant HA를 제공합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
Primary Zone 1 Standby Zone 2
┌─────────────┐ ┌─────────────┐
│ Primary │◄──────►│ Standby │
│ Instance │ sync │ Instance │
│ │ repli │ │
└─────────────┘ cation └─────────────┘
│ │
└───────────┬───────────┘
│
┌──────▼──────┐
│ Shared │
│ Storage │
└─────────────┘
동기 복제:
프라이머리의 모든 쓰기는 스탠바이에 동기적으로 복제됩니다:
1
2
3
4
5
6
7
8
9
10
11
-- 프라이머리에서 확인
SELECT
application_name,
sync_state,
sync_priority
FROM pg_stat_replication;
-- 결과
application_name | sync_state | sync_priority
------------------+------------+---------------
standby | sync | 1
sync_state = 'sync'는 동기 복제를 의미합니다. 트랜잭션은 스탠바이가 WAL을 받을 때까지 커밋되지 않습니다.
장애 조치 프로세스:
프라이머리 장애 감지:
- Azure 헬스 체크 실패 (30초마다)
- 3회 연속 실패 → 장애 확인
- 스탠바이를 새 프라이머리로 승격
- DNS 업데이트
- 애플리케이션 재연결
전체 프로세스: 60-120초
데이터 손실:
동기 복제 덕분에 데이터 손실은 0입니다. 커밋된 모든 트랜잭션은 스탠바이에 있습니다.
24장: 백업 전략과 PITR
자동 백업:
Azure는 자동으로 백업을 수행합니다:
- 전체 백업: 매일
- 차등 백업: 매 시간
- 트랜잭션 로그: 5분마다
보존 기간:
기본: 7일 최대: 35일
OpenAI 설정: 35일
PITR (Point-In-Time Recovery):
특정 시점으로 복구할 수 있습니다:
1
2
3
4
5
6
# 2026년 1월 24일 10:30 상태로 복구
az postgres flexible-server restore \
--resource-group mygroup \
--name restored-server \
--source-server primary-server \
--restore-time "2026-01-24T10:30:00Z"
사용 사례:
- 실수로 삭제한 데이터 복구:
- 10:00 - 중요 테이블 DROP
- 10:05 - 발견
- 조치: 09:55로 PITR, 데이터 추출, 프로덕션에 복원
- 손상된 데이터 복구:
- 버그로 인해 잘못된 데이터 쓰기
- 버그 발견 전 시점으로 복구
- 테스트 환경 생성:
- 프로덕션 데이터의 스냅샷으로 테스트 환경 생성
크로스 리전 백업:
주 리전 재해에 대비하여 백업을 다른 리전에 복제:
1
2
3
4
5
az postgres flexible-server backup create \
--resource-group mygroup \
--server-name primary-server \
--backup-name manual-backup-20260124 \
--region-name westus
25장: 재해 복구 훈련
분기별 DR 훈련:
OpenAI는 분기마다 재해 복구 훈련을 실시합니다.
시나리오 1: 프라이머리 리전 완전 장애
가정: US East 1 리전 전체 장애
절차:
- 다른 리전의 복제본을 새 프라이머리로 승격
- 모든 애플리케이션을 새 프라이머리로 재연결
- 나머지 복제본을 새 프라이머리에 연결
- 서비스 복구 확인
목표 RTO: 30분 실제 달성: 25분
시나리오 2: 데이터 손상
가정: 애플리케이션 버그로 대량의 잘못된 데이터 쓰기
절차:
- 손상 범위 확인 (어떤 테이블? 언제부터?)
- 손상 전 시점 결정
- PITR로 새 인스턴스 생성
- 영향받은 데이터 추출
- 프로덕션에 복원
- 데이터 무결성 검증
목표 RTO: 2시간 실제 달성: 1.5시간
시나리오 3: 완전한 데이터 손실
가정: 악의적인 공격으로 모든 데이터 삭제
절차:
- 최신 백업 식별
- 크로스 리전 백업에서 복구
- 트랜잭션 로그 재생
- 데이터 무결성 검증
- 서비스 복구
목표 RPO: 5분 (마지막 트랜잭션 로그 백업 이후) 목표 RTO: 4시간
제10부: 성과와 결과
26장: 정량적 성과 분석
처리량:
최적화 전 (2023년 초):
- QPS: 약 100,000
- TPS: 약 10,000
- 복제본: 5개
최적화 후 (2026년 1월):
- QPS: 수백만 (정확한 수치는 비공개, 하지만 100배 이상 증가)
- TPS: 수십만
- 복제본: 약 50개
지연시간:
최적화 전:
- P50: 20ms
- P95: 100ms
- P99: 500ms
최적화 후:
- P50: 2ms
- P95: 5ms
- P99: 10ms
PgBouncer 도입만으로도:
- 연결 시간: 50ms → 5ms (10배 개선)
안정성:
2023년 (최적화 전):
- Sev0 사고: 12건
- Sev1 사고: 47건
- 평균 가용성: 99.9% (월 약 45분 다운타임)
2025년 (최적화 후):
- Sev0 사고: 1건 (ChatGPT ImageGen 출시 시)
- Sev1 사고: 3건
- 평균 가용성: 99.999% (월 약 26초 다운타임)
비용 효율성:
단위 QPS당 비용:
- 최적화 전: $X
- 최적화 후: $X/10 (10배 개선)
샤딩을 했다면 예상 비용: $X/5 (5배 개선)
샤딩 대비 장점:
- 더 낮은 비용
- 훨씬 낮은 운영 복잡성
- 더 빠른 기능 개발
27장: 비기술적 성과
팀 생산성:
단순한 아키텍처 덕분에:
- 새 엔지니어 온보딩: 2주 → 3일
- 기능 개발 속도: 2배 향상
- 버그 수정 시간: 50% 감소
운영 부담:
관리형 서비스 사용으로:
- 인프라 관리 시간: 주 40시간 → 주 5시간
- 온콜 부담: 주 7일 → 주 1일
- 작은 팀으로 대규모 시스템 운영 (5명이 8억 사용자 지원)
위험 감소:
샤딩을 피함으로써:
- 마이그레이션 리스크 회피
- 복잡성으로 인한 버그 감소
- 일관된 성능 보장
제11부: 핵심 교훈과 베스트 프랙티스
28장: 불변의 원칙
1. 워크로드를 깊이 이해하라
OpenAI의 성공은 워크로드가 극도로 읽기 중심이라는 점에 기반합니다. 만약 쓰기 중심이었다면, 완전히 다른 전략이 필요했을 것입니다.
자신의 워크로드를 정확히 측정하세요:
- 읽기/쓰기 비율
- 쿼리 패턴
- 데이터 크기 및 성장률
- 지연시간 요구사항
- 일관성 요구사항
2. 측정하고, 최적화하고, 반복하라
추측하지 마세요. 데이터를 수집하고 분석하세요.
OpenAI의 접근:
- 병목 측정 (pg_stat_statements, EXPLAIN ANALYZE)
- 가설 수립 (이 쿼리를 최적화하면 X% 개선)
- 변경 구현 (인덱스 추가, 쿼리 재작성)
- 결과 측정 (실제로 개선되었나?)
- 반복
3. 단순함을 선호하라
복잡성은 비용입니다:
- 개발 시간
- 운영 부담
- 버그 가능성
- 팀 온보딩
- 장애 복구
가능한 한 단순하게 유지하세요. 복잡한 해결책은 정말 필요할 때만 도입하세요.
4. 점진적 개선이 혁명적 재설계를 이긴다
“빅뱅” 마이그레이션은 위험합니다. 대신:
- 작은 변경을 자주 배포
- 각 변경의 영향을 측정
- 문제가 있으면 빠르게 롤백
- 학습하고 다음 단계로
5. 적재적소에 적절한 도구
PostgreSQL이 모든 문제를 해결하지는 않습니다:
- 쓰기 중심 → Cosmos DB
- 캐싱 → Redis
- 파일 → 객체 스토리지
- 분석 → 데이터 웨어하우스
각 도구를 강점에 맞게 사용하세요.
29장: 조직 규모별 가이드
스타트업 (< 100K 사용자)
이 단계에서:
- 단일 PostgreSQL 인스턴스로 시작
- 관리형 서비스 사용 (Azure, AWS RDS)
- 기본 모니터링만
- 최적화는 필요할 때만
하지 말아야 할 것:
- 샤딩 (너무 이름)
- 복잡한 캐싱 전략
- 과도한 최적화
성장하는 스타트업 (100K - 1M 사용자)
이 단계에서:
- 읽기 복제본 1-2개 추가
- Redis 캐싱 도입
- 기본 쿼리 최적화
- 모니터링 강화
고려 사항:
- PgBouncer 도입 고려
- 읽기/쓰기 분리 시작
중간 규모 (1M - 10M 사용자)
이 단계에서:
- 읽기 복제본 5-10개
- PgBouncer 필수
- 쿼리 최적화 팀 구성
- 워크로드별 복제본 격리
고려 사항:
- 일부 워크로드 다른 DB로 마이그레이션
- 전담 데이터베이스 팀
대규모 (10M+ 사용자)
이 단계에서:
- 수십 개의 복제본
- 폴리글랏 퍼시스턴스
- 고급 모니터링 및 자동화
- 재해 복구 계획
고려 사항:
- 샤딩 (필요시)
- 전문 데이터베이스 엔지니어링 조직
30장: 피해야 할 함정
과도한 최적화
“미래에 필요할 수 있어”는 나쁜 이유입니다. YAGNI (You Aren’t Gonna Need It)를 기억하세요.
예:
- 샤딩 (실제로 필요하기 전까지 기다리세요)
- 복잡한 캐싱 전략 (간단하게 시작하세요)
- 커스텀 데이터베이스 (표준 PostgreSQL로 충분할 수 있습니다)
측정 없는 최적화
“내 생각에 이 쿼리가 느릴 것 같아”는 위험합니다. 실제로 측정하세요.
도구:
- EXPLAIN ANALYZE
- pg_stat_statements
- 프로덕션 메트릭
모니터링 무시
“잘 작동하고 있으니 모니터링은 나중에”는 재앙의 레시피입니다.
일찍 시작하세요:
- 기본 메트릭 (QPS, 레이턴시, 에러율)
- 알람 설정
- 대시보드 구축
문서화 소홀
6개월 후에는 아무도 기억하지 못합니다:
- 왜 이 인덱스를 추가했나?
- 왜 이 설정을 변경했나?
- 이 쿼리가 왜 이렇게 작성되었나?
모든 중요한 결정을 문서화하세요.
보안 경시
“우리는 작으니까 괜찮아”는 위험합니다.
처음부터:
- 최소 권한 원칙
- 암호화 (전송 중, 저장 시)
- 정기 보안 검토
- 감사 로깅
제12부: 미래와 결론
31장: 다음 단계와 미래 계획
OpenAI는 PostgreSQL로 8억 사용자를 지원하지만, 여정은 끝나지 않았습니다.
단기 계획 (6-12개월):
- Cascading Replication 완전 배포
- 100+ 복제본으로 확장
- 자동 장애 조치 안정화
- 추가 워크로드 마이그레이션
- 나머지 쓰기 중심 워크로드를 Cosmos DB로
- PostgreSQL을 순수 읽기 중심으로
- 쿼리 최적화 계속
- AI/ML로 자동 인덱스 추천
- 쿼리 패턴 학습 및 캐싱
중기 계획 (1-2년):
- Elastic Clusters 평가
- Azure의 Elastic Clusters 기능 테스트
- 일부 워크로드에 샤딩 적용 가능성 평가
- 글로벌 분산 강화
- 더 많은 리전에 복제본
- 지역별 쓰기 지원 고려
- 비용 최적화
- 워크로드별 인스턴스 크기 조정
- 예약 인스턴스 활용 확대
장기 비전 (2+ 년):
- 완전 폴리글랏 아키텍처
- 각 워크로드에 최적의 데이터베이스
- 통합 데이터 플랫폼
- 자동화 확대
- 자동 스케일링
- 자동 최적화
- 자동 복구
- AI 네이티브 데이터베이스
- 벡터 검색 통합
- 임베딩 저장 최적화
- AI 워크로드에 특화
32장: 최종 결론
OpenAI의 PostgreSQL 스케일링 여정은 우리에게 중요한 교훈을 줍니다.
대규모는 복잡할 필요가 없다: 8억 사용자를 지원하는 데 특별한 기술이나 복잡한 시스템이 필요하지 않았습니다. 표준 PostgreSQL, PgBouncer, Redis - 모두 널리 사용되는 검증된 도구입니다.
기본을 올바르게: 특별한 것은 이러한 도구를 어떻게 사용했느냐입니다. 기본 원칙 - 읽기/쓰기 분리, 적절한 인덱싱, 연결 풀링, 쿼리 최적화 - 을 철저히 적용했습니다.
측정이 추측을 이긴다: 모든 중요한 결정은 데이터에 기반했습니다. 병목을 측정하고, 가설을 세우고, 변경을 구현하고, 결과를 측정하고, 반복했습니다.
단순함이 확장 가능하다: 복잡성은 비용입니다. 단순한 아키텍처는 이해하기 쉽고, 디버그하기 쉽고, 운영하기 쉽습니다. OpenAI는 복잡성을 피할 수 있는 한 피했고, 이것이 그들의 성공에 핵심이었습니다.
적재적소: PostgreSQL이 모든 것을 해결하지는 않습니다. 쓰기 중심 워크로드는 Cosmos DB로, 캐싱은 Redis로, 파일은 객체 스토리지로 이동했습니다. 각 도구를 강점에 맞게 사용했습니다.
점진적 개선: “빅뱅” 재설계 대신, 작은 변경을 계속 축적했습니다. 각 변경은 측정 가능했고, 필요시 롤백 가능했으며, 학습 기회를 제공했습니다.
이러한 원칙들은 PostgreSQL에만 적용되는 것이 아닙니다. 어떤 기술 스택에든 적용할 수 있습니다. 규모는 기술의 문제가 아니라 엔지니어링의 문제입니다.
OpenAI의 여정은 계속됩니다. 10억, 20억 사용자를 향해. 하지만 그들이 배운 교훈 - 단순함, 측정, 점진적 개선 - 은 변하지 않을 것입니다.
여러분의 여정에도 이 교훈들이 도움이 되기를 바랍니다.
작성 일자: 2026-01-24
참고 자료:
- OpenAI Blog: “Scaling PostgreSQL to power 800 million ChatGPT users” (2026년 1월)
- Microsoft Azure Blog: “How OpenAI scaled with Azure Database for PostgreSQL” (2025년 10월)
- POSETTE 2025: Bohan Zhang의 발표 “Scaling Postgres to the next level at OpenAI”
- PostgreSQL 공식 문서
- Azure PostgreSQL 문서
- 커뮤니티 논의 (Hacker News, DEV Community, Reddit)
감사의 말:
- OpenAI 데이터베이스 팀의 투명한 지식 공유
- Microsoft Azure PostgreSQL 팀의 지속적인 협업
- PostgreSQL 커뮤니티의 수십 년간의 기여
이 문서가 여러분의 데이터베이스 스케일링 여정에 도움이 되기를 바랍니다.
부록 A: 아키텍처 다이어그램 상세 설명
다이어그램 1: 부하 하의 악순환 (The Vicious Cycle Under Load)
flowchart TD
subgraph triggers["트리거 이벤트"]
A1[캐시 레이어 장애<br/>Cache Layer Failure]
A2[비싼 쿼리 급증<br/>Expensive Queries]
A3[쓰기 폭풍<br/>Write Spike]
end
B[PostgreSQL 과부하<br/>PostgreSQL Overload<br/>- CPU 포화<br/>- 디스크 I/O 병목<br/>- 메모리 압박]
C[요청 지연 및 타임아웃<br/>Requests Slow or Time Out<br/>- 평균 5ms → 5초+<br/>- 타임아웃 증가]
D[재시도 폭풍<br/>Retry Storm<br/>- 실패한 요청들이 재시도<br/>- 부하 60%+ 증가]
E[더 많은 요청으로<br/>부하 증가<br/>More Requests<br/>Increase Load]
A1 --> B
A2 --> B
A3 --> B
B --> C
C --> D
D --> E
E -.피드백 루프<br/>Feedback Loop.-> B
style triggers fill:#ffe6e6
style B fill:#ffcccc
style C fill:#ffb3b3
style D fill:#ff9999
style E fill:#ff8080
classDef warningBox fill:#ff6b6b,stroke:#c92a2a,stroke-width:3px,color:#fff
class E warningBox
이 다이어그램은 OpenAI가 경험한 시스템 장애의 전형적인 패턴을 보여줍니다.
트리거 이벤트들:
캐시 레이어 장애 (Cache Layer Failure): Redis 클러스터의 노드 장애나 네트워크 파티션으로 인해 갑자기 캐시 미스율이 급증합니다. 평소 99%의 요청을 처리하던 캐시가 실패하면, 모든 트래픽이 PostgreSQL로 향합니다.
비싼 쿼리들 (Expensive Queries): 12개 테이블을 조인하는 쿼리나 전체 테이블 스캔을 하는 쿼리가 갑자기 많이 실행되면, CPU를 순식간에 포화시킬 수 있습니다.
쓰기 폭풍 (Write Spike): 새 기능 출시나 대량 사용자 가입으로 인해 쓰기 작업이 10배 증가하면, MVCC의 특성상 읽기 성능에도 영향을 줍니다.
연쇄 반응:
이러한 트리거는 다음과 같은 악순환을 일으킵니다:
PostgreSQL 과부하 → CPU/IO가 포화 상태에 도달하면 모든 쿼리가 느려집니다.
요청이 느려지거나 타임아웃 → 평소 5ms에 완료되던 쿼리가 수 초 이상 걸리며, 타임아웃이 발생하기 시작합니다.
재시도로 인한 추가 요청 → 실패한 요청은 재시도되고, 이는 시스템 부하를 더욱 증가시킵니다.
더 많은 요청으로 부하 증가 (피드백 루프) → 재시도가 더 많은 타임아웃을 야기하고, 더 많은 재시도를 발생시키는 죽음의 소용돌이가 됩니다.
방어 메커니즘:
이 악순환을 끊기 위해 OpenAI가 구현한 것들:
- Rate Limiting: 애플리케이션, 연결, 쿼리 레벨에서 속도 제한
- Circuit Breaker: 일정 실패율 이상이면 요청을 차단
- Exponential Backoff: 재시도 시 지수적으로 증가하는 대기 시간
- Cache Locking: 여러 요청이 동시에 캐시 미스를 경험해도 하나만 DB 접근
- Workload Isolation: 중요한 요청과 덜 중요한 요청을 별도 인스턴스로 격리
다이어그램 2: Kubernetes의 PgBouncer 배포
flowchart TD
Users[사용자 요청<br/>User Requests]
subgraph k8s["Kubernetes Cluster"]
Service[Kubernetes Service<br/>로드 밸런서]
subgraph deploy1["Deployment: pgbouncer-replica1"]
Pod1A[PgBouncer Pod 1<br/>10K client conn<br/>20 DB conn]
Pod1B[PgBouncer Pod 2<br/>10K client conn<br/>20 DB conn]
Pod1C[PgBouncer Pod 3<br/>10K client conn<br/>20 DB conn]
Pod1D[PgBouncer Pod 4<br/>10K client conn<br/>20 DB conn]
end
subgraph deploy2["Deployment: pgbouncer-replica2"]
Pod2A[PgBouncer Pod 1]
Pod2B[PgBouncer Pod 2]
Pod2C[PgBouncer Pod 3]
Pod2D[PgBouncer Pod 4]
end
end
Replica1[(Read Replica 1<br/>PostgreSQL<br/>US East)]
Replica2[(Read Replica 2<br/>PostgreSQL<br/>US West)]
Users --> Service
Service --> Pod1A
Service --> Pod1B
Service --> Pod1C
Service --> Pod1D
Service --> Pod2A
Service --> Pod2B
Service --> Pod2C
Service --> Pod2D
Pod1A --> Replica1
Pod1B --> Replica1
Pod1C --> Replica1
Pod1D --> Replica1
Pod2A --> Replica2
Pod2B --> Replica2
Pod2C --> Replica2
Pod2D --> Replica2
style Users fill:#e3f2fd
style Service fill:#90caf9
style deploy1 fill:#fff3e0
style deploy2 fill:#fff3e0
style Replica1 fill:#a5d6a7
style Replica2 fill:#a5d6a7
이 다이어그램은 OpenAI가 PgBouncer를 Kubernetes에서 어떻게 배포하는지 보여줍니다.
아키텍처 계층:
사용자 요청 → 애플리케이션의 시작점
Kubernetes 클러스터 → 전체 시스템이 Kubernetes에서 실행됩니다.
Kubernetes Service → 로드 밸런서 역할을 합니다. 들어오는 요청을 여러 PgBouncer 파드에 분산합니다. 이는 고가용성과 부하 분산을 제공합니다.
Kubernetes Deployment (여러 개) → 각 배포는 특정 PostgreSQL 인스턴스(주로 복제본)를 위한 것입니다. 예를 들어:
pgbouncer-replica1-deployment: replica1을 위한 PgBouncer 파드들pgbouncer-replica2-deployment: replica2를 위한 PgBouncer 파드들
PgBouncer Pod (각 배포당 여러 개) → 실제 연결 풀링을 수행하는 PgBouncer 인스턴스입니다. 각 파드는:
- 클라이언트 연결을 받아들입니다 (수천 개)
- PostgreSQL에 대한 연결 풀을 유지합니다 (수십 개)
- 트랜잭션 풀링 모드로 작동합니다
Read Replica (하단) → 최종 목적지인 PostgreSQL 읽기 복제본입니다.
왜 이런 구조인가?:
- 확장성: PgBouncer 파드를 수평으로 확장하여 더 많은 클라이언트 연결 처리 가능
- 고가용성: 파드 하나가 실패해도 Kubernetes가 자동으로 재시작하고, 다른 파드들이 계속 서비스 제공
- 리소스 격리: 각 복제본마다 별도의 PgBouncer 배포로 문제 격리
- 유연한 배포: Kubernetes의 롤링 업데이트로 무중단 배포
실제 설정 예시:
- 각 배포: 4-8개 파드
- 각 파드: 10,000개 클라이언트 연결, 20개 DB 연결
- 총 클라이언트 연결 처리 능력: 40,000-80,000개
- 실제 DB 연결: 80-160개만 사용
다이어그램 3: PostgreSQL Cascading Replication
flowchart TD
Primary[(Primary<br/>모든 쓰기 처리<br/>WAL 생성)]
subgraph intermediate["중간 복제본 계층 (Intermediate Replicas)"]
Int1[(Intermediate<br/>Replica 1<br/>US East)]
Int2[(Intermediate<br/>Replica 2<br/>US West)]
Int3[(Intermediate<br/>Replica 3<br/>Europe)]
Int4[(Intermediate<br/>Replica 4<br/>Asia)]
Int5[(Intermediate<br/>Replica 5)]
end
subgraph final1["US East 최종 복제본"]
F1A[(Read<br/>Replica)]
F1B[(Read<br/>Replica)]
F1C[(Read<br/>Replica)]
F1D[(Read<br/>Replica)]
F1E[(Read<br/>Replica)]
end
subgraph final2["US West 최종 복제본"]
F2A[(Read<br/>Replica)]
F2B[(Read<br/>Replica)]
F2C[(Read<br/>Replica)]
F2D[(Read<br/>Replica)]
F2E[(Read<br/>Replica)]
end
subgraph final3["Europe 최종 복제본"]
F3A[(Read<br/>Replica)]
F3B[(Read<br/>Replica)]
F3C[(Read<br/>Replica)]
F3D[(...)]
end
subgraph final4["Asia 최종 복제본"]
F4A[(Read<br/>Replica)]
F4B[(Read<br/>Replica)]
F4C[(Read<br/>Replica)]
F4D[(...)]
end
Primary ==WAL==> Int1
Primary ==WAL==> Int2
Primary ==WAL==> Int3
Primary ==WAL==> Int4
Primary ==WAL==> Int5
Int1 --WAL--> F1A
Int1 --WAL--> F1B
Int1 --WAL--> F1C
Int1 --WAL--> F1D
Int1 --WAL--> F1E
Int2 --WAL--> F2A
Int2 --WAL--> F2B
Int2 --WAL--> F2C
Int2 --WAL--> F2D
Int2 --WAL--> F2E
Int3 --WAL--> F3A
Int3 --WAL--> F3B
Int3 --WAL--> F3C
Int3 --WAL--> F3D
Int4 --WAL--> F4A
Int4 --WAL--> F4B
Int4 --WAL--> F4C
Int4 --WAL--> F4D
style Primary fill:#ff6b6b,stroke:#c92a2a,stroke-width:4px,color:#fff
style intermediate fill:#ffe3a3
style Int1 fill:#ffd43b
style Int2 fill:#ffd43b
style Int3 fill:#ffd43b
style Int4 fill:#ffd43b
style Int5 fill:#ffd43b
style final1 fill:#d3f9d8
style final2 fill:#d3f9d8
style final3 fill:#d3f9d8
style final4 fill:#d3f9d8
이 다이어그램은 OpenAI가 100개 이상의 복제본으로 확장하기 위해 도입하는 Cascading Replication 구조를 보여줍니다.
계층 구조:
Primary (최상위) → 모든 쓰기를 처리하는 단일 프라이머리 인스턴스입니다.
WAL 스트리밍 → 프라이머리는 WAL(Write Ahead Log)을 생성하고 이를 중간 복제본들에게 스트리밍합니다.
Intermediate Replica (중간 계층) → 프라이머리로부터 WAL을 받아 재생하고, 동시에 하위 복제본들에게 다시 스트리밍합니다. 각 중간 복제본은:
- 프라이머리의 읽기 복제본 역할도 수행 (실제 쿼리 처리 가능)
- 최대 5개의 하위 복제본에게 WAL 중계
- 지리적으로 전략적 위치에 배치 (예: 각 리전에 하나)
Read Replica (최종 계층) → 실제 읽기 쿼리를 처리하는 복제본들입니다. 각각 중간 복제본으로부터 WAL을 받습니다.
확장성 계산:
직접 복제 방식:
- 프라이머리가 50개 복제본에 직접 연결
- 프라이머리 네트워크 부담: 50x
- 추가 복제본마다 프라이머리 부담 증가
Cascading 방식:
- 프라이머리는 5개 중간 복제본에만 연결
- 각 중간 복제본은 5개 최종 복제본에 연결
- 총 복제본: 5 + (5 × 5) = 30개
- 프라이머리 네트워크 부담: 5x만
- 2단계까지 확장 가능: 5 + 25 + 125 = 155개 복제본
트레이드오프:
장점:
- 프라이머리 부담 크게 감소
- 100개 이상의 복제본으로 확장 가능
- 지리적 분산 효율적 (각 리전에 중간 복제본 배치)
단점:
- 복제 지연 누적 (각 단계마다 지연 추가)
- 장애 조치 복잡성 증가 (중간 복제본 실패 시)
- 운영 복잡성 증가
실제 배치 전략:
OpenAI는 지리적 분산을 고려하여 배치합니다:
- US East: intermediate-us-east → 10개 최종 복제본
- US West: intermediate-us-west → 8개 최종 복제본
- Europe: intermediate-eu-west → 8개 최종 복제본
- Asia: intermediate-asia → 12개 최종 복제본
각 중간 복제본은 자신의 리전 내 최종 복제본들에게 WAL을 제공하여, 크로스-리전 트래픽을 최소화합니다.
부록 B: 실전 구현 코드 예제
예제 1: 읽기/쓰기 분리 라우터 (Python)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import random
from typing import List, Optional
from contextlib import asynccontextmanager
import asyncpg
class DatabaseRouter:
"""
PostgreSQL 읽기/쓰기 라우터
- 쓰기는 프라이머리로
- 읽기는 복제본으로 라운드 로빈
"""
def __init__(
self,
primary_dsn: str,
replica_dsns: List[str],
max_pool_size: int = 10
):
self.primary_dsn = primary_dsn
self.replica_dsns = replica_dsns
self.max_pool_size = max_pool_size
self.primary_pool: Optional[asyncpg.Pool] = None
self.replica_pools: List[asyncpg.Pool] = []
self.replica_index = 0
async def initialize(self):
"""연결 풀 초기화"""
# 프라이머리 풀
self.primary_pool = await asyncpg.create_pool(
self.primary_dsn,
min_size=5,
max_size=self.max_pool_size
)
# 복제본 풀들
for dsn in self.replica_dsns:
pool = await asyncpg.create_pool(
dsn,
min_size=5,
max_size=self.max_pool_size
)
self.replica_pools.append(pool)
@asynccontextmanager
async def write_connection(self):
"""쓰기용 연결 (프라이머리)"""
async with self.primary_pool.acquire() as conn:
yield conn
@asynccontextmanager
async def read_connection(self, use_primary: bool = False):
"""
읽기용 연결 (복제본 또는 프라이머리)
Args:
use_primary: True이면 프라이머리 사용 (강한 일관성 필요시)
"""
if use_primary or not self.replica_pools:
# 프라이머리에서 읽기
async with self.primary_pool.acquire() as conn:
yield conn
else:
# 복제본에서 읽기 (라운드 로빈)
pool = self._get_next_replica_pool()
async with pool.acquire() as conn:
yield conn
def _get_next_replica_pool(self) -> asyncpg.Pool:
"""다음 복제본 풀 선택 (라운드 로빈)"""
pool = self.replica_pools[self.replica_index]
self.replica_index = (self.replica_index + 1) % len(self.replica_pools)
return pool
async def close(self):
"""모든 연결 풀 닫기"""
if self.primary_pool:
await self.primary_pool.close()
for pool in self.replica_pools:
await pool.close()
# 사용 예제
async def example_usage():
router = DatabaseRouter(
primary_dsn="postgresql://user:pass@primary.postgres.azure.com:5432/chatgpt",
replica_dsns=[
"postgresql://user:pass@replica1.postgres.azure.com:5432/chatgpt",
"postgresql://user:pass@replica2.postgres.azure.com:5432/chatgpt",
"postgresql://user:pass@replica3.postgres.azure.com:5432/chatgpt",
]
)
await router.initialize()
try:
# 쓰기 작업
async with router.write_connection() as conn:
await conn.execute(
"INSERT INTO messages (conversation_id, content) VALUES ($1, $2)",
12345, "Hello, world!"
)
# 읽기 작업 (복제본)
async with router.read_connection() as conn:
messages = await conn.fetch(
"SELECT * FROM messages WHERE conversation_id = $1",
12345
)
# 읽기 작업 (강한 일관성 필요 - 프라이머리)
async with router.read_connection(use_primary=True) as conn:
user = await conn.fetchrow(
"SELECT * FROM users WHERE id = $1",
67890
)
finally:
await router.close()
예제 2: Cache Stampede 방지 (Python + Redis)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import asyncio
import json
from typing import Any, Optional, Callable
import redis.asyncio as redis
class CacheWithLocking:
"""
Cache Stampede를 방지하는 캐시 래퍼
- 여러 요청이 동시에 캐시 미스를 경험해도
- 하나만 실제로 데이터베이스에서 가져옴
"""
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
async def get_or_compute(
self,
key: str,
compute_fn: Callable[[], Any],
ttl: int = 3600,
lock_timeout: int = 10
) -> Any:
"""
캐시에서 가져오거나, 없으면 compute_fn 실행
Args:
key: 캐시 키
compute_fn: 캐시 미스 시 실행할 함수
ttl: 캐시 TTL (초)
lock_timeout: 락 타임아웃 (초)
"""
# 1단계: 캐시 확인
cached = await self.redis.get(key)
if cached:
return json.loads(cached)
# 2단계: 락 획득 시도
lock_key = f"lock:{key}"
acquired = await self.redis.set(
lock_key,
"1",
nx=True, # SET if Not eXists
ex=lock_timeout
)
if acquired:
# 이 요청이 락을 획득함 - 데이터 계산
try:
# 재확인 (다른 요청이 이미 캐시했을 수도)
cached = await self.redis.get(key)
if cached:
return json.loads(cached)
# 실제 계산 (예: 데이터베이스 쿼리)
result = await compute_fn()
# 캐시에 저장
await self.redis.setex(
key,
ttl,
json.dumps(result)
)
return result
finally:
# 락 해제
await self.redis.delete(lock_key)
else:
# 다른 요청이 락을 보유 중 - 대기 후 재시도
for attempt in range(100): # 최대 10초 대기 (100 * 0.1초)
await asyncio.sleep(0.1)
# 캐시 재확인
cached = await self.redis.get(key)
if cached:
return json.loads(cached)
# 타임아웃: 직접 계산 (폴백)
return await compute_fn()
# 사용 예제
async def example_usage():
redis_client = await redis.from_url("redis://localhost")
cache = CacheWithLocking(redis_client)
async def fetch_user_from_db(user_id: int):
"""데이터베이스에서 사용자 가져오기 (비싼 작업)"""
print(f"Fetching user {user_id} from database...")
await asyncio.sleep(0.5) # DB 쿼리 시뮬레이션
return {"id": user_id, "name": f"User {user_id}"}
# 1000개의 동시 요청
tasks = []
for _ in range(1000):
task = cache.get_or_compute(
key="user:12345",
compute_fn=lambda: fetch_user_from_db(12345),
ttl=3600
)
tasks.append(task)
# 모두 실행
results = await asyncio.gather(*tasks)
# 결과: "Fetching user..."는 단 1번만 출력됨
# 1000개 요청이 모두 동일한 데이터 반환
await redis_client.close()
예제 3: Controlled Backfill (Python)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
import asyncio
from typing import Callable, Any
import asyncpg
from datetime import datetime
class ControlledBackfill:
"""
대규모 테이블의 안전한 백필
- 작은 배치로 처리
- 각 배치 간 지연
- 진행 상황 추적
- 중단/재시작 가능
"""
def __init__(
self,
pool: asyncpg.Pool,
batch_size: int = 1000,
delay_ms: int = 100
):
self.pool = pool
self.batch_size = batch_size
self.delay_ms = delay_ms
async def backfill(
self,
update_query: str,
count_query: str,
update_fn: Optional[Callable[[asyncpg.Connection], Any]] = None
):
"""
백필 실행
Args:
update_query: UPDATE 쿼리 (LIMIT 포함)
count_query: 남은 행 수 확인 쿼리
update_fn: 커스텀 업데이트 함수 (선택)
"""
total_updated = 0
start_time = datetime.now()
while True:
# 남은 행 수 확인
async with self.pool.acquire() as conn:
remaining = await conn.fetchval(count_query)
if remaining == 0:
break
# 배치 업데이트
async with self.pool.acquire() as conn:
if update_fn:
updated = await update_fn(conn)
else:
result = await conn.execute(update_query)
# "UPDATE 1000" -> 1000 추출
updated = int(result.split()[-1])
total_updated += updated
# 진행 상황 로깅
elapsed = (datetime.now() - start_time).total_seconds()
rate = total_updated / elapsed if elapsed > 0 else 0
eta = remaining / rate if rate > 0 else 0
print(f"Updated: {total_updated:,} | "
f"Remaining: {remaining:,} | "
f"Rate: {rate:.0f}/s | "
f"ETA: {eta/3600:.1f}h")
# 배치 간 지연
await asyncio.sleep(self.delay_ms / 1000)
total_time = (datetime.now() - start_time).total_seconds()
print(f"\nBackfill complete!")
print(f"Total updated: {total_updated:,}")
print(f"Total time: {total_time/3600:.2f} hours")
print(f"Average rate: {total_updated/total_time:.0f} rows/s")
# 사용 예제 1: 간단한 업데이트
async def simple_backfill_example():
pool = await asyncpg.create_pool(
"postgresql://user:pass@localhost/chatgpt"
)
backfill = ControlledBackfill(
pool=pool,
batch_size=5000,
delay_ms=50
)
await backfill.backfill(
update_query="""
UPDATE conversations
SET priority = 1
WHERE priority IS NULL
LIMIT 5000
""",
count_query="""
SELECT COUNT(*)
FROM conversations
WHERE priority IS NULL
"""
)
await pool.close()
# 사용 예제 2: 복잡한 업데이트
async def complex_backfill_example():
pool = await asyncpg.create_pool(
"postgresql://user:pass@localhost/chatgpt"
)
async def update_user_tier(conn: asyncpg.Connection) -> int:
"""
사용자 티어 계산 및 업데이트
- 메시지 수에 따라 티어 결정
"""
# 업데이트할 사용자 선택
users = await conn.fetch("""
SELECT id,
(SELECT COUNT(*) FROM messages WHERE user_id = users.id) as msg_count
FROM users
WHERE tier IS NULL
ORDER BY id
LIMIT 1000
""")
if not users:
return 0
# 각 사용자의 티어 계산
updates = []
for user in users:
if user['msg_count'] > 1000:
tier = 'premium'
elif user['msg_count'] > 100:
tier = 'standard'
else:
tier = 'basic'
updates.append((tier, user['id']))
# 배치 업데이트
await conn.executemany(
"UPDATE users SET tier = $1 WHERE id = $2",
updates
)
return len(updates)
backfill = ControlledBackfill(
pool=pool,
batch_size=1000,
delay_ms=100
)
await backfill.backfill(
update_query=None, # update_fn 사용
count_query="SELECT COUNT(*) FROM users WHERE tier IS NULL",
update_fn=update_user_tier
)
await pool.close()
# 사용 예제 3: 우선순위 기반 백필
async def priority_backfill_example():
pool = await asyncpg.create_pool(
"postgresql://user:pass@localhost/chatgpt"
)
backfill = ControlledBackfill(pool=pool, batch_size=2000, delay_ms=50)
# 1단계: 활성 사용자 먼저
print("Stage 1: Backfilling active users...")
await backfill.backfill(
update_query="""
UPDATE conversations c
SET priority = calculate_priority(c.id)
WHERE c.id IN (
SELECT c2.id
FROM conversations c2
JOIN users u ON c2.user_id = u.id
WHERE c2.priority IS NULL
AND u.last_seen > NOW() - INTERVAL '7 days'
ORDER BY c2.id
LIMIT 2000
)
""",
count_query="""
SELECT COUNT(*)
FROM conversations c
JOIN users u ON c.user_id = u.id
WHERE c.priority IS NULL
AND u.last_seen > NOW() - INTERVAL '7 days'
"""
)
# 2단계: 나머지 (더 큰 배치 사용)
print("\nStage 2: Backfilling remaining conversations...")
backfill.batch_size = 10000
backfill.delay_ms = 200
await backfill.backfill(
update_query="""
UPDATE conversations
SET priority = calculate_priority(id)
WHERE priority IS NULL
LIMIT 10000
""",
count_query="""
SELECT COUNT(*)
FROM conversations
WHERE priority IS NULL
"""
)
await pool.close()
부록 C: 모니터링 쿼리 모음
1. 성능 모니터링
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
-- 현재 QPS와 TPS
SELECT
datname,
xact_commit + xact_rollback as total_transactions,
blks_read + blks_hit as total_blocks
FROM pg_stat_database
WHERE datname = 'chatgpt';
-- 활성 쿼리 및 대기 상태
SELECT
pid,
usename,
application_name,
client_addr,
state,
wait_event_type,
wait_event,
query_start,
state_change,
EXTRACT(EPOCH FROM (now() - query_start)) as duration_seconds,
LEFT(query, 100) as query_preview
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;
-- 느린 쿼리 Top 20
SELECT
query,
calls,
total_exec_time,
mean_exec_time,
stddev_exec_time,
min_exec_time,
max_exec_time,
rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
-- 캐시 히트율 (테이블별)
SELECT
schemaname,
tablename,
heap_blks_read,
heap_blks_hit,
CASE
WHEN heap_blks_hit + heap_blks_read = 0 THEN 0
ELSE heap_blks_hit::float / (heap_blks_hit + heap_blks_read)
END as cache_hit_ratio
FROM pg_statio_user_tables
WHERE heap_blks_hit + heap_blks_read > 0
ORDER BY cache_hit_ratio;
-- 인덱스 사용률
SELECT
schemaname,
tablename,
indexname,
idx_scan as index_scans,
idx_tup_read,
idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
ORDER BY idx_scan;
2. 데이터베이스 건강
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
-- Dead tuples와 블로트
SELECT
schemaname,
tablename,
n_live_tup,
n_dead_tup,
CASE
WHEN n_live_tup = 0 THEN 0
ELSE n_dead_tup::float / n_live_tup
END as dead_ratio,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC;
-- 테이블 크기
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as total_size,
pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) as table_size,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) -
pg_relation_size(schemaname||'.'||tablename)) as index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
-- Autovacuum 진행 상황
SELECT
pid,
datname,
usename,
query_start,
EXTRACT(EPOCH FROM (now() - query_start)) as duration_seconds,
query
FROM pg_stat_activity
WHERE query LIKE 'autovacuum:%'
ORDER BY query_start;
-- 장기 실행 트랜잭션
SELECT
pid,
usename,
application_name,
client_addr,
state,
xact_start,
EXTRACT(EPOCH FROM (now() - xact_start)) as duration_seconds,
LEFT(query, 100) as query_preview
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND state != 'idle'
ORDER BY xact_start;
3. 복제 모니터링
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- 복제 상태 (프라이머리에서)
SELECT
client_addr,
usename,
application_name,
state,
sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) as pending_bytes,
pg_wal_lsn_diff(pg_current_wal_lsn(), write_lsn) as write_lag_bytes,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) as replay_lag_bytes,
pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn) as flush_lag_bytes
FROM pg_stat_replication;
-- 복제 지연 (복제본에서)
SELECT
CASE
WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()
THEN 0
ELSE EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))
END AS lag_seconds,
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_last_xact_replay_timestamp();
-- WAL 생성 속도
SELECT
pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')::bigint /
EXTRACT(EPOCH FROM (now() - pg_postmaster_start_time()))::bigint
as wal_bytes_per_second;
-- 복제 슬롯 상태
SELECT
slot_name,
slot_type,
database,
active,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) as retained_wal_bytes,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as retained_wal
FROM pg_replication_slots;
4. 연결 모니터링
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- 연결 수 (상태별)
SELECT
state,
COUNT(*) as count
FROM pg_stat_activity
GROUP BY state
ORDER BY count DESC;
-- 애플리케이션별 연결
SELECT
application_name,
COUNT(*) as connections,
COUNT(*) FILTER (WHERE state = 'active') as active,
COUNT(*) FILTER (WHERE state = 'idle') as idle,
COUNT(*) FILTER (WHERE state = 'idle in transaction') as idle_in_transaction
FROM pg_stat_activity
WHERE application_name IS NOT NULL
GROUP BY application_name
ORDER BY connections DESC;
-- 오래된 idle in transaction 연결
SELECT
pid,
usename,
application_name,
client_addr,
state_change,
EXTRACT(EPOCH FROM (now() - state_change)) as idle_seconds,
LEFT(query, 100) as last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND state_change < now() - interval '5 minutes'
ORDER BY state_change;
-- PgBouncer 통계 (PgBouncer 연결에서)
SHOW STATS;
SHOW POOLS;
SHOW CLIENTS;
SHOW SERVERS;
부록 D: 추가 리소스 및 도구
공식 문서
- PostgreSQL 공식 문서
- URL: https://www.postgresql.org/docs/
- 특히 중요한 섹션:
- Chapter 13: Concurrency Control (MVCC)
- Chapter 24: Routine Database Maintenance (VACUUM)
- Chapter 26: High Availability
- Chapter 27: Monitoring Database Activity
- Azure Database for PostgreSQL 문서
- URL: https://learn.microsoft.com/azure/postgresql/
- 주요 주제:
- Read replicas
- High availability
- Performance recommendations
- Monitoring and alerts
- PgBouncer 문서
- URL: https://www.pgbouncer.org/
- 설치, 설정, 트러블슈팅
도구 및 유틸리티
- 모니터링
- pg_stat_statements: 쿼리 성능 추적
- pgBadger: 로그 분석 도구
- Prometheus + Grafana: 메트릭 수집 및 시각화
- postgres_exporter: Prometheus용 PostgreSQL 익스포터
- 성능 분석
- EXPLAIN ANALYZE: 쿼리 실행 계획 분석
- pg_stat_activity: 현재 활동 모니터링
- pg_top: PostgreSQL용 top 도구
- 관리
- pgAdmin: GUI 관리 도구
- psql: 커맨드라인 인터페이스
- pg_repack: 온라인 테이블 재구성
커뮤니티 리소스
- 메일링 리스트
- pgsql-general: 일반 토론
- pgsql-performance: 성능 관련
- pgsql-admin: 관리 관련
- 포럼 및 Q&A
- Stack Overflow: [postgresql] 태그
- Reddit: r/PostgreSQL
- Database Administrators Stack Exchange
- 컨퍼런스
- PGConf.Dev: 개발자 컨퍼런스
- POSETTE: PostgreSQL 온라인 이벤트
- 지역별 PostgreSQL 사용자 그룹
추천 도서
- “PostgreSQL: Up and Running” by Regina Obe and Leo Hsu
- 초보자부터 중급자까지
- 실용적인 가이드
- “Mastering PostgreSQL” by Hans-Jürgen Schönig
- 고급 주제
- 성능 튜닝, 복제, 고가용성
- “PostgreSQL High Performance” by Gregory Smith
- 성능 최적화 심화
- 대규모 배포
관련 블로그 및 아티클
- OpenAI Blog
- “Scaling PostgreSQL to power 800 million ChatGPT users”
- 이 문서의 기반이 된 원본 아티클
- Microsoft Azure Blog
- Azure PostgreSQL 관련 최신 기능 및 베스트 프랙티스
- Crunchy Data Blog
- PostgreSQL 전문 기업의 기술 블로그
- 심화 기술 아티클
- 2ndQuadrant Blog
- PostgreSQL 컨설팅 회사의 블로그
- 고급 주제 다수
마치며
이 문서는 OpenAI가 PostgreSQL로 8억 사용자를 지원하는 놀라운 여정을 상세히 분석했습니다. 40,000단어가 넘는 이 가이드는 이론과 실무를 결합하여, 단순히 “무엇을” 했는지뿐만 아니라 “왜” 그리고 “어떻게” 했는지를 설명합니다.
핵심 메시지는 명확합니다: 대규모 시스템은 복잡할 필요가 없습니다. 올바른 원칙 - 워크로드 이해, 측정 기반 최적화, 점진적 개선 - 을 적용하면, 표준 도구로도 놀라운 결과를 달성할 수 있습니다.
OpenAI의 경험은 모든 규모의 조직에 귀중한 교훈을 제공합니다. 스타트업이든 대기업이든, 이 원칙들은 여러분의 데이터베이스 아키텍처를 개선하는 데 도움이 될 것입니다.
기술은 계속 발전하지만, 좋은 엔지니어링의 원칙은 변하지 않습니다. 단순함, 측정, 점진적 개선 - 이것이 OpenAI가 8억 사용자를 지원할 수 있었던 비결이며, 여러분도 적용할 수 있는 원칙입니다.
“PostgreSQL didn’t scale because it’s magical. It scaled because engineers respected its limits and designed around them instead of fighting them.”
여러분의 데이터베이스 스케일링 여정에 이 가이드가 도움이 되기를 바랍니다.
최종 작성 일자: 2026-01-24
저자: 이 문서는 OpenAI의 공식 블로그 게시물, Microsoft Azure 팀의 협업 사례, POSETTE 2025 컨퍼런스의 Bohan Zhang 발표, 그리고 PostgreSQL 커뮤니티의 집단 지혜를 종합하여 작성되었습니다.