PostgreSQL의 모든 데이터가 저장되는 물리 구조를 알아보자. 이후 편에서 다룰 MVCC, VACUUM, 인덱스가 왜 그렇게 동작하는지는 이 구조를 알아야 이해된다.
이 글에서 다루는 것:
- 8KB 페이지 레이아웃 (PageHeaderData, Line Pointer, 튜플 헤더 (xmin · xmax · ctid · t_infomask))
- Shared Buffers (Hash Table · Buffer Descriptor · Buffer Block)
- Clock Sweep
- Ring Buffer
모든 것은 8KB 페이지다
PostgreSQL에서 테이블, 인덱스, 시퀀스는 전부 고정 크기 8KB 페이지의 배열로 저장된다. 디스크에서 읽을 때도, 메모리에 올릴 때도, 단위는 항상 이 8KB 페이지다. "블록(block)"과 "페이지(page)"는 같은 것을 의미한다.
테이블 파일이 1GB를 넘으면 OS 호환성을 위해 1GB 단위로 파일이 분할되지만, 내부 구조는 동일하다.

하나의 8KB 페이지는 세 영역으로 나뉜다.
실제 숫자로 보면 바로 이해된다. 빈 테이블에 행을 넣어보자.
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE TABLE demo (id int, name text);
-- 행을 3개 넣은 직후
INSERT INTO demo VALUES (1, 'Alice'), (2, 'Bob'), (3, 'Carol');
SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('demo', 0));

- lower = 36: PageHeaderData 24바이트 + Line Pointer 3개 × 4바이트 = 36. Line Pointer 배열이 여기서 끝난다.
- upper = 8080: Tuple 데이터가 여기서 시작한다. 8080~8192 사이에 3개의 튜플이 들어 있다.
- pagesize = 8192: 페이지 크기는 기본값 8KB.
- Free Space = upper - lower = 8044바이트. 새 행이 들어올 자리가 이만큼 남았다.
행을 더 넣으면 lower는 커지고 upper는 작아진다. 둘이 만나면 페이지가 가득 찬 것이고, PostgreSQL은 새 페이지를 할당한다.
4개의 행을 더 넣은 결과(총 7개의 행)는 다음과 같다.

이제 각 영역을 하나씩 알아보자.
한 가지 알아두면 좋은 점:
튜플은 실제 데이터 크기보다 더 많은 공간을 차지할 수 있다. PostgreSQL은 CPU가 효율적으로 접근하도록 데이터를 MAXALIGN(보통 8바이트) 단위로 정렬한다. 예를 들어 int(4바이트) + smallint(2바이트) = 6바이트이지만, 정렬 패딩이 들어가 8바이트를 차지한다. 튜플 헤더(23바이트)도 24바이트로 정렬되는 것과 같은 원리다. 컬럼이 많은 테이블에서 컬럼 순서를 바꾸면 행 크기가 달라질 수 있는 이유가 이것이다.
PageHeaderData (24바이트)
페이지의 메타데이터가 담긴다.


가장 자주 보게 되는 필드는 pd_lsn, pd_lower, pd_upper 세 가지다.
Line Pointer
헤더 바로 다음, 24번 바이트부터 위에서 아래로 자란다. 각 Line Pointer는 4바이트짜리 구조체로, 세 가지 정보를 담는다.
- lp_off — 이 Line Pointer가 가리키는 튜플의 페이지 내 바이트 오프셋
- lp_len — 튜플의 전체 길이 (헤더 + null bitmap + 패딩 + 실제 데이터)
- lp_flags — 현재 상태
lp_flags 상태는 네 가지다.
| lp_flags |
의미 |
| LP_NORMAL (1) | 정상 튜플을 가리킴 |
| LP_REDIRECT (2) | HOT update 후 다른 라인 포인터로 리다이렉트 |
| LP_DEAD (3) | dead tuple — VACUUM 대기 |
| LP_UNUSED (0) | 비어 있음, 재사용 가능 |
새 행이 들어올 때마다 새 Line Pointer가 추가되어 pd_lower가 점점 커진다.
Tuple 데이터

