Skip to content

필사 모드: PostgreSQL 내부 구조 완벽 가이드 — MVCC, WAL, Vacuum, Heap, B-tree, Query Planner 모든 것 (2025)

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며 — 왜 또 Postgres인가

백엔드 엔지니어라면 누구나 PostgreSQL을 쓴다. `SELECT`를 짜고, 인덱스를 만들고, `EXPLAIN`을 읽고, 가끔 autovacuum이 밀릴 때 한숨을 쉰다. 하지만 대부분은 그 밑에서 무슨 일이 일어나는지 모른다. 왜 `UPDATE`가 새 row를 만드는가? 왜 long-running transaction 하나가 디스크를 채우는가? 왜 autovacuum이 필요한가? 왜 index-only scan이 항상 가능하지는 않은가?

이 질문들은 전부 하나의 단어로 수렴한다: **MVCC**. PostgreSQL의 모든 설계 결정은 Multi-Version Concurrency Control에서 파생되며, 이 모델을 이해하면 Postgres의 모든 "이상한 동작"이 논리적으로 설명된다.

이 글은 Postgres의 모든 내부 메커니즘을 1,500줄에 걸쳐 다룬다. 프로세스 아키텍처, 페이지 레이아웃, tuple 헤더의 바이트 구조, xmin/xmax 가시성 규칙, WAL의 LSN과 체크포인트, Vacuum의 Visibility Map과 freeze, B-tree의 leaf page 구조, GIN/GiST/BRIN의 사용처, Query Planner의 cost 모델, shared_buffers와 페이지 캐시, streaming/logical replication까지. 각 절은 독립적이므로 필요한 부분만 읽어도 된다.

이 글은 [DuckDB 내부 구조 글](./2026-04-15-duckdb-internals-vectorized-morsel-driven-columnar-deep-dive-guide-2025)과 짝을 이룬다. DuckDB가 OLAP의 내부를 보여주었다면, 이 글은 OLTP의 내부를 보여준다. 같은 "database"라는 단어가 얼마나 다른 시스템을 가리킬 수 있는지 느껴보자.

1. PostgreSQL의 프로세스 아키텍처

1.1 왜 프로세스인가, 왜 스레드가 아닌가

MySQL은 스레드 기반이다. 연결마다 스레드 하나. PostgreSQL은 **프로세스 기반**이다. 연결마다 프로세스 하나. 이 결정은 1980년대 초 Berkeley Postgres 시절에 내려졌고, 지금까지 유지되고 있다.

왜 프로세스인가? 역사적으로는 스레드가 모든 OS에 잘 지원되지 않던 시절의 선택이었다. 현실적으로는 **격리(isolation)**가 핵심이다. 한 백엔드가 segfault로 죽어도 다른 백엔드는 영향받지 않는다. 메모리 관리 버그가 전파되지 않는다. PostgreSQL의 안정성 명성은 이 구조에 많이 빚지고 있다.

단점도 명확하다. 프로세스 fork는 스레드 생성보다 무겁다. 그래서 수만 개의 동시 연결이 있으면 **PgBouncer** 같은 커넥션 풀러가 거의 필수다.

1.2 프로세스 가족

Postgres 서버를 시작하면 여러 프로세스가 뜬다.

- **postmaster (postgres main process)**: 최상위 부모. 연결을 받아 fork하고 자식 프로세스를 관리.

- **backend**: 클라이언트 연결마다 하나. SQL 파싱, 플래닝, 실행을 담당.

- **background worker**: `CREATE EXTENSION` 등으로 등록된 사용자 정의 백그라운드 작업자.

- **autovacuum launcher + workers**: autovacuum 오케스트레이션.

- **checkpointer**: 주기적으로 dirty page를 디스크에 flush.

- **background writer (bgwriter)**: shared_buffers의 dirty page를 OS 페이지 캐시로 천천히 내보냄.

- **walwriter**: WAL 버퍼를 디스크에 flush.

- **logical replication launcher + workers**: logical replication 처리.

- **stats collector (또는 Postgres 15+의 stat shared memory)**: 통계 수집.

다음 명령으로 한눈에 볼 수 있다 (Postgres가 뜬 상태에서):

ps -ef | grep postgres | grep -v grep

출력 예시:

postgres 1234 1234 postgres: checkpointer

postgres 1235 1234 postgres: background writer

postgres 1236 1234 postgres: walwriter

postgres 1237 1234 postgres: autovacuum launcher

postgres 1238 1234 postgres: logical replication launcher

postgres 2000 1234 postgres: myapp myuser 10.0.0.5(54321) idle

마지막 줄이 사용자 연결의 backend 프로세스다. `postgres:` 뒤에 DB 이름, 사용자, 클라이언트 IP/포트, 상태가 보인다. 운영 중 `idle in transaction` 같은 문자열이 보이면 long-running tx다 — 의심해야 한다.

1.3 공유 메모리 (shared memory)

프로세스들이 데이터를 어떻게 공유할까? **POSIX shared memory**다. postmaster 시작 시 큰 공유 메모리 영역을 할당하고, fork된 자식들이 이 메모리를 매핑한다.

공유 메모리에는 이런 것들이 들어간다:

- **shared_buffers**: 페이지 캐시 (기본 128MB, 프로덕션에서는 RAM의 25%).

- **WAL buffers**: 아직 디스크에 flush되지 않은 WAL 레코드 (기본 `-1` → shared_buffers의 1/32).

- **CLOG (Commit Log)**: 각 트랜잭션의 상태 (COMMITTED / ABORTED / IN_PROGRESS).

- **Lock table**: 테이블/row 잠금.

- **Procarray**: 현재 활성 트랜잭션 목록.

공유 메모리 접근은 **LWLock (Lightweight Lock)** 으로 보호된다. LWLock은 세마포어처럼 동작하지만 더 가볍고 spin + sleep 전략을 섞어 쓴다. Postgres의 확장성 문제 대부분은 LWLock contention이다 — 특히 `WALInsertLock`, `BufferContent`, `ProcArrayLock`.

