본문 바로가기
데이터베이스/PostgreSQL 내부 구조

PostgreSQL 내부구조 1~8편 총정리

by 조민서 2026. 9. 25.

PostgreSQL 내부 구조 시리즈

시작. 시리즈를 시작하며

0. 연결과 프로세스 구조

1. 8KB 페이지와 Shared Buffers

2. MVCC와 격리 수준

3. Locking

4. VACUUM

5. WAL과 Checkpoint

6. Index

7. Scan과 Optimizer

8. TOAST

정리. 1~8편 총정리

 

PostgreSQL 내부구조 1~8편 한눈에 보기

 

PostgreSQL 내부구조 시리즈에서는 각각의 기능을 따로 외우기보다, 데이터 하나가 저장되고, 읽히고, 수정되고, 복구되고, 정리되는 과정을 따라가며 PostgreSQL의 내부 동작을 살펴봤다.

 

시리즈 전체에서 반복해서 등장한 가장 기본적인 단위는 8KB Heap Page와 Tuple이다.

Page에 저장된 Tuple을 여러 Transaction이 동시에 읽고 수정하고, 변경 내용은 WAL에 기록되며, Index와 Planner는 필요한 Tuple을 효율적으로 찾고 실행하는 방법을 결정한다.

 

각 기능은 독립적으로 존재하는 것이 아니라 같은 데이터 구조 위에서 서로 연결되어 동작한다.

목차

  1. 데이터를 저장하고, 데이터를 동시에 다루는 구조
    - 1편 — Page와 Shared Buffers
    - 2편 — MVCC
    - 3편 — Lock
  2. 변경된 데이터를 유지하고 복구하는 구조
    - 4편 — VACUUM
    - 5편 — WAL과 Checkpoint
  3. 데이터를 빠르게 찾고 실행 방법을 선택하는 구조
    - 6편 — Index
    - 7편 — Scan과 Optimizer
    - 8편 — TOAST
  4. UPDATE Query로 1~8편 연결하기

1. 데이터를 저장하고, 데이터를 동시에 다루는 구조

1편 — Page와 Shared Buffers

PostgreSQL의 Table은 여러 개의 Page로 구성되고, 기본 Page 크기는 8KB다.

하나의 Relation은 여러 Page로 나뉘고, Page 내부에는 Tuple의 위치를 가리키는 Line Pointer와 실제 Heap Tuple이 저장된다.

Relation → 8KB Page → Line Pointer → Heap Tuple

각 구조의 역할을 정리하면 다음과 같다.

  • Relation: Table이나 Index 같은 PostgreSQL의 저장 객체
  • 8KB Page: PostgreSQL이 디스크와 메모리에서 다루는 기본 Block 단위
  • Line Pointer: Page 내부에서 Tuple의 위치를 가리키는 정보
  • Heap Tuple: 실제 Row Version과 Tuple Header가 저장되는 구조

Tuple Header에는 이후 여러 편에서 다시 등장하는 정보가 들어 있다.

  • xmin: 해당 Tuple Version을 만든 Transaction과 관련된 정보
  • xmax: 해당 Tuple을 삭제하거나 갱신한 Transaction과 관련된 정보
  • ctid: 현재 Tuple의 물리적인 위치를 나타내는 TID
  • t_infomask: Tuple의 상태를 해석할 때 사용하는 여러 Flag

Backend Process가 필요한 Page를 매번 디스크에서 직접 읽는 것은 아니다.

Disk → Shared Buffers → Backend Process

필요한 Page는 Shared Buffers에 올라오고, 이후 읽기와 수정 작업은 이 Page를 중심으로 진행된다.

Page와 Tuple 구조는 이후 MVCC, WAL, Index, VACUUM 같은 기능이 동작하는 물리적인 기반이 된다.

 

2편 — MVCC

PostgreSQL은 UPDATE할 때 기존 Tuple을 같은 위치에서 직접 덮어쓰는 방식으로 처리하지 않는다.

기존 Tuple Version을 남기고 새로운 Tuple Version을 생성한다.

Old Tuple Version + New Tuple Version

