본문으로 건너뛰기

1.5 디스크 구조

PostgreSQL의 모든 테이블·인덱스는 같은 단위로 디스크에 저장된다 — 8KB 페이지(page) → 1GB 세그먼트(segment) 파일 → relation → database → cluster. 페이지 안에는 tuple과 메타 정보가 들어 있고, 옆에는 Free Space Map과 Visibility Map이 따라다닙니다. 이 계층을 위에서 아래로 정리합니다.

디스크 저장 계층

    flowchart TD
  DB["Database<br/>(base/&lt;db oid&gt;/)"]
  REL["Relation<br/>(테이블/인덱스 1개)"]
  FORK["Fork<br/>main · fsm · vm · init"]
  SEG["Segment<br/>1GB 단위 파일"]
  PAGE["Page<br/>8KB"]
  TUP["Tuple<br/>(row + header)"]

  DB --> REL
  REL --> FORK
  FORK --> SEG
  SEG --> PAGE
  PAGE --> TUP

  classDef l1 fill:#ede9fe,stroke:#6d28d9,color:#3b0764,stroke-width:2px
  classDef l2 fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
  classDef l3 fill:#d1fae5,stroke:#047857,color:#064e3b
  classDef l4 fill:#fef3c7,stroke:#b45309,color:#78350f
  class DB,REL l1
  class FORK,SEG l2
  class PAGE l3
  class TUP l4
  

Relation과 fork

PostgreSQL이 “테이블 한 개"를 디스크에 저장할 때는 실제로 여러 개의 파일 그룹(fork)을 사용합니다.

Fork파일명역할
main<relfilenode>실제 tuple 데이터. 인덱스의 경우 인덱스 페이지
fsm<relfilenode>_fsmFree Space Map — 어느 페이지에 빈 공간이 얼마나 있는지
vm<relfilenode>_vmVisibility Map — 어느 페이지가 “모든 트랜잭션에서 보임” 상태인지
init<relfilenode>_initunlogged 테이블 초기화용. crash 후 복원 시 사용

relfilenode는 8자리 숫자로, pg_class.relfilenode 컬럼에서 조회 가능합니다. 보통 pg_class.oid와 같지만 VACUUM FULL·REINDEX·CLUSTER·TRUNCATE 후에는 새 relfilenode가 발급되어 달라집니다.

SELECT relname, relfilenode, pg_relation_filepath(oid)
  FROM pg_class
 WHERE relname = 'orders';
-- relname | relfilenode | pg_relation_filepath
-- --------+-------------+----------------------
-- orders  |       16389 | base/16384/16389

Segment — 1GB 단위 파일

한 relation이 커지면 OS의 큰 파일 처리 제약을 피하기 위해 1GB마다 새 파일로 잘라 저장합니다.

base/16384/16389       # 0 ~ 1GB
base/16384/16389.1     # 1 ~ 2GB
base/16384/16389.2     # 2 ~ 3GB
...

세그먼트 크기 기본값 1GB는 **빌드 시점 옵션 --with-segsize**로 정해집니다. 일반 패키지는 1GB. PGDG·공식 Docker도 1GB. 운영 중 변경 불가능합니다.

한 relation이 1GB를 넘으면 자동으로 .1, .2 세그먼트가 만들어집니다. 파일이 여러 개라도 PostgreSQL에서는 논리적으로 하나의 relation으로 보입니다. backup·rsync 도구는 모두 같이 챙겨 가야 합니다.

Page — 8KB의 기본 I/O 단위

세그먼트는 8KB 페이지의 연속입니다. 페이지 크기는 빌드 시 옵션 --with-blocksize=8(KB)로 정해지며, 표준은 8KB다.

페이지 한 장의 내부 구조:

    flowchart LR
  subgraph Page["Page (8192 bytes)"]
    H["PageHeader<br/>24 bytes"]
    LP["LinePointer 배열<br/>(itemId, 4 bytes each)"]
    FREE["빈 공간"]
    TUP3["tuple 3"]
    TUP2["tuple 2"]
    TUP1["tuple 1"]
    SPECIAL["Special<br/>(인덱스만)"]
  end
  H --> LP --> FREE --> TUP3 --> TUP2 --> TUP1 --> SPECIAL
  classDef hdr fill:#ede9fe,stroke:#6d28d9,color:#3b0764
  classDef lp fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
  classDef free fill:#f3f4f6,stroke:#4b5563,color:#1f2937
  classDef tup fill:#fef3c7,stroke:#b45309,color:#78350f
  classDef sp fill:#d1fae5,stroke:#047857,color:#064e3b
  class H hdr
  class LP lp
  class FREE free
  class TUP1,TUP2,TUP3 tup
  class SPECIAL sp
  
구역내용
PageHeader (24B)LSN, checksum, free space pointer, page size 등
LinePointer 배열페이지 안 각 tuple의 오프셋·길이. tuple ID(ctid)의 두 번째 숫자
빈 공간새 tuple이 들어올 자리. 위에서 line pointer가 자라고 아래에서 tuple이 자란다
Tuple 본체header + null bitmap + 정렬 패딩 + 컬럼 값
Special (인덱스 전용)B-tree의 형제 포인터 등

ctid는 (page_no, line_pointer_no) 쌍입니다.