페이지의 끝(8192번 바이트)부터 아래에서 위로 자란다. Line Pointer가 위에서 자라고 Tuple이 아래에서 자라니, 양쪽에서 Free Space를 줄여가는 구조다.
왜 이렇게 양쪽에서 자라게 만들었을까? 한 방향으로만 쌓으면 빈 공간을 알려면 둘의 합을 따로 추적해야 한다. 양쪽에서 자라게 하면 pd_lower와 pd_upper 사이의 거리가 곧 남은 공간이라 계산이 간단해진다.
한 가지 알아두면 좋은 점: 튜플은 실제 데이터 크기보다 더 많은 공간을 차지할 수 있다. PostgreSQL은 CPU가 효율적으로 접근하도록 데이터를 MAXALIGN(보통 8바이트) 단위로 정렬한다. 예를 들어 int(4바이트) + smallint(2바이트) = 6바이트이지만, 정렬 패딩이 들어가 8바이트를 차지한다. 튜플 헤더(23바이트)도 24바이트로 정렬되는 것과 같은 원리다. 컬럼이 많은 테이블에서 컬럼 순서를 바꾸면 행 크기가 달라질 수 있는 이유가 이것이다.
튜플 헤더 — 행 하나에 딸린 메타데이터
각 튜플은 HeapTupleHeaderData라는 헤더로 시작한다. 크기는 23바이트이지만 MAXALIGN 정렬로 실제로는 24바이트를 차지한다.
| 필드 | 크기 | 역할 |
| t_xmin | 4바이트 | 이 튜플을 만든 트랜잭션 ID |
| t_xmax | 4바이트 | 이 튜플을 삭제/갱신한 트랜잭션 ID (0이면 살아 있음) |
| t_cid | 4바이트 | 같은 트랜잭션 내 명령 순번 (cmin/cmax). t_xvac과 같은 공간을 공유 |
| t_ctid | 6바이트 | 이 튜플의 물리적 위치 — 페이지 번호(4바이트) + 라인 포인터 번호(2바이트) |
| t_infomask2 | 2바이트 | 컬럼 수(하위 11비트) + HOT 관련 플래그 |
| t_infomask | 2바이트 | hint bits (커밋 여부 캐시), 행 락 플래그 등 |
| t_hoff | 1바이트 | 실제 사용자 데이터가 시작되는 오프셋. null bitmap 유무에 따라 달라짐 |
- t_xmin / t_xmax
MVCC의 핵심이다. INSERT하면 t_xmin에 현재 트랜잭션 ID가 기록된다. DELETE하면 t_xmax에 현재 트랜잭션 ID가 기록된다. UPDATE는 옛 튜플의 t_xmax를 채우고, 새 튜플의 t_xmin을 채운다. 어떤 트랜잭션이 SELECT할 때, 이 두 값과 자기 스냅샷을 비교해서 "이 튜플이 보이는가"를 판단한다. (2편 MVCC에서 상세히 다룸) - t_ctid
한 트랜잭션 안에서 여러 SQL 문을 실행할 때 쓰인다. 같은 트랜잭션 안에서 INSERT한 행을 뒤의 SELECT가 볼 수 있어야 하고, 커서가 열린 시점 이후의 변경은 안 보여야 한다. 이 순서를 t_cid로 구분한다. t_xvac(VACUUM FULL이 행을 옮길 때 사용)과 메모리를 공유하지만, 둘이 동시에 쓰이는 일은 없다. - t_ctid
이 튜플의 물리적 주소이자, UPDATE 시 새 버전으로 가는 포인터다. 자기 자신을 가리키면 최신 버전이고, 다른 위치를 가리키면 "내 새 버전은 저기 있다"는 뜻이다. 아래 ctid 섹션에서 상세히 다룬다. - t_infomask2
하위 11비트에 이 튜플의 컬럼 수가 들어 있다 (최대 2048개, 하지만 실제 한계는 250~1600개). 나머지 비트에는 HOT update 여부(HEAP_HOT_UPDATED, HEAP_ONLY_TUPLE) 등의 플래그가 저장된다. - t_infomask
두 가지 역할을 한다.- hint bits: 튜플의 xmin/xmax 트랜잭션이 커밋됐는지 여부를 캐싱한다. 원래는 매번 CLOG(커밋 로그)를 조회해야 하지만, 한 번 확인한 결과를 hint bits에 기록해두면 다음부터는 튜플 헤더만 보고 바로 판단할 수 있다.
- 행 락 플래그: t_xmax에 기록된 값이 "이 행을 삭제한 트랜잭션"인지 "이 행에 락을 건 트랜잭션"인지를 구분하는 비트를 저장한다. (3편 Locking에서 다룸)
- t_hoff
튜플 헤더 끝에서 실제 사용자 데이터가 시작되는 바이트 오프셋이다. 왜 별도 필드가 필요할까? NULL을 포함하는 컬럼이 있으면 헤더 뒤에 null bitmap이 추가되어 헤더 길이가 달라진다. 어디서부터 실제 데이터인지를 t_hoff로 알려준다.
직접 보면 더 이해가 빠르다. 위에서 만든 demo 테이블에 UPDATE를 한 번 해보자.
UPDATE demo SET name = 'Bobby' WHERE id = 2;
SELECT lp, lp_off, lp_flags, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('demo', 0));