여러 Version이 존재하는 상황에서 Transaction은 자신의 Snapshot과 Tuple의 xmin, xmax 같은 정보를 이용해 어떤 Version을 볼 수 있는지 판단한다.

 

Tuple Version → xmin / xmax → Snapshot → Visible / Invisible 판단

이 구조가 PostgreSQL의 MVCC(Multi-Version Concurrency Control)다.

덕분에 읽기 Transaction과 쓰기 Transaction이 항상 서로를 막지 않고, 각 Transaction이 자신에게 보이는 일관된 Version을 읽을 수 있다.

 

3편 — Lock

MVCC가 있다고 해서 동시에 발생하는 모든 작업의 충돌이 사라지는 것은 아니다.

예를 들어 여러 Transaction이 같은 Row를 동시에 수정하거나 Table 구조를 변경하려고 한다면 실행 순서를 조율해야 한다.

  • MVCC는 각 Transaction이 어떤 Tuple Version을 볼 것인지 결정한다.
  • Lock은 충돌하는 작업을 동시에 어디까지 수행할 수 있는지 조율한다.

PostgreSQL은 Row Lock과 Table Lock 등을 사용해 충돌을 제어하고, 필요한 경우 다른 Transaction이 Lock을 해제할 때까지 기다린다.

여러 Transaction이 서로 상대방의 Lock을 기다리는 순환 관계가 만들어지면 Deadlock이 발생할 수도 있다.

 

결국 MVCC와 Lock은 서로 대체하는 기능이 아니라 PostgreSQL 동시성 제어의 서로 다른 두 축이다.


2. 변경된 데이터를 유지하고 복구하는 구조

4편 — VACUUM

MVCC는 UPDATE나 DELETE가 발생했다고 해서 기존 Tuple Version을 즉시 제거하지 않는다.

 

UPDATE로 새로운 Version이 만들어지면 이전 Version은 당분간 남아 있고, 더 이상 어떤 Transaction에서도 볼 필요가 없는 시점이 되면 Dead Tuple로 정리할 수 있게 된다.

UPDATE → 이전 Tuple Version → 더 이상 필요하지 않음 → Dead Tuple

 

Dead Tuple이 계속 쌓이면 Table과 Index가 불필요하게 커질 수 있다.

VACUUM은 다음과 같은 역할을 한다.

  • Dead Tuple 공간 재사용: 더 이상 필요한 Transaction이 없는 Tuple이 차지하던 공간을 다시 사용할 수 있게 한다.
  • Freeze: 오래된 Transaction ID로 인해 발생할 수 있는 Wraparound를 방지한다.
  • Visibility Map 관리: Page의 Tuple이 모두 visible한지 등의 정보를 관리한다.

Visibility Map은 이후 6편에서 살펴본 Index Only Scan과 다시 연결된다.

 

5편 — WAL과 Checkpoint

Shared Buffers에서 데이터가 변경된 직후 PostgreSQL 프로세스나 서버가 갑자기 종료될 수도 있다.

PostgreSQL은 변경된 Data Page를 디스크에 기록하는 것과 별도로, 해당 변경을 다시 재현할 수 있는 WAL(Write-Ahead Log) Record를 남긴다.

 

Write-Ahead Logging의 핵심은 이름 그대로 변경된 Data Page보다 관련 WAL Record가 먼저 안전하게 기록되어야 한다는 것이다.

데이터 변경 → WAL 기록 → 필요한 WAL Flush → 이후 Dirty Page Flush

 

Data Page가 아직 디스크에 반영되지 않은 상태에서 장애가 발생해도 필요한 WAL이 남아 있다면 변경 내용을 다시 적용할 수 있다.

Checkpoint는 복구가 시작될 기준점을 만드는 데 사용된다.

Checkpoint의 Redo Point → Crash → WAL Replay → Recovery

MVCC가 정상 실행 중 여러 Transaction의 동시성을 다룬다면, WAL은 장애가 발생했을 때 아직 Data File에 완전히 반영되지 않은 변경을 복구할 수 있게 한다.