2. 페이지와 Heap Tuple — 바이트 수준에서

2.1 왜 페이지인가

Postgres는 데이터를 **페이지(page)** 단위로 관리한다. 페이지는 8KB (컴파일 타임 상수 `BLCKSZ`)다. 모든 디스크 I/O는 페이지 단위로 일어나고, shared_buffers도 페이지 단위로 캐시한다.

왜 8KB인가? 역사적 선택이다. 디스크 섹터(512B)의 배수면서, OS 페이지(4KB)의 배수면서, 한 페이지에 여러 row를 담을 만큼 충분히 크다. 다른 DB (Oracle: 8KB 기본, MySQL InnoDB: 16KB)도 비슷한 범위다.

2.2 페이지 레이아웃

8KB 페이지 하나의 구조는 다음과 같다:

+---------------------------------+ offset 0

| PageHeaderData (24 bytes) |

+---------------------------------+

| ItemIdData[] (line pointers) | 앞에서 자라남

| ... |

+- - - - - - - - - - - - - - - - -+

| |

| (free space) |

| |

+- - - - - - - - - - - - - - - - -+

| ... |

| HeapTupleData (actual tuples) | 뒤에서 자라남

+---------------------------------+ offset 8192

| (optional) Special space |

+---------------------------------+

핵심 아이디어: **line pointer는 앞에서, tuple은 뒤에서 자란다**. 가운데가 free space다. 이 구조의 이점은 tuple의 실제 위치가 바뀌어도 line pointer의 인덱스 (OffsetNumber)는 유지된다는 것이다. 그래서 외부에서 tuple을 참조할 때 `(block_number, offset_number)` 쌍 — 즉 **ctid** — 을 쓸 수 있다.

2.3 PageHeader

typedef struct PageHeaderData {

PageXLogRecPtr pd_lsn; // 이 페이지를 수정한 마지막 WAL의 LSN

uint16 pd_checksum; // 선택적 체크섬

uint16 pd_flags; // 페이지 플래그

LocationIndex pd_lower; // free space 시작 offset

LocationIndex pd_upper; // free space 끝 offset

LocationIndex pd_special; // special space 시작

uint16 pd_pagesize_version;

TransactionId pd_prune_xid; // 이 페이지의 prune 후보 xid

ItemIdData pd_linp[]; // flexible array — line pointers

} PageHeaderData;

**pd_lsn**이 특히 흥미롭다. 페이지마다 "이 페이지가 마지막으로 수정된 WAL 위치"를 기록한다. 덕분에 crash recovery 시 "이 페이지는 이미 해당 WAL 이후 상태다" 같은 판단이 가능하다. 이는 **WAL rule**의 구현체다: WAL은 대응되는 데이터 페이지가 디스크에 쓰이기 **전**에 먼저 flush되어야 한다.

2.4 HeapTuple 헤더 — MVCC의 심장

이제 진짜 흥미로운 부분. tuple 하나의 구조는:

typedef struct HeapTupleHeaderData {

union {

HeapTupleFields t_heap; // MVCC 필드

DatumTupleFields t_datum;

} t_choice;

ItemPointerData t_ctid; // 다음 버전의 ctid (self 또는 new)

uint16 t_infomask2; // 속성 수 + 플래그

uint16 t_infomask; // 플래그 비트

uint8 t_hoff; // 사용자 데이터 시작 offset

bits8 t_bits[]; // NULL bitmap

} HeapTupleHeaderData;

typedef struct HeapTupleFields {

TransactionId t_xmin; // 이 tuple을 만든 tx

TransactionId t_xmax; // 이 tuple을 삭제/업데이트한 tx

union {

CommandId t_cid; // cmin/cmax

TransactionId t_xvac; // old-style VACUUM FULL에서 사용

} t_field3;

} HeapTupleFields;

**xmin**, **xmax**, **ctid**, **t_infomask**. 이 네 개가 MVCC의 전부다. 다음 절에서 하나하나 파헤친다.

3. MVCC — Postgres가 세상을 보는 방식

3.1 근본 아이디어

MVCC (Multi-Version Concurrency Control)의 근본 명제: **UPDATE는 row를 변경하지 않는다. 새 버전을 만든다**.

예를 보자. `users` 테이블에 `id=1, name='Alice'`가 있다. 누군가 `UPDATE users SET name='Bob' WHERE id=1`을 실행한다. 무슨 일이 벌어지는가?

1. 기존 tuple (Alice)의 `xmax`에 현재 tx id를 쓴다. "나는 tx N에서 삭제됨."

2. 같은 페이지 (또는 다른 페이지)에 새 tuple (Bob)을 만든다. `xmin = N`.

3. 기존 tuple의 `ctid`를 새 tuple 위치로 업데이트.

이제 페이지에는 **두 버전의 row**가 존재한다. 어느 것을 봐야 하는가? 그건 읽는 트랜잭션의 스냅샷에 따라 다르다.

3.2 스냅샷 (Snapshot)

**스냅샷**은 "이 트랜잭션이 볼 수 있는 세계"다. 구체적으로는 네 개의 값:

- **xmin (snapshot xmin)**: 이 tx보다 작은 (더 오래된) 모든 커밋된 tx는 가시성 보장.

- **xmax (snapshot xmax)**: 이 tx보다 크거나 같은 tx는 불가시.

- **xip_list**: xmin과 xmax 사이에서 아직 실행 중인 tx 목록 (Procarray에서 긁어옴).

- **my xid**: 자신의 tx id.

가시성 규칙은 대략 이렇다 (`HeapTupleSatisfiesMVCC`를 요약):

tuple의 xmin이 커밋됐는가?

NO → tuple은 보이지 않는다 (aborted or in-progress).

YES:

tuple의 xmin이 내 스냅샷의 xmin보다 작은가?

YES → 볼 수 있음.

NO:

