DB 스토리지 레이아웃

페이지, 힙 테이블, clustered index

DB는 테이블을 바이너리 파일로 저장하고, 그 안을 8KB 내외의 페이지 단위로 나눈다. 힙 테이블은 빈자리를 찾아 아무데나 행을 쑤셔 넣는 구조고, clustered index는 테이블 자체가 PK 순서로 정렬된 B+tree다. Postgres는 힙, MySQL InnoDB는 clustered가 기본이라 같은 쿼리도 성격이 다르게 작동한다.

Storage

개념

DB는 결국 디스크 위에 있는 파일 덩어리와 그걸 다루는 프로세스다. SELECT, INSERT 같은 SQL 뒤에는 "어떤 파일의 어느 위치를 읽고 쓸 것인가"라는 물리적 질문이 숨어 있다. 그 물리 구조의 가장 밑단이 페이지, 그 위에 쌓이는 방식이 힙 테이블clustered index다.

DB는 어디에 뭘로 저장하나

Postgres 기준 /var/lib/postgresql/data/base/ 아래를 보면 테이블마다 파일이 하나씩 있다. 파일명은 사람이 읽는 이름이 아니라 내부 OID(숫자).

base/16384/
├── 24576          ← users 테이블 파일
├── 24576_fsm      ← Free Space Map (빈자리 맵)
├── 24576_vm       ← Visibility Map (MVCC용)
├── 24577          ← 인덱스 파일
└── ...
  • 테이블 하나 = 파일 하나 (1GB 넘으면 24576.1, 24576.2로 쪼갬)
  • 파일은 바이너리. JSON·텍스트가 아니라 고정 오프셋으로 점프 가능한 포맷
  • 인덱스도 자기 파일을 가짐

JSON이 아닌 이유는 단순하다. 크기, 파싱 비용, 고정 오프셋 점프 전부 텍스트가 불리하다.

페이지

파일 내부는 페이지 단위로 끊겨 있다. Postgres 8KB, MySQL InnoDB 16KB가 기본.

users 테이블 파일 (24MB)
├── Page 0 (8KB) ← 행 여러 개
├── Page 1 (8KB)
├── Page 2 (8KB)
└── ...

페이지 내부 레이아웃(Postgres):

┌─────────────────────┐ ← 페이지 시작 (8KB)
│ Page Header (24B)   │
├─────────────────────┤
│ Item Pointers →→→→  │   "행 X는 이 오프셋" 포인터
│                     │
│      (빈 공간)       │
│                     │
│ ←←← Row 데이터       │
│ ←← Row 데이터        │
└─────────────────────┘

위에서 포인터가 자라고 아래에서 행 데이터가 자란다. 중간이 빈 공간이고 꽉 차면 새 페이지를 만든다.

왜 페이지 단위인가

디스크 I/O가 블록 단위로만 작동하기 때문이다. HDD 섹터는 4KB(요즘 표준), ext4 블록도 4KB. "1바이트 읽기"가 물리적으로 불가능하고, 어차피 블록 하나를 통째로 읽을 거면 DB 페이지를 OS 블록 정수 배수(8KB = 블록 2개, 16KB = 블록 4개)로 맞추는 게 최적이다.

비용 감각은 이렇게 정리된다.

  • 디스크 I/O 비용은 "몇 바이트 읽냐"가 아니라 "몇 번 찌르냐"
  • 한 번 찌르면 8KB 통째로 딸려오므로 근처 행은 공짜
  • CPU 캐시 라인이 64바이트 통째로 가져오는 것과 같은 논리

이래서 같은 "100행 조회"라도 페이지 1개에 몰려 있으면 I/O 1번, 페이지 100개에 흩어져 있으면 I/O 100번이 될 수 있다.

힙 테이블

정렬 없이 빈자리 아무데나 쑤셔 넣는 저장 방식. Postgres는 모든 테이블이 기본 힙이다. "힙"은 자료구조 heap이 아니라 "쌓아놓은 더미"라는 뜻.

INSERT 흐름:

  1. FSM(Free Space Map) 조회 — "이 행이 들어갈 빈자리 있는 페이지?"
  2. 있으면 그 페이지에 끼워 넣음
  3. 모든 페이지가 꽉 차면 그제서야 파일 끝에 새 페이지 추가