3. 데이터를 빠르게 찾고 실행 방법을 선택하는 구조

6편 — Index

조건에 맞는 Tuple을 찾기 위해 Heap 전체를 항상 처음부터 읽을 필요는 없다.

PostgreSQL은 Index를 이용해 필요한 Tuple이 있는 위치를 빠르게 찾을 수 있다.

 

일반적인 B-tree Index Entry에는 두 가지 중요한 정보가 들어 있다.

  • Key: Index에서 정렬하고 검색하는 값
  • TID: Heap Page와 Tuple의 위치를 가리키는 정보

일반적인 Index Scan은 다음 순서로 진행된다.

B-tree에서 Key 검색 → TID 확인 → Heap Page 접근 → Tuple 확인

즉 Index는 일반적으로 실제 Row 전체를 대체하는 구조라기보다 필요한 Heap Tuple을 빠르게 찾기 위한 별도의 접근 경로다.

 

반면 Index Only Scan은 필요한 Column을 Index에서 얻을 수 있고 Visibility Map을 통해 해당 Heap Page의 Tuple들이 모두 visible하다고 판단할 수 있다면 Heap 접근을 생략할 수 있다.

Index → Visibility Map 확인 → all-visible → Heap 접근 생략 가능

여기에서 4편의 VACUUM과 6편의 Index가 연결됐다.

 

7편 — Scan과 Optimizer

Index가 존재한다고 해서 PostgreSQL이 항상 Index Scan을 선택하는 것은 아니다.

Planner는 조건에 맞는 Row가 얼마나 나올지 추정하고, 여러 실행 방법의 Cost를 비교해 현재 쿼리에 적합하다고 판단한 Plan을 선택한다.

 

대표적인 Scan 방식은 다음과 같다.

  • Sequential Scan: Heap Page를 순차적으로 읽는다.
  • Index Scan: Index를 통해 필요한 Heap Tuple의 위치를 찾는다.
  • Index Only Scan: 조건이 맞으면 Heap 접근을 생략하고 Index만으로 처리한다.
  • Bitmap Scan: 여러 Index 결과를 Page 단위로 모아 Heap 접근을 최적화한다.

Planner는 ANALYZE가 수집한 통계 정보를 이용해 조건에 맞는 Row 수를 추정한다.

Statistics → Estimated Rows → Candidate Paths → Cost 비교 → Plan 선택

여러 Table을 JOIN하는 경우에는 Scan 방식뿐 아니라 JOIN 방법과 JOIN 순서도 함께 선택한다.

  • Nested Loop: 한쪽 Row를 기준으로 다른 쪽에서 반복해서 조건에 맞는 Row를 찾는다.
  • Hash Join: 한쪽 입력으로 Hash Table을 만들고 다른 쪽 값을 조회한다.
  • Merge Join: 정렬된 두 입력을 순서대로 비교하면서 JOIN한다.

EXPLAIN ANALYZE를 사용하면 Planner가 예상했던 Row 수와 실제 실행 결과를 비교할 수 있다.

실행 계획을 이해할 때 중요한 것은 단순히 인덱스를 사용했는가가 아니라 왜 Planner가 이 Plan을 선택했는가를 확인하는 것이다.

 

8편 — TOAST

마지막 8편에서는 다시 1편에서 살펴본 8KB Page 구조의 한계를 확인했다.

 

PostgreSQL의 Heap Tuple 하나는 여러 Heap Page에 걸쳐 저장될 수 없다.

하지만 TEXT, JSONB, BYTEA 같은 가변 길이 데이터에는 하나의 Page보다 훨씬 큰 값이 들어갈 수 있다.

PostgreSQL은 이런 큰 값을 필요에 따라 압축하고, 그래도 Tuple이 너무 크다면 Main Heap 밖의 별도 TOAST Table에 저장한다.

Large Value → Compression → 필요하면 Out-of-Line 저장 → TOAST Table

 