tuple의 xmin이 내 스냅샷의 xmax보다 크거나 같은가?

YES → 볼 수 없음.

NO → xip_list에 xmin이 있으면 보지 못함, 없으면 봄.

보인다면:

tuple의 xmax가 없거나 aborted면 → 유효.

tuple의 xmax가 커밋됐고 내 스냅샷 기준으로 보이면 → 이미 삭제됨, 안 보임.

tuple의 xmax가 내 스냅샷 기준 불가시면 → 아직 유효.

이 검사는 `src/backend/utils/time/snapmgr.c`와 `src/backend/access/heap/heapam_visibility.c`에 있다. Postgres 소스 코드에서 가장 자주 실행되는 함수 중 하나다.

3.3 커밋 로그 (CLOG)

"tx가 커밋됐는가?"를 어떻게 아는가? **CLOG (Commit Log)** 를 본다. 각 tx id마다 2비트:

- `00`: IN_PROGRESS

- `01`: COMMITTED

- `10`: ABORTED

- `11`: SUB_COMMITTED (서브트랜잭션 커밋 대기)

4억 개의 tx마다 1GB의 CLOG가 필요하다. 실제로는 최근 것만 디스크에 있고 오래된 것은 freeze 후 삭제된다 (아래 Vacuum 절 참조).

3.4 격리 수준 (Isolation Level)

Postgres가 지원하는 격리 수준은:

- **Read Uncommitted**: 실제로는 Read Committed와 동일.

- **Read Committed** (기본): 각 **문장**마다 새 스냅샷.

- **Repeatable Read**: 트랜잭션 시작 시 하나의 스냅샷을 고정. Postgres의 Repeatable Read는 사실 **Snapshot Isolation**이다.

- **Serializable**: SSI (Serializable Snapshot Isolation) — 2PL 없이 직렬화 가능성 보장.

대부분 앱은 Read Committed로 충분하지만, 재무/회계같은 곳은 Serializable을 권한다. SSI는 read/write dependency를 추적하여 rw-antidependency cycle이 생기면 tx 하나를 abort한다.

3.5 긴 트랜잭션의 재앙

이제 왜 "long-running transaction이 재앙"인지 보인다. 긴 tx는 오래된 xmin을 보유한다. Postgres는 "이 xmin보다 오래된 tuple 버전은 아무도 안 본다"라고 판단해야 Vacuum이 가능하다. 긴 tx는 이 판단을 막는다. 그래서 dead tuple이 쌓이고, 테이블이 부풀고, 인덱스가 비효율적이 된다.

실제로 Postgres 운영자가 가장 자주 외치는 말: **"누가 `idle in transaction`을 남겼는가?"**

SELECT pid, age(backend_xid), state, query

FROM pg_stat_activity

WHERE state = 'idle in transaction'

ORDER BY backend_xid;

4. WAL — Write-Ahead Log

4.1 WAL의 약속

**WAL rule**: 데이터 페이지를 디스크에 쓰기 전에, 그 변경을 설명하는 WAL 레코드를 먼저 디스크에 써야 한다.

이 간단한 규칙이 ACID의 D (Durability)를 보장한다. 크래시가 나도 WAL을 다시 읽어 데이터를 재구성할 수 있다.

WAL 없으면 이런 일이 벌어진다. `UPDATE`가 페이지 두 개를 수정한다 (원래 페이지 + 인덱스 페이지). 첫 페이지는 디스크에 갔고 두 번째는 아직 buffer에 있다. 이 때 크래시. 디스크는 half-updated 상태. 데이터 무결성 파탄.

4.2 LSN (Log Sequence Number)

WAL의 모든 레코드는 **LSN**으로 주소 지정된다. LSN은 단조 증가하는 64비트 값이고, WAL 파일 내의 바이트 오프셋으로 해석된다.

WAL 파일은 기본 16MB 세그먼트로 잘려 `pg_wal/` 디렉토리에 저장된다. 파일 이름은 `000000010000000000000001` 같은 hex 문자열 — timeline + LSN 상위 비트.

LSN은 Postgres 내부의 여러 곳에서 등장한다:

- 페이지 헤더의 `pd_lsn` (이 페이지를 수정한 마지막 WAL)

- replication slot의 `restart_lsn` (replica가 소비 중인 LSN)

- `pg_current_wal_lsn()` 함수

- backup의 시작/끝 표시

SELECT pg_current_wal_lsn(); -- e.g. 0/1A3F2B8

4.3 WAL 레코드의 구조

각 WAL 레코드는 헤더 + 페이로드:

+--------------------------------+

| XLogRecord header |

| - xl_tot_len (총 길이) |

| - xl_xid (트랜잭션 id) |

| - xl_prev (이전 레코드 LSN) |

| - xl_info (op code) |

| - xl_rmid (resource manager) |

| - xl_crc (체크섬) |

+--------------------------------+

| block refs (수정된 페이지 참조)|

+--------------------------------+

| rmgr-specific data |

+--------------------------------+

**rmgr (Resource Manager)** 개념이 핵심이다. WAL은 통합 로그지만, 각 레코드는 "어느 서브시스템이 해석하는가"에 따라 분류된다. `RM_HEAP_ID`, `RM_BTREE_ID`, `RM_XLOG_ID`, `RM_STANDBY_ID` 등. 각 rmgr은 redo 함수를 제공해 crash recovery 시 호출된다.

`pg_waldump` 명령으로 실제 WAL을 볼 수 있다:

rmgr: Heap len: 54 tx: 742 lsn: 0/1A3F2B8 desc: INSERT off 3

rmgr: Btree len: 72 tx: 742 lsn: 0/1A3F2F0 desc: INSERT_LEAF off 5

rmgr: Transaction len: 34 tx: 742 lsn: 0/1A3F340 desc: COMMIT 2026-04-15 ...

이 세 줄은 "tx 742가 heap에 INSERT하고, btree 인덱스에 INSERT_LEAF하고, COMMIT했다"를 의미한다.