FSM이 왜 필요한가? DELETE/UPDATE 때문이다.

INSERT id=1;  -- Page 0
INSERT id=2;  -- Page 0
INSERT id=3;  -- Page 0
DELETE id=2;  -- Page 0에 구멍
INSERT id=4;  -- Page 0의 구멍에 들어감

물리적으로는 1, 4, 3 순서로 쌓인다. 입력 순서도, PK 순서도, 시간 순서도 아님. ORDER BY 없이 SELECT * 하면 순서가 보장 안 되는 이유가 이거다.

Clustered Index

테이블 자체가 B+tree로 정렬되어 저장되는 구조. 인덱스와 데이터가 분리돼 있지 않고 하나로 합쳐져 있다. MySQL InnoDB의 기본 방식.

           [PK: 5]
          /       \
      [1,2,3]    [6,7,8,9]        ← 내부 노드 (키만)
      /    \    /    \
   [데이터] [데이터] [데이터] [데이터]  ← 리프 = 실제 행
   id=1     id=3    id=6    id=8
  • 리프 = 실제 행 데이터. B+tree 탐색이 끝나는 순간 행 도달
  • PK 순서대로 물리적으로 연속 저장
  • Secondary 인덱스(PK 아닌 인덱스)의 리프에는 PK 값이 저장됨

힙 vs Clustered

WHERE id = 3 조회 흐름으로 비교.

힙 (Postgres)

  1. PK 인덱스 B-tree 타고 내려감 → "Page X의 Y번 슬롯" 알아냄
  2. Page X로 점프해 행 읽음 → 탐색 2번

Clustered (InnoDB)

  1. B+tree 타고 내려가면 리프가 곧 행 → 탐색 1번

단건 조회는 버퍼 풀 덕에 체감이 작지만, 범위 조회에서 격차가 크게 벌어진다.

SELECT * FROM users WHERE id BETWEEN 100 AND 200;
  • 힙: 100개 행이 여러 페이지에 흩어져 있을 수 있음 → 페이지 여러 개 읽기
  • Clustered: 연속된 페이지 몇 개만 순차 읽기 → 훨씬 빠름

Secondary 인덱스 조회는 반대다.

SELECT * FROM users WHERE email = 'x@y.com';
  • 힙: email 인덱스 → 페이지 포인터 → 행. 1 + 1
  • Clustered: email 인덱스 → PK → PK 인덱스 → 행. 2 + 2 (B-tree 두 번)

Postgres vs MySQL

Postgres MySQL InnoDB
기본 구조 힙 테이블 Clustered index
PK 인덱스 별도 파일 테이블 그 자체
Secondary 리프 페이지 포인터(CTID) PK 값
UUID PK 괜찮음 성능 나쁨 (page split 지옥)
PK 범위 조회 인덱스 탐색 + 페이지 점프 물리 연속이라 압도적으로 빠름
UPDATE 행 이동 없음 PK 바꾸면 페이지 이동

Postgres에도 CLUSTER 명령이 있지만 한 번 정렬해주는 일회성 작업이고, 이후 INSERT로 금방 흐트러진다. 구조적 clustered는 없다.

언제 뭐가 유리한가

스토리지 구조만 보면 트레이드오프가 이렇다.

  • PK 기반 조회·범위 스캔 많음 → InnoDB 쪽이 유리
  • Secondary 인덱스 조회 많음, UUID PK, 쿼리 패턴 다양 → Postgres 쪽이 유리

다만 실무의 DB 선택은 스토리지 구조만으로 정해지지 않는다. 기능(JSON, 배열, 확장), 운영 생태계, 팀 숙련도가 더 큰 요인인 경우가 많다. 스토리지 구조는 엔진 성격의 뿌리를 설명할 뿐이다.

더 보기

  • 파일시스템 — 페이지 캐시, fsync, 블록 단위 I/O의 OS 쪽 원리
  • 가상-메모리 — 페이지라는 개념이 OS에서 어떻게 작동하는지
  • CPU-캐시 — "근처 것 통째로 가져오기" 전략의 CPU 버전
sunshinemoon · 2026