볼 것들:
- t_xmin = 255863: 처음 INSERT한 트랜잭션이 255863번이었다는 뜻이다. 행 1, 2, 3 모두 같은 트랜잭션에서 들어왔다.
- 2번 라인의 t_xmax = 255866: UPDATE 트랜잭션(255866번)이 옛 Bob을 "삭제됨"으로 표시했다. 물리적으로 삭제하지 않고 xmax만 기록했다는 점이 핵심이다.
- 2번 라인의 t_ctid = (0,8): 옛 Bob이 자기 자신이 아니라 8번을 가리킨다. "내 새 버전은 저기 있다"는 포인터다.
- 8번 라인은 새로 생긴 Bobby: t_xmin이 UPDATE 트랜잭션 ID(255866)와 같다.
이게 PostgreSQL의 핵심 동작이다. UPDATE는 행을 덮어쓰지 않는다. 옛 버전의 xmax를 채우고, 새 버전을 추가한다. DELETE도 마찬가지로 xmax만 채우고 끝낸다. 둘 다 "옛 버전은 여전히 페이지에 남아 있다."
이 설계는 동시성 제어(MVCC)를 위한 것이다. 옛 버전이 남아 있어야 다른 트랜잭션이 자기 시점에 맞는 데이터를 볼 수 있다. 대신 시간이 지나면 어떤 트랜잭션도 더 이상 보지 않는 옛 버전(dead tuple)이 쌓이고, 이를 정리하는 것이 VACUUM이다 (4편에서 다룸).
ctid — 행의 물리적 주소
ctid는 (페이지 번호, 라인 포인터 번호) 형태의 물리적 주소다. 직접 확인할 수 있다.
SELECT ctid, * FROM my_table LIMIT 5;
ctid | id | name
-------+----+------
(0,1) | 1 | Alice
(0,2) | 2 | Bob
(0,3) | 3 | Carol
(1,1) | 4 | Dave -- 페이지 1로 넘어감
(1,2) | 5 | Eve
인덱스는 이 ctid를 저장한다.
여기서 중요한 점이 하나 있다. 인덱스는 튜플의 물리적 바이트 위치(오프셋)가 아니라, 라인 포인터 번호를 가리킨다. 인덱스가 (0,2)를 저장하고 있다면, 이건 "0번 페이지의 8032번 바이트"가 아니라 "0번 페이지의 2번 라인 포인터"를 의미한다.
이 간접 참조(indirection) 덕분에 PostgreSQL은 튜플이 페이지 안에서 물리적으로 자리를 옮겨도 인덱스를 건드리지 않아도 된다. 라인 포인터만 새 위치를 가리키게 바꾸면, 인덱스는 여전히 같은 라인 포인터 번호를 가리키고 있으니 그대로 동작한다.
이게 실제로 쓰이는 대표적인 경우가 HOT(Heap-Only Tuple) update다. UPDATE 시 인덱스 컬럼이 바뀌지 않았고 새 버전이 같은 페이지에 들어갈 수 있으면, PostgreSQL은 옛 라인 포인터를 LP_REDIRECT로 바꿔서 새 튜플의 라인 포인터를 가리키게 한다. 인덱스는 수정하지 않는다.
인덱스 항목: ctid = (0, 2)
↓
Line Pointer 2 [LP_REDIRECT → Line Pointer 4]
↓
Line Pointer 4 → 새 튜플 (실제 데이터)
페이지 레이아웃에 대한 자세한 내용은 PostgreSQL 공식 문서에서 볼 수 있다.