4.4 Full-Page Writes

**torn page** 문제: OS/디스크가 페이지를 원자적으로 쓰지 못한다. 8KB 페이지를 쓰는 도중 전원이 나가면 앞 4KB는 새 내용, 뒤 4KB는 옛 내용이 될 수 있다.

해결책: 체크포인트 이후 페이지가 처음 수정될 때 **full-page image**를 WAL에 넣는다. redo 시 이 전체 이미지로 덮어쓰면 된다. 약간 비싸지만 안전하다.

`full_page_writes = on` (기본값)이다. 끄면 안 된다. 단, 파일시스템이 atomic write를 보장하는 ZFS 같은 경우는 예외.

4.5 체크포인트 (Checkpoint)

WAL만 있으면 복구가 가능하다. 그런데 왜 데이터 페이지를 flush하는가? **복구 시간**을 짧게 하기 위해서다. WAL이 100GB이면 복구에 오래 걸린다. 체크포인트는 "이 LSN까지의 모든 변경은 데이터 파일에 반영됐음"을 보장한다. 복구는 그 LSN부터 시작하면 된다.

체크포인트 동안:

1. 현재 redo pointer (체크포인트 시작 시의 LSN)를 기록.

2. shared_buffers의 모든 dirty 페이지를 OS로 내보냄.

3. fsync로 OS 캐시를 flush.

4. `pg_control` 파일에 체크포인트 LSN 기록.

5. redo pointer 이전의 WAL 파일은 재활용 가능.

체크포인트는 I/O 폭주를 유발한다. 그래서 `checkpoint_completion_target = 0.9` (기본 0.9)로 체크포인트 시간을 다음 체크포인트까지의 90%로 늘려 I/O를 분산시킨다.

관련 파라미터:

checkpoint_timeout = 5min # 시간 기반 트리거

max_wal_size = 1GB # 크기 기반 트리거

min_wal_size = 80MB # WAL 재활용 하한

checkpoint_completion_target = 0.9

프로덕션에서는 `max_wal_size`를 10GB–100GB 수준으로 키워 체크포인트 빈도를 낮추는 게 일반적이다.

5. Vacuum — 그림자 속의 정원사

5.1 왜 Vacuum이 필요한가

MVCC 때문에 dead tuple이 쌓인다. `UPDATE` 한 번 → dead tuple 하나. 10만 번 업데이트된 테이블은 dead tuple이 가득 차 있다. Vacuum은 이 dead tuple을 청소한다.

더 중요한 일도 한다: **xid wraparound 방지**.

5.2 XID Wraparound — 32비트의 저주

PostgreSQL의 tx id는 32비트다. 약 42억. 초당 1천 개 tx 실행하면 약 50일에 소진. 이후 어떻게 되는가?

**Wraparound**. xid가 0으로 돌아간다. 그런데 "오래된 tx인지 새 tx인지" 비교는 modular arithmetic으로 한다 — 과거와 미래를 각각 21억 개의 윈도우로 나눈다. Wraparound가 일어나면 "먼 과거의 커밋된 tx"가 갑자기 "먼 미래의 미래 tx"로 보인다. 데이터 대참사.

방지책: **freeze**. 충분히 오래된 tuple의 xmin을 `FrozenTransactionId (2)`로 교체한다. 이 특수 값은 "모든 현재 tx에게 보임"을 의미한다. xid 값 자체가 비교에서 제외된다.

Vacuum이 freeze를 담당한다. `autovacuum_freeze_max_age = 200_000_000` (2억). 한 테이블에서 가장 오래된 xid와 현재 xid의 차이가 이 값을 넘으면, 어떤 상황에서도 aggressive vacuum이 시작된다 — 세상의 누구도 이 vacuum을 막을 수 없다.

이 wraparound autovacuum이 발동하면 시스템이 느려지고, 심한 경우 wraparound를 막지 못하면 Postgres는 스스로 **READ ONLY** 모드로 들어간다. 이 상황이 벌어지면 다음 명령을 본다:

WARNING: database "foo" must be vacuumed within 10000000 transactions

HINT: To avoid a database shutdown, execute a database-wide VACUUM in that database.

5.3 Visibility Map

Vacuum이 매번 모든 페이지를 읽을 수는 없다. **Visibility Map (VM)** 은 각 페이지마다 2비트를 관리한다:

- `ALL_VISIBLE`: 이 페이지의 모든 tuple이 모든 현재 tx에게 보임 (dead tuple 없음).

- `ALL_FROZEN`: 모든 tuple이 frozen 상태.

`ALL_VISIBLE` 페이지는 Vacuum이 건너뛴다. 또한 **index-only scan**에도 사용된다 — 인덱스에서 찾은 tuple이 visible인지 확인하려면 원래 heap을 봐야 하는데, VM이 `ALL_VISIBLE`이면 생략 가능.

VM 파일은 테이블당 `<oid>_vm`으로 저장된다. 테이블의 0.025% 크기 정도다.

5.4 HOT (Heap-Only Tuple) Update

UPDATE는 비싸다. 새 heap tuple을 만들고, 모든 인덱스에 새 entry를 추가해야 한다. 10개 인덱스가 있는 테이블은 UPDATE 한 번에 11번의 쓰기.

**HOT update**는 최적화다. 조건:

1. 업데이트가 인덱스 컬럼을 건드리지 **않음**.

2. 같은 페이지에 새 tuple을 담을 공간이 있음.

이 조건이 맞으면:

- 새 tuple을 같은 페이지에 만든다.

- 인덱스를 업데이트하지 **않는다**.

- 기존 tuple의 ctid가 새 tuple을 가리킨다. 인덱스는 옛 tuple을 계속 가리키지만, 페이지 내 ctid 체인을 따라 새 tuple에 도달.