SELECT ctid, * FROM orders LIMIT 3;
-- ctid  | id | ...
-- (0,1) |  1 | ...
-- (0,2) |  2 | ...
-- (0,3) |  3 | ...

Tuple 구조

heap tuple 한 개의 메모리 표현:

영역크기내용
HeapTupleHeader23B (+ 정렬 패딩)xmin, xmax, cmin/cmax, ctid, infomask, natts, hoff 등
Null bitmap(컬럼 수 / 8) bytenullable 컬럼이 하나라도 있을 때만 등장
OID4B시스템 카탈로그 전용 (사용자 테이블에는 PG 12부터 없음)
실제 컬럼 값가변컬럼 정렬 규칙에 따라 패딩

운영 측면 의미:

  • xmin·xmax는 MVCC 핵심입니다. tuple이 어느 트랜잭션에서 만들어졌고 언제 죽었는지 기록 (자세한 동작은 Part III에서)
  • 헤더만 23B + 정렬 → 작은 컬럼만 있는 테이블은 헤더 오버헤드 비중이 큼. tinyint 1개짜리 테이블도 한 row가 28B에 가까움
  • 컬럼 순서를 큰 타입 → 작은 타입 순으로 두면 정렬 패딩이 줄어 디스크 절약 (안티패턴은 Part XIV 참고)

Free Space Map (FSM)

INSERT가 들어올 때 backend가 매번 모든 페이지를 뒤지면 비효율적입니다. FSM은 각 페이지의 가용 공간을 트리 구조로 캐싱해 빠른 후보 페이지 선택을 돕습니다.

  • 파일: <relfilenode>_fsm
  • VACUUM 시 갱신 (또는 INSERT의 휴리스틱 갱신)
  • 손상되면 pg_freespacemap 확장으로 확인·재구성 가능
CREATE EXTENSION pg_freespacemap;
SELECT * FROM pg_freespace('orders'::regclass) LIMIT 5;

Visibility Map (VM)

VM은 페이지 단위의 두 비트 플래그를 들고 있습니다.

비트의미
all-visible페이지의 모든 tuple이 모든 트랜잭션에서 보임 (dead tuple 없음)
all-frozen페이지의 모든 tuple이 frozen 상태 (XID wraparound 안전)

활용:

  • index-only scan: 인덱스만 보고 답을 줄 때 VM의 all-visible 비트가 켜져 있으면 heap 페이지를 안 봐도 됨 (Part IV 4.3 참고)
  • VACUUM 효율: all-visible 페이지는 VACUUM이 건너뛰어, dead tuple만 있는 페이지에 집중
  • XID wraparound 방지: all-frozen 페이지는 freezing이 끝났으므로 vacuum freeze가 건드리지 않음

페이지·세그먼트 크기 변경

기본값과 다르게 빌드하려면 컴파일 시 옵션이 필요합니다.

옵션기본비고
--with-blocksize=N8KB4/8/16/32KB 지원. 페이지 크기 변경은 사실상 거의 안 함
--with-wal-blocksize=N8KBWAL 레코드 페이지 크기
--with-segsize=N1GBOS 파일 시스템 제한이 높은 환경에서 32GB까지 쓰는 사례 있음

같은 클러스터에서 페이지·세그먼트 크기를 섞을 수 없으니 한 번 정한 뒤 바꾸려면 pg_dump → 재초기화 → 복원해야 합니다. 운영 시스템에서는 기본값을 그대로 유지하는 게 정석입니다.

파일 위치 확인

운영 중 “이 테이블이 정확히 어느 파일에 있는가?“를 확인하는 SQL:

-- 데이터베이스 OID
SELECT oid, datname FROM pg_database WHERE datname = current_database();

-- 테이블 파일 경로
SELECT pg_relation_filepath('orders');
--  pg_relation_filepath
-- ----------------------
--  base/16384/16389

-- 크기 (모든 세그먼트 합계)
SELECT pg_size_pretty(pg_relation_size('orders'));
-- pg_size_pretty
-- ----------------
--  3120 kB

-- main + fsm + vm + toast 모두 합한 총 크기
SELECT pg_size_pretty(pg_total_relation_size('orders'));

pg_relation_size·pg_total_relation_size는 모니터링·용량 계획에서 자주 씁니다.

상대 경로 base/16384/16389는 PGDATA 기준 상대 경로다. 절대 경로는 $PGDATA/base/16384/16389. 백업 도구로 옮길 때 tablespace가 있는 relation은 pg_relation_filepathpg_tblspc/<oid>/PG_15_202307071/16384/16389 같은 형식으로 반환합니다.

정리

  • 데이터 저장 단위: cluster → database → relation → fork → segment → page → tuple
  • 페이지 8KB, 세그먼트 1GB가 기본. 컴파일 옵션이라 운영 중 변경 불가
  • 한 relation = main + fsm + vm + (init for unlogged)
  • relfilenode는 VACUUM FULL·REINDEX·TRUNCATE 후 바뀐다 — 백업 추적 시 주의
  • ctid = (page_no, line_pointer_no). MVCC·인덱스에서 핵심 좌표
  • FSM/VM은 VACUUM과 짝을 이뤄 효율을 만듭니다. index-only scan은 VM 의존

다음 절(1.6)에서는 8KB 페이지에 안 들어가는 큰 값(긴 텍스트·JSON·bytea)을 PostgreSQL이 어떻게 처리하는지 — TOAST — 봅니다.