Shared Buffers — 디스크와 쿼리 사이의 메모리

PostgreSQL의 모든 읽기와 쓰기는 shared buffer pool을 거친다. 쿼리가 페이지를 필요로 하면 먼저 shared buffers에 있는지 확인하고, 없으면 디스크에서 읽어 올린다. 쓰기도 shared buffers에서 먼저 변경하고, 나중에 디스크에 반영한다.
기본값은 128MB이고, 보통 전체 RAM의 25% 정도로 설정한다.
Shared buffers 세 가지 구성요소

Hash Table
(테이블 OID, 페이지 번호)를 입력하면 슬롯 번호를 O(1)로 반환한다.
쿼리가 특정 페이지를 필요로 할 때, 먼저 Hash Table에서 "이 페이지가 이미 메모리에 올라와 있는가?"를 확인한다. 있으면 해당 슬롯으로 바로 가고(cache hit), 없으면 디스크에서 읽어 빈 슬롯에 올린다(cache miss).
Buffer Descriptors
각 슬롯의 상태를 추적하는 메타데이터다. 슬롯마다 하나씩 붙어 있다.
| 필드 | 역할 |
| tag | 이 슬롯에 어떤 테이블의 몇 번 페이지가 올라와 있는가 |
| dirty | 메모리에서 변경됐지만 아직 디스크에 안 쓰인 상태인가 |
| pin_count | 현재 이 슬롯을 사용 중인 백엔드 프로세스 수. 0이 아니면 퇴거 불가 |
| usage_count | 접근할 때마다 +1 (최대 5). Clock Sweep이 퇴거 대상을 고를 때 사용 |
Buffer Block
실제 8KB 페이지 데이터가 올라가는 슬롯. Buffer Descriptor와 1:1로 대응한다. 쿼리가 읽고 쓰는 것은 이 Buffer Block 안의 페이지다.
페이지를 찾는 흐름
쿼리: "orders 테이블의 5번 페이지가 필요하다"