HOT chain이 길어지면 성능이 떨어진다. Vacuum은 HOT chain을 끊고 dead tuple을 제거하는 **prune**을 같이 수행한다. prune은 full vacuum보다 가볍다 — autovacuum이 도달하기 전에 정상 쿼리 경로에서도 수행된다.

SELECT schemaname, relname, n_tup_upd, n_tup_hot_upd,

round(n_tup_hot_upd::numeric / NULLIF(n_tup_upd, 0) * 100, 2) AS hot_pct

FROM pg_stat_user_tables

ORDER BY n_tup_upd DESC LIMIT 10;

HOT 비율이 50% 이상이면 건강하다. 10% 미만이면 UPDATE가 인덱스 컬럼을 건드리거나 페이지 fill factor 튜닝이 필요하다는 신호.

5.5 Autovacuum 튜닝

Autovacuum은 통계 기반으로 트리거된다. 테이블의 dead tuple 비율이 threshold를 넘으면 실행.

autovacuum_vacuum_threshold = 50 # 최소 row 수

autovacuum_vacuum_scale_factor = 0.2 # 테이블 크기의 20%

trigger = threshold + scale_factor * reltuples

100만 row 테이블은 20만 dead tuple이 쌓여야 vacuum. 큰 테이블에서는 너무 늦다. 프로덕션에서는:

ALTER TABLE large_table SET (

autovacuum_vacuum_scale_factor = 0.05,

autovacuum_vacuum_threshold = 1000,

autovacuum_analyze_scale_factor = 0.02

);

이런 식으로 특정 테이블만 더 공격적으로 설정한다.

또 하나 중요한 파라미터: `autovacuum_vacuum_cost_limit`. autovacuum이 I/O를 얼마나 쓸 수 있는지. 기본 200. SSD 환경에서는 2000–10000까지 올려도 문제없다.

6. B-tree 인덱스 — 가장 흔한 자료구조

6.1 왜 B-tree인가

디스크 기반 인덱스에서 B-tree (정확히는 Postgres의 B+tree 변형)가 지배적인 이유:

1. **높이가 낮다**: 수십억 row도 3–4 레벨.

2. **범위 스캔 친화적**: leaf가 linked list.

3. **순차/역순 모두 효율적**.

4. **동시성 친화적**: Lehman-Yao 알고리즘으로 높은 동시성.

6.2 B-tree 페이지 구조

PostgreSQL B-tree 페이지에는 두 종류:

- **internal page**: 자식 페이지 포인터만.

- **leaf page**: 실제 key + ctid (heap tuple 참조).

leaf page의 entry 구조:

+------------------+

| IndexTupleData |

| - t_tid (ctid) |

| - t_info |

+------------------+

| key data |

+------------------+

`t_tid`가 heap tuple의 (block, offset)을 가리킨다. 인덱스 스캔은 이 ctid를 따라 heap으로 간다. 이 단계를 **heap fetch**라고 부른다.

6.3 Lehman-Yao B-link tree

Postgres의 B-tree는 전통적인 B-tree와 다르다. 각 페이지에 **right-link** 포인터가 있다. 왜?

동시성 때문이다. 한 스레드가 페이지 P를 읽고 있는데 다른 스레드가 P를 분할한다면? 읽던 스레드가 찾고 있던 key가 새로 생긴 페이지 P'로 이동했을 수 있다. Lehman-Yao는 P에 "P'로 가는 링크"를 남긴다. 읽던 스레드는 key가 자기 범위를 벗어나는 걸 감지하면 right-link를 따라가면 된다.

덕분에 Postgres B-tree는 lock을 매우 짧게만 잡는다 — 한 번에 한두 페이지. 고동시성에서도 잘 확장된다.

6.4 Duplicate Key와 Bloat

MVCC 때문에 dead tuple은 인덱스에도 남는다. 동일 key가 여러 번 나타날 수 있다. 이것이 **index bloat**의 원인.

Postgres 12부터 **deduplication** 기능이 추가됐다. 같은 key의 여러 TID를 하나의 posting list로 묶어 공간 절약. B-tree가 GIN처럼 동작한다.

-- Postgres 12+에서 기본 활성화

CREATE INDEX idx_name ON t (col) WITH (deduplicate_items = on);

중복이 많은 컬럼 (예: 성별, 상태 플래그)에서 인덱스 크기가 2–10배 줄어든다.

6.5 Index Bloat 진단

-- pgstattuple extension 필요

SELECT * FROM pgstatindex('idx_name');

`avg_leaf_density`가 50% 미만이면 bloat 심각. 해법은 `REINDEX CONCURRENTLY`:

REINDEX INDEX CONCURRENTLY idx_name;

Postgres 12+에서 concurrently 옵션이 추가됐다. 쓰기를 막지 않고 인덱스 재구축.

7. GIN, GiST, BRIN, Hash — 특수 인덱스들

7.1 GIN (Generalized Inverted Index)

**역색인**. 배열, JSONB, 전문 검색(tsvector)에 쓴다. 각 요소가 key, 그 요소를 포함한 row들이 value.

CREATE INDEX idx_tags ON articles USING GIN (tags);

-- tags는 text[] 타입

SELECT * FROM articles WHERE tags @> ARRAY['postgres', 'mvcc'];

GIN의 내부는 "main tree (key 검색용 B-tree) + 각 key 아래의 posting tree (ctid 목록)". posting tree도 B-tree.

GIN은 삽입이 느리다 (각 요소가 따로 인덱싱되므로). 해결책: **fastupdate**. 새 entry를 일단 pending list에 쌓고, 주기적으로 main index에 병합.

7.2 GiST (Generalized Search Tree)

**다차원/공간 인덱스**. PostGIS의 geometry, 범위 타입 (int4range, tsrange), 전문 검색도 지원.

GiST는 프레임워크다. 사용자가 consistent, union, compress, penalty, picksplit 같은 메서드를 정의하면 인덱스 구조는 자동 생성. 덕분에 임의의 데이터 타입에 인덱스 붙일 수 있다.

CREATE INDEX idx_geom ON places USING GIST (geom);

