2장. 아키텍처 — 프로세스와 메모리
PostgreSQL은 스레드가 아니라 프로세스로 일합니다. 접속 하나에 프로세스 하나 — 이 40년째 유지되는 결정이 왜 내려졌고, 서버 안에서 쿼리가 어떤 길을 걷는지 해부합니다.
① postmaster와 backend — fork 기반 프로세스 모델 ② shared buffers를 중심으로 한 공유 메모리 구성 ③ 쿼리가 처리되는 4단계(Parse→Rewrite→Plan→Execute) ④ 커넥션 풀링이 필수인 이유와 PGDATA·설정 계층
2.1 프로세스 모델 — postmaster는 감독관이다
MySQL·Oracle이 스레드 기반인 것과 달리, PostgreSQL은 프로세스 기반 멀티태스킹 DBMS입니다. 서버를 시작(pg_ctl start)하면 postmaster라는 최상위 프로세스가 뜹니다. postmaster의 역할은 단 하나 — 감독관(supervisor). 쿼리는 직접 처리하지 않고, 접속 요청이 오면 자식 프로세스(backend)를 fork()하고, 자식이 죽으면 뒷수습을 합니다.
서버 시작. postmaster가 5432 포트를 감시하고, checkpointer·background writer·walwriter·autovacuum launcher 같은 백그라운드 일꾼들을 거느립니다.
클라이언트 A가 접속하면 postmaster가 fork()로 backend A를 만듭니다. 이후 A와 backend A는 직접 통신 — postmaster는 손을 뗍니다. 프로세스 하나당 약 5~10MB 메모리를 씁니다.
클라이언트 B가 오면 또 하나의 backend. 연결 1개 = 프로세스 1개, 이것이 PostgreSQL의 규칙입니다.
backend A가 SIGSEGV로 죽어도 backend B는 멀쩡합니다. 스레드 모델이었다면 한 스레드의 메모리 오염이 서버 전체를 흔들었을 것입니다.
이것이 "왜 프로세스인가"의 답 — 크래시 격리입니다. 안정성(격리) vs 성능(컨텍스트 스위치)의 트레이드오프에서 PostgreSQL은 안정성을 택했습니다.
실제 서버에서 ps로 보면 이 구조가 그대로 보입니다.
$ ps -ef | grep postgres
postgres 1001 1 ... /usr/pgsql-17/bin/postgres -D /var/lib/pgsql/data ← postmaster
postgres 1005 1001 ... postgres: checkpointer
postgres 1006 1001 ... postgres: background writer
postgres 1007 1001 ... postgres: walwriter
postgres 1008 1001 ... postgres: autovacuum launcher
postgres 2001 1001 ... postgres: myuser mydb 10.0.0.5(54321) idle ← backend
| 보조 프로세스 | 역할 |
|---|---|
| Checkpointer | 주기적으로 shared buffers의 dirty page를 디스크에 반영(체크포인트, 9장) |
| Background Writer | 오래된 dirty page를 선제적으로 디스크에 기록 |
| WAL Writer | WAL buffer를 pg_wal/로 flush (COMMIT 시엔 backend가 직접 fsync) |
| Autovacuum Launcher/Worker | dead tuple이 쌓인 테이블을 찾아 VACUUM·ANALYZE 수행(8장) |
| Archiver | archive_mode=on일 때 WAL 세그먼트를 외부로 복사 |
| WAL Sender / Receiver | 스트리밍 복제의 발신·수신측(10장) |
| Parallel Worker | 병렬 쿼리 실행 시 동적으로 생성 |
2.2 Shared Memory — 모두가 공유하는 작업대
프로세스는 서로 메모리를 공유하지 않는 게 원칙이지만, DB는 모든 backend가 같은 데이터 페이지·같은 트랜잭션 상태를 봐야 합니다. 그래서 시작 시 공유 메모리(shared memory) 영역을 만들어 모두가 붙습니다.
| 영역 | 내용 |
|---|---|
shared_buffers | 8KB 디스크 페이지의 캐시. 기본 128MB, 프로덕션은 물리 RAM의 20~25%가 출발점 |
| WAL Buffers | COMMIT 전 WAL 레코드의 임시 저장소. 기본 shared_buffers/32, 최대 16MB |
| CLOG (pg_xact) | 모든 트랜잭션 ID의 커밋 상태를 비트 2개로 저장 — MVCC 가시성 판단의 필수 경로(3장) |
| Lock Table | 테이블 락 등 heavy-weight lock의 해시 테이블(7장) |
| ProcArray | 지금 살아 있는 트랜잭션 XID 목록. 스냅샷을 뜰 때마다 스캔 |
이 중 주인공은 단연 shared_buffers입니다. 쿼리가 이 캐시를 어떻게 통과하는지 봅시다.
backend가 page 7이 필요합니다. 무조건 디스크로 가는 게 아니라 shared buffers부터 확인합니다. 지금은 없네요 — cache miss.
디스크에서 8KB 페이지를 읽어 버퍼의 빈 슬롯에 올립니다. 이제 page 7은 모든 backend가 공유하는 캐시에 있습니다.
UPDATE도 디스크가 아니라 버퍼 안의 페이지를 수정합니다. 수정된 페이지는 dirty 상태가 되고, 변경 내역은 먼저 WAL에 기록됩니다(Write-Ahead!).
dirty page는 즉시 디스크에 안 씁니다. checkpointer·background writer가 나중에 모아서 효율적으로 flush합니다.
같은 페이지를 다시 조회하면? 캐시 히트 — 디스크 I/O 없이 즉시 응답. 이 캐시 적중률이 PostgreSQL 성능의 뼈대입니다.
PostgreSQL은 OS 페이지 캐시 위에 자체 캐시를 하나 더 두는 이중 구조입니다. shared_buffers를 너무 키우면 같은 페이지가 두 캐시에 중복 저장되는 낭비가 커지므로, 20~25%에서 시작해 워크로드로 검증하는 것이 통설입니다.
2.3 쿼리의 4단계 — Parse → Rewrite → Plan → Execute
backend 안에서 SELECT 한 문장은 네 단계를 거칩니다. (이 파이프라인의 애니메이션은 6장에서 자세히 다룹니다.)
| 단계 | 하는 일 | 산출물 |
|---|---|---|
| Parse | Lexer+Parser(yacc)가 문자열을 분석. 문법 오류는 여기서 검출 | Parse Tree |
| Rewrite | Rule System이 View를 기반 테이블 쿼리로 치환 | Query Tree |
| Plan | 통계 기반으로 Seq Scan/Index Scan, 조인 순서·방식을 비용 비교로 결정 | Plan Tree |
| Execute | Volcano(iterator) 모델 — 노드가 튜플을 하나씩 위로 올림 | 결과 튜플 |
-- Rewrite 단계의 실체: 뷰는 "펼쳐진다"
CREATE VIEW active_users AS SELECT * FROM users WHERE status = 'active';
SELECT id FROM active_users WHERE country = 'KR';
-- Rewriter가 아래로 치환한 뒤 Planner에게 전달:
-- SELECT id FROM users WHERE status = 'active' AND country = 'KR';
2.4 커넥션 풀링 — "연결 = 프로세스"의 청구서
연결 하나가 프로세스 하나라는 구조는 연결 급증을 허용하지 않습니다. 10,000 커넥션 = 10,000 프로세스 = 수십 GB 메모리에, ProcArray·Lock 스캔까지 급격히 나빠집니다. 커뮤니티 합의는 명확합니다 — max_connections는 200~500 수준으로 두고, 그 앞에 pooler(pgBouncer 등)를 세운다.
클라이언트 3,000개가 DB에 직접 붙으면? backend 프로세스 3,000개, 수십 GB 메모리, 그리고 스냅샷 계산까지 느려지는 재앙입니다.
중간에 pgBouncer를 세웁니다. 클라이언트는 모두 pgBouncer에 접속하고, pgBouncer는 뒤로 소수의 실제 연결만 유지합니다.
transaction 모드의 핵심: 트랜잭션이 끝나는 순간 backend를 회수해 다음 클라이언트에게 빌려줍니다. 20개의 backend로 3,000 클라이언트를 감당할 수 있는 이유입니다.
결과: 메모리 절약 + 안정적인 동시성. 단, backend가 계속 바뀌므로 SET 같은 세션 상태에 의존하는 코드는 주의해야 합니다.
| pgBouncer 모드 | backend 재할당 단위 | 비고 |
|---|---|---|
| session | 세션 종료 시 | 풀 효과 미미 |
| transaction | 트랜잭션 종료 시 | 권장. 세션 변수·prepared statement 제약 주의 |
| statement | 쿼리 하나마다 | 멀티 문장 트랜잭션 불가, 거의 안 씀 |
2.5 PGDATA — 데이터 디렉터리 지도
initdb가 만드는 데이터 디렉터리($PGDATA)의 핵심 구조입니다. 장애 대응 때 "어디에 뭐가 있는지"를 알면 절반은 이긴 겁니다.
| 경로 | 내용 |
|---|---|
base/<db_oid>/<filenode> | 테이블·인덱스 본체. 1GB 단위로 분할(.1, .2…) |
base/…/_fsm, _vm | Free Space Map · Visibility Map (4장) |
global/ | pg_database, pg_authid 등 클러스터 공통 카탈로그 |
pg_wal/ | WAL 세그먼트(기본 16MB/파일) |
pg_xact/ | CLOG — 트랜잭션 커밋 상태 비트맵 |
postgresql.conf / postgresql.auto.conf | 메인 설정 / ALTER SYSTEM이 기록하는 오버라이드(conf보다 우선) |
pg_hba.conf | 접속 인증 규칙(어느 IP·사용자·DB가 어떤 방식으로) |
postmaster.pid | 실행 중인 postmaster의 PID·포트 |
-- 특정 테이블의 실제 파일 경로 찾기
SELECT pg_relation_filepath('orders');
-- base/16384/24576
2.6 설정 계층 — 같은 파라미터, 다른 우선순위
동일 파라미터가 여러 곳에서 지정될 수 있고, 우선순위는 명확합니다: SET(세션) > ALTER ROLE … SET > ALTER DATABASE … SET > postgresql.auto.conf > postgresql.conf.
-- 현재 세션만
SET work_mem = '256MB';
-- 특정 사용자 접속 시 항상 적용
ALTER ROLE analyst SET work_mem = '512MB';
-- 서버 전역 (postgresql.auto.conf에 기록)
ALTER SYSTEM SET shared_buffers = '4GB';
SELECT pg_reload_conf();
-- 재시작이 필요한지 reload로 충분한지 확인
SELECT name, setting, context FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem');
-- shared_buffers | context = postmaster ← 재시작 필요
-- work_mem | context = user ← SET으로도 가능
ALTER SYSTEM으로 기록된 postgresql.auto.conf가 postgresql.conf를 덮어씁니다. 설정이 안 먹을 때 가장 먼저 auto.conf를 확인하세요. pg_settings의 source 컬럼이 어느 파일에서 온 값인지 알려줍니다.
✍️ 이해도 체크
이 장의 원문 문서: chapters/ch02_architecture.md — CLOG·SUBTRANS·MultiXact 등 SLRU 구조, PGDATA 전체 트리, 설정 context 표가 담겨 있습니다.