슬롯이 가득 차면?
모든 슬롯이 사용 중인데 새 페이지를 올려야 하면, 누군가를 내보내야 한다. 이때 Buffer Descriptor의 usage_count와 pin_count를 보고 퇴거 대상을 고른다. 퇴거 대상을 고르는 알고리즘은 아래에서 다룰 Clock Sweep이다.
단, 퇴거 대상이 dirty 페이지라면 그냥 버리면 안 된다. dirty 페이지는 메모리에서 변경됐지만 아직 디스크에 반영되지 않은 상태다. 그대로 버리면 변경 내용이 사라진다. 디스크에 먼저 쓴 다음에야 퇴거할 수 있다. 그리고 디스크에 쓰기 전에 해당 WAL 레코드가 먼저 flush되어야 한다 — 이 순서가 5편 WAL의 핵심내용이다.
Clock Sweep — 어떤 페이지를 내보낼 것인가
shared buffers가 가득 차면 누군가를 내보내야 한다. PostgreSQL은 LRU 대신 clock sweep 알고리즘을 쓴다.
동작은 단순하다. 버퍼 풀을 원형 리스트로 취급하고, 포인터가 한 바퀴를 돌면서 각 슬롯의 usage_count를 확인한다.
- 페이지에 접근할 때마다 usage_count가 1 증가한다 (최대 5).
- sweep 시: usage_count > 0이면 1을 빼고 넘어간다.
- usage_count = 0이고 pin되지 않은 슬롯을 만나면 → 퇴거 대상.
[buf0: usage=3] → [buf1: usage=0 ← 퇴거!] → [buf2: usage=1→0] → [buf3: pinned, skip] → ...
↑ sweep 포인터가 시계 방향으로 이동
LRU라면 매번 접근 순서를 리스트로 재정렬해야 하지만, clock sweep은 카운터만 증감하면 되니 오버헤드가 훨씬 낮다. 자주 접근되는 페이지는 usage_count가 높아서 여러 바퀴 동안 살아남고, 한 번만 읽힌 페이지는 금방 밀려난다.
pg_buffercache로 실제 shared buffers의 상태를 볼 수 있다.
CREATE EXTENSION pg_buffercache;
-- usage_count 분포 확인
SELECT usagecount, count(*)
FROM pg_buffercache
GROUP BY usagecount
ORDER BY usagecount;
usage_count 분포를 보면 buffer pool이 잘 활용되고 있는지(높은 카운트가 많음), 아니면 일회성 스캔에 휘둘리고 있는지(0이 많음) 감을 잡을 수 있다.

특정 테이블이 buffer pool에서 차지하는 비중이 어느정도 인지 확인할 수 있다.
-- 특정 테이블이 buffer pool에서 차지하는 비중
SELECT count(*) AS buffers,
pg_size_pretty(count(*) * 8192) AS size
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = c.relfilenode
WHERE c.relname = 'my_table';

Ring Buffer — 대형 스캔으로부터 캐시 보호
clock sweep만으로는 한 가지 문제가 남는다. 대형 테이블을 sequential scan하면 수만 개의 페이지가 한꺼번에 shared buffers를 훑고 지나가면서, 다른 핫 데이터를 전부 밀어낼 수 있다.
PostgreSQL은 이를 ring buffer로 해결한다. 특정 작업에는 shared buffers 전체가 아닌 전용 링을 할당하고, 그 링 안에서만 페이지를 교체한다.
| 작업 링 | 크기 | 조건 |
| 대형 테이블 Seq Scan | 32페이지 (256KB) | 테이블 크기 > shared_buffers의 1/4 |
| VACUUM | 32페이지 | 항상 |
| Bulk Write (COPY, CTAS) | 2048페이지 (16MB) | 최대 shared_buffers의 1/8 |
덕분에 COPY로 수십 GB를 밀어 넣어도, 자주 읽히는 인덱스 페이지가 shared buffers에서 밀려나지 않는다.
정리

다음 글에서는 MVCC를 다룬다. 여기서 다룬 페이지와 페이지에 들어 있는 튜플(t_xmin, t_xmax)가 어떻게 동시성 제어에 쓰이는지,
왜 PostgreSQL은 행을 덮어쓰지 않고 여러 버전을 남겨두는지 알 수 있다.
'데이터베이스 > PostgreSQL 내부 구조' 카테고리의 다른 글
| PostgreSQL 내부구조 (6) ㅡ Index (0) | 2026.07.18 |
|---|---|
| PostgreSQL 내부 구조 (5) — WAL과 Checkpoint, 장애 복구의 핵심 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 (4) — VACUUM, 옛 버전은 누가 치우는가 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 (3) — Locking, 동시성의 또 다른 축 (1) | 2026.04.18 |
| PostgreSQL 내부 구조 (2) — MVCC와 격리 수준 (1) | 2026.04.18 |
| PostgreSQL 기본 (0) — 연결과 프로세스 구조 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 — 시리즈를 시작하며 (0) | 2026.04.16 |