-- geom는 PostGIS geometry

SELECT * FROM places

WHERE ST_DWithin(geom, 'POINT(-74 40)', 1000);

7.3 BRIN (Block Range Index)

**블록 범위 인덱스**. 각 블록 범위 (기본 128블록 = 1MB)의 요약 (min/max 등)을 저장.

초대형 테이블에서 **물리적 순서가 데이터 값과 상관관계가 있을 때** 매우 효과적. 로그 테이블, 시계열 데이터가 전형.

CREATE INDEX idx_created ON events USING BRIN (created_at);

10억 row 테이블에 B-tree는 수십GB, BRIN은 수MB. 단, 정확한 검색은 못 한다 — 후보 블록을 좁힐 뿐, 여전히 블록 내 scan 필요.

7.4 Hash

Postgres 10 이전에는 WAL 지원 없어 사용 권장하지 않았다. 10부터 WAL 지원, 사용 가능. 단 B-tree 대비 이점이 크지 않아 거의 안 쓰인다.

7.5 어떤 인덱스를 언제

| 사용처 | 인덱스 |

|--------|--------|

| equality / range (int, string) | B-tree |

| 배열 contains, JSONB path | GIN |

| 전문 검색 (tsvector) | GIN (또는 GiST) |

| 공간/다차원 | GiST |

| 시계열 / append-only | BRIN |

| equality only, 매우 큰 데이터 | Hash (B-tree도 괜찮음) |

8. Query Planner — SQL의 번역가

8.1 Planner 파이프라인

SQL text

↓ Lexer/Parser

raw parse tree

↓ Analyzer (rewriter 포함)

Query (resolved references)

↓ Planner

Plan tree (Path → Plan)

↓ Executor

결과

8.2 비용 기반 최적화

Postgres는 **cost-based optimizer**다. 각 plan마다 cost를 추정하고 최소 cost를 선택.

cost는 두 숫자: `(startup_cost, total_cost)`. 단위는 "디스크 페이지 하나 순차 읽기"다. 기본 상수들:

seq_page_cost = 1.0 # 순차 읽기

random_page_cost = 4.0 # 랜덤 읽기

cpu_tuple_cost = 0.01

cpu_index_tuple_cost = 0.005

cpu_operator_cost = 0.0025

SSD 환경에서는 `random_page_cost`를 1.1 정도로 낮춘다. 기본값이 HDD를 가정한 것.

8.3 통계 (Statistics)

planner는 통계에 의존한다:

- **reltuples**: 테이블의 row 수 추정.

- **relpages**: 페이지 수.

- **n_distinct**: 컬럼의 distinct 값 수.

- **histogram**: 값 분포.

- **most common values (MCV)**: 자주 나오는 값과 빈도.

- **correlation**: 물리적 순서와 논리적 순서의 상관.

`ANALYZE` 명령이 이 통계를 갱신한다. autoanalyze가 주기적으로 실행한다 (기본: row 10% 변경 시).

통계가 낡으면 plan이 최악이 된다. 운영 중 갑자기 느려진 쿼리가 있으면 우선 의심할 것:

SELECT relname, n_live_tup, n_dead_tup, last_analyze, last_autoanalyze

FROM pg_stat_user_tables

WHERE relname = 'your_table';

8.4 Join Algorithms

- **Nested Loop**: 외부 루프의 각 row마다 내부 테이블 스캔. 작은 집합에 적합.

- **Hash Join**: 한 쪽으로 hash table 만들고 다른 쪽 scan하며 probe. 중간 크기 조인에 최적.

- **Merge Join**: 양쪽 정렬된 상태에서 merge. 이미 정렬됐거나 정렬 비용이 싸면 선택.

planner는 이 셋을 추정 비용으로 비교한다. `EXPLAIN`의 `Nested Loop`, `Hash Join`, `Merge Join`이 그것.

8.5 Genetic Query Optimizer (GEQO)

12개 이상 테이블 조인하면 plan 조합이 폭발한다. exhaustive search 대신 **유전 알고리즘**으로 준최적해 찾음.

geqo_threshold = 12 # 몇 개 relation부터 GEQO 사용

geqo_effort = 5 # 1-10, 높을수록 품질 vs 시간

OLTP에서는 거의 건드릴 필요 없다. 복잡한 BI 쿼리에서는 `geqo_effort`를 올리거나 `join_collapse_limit`를 조정.

8.6 EXPLAIN 읽는 법

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, FORMAT TEXT)

SELECT u.name, COUNT(o.id)

FROM users u LEFT JOIN orders o ON o.user_id = u.id

WHERE u.created_at > '2026-01-01'

GROUP BY u.name;

출력을 읽을 때 볼 것:

1. **rows 예상 vs 실제**: 큰 차이가 나면 통계 문제.

2. **loops**: nested loop 깊은 곳에서 loops가 크면 재앙.

3. **Buffers**: shared hit vs read. read가 많으면 cache miss — 워밍업 필요하거나 메모리 부족.

4. **Filter 아래의 `Rows Removed`**: 인덱스가 못 잡아준 row 수. 너무 크면 인덱스 후보.

5. **Heap Fetches** (index-only scan): VM이 ALL_VISIBLE이 아니면 이 값이 0이 아니다 → VACUUM 필요.

9. Shared Buffers와 OS 페이지 캐시 — 이중 캐싱

9.1 구조

Postgres는 **자체 버퍼 캐시** (shared_buffers)를 관리한다. 동시에 Linux는 **페이지 캐시**를 관리한다. 같은 페이지가 양쪽에 있을 수 있다 — **double buffering**.

왜 이중인가? 다른 DB (Oracle, DB2)는 O_DIRECT로 OS 페이지 캐시를 우회한다. Postgres는 역사적 이유로 우회하지 않는다. "OS가 잘 관리하니 맡기자"라는 철학.

9.2 Buffer Pool 관리