TOAST Table로 분리된 값은 여러 Chunk로 나뉘어 저장되고, Main Tuple에는 실제 전체 값 대신 외부 데이터를 찾기 위한 TOAST Pointer가 남는다.

  • Main Tuple: 일반적인 Row 데이터와 필요한 경우 TOAST Pointer를 저장한다.
  • TOAST Pointer: 외부에 저장된 TOAST Value를 찾기 위한 정보를 가진다.
  • TOAST Table: Main Heap 밖으로 분리된 큰 값을 저장한다.
  • Chunk: 큰 값을 여러 조각으로 나누어 저장하는 단위다.

쿼리에서 큰 Column이 필요하지 않다면 TOAST Table에 저장된 값을 읽지 않고 Main Heap의 다른 Column만 처리할 수 있다.

결국 TOAST 역시 완전히 별개의 저장 시스템이 아니라 Heap Tuple 하나가 Page를 넘어갈 수 없다는 PostgreSQL의 기존 Page 구조를 유지하기 위한 방식이다.


4. UPDATE Query로 1~8편 연결하기

지금까지 살펴본 1편부터 8편의 구조는 실제 쿼리 하나가 실행될 때 서로 연결되어 동작한다.

다음 UPDATE가 실행된다고 해보자.

UPDATE documents
SET content = 'new value'
WHERE id = 100;
  1. 먼저 7편의 Planner가 통계 정보와 Cost를 바탕으로 대상 Row를 찾을 실행 계획을 선택한다.
  2. Index Scan이 선택됐다면 6편의 Index에서 id = 100에 해당하는 Heap Tuple의 위치를 찾는다.
  3. 필요한 Heap Page가 메모리에 없다면 1편에서 살펴본 Shared Buffers로 읽어온다.
  4. 동시에 다른 Transaction이 같은 Row를 수정 중이라면 3편의 Lock이 충돌을 조율한다.
  5. 실제 UPDATE에서는 2편의 MVCC 구조에 따라 기존 Tuple을 같은 위치에서 덮어쓰기보다 새로운 Tuple Version을 만든다.
  6. 새로운 content 값이 충분히 크다면 8편의 TOAST가 개입해 값을 압축하거나 TOAST Table로 분리할 수 있다.
  7. 변경 내용을 장애 이후에도 복구할 수 있도록 5편의 WAL에도 필요한 변경 기록이 생성된다.
  8. 시간이 지나 이전 Tuple Version을 더 이상 어떤 Transaction도 필요로 하지 않게 되면 Dead Tuple이 되고, 4편의 VACUUM이 해당 공간을 다시 사용할 수 있도록 정리한다.
  9. VACUUM이 관리하는 Visibility Map은 다시 Index Only Scan의 Heap 접근 여부에도 영향을 줄 수 있다.

즉 하나의 UPDATE 안에서 Page, Shared Buffers, MVCC, Lock, WAL, Index, Planner, VACUUM, TOAST가 서로 연결된다.

 

1편부터 8편의 핵심 질문을 다시 정리하면 다음과 같다.

  • 1편 — Page와 Shared Buffers
    데이터는 어디에 어떻게 저장되는가?
  • 2편 — MVCC
    동시에 실행되는 Transaction은 어떤 Tuple Version을 보는가?
  • 3편 — Lock
    여러 Transaction이 동시에 같은 데이터를 수정하면 어떻게 조율하는가?
  • 4편 — VACUUM
    더 이상 필요하지 않은 Tuple Version은 어떻게 정리하는가?
  • 5편 — WAL과 Checkpoint
    장애가 발생한 뒤 변경 내용을 어떻게 복구하는가?
  • 6편 — Index
    원하는 Tuple을 어떻게 빠르게 찾는가?
  • 7편 — Scan과 Optimizer
    여러 실행 방법 중 어떤 Plan을 선택하는가?
  • 8편 — TOAST
    Page보다 큰 값은 어떻게 저장하는가?

PostgreSQL의 내부 기능은 각각 다른 문제를 해결하지만 출발점은 같다.

Page 안에 Tuple을 저장하고, 여러 Transaction이 그 Tuple을 동시에 읽고 수정하면서도 데이터의 일관성과 영속성을 유지하는 것이다.

 

PostgreSQL 내부구조 1~8편 한눈에 보기