들어가며
PostgreSQL은 오랜 시간에 걸쳐 다듬어진, 견고하고 확장 가능한 오픈소스 관계형 데이터베이스입니다. 단순히 SQL을 실행하는 도구를 넘어, 동시성 제어, 저장 구조, 쿼리 최적화, 복제, 확장성까지 하나의 일관된 설계 철학으로 묶여 있습니다.
이 글은 PostgreSQL의 내부를 "프로세스와 메모리는 어떻게 생겼고, 트랜잭션은 서로를 어떻게 보지 않으며, 쿼리는 어떤 경로로 실행되는가"라는 질문을 중심으로 풀어냅니다. 그림을 많이 사용해 직관을 먼저 세우고, 세부 동작은 버전에 따라 달라질 수 있으므로 정확한 동작은 공식 문서(postgresql.org)를 함께 확인하시길 권합니다.
1. 프로세스와 메모리 구조
PostgreSQL은 멀티프로세스 아키텍처를 채택합니다. 클라이언트가 접속할 때마다 전용 백엔드 프로세스가 생기고, 여러 백그라운드 프로세스가 공유 메모리를 중심으로 협력합니다.
┌──────────────────────────────────┐
클라이언트 ───▶│ Postmaster (메인 프로세스) │
클라이언트 ───▶│ - 접속 수락, 백엔드 fork │
└───────────────┬──────────────────┘
│ fork
┌─────────────────────────┼─────────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Backend 1 │ │ Backend 2 │ │ Backend N │
│ (세션 전용)│ │ (세션 전용)│ │ (세션 전용)│
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└────────────┬───────────┴────────────┬────────────┘
▼ ▼
┌───────────────────────────────────────────────┐
│ 공유 메모리 (Shared Memory) │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ Shared Buffers │ │ WAL Buffers │ │
│ │ (테이블/인덱스 페이지)│ │ (변경 로그 버퍼) │ │
│ └─────────────────┘ └─────────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ Lock / CLOG │ │ 기타 공유 상태 │ │
│ └─────────────────┘ └─────────────────────┘ │
└───────────────────────────────────────────────┘
▲ ▲
┌────────────┴───────────┬─────────────┴────────────┐
▼ ▼ ▼
┌───────────┐ ┌──────────────┐ ┌──────────────┐
│ Background│ │ WAL Writer │ │ Checkpointer │
│ Writer │ │ (WAL 디스크화) │ │ (체크포인트) │
└───────────┘ └──────────────┘ └──────────────┘
┌───────────┐ ┌──────────────┐
│ Autovacuum│ │ Archiver 등 │
└───────────┘ └──────────────┘
핵심 메모리 영역
- **Shared Buffers**: 디스크의 테이블/인덱스 페이지를 메모리에 캐시하는 공유 풀. 읽기/쓰기는 대부분 여기서 일어나고, 디스크 I/O를 줄이는 1차 방어선입니다.
- **WAL Buffers**: 변경 사항을 먼저 기록하는 WAL(Write-Ahead Log) 레코드를 잠시 모아두는 버퍼.
- **work_mem / maintenance_work_mem**: 정렬·해시·VACUUM 등 작업별로 백엔드가 사용하는 작업 메모리.
읽기 경로: 쿼리 ─▶ Shared Buffers 적중? ─예─▶ 메모리에서 반환
│ 아니오
▼
디스크에서 페이지 로드 ─▶ Shared Buffers 적재 ─▶ 반환
쓰기 경로: 변경 ─▶ Shared Buffers의 페이지를 더티로 표시
─▶ WAL에 변경 기록(먼저!)
─▶ 나중에 Background Writer/Checkpointer가 디스크 반영
WAL을 먼저 쓰는 것(Write-Ahead Logging)이 핵심입니다. 데이터 파일보다 로그를 먼저 안전하게 기록하므로, 장애가 나도 로그를 재생(replay)해 일관성을 복구할 수 있습니다.
2. MVCC — 다중 버전 동시성 제어
PostgreSQL의 동시성 모델은 **MVCC(Multi-Version Concurrency Control)**입니다. 핵심 아이디어는 "읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다"입니다. 이를 위해 같은 행(row)의 여러 버전을 보관합니다.
각 행 버전(튜플)에는 보이지 않는 시스템 컬럼이 붙습니다.
튜플 헤더의 가시성 정보
┌──────────┬─────────────────────────────────────┐
│ xmin │ 이 버전을 생성한 트랜잭션 ID │
│ xmax │ 이 버전을 삭제/갱신한 트랜잭션 ID(있으면)│
│ ctid │ 물리적 위치(블록, 오프셋) │
└──────────┴─────────────────────────────────────┘
UPDATE는 기존 행을 제자리에서 고치지 않고, **새 버전을 추가하고 옛 버전에 xmax를 표시**합니다. 이것이 PostgreSQL UPDATE가 사실상 "삭제 + 삽입"에 가깝게 동작하는 이유입니다.
UPDATE 전:
[v1: xmin=100, xmax=0 ] ← 현재 보이는 버전
UPDATE (txid 150) 후:
[v1: xmin=100, xmax=150] ← 옛 버전, 150에 의해 만료
[v2: xmin=150, xmax=0 ] ← 새 버전
가시성 판단
어떤 트랜잭션이 어떤 버전을 볼 수 있는지는 **스냅샷**으로 결정됩니다. 스냅샷은 "지금 이 시점에 커밋된 트랜잭션은 무엇인가"를 담습니다.
가시성 규칙 (단순화)
버전이 보이려면:
1) xmin 의 트랜잭션이 커밋되었고, 내 스냅샷보다 앞서 있으며
2) xmax 가 없거나, xmax 의 트랜잭션이 아직 커밋되지 않았다
──▶ 따라서 동시에 실행되는 두 트랜잭션은
서로 다른 "버전의 세계"를 볼 수 있다.
VACUUM — 죽은 튜플 청소
MVCC의 대가는 "죽은 튜플(dead tuple)"의 누적입니다. 더 이상 어떤 스냅샷에서도 보이지 않는 옛 버전은 공간만 차지합니다. 이를 회수하는 것이 **VACUUM**입니다.
죽은 튜플 누적 ──▶ 테이블 팽창(bloat) ──▶ 성능 저하
│
▼
VACUUM: 죽은 튜플 공간을 재사용 가능 목록으로 회수
VACUUM FULL: 테이블을 다시 써서 물리적으로 축소(잠금 큼)
Autovacuum: 임계치 도달 시 자동 실행되는 백그라운드 청소
또한 트랜잭션 ID는 유한하므로, 오래된 튜플의 ID를 "동결(freeze)"해 **wraparound** 문제를 방지하는 것도 VACUUM의 중요한 역할입니다. autovacuum 튜닝은 운영에서 매우 중요합니다.
3. 인덱스 구조
PostgreSQL은 데이터와 질의 유형에 맞춰 여러 인덱스 종류를 제공합니다. 올바른 인덱스 선택이 곧 성능입니다.
| 인덱스 | 적합한 질의 | 대표 사례 |
| --- | --- | --- |
| B-tree | 등호/범위/정렬 | 기본 키, 일반 컬럼 |
| Hash | 등호만 | 단순 동등 비교 |
| GIN | 다중 값 포함 | 배열, JSONB, 전문검색 |
| GiST | 기하/근접/범위 | 위치 데이터, 범위 타입 |
| BRIN | 물리적 정렬된 대용량 | 시계열, 로그 |
B-tree (가장 흔함)
[ 50 ]
/ \
[20 35] [70 90]
/ | \ / | \
리프 노드(정렬된 키 + 행 포인터)들이 서로 연결
──▶ 범위 스캔이 매우 효율적
BRIN (블록 범위 인덱스)
테이블 블록을 구간으로 묶어 "이 구간의 최소/최대" 만 저장
┌──────────┬──────────┬──────────┐
│ blk 0-127│ blk128-255│ blk256-..│
│ min/max │ min/max │ min/max │
└──────────┴──────────┴──────────┘
──▶ 인덱스가 매우 작고, 자연 정렬된 대용량 데이터에 적합
GIN은 하나의 행이 여러 키를 가질 때(예: JSONB 문서, 배열, 단어 토큰) 강력합니다. GiST는 "가까움"이나 "겹침" 같은 공간/범위 질의에 적합합니다.
4. 쿼리 실행 — 플래너와 실행기
SQL 한 줄이 결과로 바뀌기까지는 여러 단계를 거칩니다.
SQL 텍스트
│
▼
[ 파서 ] ──▶ 구문 트리(파스 트리)
│
▼
[ 분석/재작성 ] ──▶ 뷰 펼치기, 규칙 적용
│
▼
[ 플래너/옵티마이저 ]
│ - 가능한 실행 계획들을 생성
│ - 통계(pg_statistic)로 각 계획의 "비용" 추정
│ - 가장 싼 계획 선택
▼
[ 실행기(Executor) ] ──▶ 계획 트리를 따라 튜플을 끌어올림
│
▼
결과
비용 기반 최적화
옵티마이저는 통계를 바탕으로 비용을 추정합니다. 같은 질의라도 데이터 분포에 따라 인덱스 스캔이 나을 수도, 순차 스캔이 나을 수도 있습니다.
WHERE status = 'active'
분기 1) 'active'가 전체의 1% ──▶ 인덱스 스캔이 저렴
분기 2) 'active'가 전체의 90% ──▶ 순차 스캔이 오히려 저렴
(랜덤 I/O보다 순차 I/O가 빠름)
──▶ ANALYZE 로 최신 통계를 유지해야 옵티마이저가 옳은 선택을 한다.
조인 전략
Nested Loop : 한쪽이 작고 인덱스가 있을 때 유리
Hash Join : 큰 두 테이블을 등호 조인할 때 (해시 테이블 구성)
Merge Join : 양쪽이 정렬되어 있을 때 효율적
`EXPLAIN ANALYZE`는 추정 계획과 실제 실행을 함께 보여주므로, 추정과 현실의 괴리를 진단하는 가장 중요한 도구입니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
5. 복제 — 스트리밍과 논리 복제
PostgreSQL의 복제는 WAL을 토대로 합니다. 핵심 구분은 "물리적 복제(스트리밍)"와 "논리적 복제"입니다.
스트리밍 복제 (물리적)
Primary ──── WAL 레코드 스트림 ───▶ Standby (바이트 단위 복제)
│ │
쓰기 가능 읽기 전용(핫 스탠바이)
──▶ 전체 클러스터를 통째로 복제, 버전/구조 동일해야 함
논리 복제 (퍼블리시/구독)
Publisher ── 변경(행 단위 INSERT/UPDATE/DELETE) ──▶ Subscriber
──▶ 테이블 단위 선택 복제 가능
──▶ 서로 다른 메이저 버전 간, 부분 복제, 데이터 통합에 유리
동기 vs 비동기
비동기(기본): Primary 가 커밋을 먼저 확정, Standby 는 따라옴
──▶ 빠르지만, 장애 시 마지막 일부 손실 가능
동기: Standby 가 WAL 수신을 확인해야 커밋 확정
──▶ 안전하지만 지연 증가
복제는 고가용성(HA)과 읽기 확장(read scaling)의 토대입니다. 읽기 부하를 Standby로 분산하고, 장애 시 승격(failover)으로 가용성을 확보합니다.
6. 파티셔닝
큰 테이블을 논리적으로 하나처럼 다루되, 물리적으로는 여러 조각(파티션)으로 나누는 기법입니다.
orders (부모, 파티션 키: order_date)
┌──────────────┬──────────────┬──────────────┐
▼ ▼ ▼ ▼
orders_2026q1 orders_2026q2 orders_2026q3 orders_2026q4
(1~3월) (4~6월) (7~9월) (10~12월)
CREATE TABLE orders (
id bigint,
order_date date NOT NULL,
amount numeric
) PARTITION BY RANGE (order_date);
CREATE TABLE orders_2026q1 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
파티셔닝의 가장 큰 이점은 **파티션 가지치기(pruning)**입니다. 질의 조건이 특정 파티션만 가리키면 나머지는 아예 스캔하지 않습니다.
WHERE order_date >= '2026-07-01'
플래너: q1, q2 파티션은 제외(prune) ─▶ q3, q4 만 스캔
──▶ 스캔 범위가 줄어 성능 향상
범위(RANGE) 외에도 LIST, HASH 파티셔닝을 지원하며, 오래된 파티션을 통째로 분리(DETACH)해 데이터 수명 관리를 단순화할 수 있습니다.
7. 확장(Extension) 생태계 — pgvector를 중심으로
PostgreSQL의 큰 강점은 **확장(extension)**으로 코어 기능을 넓힐 수 있다는 점입니다. 대표 사례가 AI 시대에 주목받는 `pgvector`입니다.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
content text,
embedding vector(1536) -- 임베딩 차원
);
벡터 유사도 검색은 "의미적으로 가까운" 문서를 찾는 데 쓰입니다. 거리 연산자로 최근접 이웃을 질의합니다.
-- 코사인 거리 기준 가장 가까운 5개
SELECT id, content
FROM documents
ORDER BY embedding <=> '[0.12, -0.04, ...]'
LIMIT 5;
대규모에서는 근사 최근접 탐색(ANN) 인덱스로 속도를 확보합니다.
정확 탐색(brute force) : 모든 벡터와 거리 계산 ──▶ 정확하지만 느림
ANN 인덱스(HNSW/IVF) : 그래프/군집 구조로 후보를 좁혀 탐색
──▶ 약간의 근사 대가로 큰 속도 향상
RAG 파이프라인에서의 위치
문서 ─▶ 임베딩 ─▶ [pgvector 테이블에 저장]
질문 ─▶ 임베딩 ─▶ [<=> 로 유사 문서 검색] ─▶ LLM 컨텍스트로 주입
이 외에도 PostGIS(공간), pg_stat_statements(쿼리 통계), 다양한 외부 데이터 래퍼(FDW) 등 풍부한 확장 생태계가 PostgreSQL을 "프로그래머블한 데이터 플랫폼"으로 만듭니다.
8. 운영에서 자주 만나는 함정
[ ] Autovacuum 을 끄거나 방치 ─▶ 테이블 bloat, wraparound 위험
[ ] 통계 미갱신(ANALYZE 부재) ─▶ 옵티마이저 오판, 잘못된 계획
[ ] 인덱스 남용 ─▶ 쓰기 비용 증가, 디스크/유지보수 부담
[ ] long-running 트랜잭션 방치 ─▶ VACUUM 이 죽은 튜플을 못 치움
[ ] 연결 수 폭증 ─▶ 프로세스당 메모리 부담, 풀러(PgBouncer) 고려
[ ] work_mem 과대 설정 ─▶ 동시성 시 메모리 폭발
특히 "오래 열린 트랜잭션"은 조용한 살인자입니다. 트랜잭션이 오래 열려 있으면 그 스냅샷이 옛 버전을 계속 "필요"하게 만들어, VACUUM이 죽은 튜플을 회수하지 못하고 bloat가 쌓입니다.
9. 체크포인트와 충돌 복구
WAL은 변경을 먼저 기록하지만, 데이터 파일에 실제로 반영되는 시점은 따로 있습니다. 그 동기화 지점이 **체크포인트(checkpoint)**입니다.
시간 ───────────────────────────────────────────▶
│ │ │ │
체크포인트 A 체크포인트 B 체크포인트 C
│◀── 이 구간의 변경은 WAL 에만 기록 ──▶│
│
체크포인트 시: 더티 페이지를 디스크에 flush
──▶ 이 지점 이전 WAL 은 복구에 불필요
충돌이 발생하면, 마지막 체크포인트 이후의 WAL만 재생(replay)하면 됩니다. 체크포인트가 잦으면 복구는 빨라지지만 I/O 부담이 커지고, 드물면 그 반대입니다. 이 트레이드오프를 조정하는 것이 운영의 한 축입니다.
충돌 발생 ──▶ 재시작
│
▼
마지막 체크포인트 위치 확인
│
▼
그 이후의 WAL 레코드를 순서대로 재생(REDO)
│
▼
커밋되지 않은 트랜잭션 정리 ──▶ 일관된 상태로 복구 완료
PITR(Point-In-Time Recovery)
WAL을 아카이빙해 두면, 단순 복구를 넘어 "특정 시점"으로 되돌리는 PITR가 가능합니다. 기본 백업(base backup) 위에 원하는 시각까지의 WAL을 재생하는 방식입니다.
기본 백업(스냅샷) ──▶ + 아카이브된 WAL 재생 ──▶ 목표 시각의 상태
──▶ 실수로 지운 데이터 직전 시점으로 복구하는 데 유용
이 메커니즘은 백업/복구 전략과 고가용성 설계의 핵심 토대이며, 운영 환경에서는 정기적인 기본 백업과 WAL 아카이빙을 함께 운용하는 것이 권장됩니다.
마치며
PostgreSQL의 아키텍처는 하나의 일관된 철학으로 짜여 있습니다. MVCC는 동시성을 위해 버전을 늘리고, 그 대가를 VACUUM이 갚습니다. WAL은 안정성과 복제의 토대가 되고, 비용 기반 옵티마이저는 통계라는 연료로 최선의 경로를 고릅니다. 인덱스와 파티셔닝은 데이터의 모양에 맞춘 도구이며, 확장은 코어를 새로운 영역으로 확장합니다.
이 메커니즘들을 이해하면 "왜 이 쿼리가 느린가", "왜 디스크가 자꾸 차는가" 같은 운영 질문에 구조적으로 답할 수 있습니다. 세부 동작과 기본값은 버전에 따라 달라질 수 있으니, 실제 튜닝과 운영에서는 공식 문서를 함께 참조하시길 권합니다.
참고 자료
- [PostgreSQL 공식 문서](https://www.postgresql.org/docs/)
- [PostgreSQL — MVCC 동시성 제어](https://www.postgresql.org/docs/current/mvcc.html)
- [PostgreSQL — Write-Ahead Logging](https://www.postgresql.org/docs/current/wal-intro.html)
- [PostgreSQL — Index Types](https://www.postgresql.org/docs/current/indexes-types.html)
- [PostgreSQL — Table Partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html)
- [PostgreSQL — Logical Replication](https://www.postgresql.org/docs/current/logical-replication.html)
- [PostgreSQL — Routine Vacuuming](https://www.postgresql.org/docs/current/routine-vacuuming.html)
- [pgvector 저장소](https://github.com/pgvector/pgvector)
현재 단락 (1/225)
PostgreSQL은 오랜 시간에 걸쳐 다듬어진, 견고하고 확장 가능한 오픈소스 관계형 데이터베이스입니다. 단순히 SQL을 실행하는 도구를 넘어, 동시성 제어, 저장 구조, 쿼리 ...