shared_buffers는 고정 개수의 슬롯. 각 슬롯은 한 페이지를 담음. 페이지 교체는 **Clock-Sweep** 알고리즘:

- 각 버퍼에 usage count (0–5).

- 사용되면 증가. 단, 최대 5.

- 교체 대상 찾기: 포인터를 시계처럼 돌며 usage count를 감소. 0이 되면 evict.

**pinned buffer** 는 교체 대상에서 제외. 쿼리 실행 중 tuple을 가리키는 pin이 있다.

9.3 shared_buffers 얼마로?

통념: **RAM의 25%**. 극단은 추천하지 않는다:

- 너무 작음 → cache miss 많음, OS 페이지 캐시에 의존.

- 너무 큼 → double buffering 낭비, 체크포인트 시 flush 대폭.

RAM 64GB 서버면 `shared_buffers = 16GB`가 출발점.

9.4 효과 측정

SELECT

sum(heap_blks_hit) AS hit,

sum(heap_blks_read) AS read,

round(sum(heap_blks_hit)::numeric / NULLIF(sum(heap_blks_hit + heap_blks_read), 0) * 100, 2) AS hit_ratio

FROM pg_statio_user_tables;

`hit_ratio`가 99% 미만이면 메모리 부족 신호. 단, 이 값은 OS 페이지 캐시를 포함하지 않는다 — 실제 디스크 I/O는 더 적을 수 있다.

9.5 pg_buffercache

CREATE EXTENSION pg_buffercache;

SELECT c.relname, count(*) AS buffers

FROM pg_buffercache b

JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)

GROUP BY c.relname

ORDER BY 2 DESC LIMIT 10;

어느 테이블이 버퍼를 얼마나 쓰는지 본다. hot 테이블을 찾는 데 유용.

10. Replication — 한 서버에서 여러 서버로

10.1 Streaming Replication

primary의 WAL을 replica가 네트워크로 받아 적용. 가장 일반적인 방식.

설정:

postgresql.conf (primary)

wal_level = replica

max_wal_senders = 10

pg_hba.conf

host replication replicator 10.0.0.0/24 md5

replica 초기화

pg_basebackup -h primary -U replicator -D /var/lib/postgres/data -R -P

`pg_basebackup` 은 primary의 full copy + WAL을 뜬다. `-R`은 recovery 설정을 자동 생성. replica를 시작하면 primary에 붙어 WAL을 스트리밍한다.

10.2 Synchronous vs Asynchronous

- **asynchronous** (기본): primary가 커밋 → 즉시 응답. replica는 나중에 받음. 빠르지만 primary 크래시 시 데이터 유실 가능.

- **synchronous**: primary가 커밋 → replica의 receipt/flush/apply를 기다리고 응답. 느리지만 0 유실.

설정:

synchronous_commit = on

synchronous_standby_names = 'FIRST 1 (replica1, replica2)'

"replica1/2 중 가장 먼저 응답한 하나를 기다림". `FIRST 2`면 두 replica를 기다림.

`synchronous_commit`는 세션별로도 설정 가능. 중요한 트랜잭션만 synchronous로 할 수 있다.

10.3 Logical Replication

WAL 스트리밍이 physical이라면 logical은 **논리적 변경** (INSERT/UPDATE/DELETE 문 단위)을 주고받는다. 같은 Postgres 버전이 아니어도 됨. 선택적 테이블 복제 가능.

-- primary

CREATE PUBLICATION mypub FOR TABLE users, orders;

-- replica (다른 DB)

CREATE SUBSCRIPTION mysub

CONNECTION 'host=primary user=repl password=...'

PUBLICATION mypub;

내부적으로는 **logical decoding**을 쓴다. WAL을 읽어 "INSERT into users(1, 'Alice')" 같은 논리적 표현으로 변환. Debezium 등 CDC 도구가 이걸 활용한다.

10.4 Replication Slot

**replication slot**은 primary가 "이 replica/consumer가 아직 못 읽은 WAL"을 기억하는 메커니즘. slot이 활성 상태인 동안은 primary가 그 WAL을 지우지 못한다.

양날의 검:

- 장점: replica가 잠깐 끊겨도 WAL 유실 없음.

- 단점: replica가 영원히 죽었는데 slot이 남아있으면 primary의 `pg_wal/` 디렉토리가 무한 성장.

운영 중 `pg_wal`이 터질 듯 커지면 제일 먼저:

SELECT slot_name, active, pg_size_pretty(

pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)

) AS lag

FROM pg_replication_slots;

inactive인데 lag가 큰 slot이 있으면 `pg_drop_replication_slot('slot_name')` 후보.

10.5 Failover

primary 다운 시 replica를 승격:

SELECT pg_promote();

수동으로는 간단하지만, 자동화는 어렵다 — split-brain을 막으려면 quorum 기반 상태 판단이 필요. **Patroni**, **repmgr**, **pg_auto_failover** 같은 도구를 쓴다.

Patroni는 etcd/Consul에 리더십을 기록한다. primary가 주기적으로 갱신, 실패하면 다른 노드가 takeover. Kubernetes 위에 Postgres 올리면 거의 Patroni + CNPG 조합.

11. 파티셔닝 (Partitioning)

11.1 왜 파티셔닝

10억 row 테이블에서:

- 인덱스가 거대해져 캐싱 어려움.

- Vacuum이 영원히 걸림.

- 오래된 데이터 삭제가 DELETE로는 비현실적.

파티셔닝: 하나의 논리적 테이블을 여러 물리적 테이블로 쪼개기.

11.2 선언적 파티셔닝 (Postgres 10+)

CREATE TABLE events (

id bigserial,

user_id int,

created_at timestamp NOT NULL,

data jsonb

) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_04 PARTITION OF events

FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

CREATE TABLE events_2026_05 PARTITION OF events

FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

`created_at`에 WHERE 조건을 걸면 planner가 **partition pruning** — 관련 파티션만 스캔.

11.3 오래된 데이터 drop

월별 파티션이면:

DROP TABLE events_2025_04; -- 1년 전 데이터 즉시 삭제

테라바이트 삭제를 초 단위로. DELETE로는 몇 시간씩 걸릴 작업.

11.4 주의점

- **Primary key**는 파티션 키를 포함해야 한다.

- **Unique constraint**는 파티션 단위로만 보장. 전역 unique는 별도 작업 필요.

- **ALTER TABLE ATTACH PARTITION CONCURRENTLY** (Postgres 14+)로 락 최소화.

- 파티션 개수가 많으면 planner overhead. 100개까지는 괜찮음, 1000개는 문제.

자동 파티션 관리는 **pg_partman** extension이 표준.

12. 실전 운영 체크리스트

12.1 부팅 시 설정

postgresql.conf

shared_buffers = 16GB # RAM 25%

work_mem = 32MB # 쿼리당 sort/hash 메모리

maintenance_work_mem = 2GB # VACUUM, CREATE INDEX

effective_cache_size = 48GB # planner가 참고 — OS 캐시 포함 추정

random_page_cost = 1.1 # SSD

effective_io_concurrency = 200 # SSD

wal_compression = on

wal_level = replica

max_wal_size = 16GB

checkpoint_completion_target = 0.9

autovacuum_vacuum_cost_limit = 2000

log_min_duration_statement = 1000 # 1초 넘는 쿼리 로깅

log_lock_waits = on

12.2 필수 extension

CREATE EXTENSION pg_stat_statements; -- 쿼리별 통계

CREATE EXTENSION pgstattuple; -- bloat 측정

CREATE EXTENSION auto_explain; -- 느린 쿼리 EXPLAIN 자동 로깅

12.3 모니터링 쿼리

**Top slow queries**:

SELECT query, calls, total_exec_time, mean_exec_time, rows

FROM pg_stat_statements

ORDER BY total_exec_time DESC

LIMIT 20;

**Bloat 체크**:

SELECT schemaname, relname,

pg_size_pretty(pg_total_relation_size(relid)) AS total_size,

n_dead_tup, n_live_tup,

round(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct

FROM pg_stat_user_tables

WHERE n_dead_tup > 10000

ORDER BY n_dead_tup DESC;

**사용되지 않는 인덱스**:

SELECT schemaname, relname, indexrelname, idx_scan,

pg_size_pretty(pg_relation_size(indexrelid)) AS size

FROM pg_stat_user_indexes

WHERE idx_scan = 0

AND schemaname NOT IN ('pg_catalog', 'pg_toast')

ORDER BY pg_relation_size(indexrelid) DESC;

**락 대기**:

SELECT blocked.pid AS blocked_pid,

blocked.query AS blocked_query,

blocking.pid AS blocking_pid,

blocking.query AS blocking_query

FROM pg_stat_activity blocked

JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))

WHERE blocked.state = 'active';

12.4 안티 패턴

- **`SELECT * FROM t WHERE LOWER(col) = 'x'`**: `LOWER(col)` 인덱스가 없으면 풀 스캔. `CREATE INDEX ON t (LOWER(col))` 또는 `CITEXT` 타입.

- **long-running transaction**: autovacuum 차단. 배치는 잘게 쪼개기.

- **`UPDATE`로 많은 row 수정**: dead tuple 폭발. `CREATE TABLE new AS SELECT` 후 swap을 고려.

- **무의식적 `NOT IN` with NULLable**: `NOT IN` 은 NULL 있으면 전체 false. `NOT EXISTS` 쓰기.

- **`OFFSET 1000000`**: 큰 offset은 그 앞의 row를 모두 읽어야 함. keyset pagination ("seek method") 쓰기.

13. 미래 — Postgres 17/18 이후

- **Asynchronous I/O**: Postgres 17에서 실험적, 18에서 안정화 방향. io_uring 활용.

- **Direct I/O**: O_DIRECT 지원 진전. double buffering 해소.

- **64-bit xid**: 오랜 숙원. Wraparound 문제 영구 해결. 진행 중.

- **Incremental Materialized View**: Postgres 18 후보. 전체 REFRESH 없이 증분 갱신.

- **Logical replication의 DDL 지원**: 현재는 DDL 수동 동기화 필요. 자동화 예정.

맺음 — 코끼리를 타는 법

Postgres를 한마디로 요약하면 **"MVCC를 극단적으로 일관되게 적용한 시스템"**이다. UPDATE가 새 row를 만들고, dead tuple이 쌓이고, Vacuum이 청소하고, Freeze가 xid wraparound를 막는다. 이 한 줄짜리 서사를 다면적으로 풀어낸 게 이 글이다.

운영자로서 가장 중요한 세 가지 본능을 정리한다:

1. **Long-running transaction을 두려워하라**. 모든 재앙의 시작이다.

2. **Autovacuum을 신뢰하되 감시하라**. 기본값은 대부분 작업에 불충분하다.

3. **EXPLAIN ANALYZE를 읽을 수 있어야 한다**. 통계가 낡으면 plan이 망가진다.

이 세 가지를 본능적으로 체크하게 되면, 대부분의 Postgres 이슈는 30분 안에 잡힌다.

다음 글은 **B-tree vs LSM** — Postgres와 대척점에 있는 RocksDB/Cassandra/ScyllaDB의 세계를 다룬다. 같은 "데이터베이스"라는 단어가 얼마나 다른 설계 철학을 품을 수 있는지, 그리고 각 모델이 어떤 워크로드에서 승리하는지를 보인다.

코끼리는 길들일 수 있다. 그 뼈대를 이해하면.

현재 단락 (1/467)

백엔드 엔지니어라면 누구나 PostgreSQL을 쓴다. `SELECT`를 짜고, 인덱스를 만들고, `EXPLAIN`을 읽고, 가끔 autovacuum이 밀릴 때 한숨을 쉰다. 하지...

작성 글자: 0원문 글자: 21,927작성 단락: 0/467