데이터베이스/Postgres

[PostgreSQL] PostgreSQL이 메모리를 관리하는 법 - Shared Buffer Cache & Clock Sweep

러러 2026. 5. 7. 16:16

한 줄 요약: PostgreSQL은 OS의 page cache를 믿지 않고, shared_buffers로 지정한 메모리에 자체 버퍼 풀을 운영한다. 어떤 페이지를 내보낼지는 LRU가 아닌 Clock Sweep이 결정한다.


설계 배경 — OS page cache가 있는데 왜 또 버퍼가 필요한가

파일 I/O를 하면 운영체제는 자동으로 읽은 블록을 page cache에 보관한다. 같은 블록을 다시 읽으면 디스크에 가지 않고 메모리에서 돌려준다. 그렇다면 PostgreSQL이 shared_buffers라는 별도 버퍼 풀을 유지하는 이유는 무엇인가?

  • 첫째, OS page cache는 WAL 선행 원칙을 알지 못한다. PostgreSQL은 dirty page를 디스크에 쓰기 전에 반드시 해당 페이지 변경을 기술한 WAL(Write-Ahead Log) 레코드가 먼저 안정 저장소에 기록되었음을 보장해야 한다. OS에 맡기면 page cache 플러시 순서를 통제할 수 없어 크래시 복구 보장이 무너진다.
  • 둘째, double buffering 문제다. read()/write() 시스템 콜을 사용하면 동일한 데이터가 OS page cache와 PostgreSQL 버퍼 풀에 중복으로 적재된다. 이를 원천 차단하려면 O_DIRECT 플래그로 파일을 열어 OS 캐싱을 우회해야 하는데, 그러면 정렬된 I/O와 전적인 자체 버퍼 관리가 필요해진다. PostgreSQL은 기본적으로 O_DIRECT 없이 pread()/pwrite()를 쓰는 대신, 자체 버퍼에 이미 올라온 페이지는 시스템 콜 자체를 생략하여 double buffering의 CPU 비용을 최소화한다.
  • 셋째, 데이터베이스 의미를 아는 교체 정책이 필요하다. 인덱스 루트 페이지처럼 모든 쿼리가 거쳐가 페이지, 순차 스캔처럼 한 번만 읽고 버려질 차가운 페이지, VACUUM이 훑고 지나간 dead tuple 페이지는 재사용 가능성이 전혀 다르다. 범용 OS의 LRU는 이 차이를 모르지만 PostgreSQL은 알 수 있다.

이 세 가지 이유가 shared_buffers가 존재하는 근거다.


핵심 개념과 용어 정의

BufferTag : "이 버퍼는 어느 페이지인가"

버퍼 풀의 각 슬롯이 어느 페이지를 담고 있는지 식별하는 키다. PostgreSQL 소스(src/include/storage/buf_internals.h)에서 BufferTag는 세 필드의 구조체다.

typedef struct buftag {
    RelFileLocator rlocator;   /* tablespace OID + database OID + relation OID */
    ForkNumber     forkNum;    /* main(0), fsm(1), vm(2), init(3) */
    BlockNumber    blockNum;   /* 파일 내 블록 번호 (0-based) */
} BufferTag;

앞의 포스트의 8KB 페이지 구조를 떠올리면, rlocator + forkNum + blockNum$PGDATA/base/<dboid>/<relfilenode> 파일의 몇 번째 블록인지를 정확히 가리킨다. ForkNumber는 같은 테이블에 속하지만 용도가 다른 파일을 구분한다. main(0)이 실제 행 데이터, fsm(1)이 Free Space Map, vm(2)이 Visibility Map이다.

CAS (Compare-And-Swap)

CAS는 CPU가 제공하는 원자적 명령어로, "현재 값이 내가 예상한 값과 같을 때만 새 값으로 교체하라"는 조건부 쓰기를 단일 명령으로 수행한다.

bool CAS(addr, expected, new_value):
    if *addr == expected:
        *addr = new_value
        return true   // 성공
    else:
        return false  // 실패, *addr은 변경되지 않음

검사(compare)와 교체(swap)가 CPU 수준에서 쪼갤 수 없는 하나의 연산이기 때문에, 멀티코어 환경에서 여러 스레드/프로세스가 동시에 같은 주소에 CAS를 시도해도 딱 하나만 성공하고 나머지는 실패를 반환받는다.

 

스핀루프 패턴

CAS 실패는 "누군가 먼저 바꿔버렸다"는 신호다. 실패한 쪽은 새 값을 읽고 다시 시도한다. 이 재시도 루프를 스핀루프라고 한다.

uint32 old, new;
do {
    old = pg_atomic_read_u32(&buf->state);
    new = old + 1;  // 예: refcount 증가
} while (!pg_atomic_compare_exchange_u32(&buf->state, &old, new));

경합이 적으면 대부분 첫 번째 시도에 성공한다. 경합이 심해도 락처럼 컨텍스트 스위치가 발생하지 않아 오버헤드가 낮다.

 

락과의 비교

  CAS(낙관적) 뮤텍스/락 (비관적)
전제 충돌이 드물다 충돌이 잦다
충돌 시 재시도 (스핀) 대기 큐에서 슬립
컨텍스트 스위치 없음 있음
적합한 경우 짧은 연산, 낮은 경합 긴 임계 구역, 높은 경합

 

BufferDesc : 버퍼 슬롯의 제어 블록

BufferDesc는 실제 데이터(8KB)와 분리된 메타데이터 구조체다. 주요 필드는 다음과 같다.

필드 타입 역할
tag BufferTag 어느 페이지인지 식별
state pg_atomic_uint32 refcount + usageCount + flags 패킹
buf_id int 버퍼 배열 내 인덱스 (0 ~ NBuffers-1)
wait_backend_pgprocno int 핀 해제를 대기 중인 백엔드의 PGPROC 번호
freeNext int freelist 체인의 다음 슬롯

 

state 필드는 32비트 원자 정수 하나에 세 가지 정보를 패킹한다.

비트 31~21 (11비트): flags
비트 20~18 ( 3비트): usageCount (0~5)
비트 17~ 0 (18비트): refcount (0~262143)

flags 비트 (buf_internals.h):
  BM_DIRTY           0x001  수정됨, 디스크와 불일치
  BM_VALID           0x002  유효한 페이지 데이터 있음
  BM_TAG_VALID       0x004  tag 필드 유효함
  BM_IO_IN_PROGRESS  0x008  I/O 진행 중
  BM_IO_ERROR        0x010  직전 I/O 실패
  BM_JUST_DIRTIED    0x020  방금 더럽혀짐 (bgwriter 최적화)
  BM_PIN_COUNT_WAITER 0x040 핀 해제 대기자 있음
  BM_CHECKPOINT_NEEDED 0x080 체크포인터가 플러시해야 함
  BM_PERMANENT       0x100  unlogged 테이블이 아님

이렇게 패킹한 이유는 단일 CAS(Compare-And-Swap) 명령으로 복합 상태 전이를 원자적으로 처리하기 위해서다. 예를 들어 "refcount가 0이고 dirty가 아닐 때만 victim으로 선택"하는 조건을 별도의 락 없이 단 한 번의 원자 연산으로 검사하고 갱신할 수 있다.

 

usage_count는 0~5 사이의 포화 카운터(saturating counter)다. 버퍼를 핀(pin)할 때마다 최대 5까지 증가한다. Clock Sweep이 이 슬롯을 지나칠 때마다 1씩 감소하며, 0에 도달한 슬롯이 교체 대상(victim)이 된다.

 

refcount는 현재 이 버퍼를 핀한 백엔드의 수다. refcount > 0인 버퍼는 I/O 도중이나 접근 중임을 의미하므로 교체 불가다.

버퍼 접근 프로토콜 : 핀(Pin)과 콘텐츠 락(Content Lock)

버퍼에 담긴 튜플을 읽거나 쓰려면 두 단계 잠금 프로토콜을 따른다. 이 프로토콜은 PostgreSQL 소스 전반(src/backend/storage/buffer/bufmgr.c)에서 일관되게 적용된다.

1단계 — 핀 획득 (ReadBuffer / PinBuffer):
  - state의 refcount를 원자적으로 +1
  - 이 버퍼는 Clock Sweep이 교체 후보로 삼을 수 없다

2단계 — 콘텐츠 락 획득 (LockBuffer):
  - 읽기: LWLockAcquire(buffer_content_lock, LW_SHARED)
    → 여러 백엔드가 동시에 shared 모드로 잡을 수 있음
  - 쓰기: LWLockAcquire(buffer_content_lock, LW_EXCLUSIVE)
    → 다른 모든 접근을 배제

3단계 — 튜플 읽기/수정 수행

4단계 — 콘텐츠 락 해제 (UnlockBuffer)
5단계 — 핀 해제 (ReleaseBuffer): refcount를 원자적으로 -1

LWLock(Lightweight Lock) 은 PostgreSQL 고유의 경량 잠금 메커니즘으로, POSIX 뮤텍스보다 가볍고 공유/배타 모드를 모두 지원한다. 버퍼 콘텐츠 락(buffer content lock)은 NBuffers개가 개별적으로 존재하므로 서로 다른 버퍼에 접근하는 백엔드는 전혀 경합하지 않는다.

 

여러 백엔드가 같은 버퍼를 읽는 정상 흐름을 시퀀스 다이어그램으로 확인하자.

읽기 간에는 shared LWLock을 동시에 보유할 수 있으므로 락 대기가 없다. refcount가 0으로 돌아와야만 Clock Sweep이 이 버퍼를 교체 후보로 삼는다.

Clock Sweep 알고리즘

freelist가 소진된 이후 버퍼 매니저가 교체 대상(victim) 슬롯을 선택할 때 사용하는 알고리즘이다.

StrategyControl->nextVictimBuffer가 가리키는 위치에서 출발해 버퍼 배열을 시계 초침처럼 순환하며 슬롯을 하나씩 검사한다.

 

선택 조건과 순회 규칙

슬롯을 victim으로 선택하려면 두 조건을 동시에 만족해야 한다.

  • refcount == 0 : 현재 핀을 잡은 백엔드가 없어야 한다. refcount > 0이면 I/O 중이거나 접근 중인 슬롯이므로 건드릴 수 없다.
  • usageCount == 0 : 최근 참조 횟수가 소진되어 있어야 한다.

둘 중 하나라도 불만족이면 다음 규칙을 적용하고 다음 슬롯으로 넘어간다.

  • refcount > 0 : usageCount를 건드리지 않고 그냥 통과한다.
  • refcount == 0 && usageCount > 0 : usageCount를 1 감소시키고 통과한다.

usageCount는 0~5의 포화 카운터(saturating counter)로, 버퍼를 핀할 때마다 최대 5까지 올라간다. 자주 참조된 슬롯은 초침이 여러 바퀴를 돌아야 소진되므로 오래 살아남고, 거의 쓰이지 않는 슬롯은 한두 바퀴 안에 교체된다. 이런 특성 때문에 Clock Sweep을 LRU의 근사 알고리즘으로 볼 수 있다.

 

두 조건을 모두 만족하는 슬롯을 찾으면, 단일 CAS로 state를 원자적으로 갱신해 victim으로 확정한다. 이 덕분에 여러 백엔드가 동시에 victim을 요청해도 각자 서로 다른 슬롯을 가져가며 락 없이 병렬 처리된다.

 

victim이 확정되면 버퍼 매니저는 해당 슬롯이 dirty(BM_DIRTY 플래그)인지 확인한다. dirty라면 새 페이지를 올리기 전에 먼저 디스크로 플러시해야 한다.

 

순회 흐름

멀티백엔드 환경에서의 동작

nextVictimBuffer는 전역 원자 카운터다. 백엔드 A가 인덱스 42를 CAS로 가져가는 순간, 백엔드 B는 43을 가져간다. 두 백엔드가 동시에 순회를 시작해도 항상 서로 다른 슬롯을 검사하므로 중복 선택이 발생하지 않는다.

 

버퍼 배열을 시계 초침처럼 순환하면서 각 슬롯의 usage_count를 확인하는 알고리즘으로, 0이면 그 슬롯을 재사용 대상(victim)으로 선택하고 0보다 크면 1 감소시킨 뒤 다음 슬롯으로 넘어간다. 자주 참조된 슬롯은 여러 바퀴를 버티고 거의 쓰이지 않은 슬롯은 빠르게 교체된다(LRU의 근사 알고리즘으로 볼 수 있다). 


내부 구조

shared_buffers = 128MB이면 NBuffers = 16384. 이 수만큼의 BufferDesc와 8KB 블록이 postmaster 기동 시 공유 메모리에 연속으로 할당된다. 이 영역은 이후 fork()된 모든 백엔드가 동일한 가상 주소로 접근한다. (System V shared memory 또는 mmap 기반 공유 메모리 구조를 사용한다)

BufTable — 해시 파티셔닝으로 경합 분산

BufTable은 파티션된 해시 테이블이다. PostgreSQL 16 기준 128개의 파티션으로 나뉘며, 각 파티션은 독립적인 BufMappingLock(LWLock)으로 보호된다.

 

파티션 번호는 BufferTag의 해시값을 128로 모듈로 연산하여 결정한다. 이렇게 분산하면 전혀 다른 테이블의 페이지를 동시에 접근하는 128개의 백엔드가 서로 다른 파티션 락을 잡으므로 BufTable 경합 가능성이 극적으로 줄어든다.

 

룩업 시에는 해당 파티션의 BufMappingLock공유(shared) 모드로만 잡는다. 새 태그를 삽입하거나 기존 태그를 교체할 때는 shared 락을 해제하고 배타(exclusive) 모드로 재획득한다. LWLock은 shared → exclusive 원자 업그레이드를 지원하지 않기 때문에 반드시 해제 후 재획득 순서를 따른다. 읽기 워크로드가 압도적인 OLTP 환경에서 이 설계가 확장성의 핵심이다.

StrategyControl — FreeList와 ClockHand의 집합

PostgreSQL은 StrategyControl이라는 구조체 하나에 두 가지를 관리한다.

  • firstFreeBuffer: 아직 한 번도 디스크 페이지를 올린 적 없는 슬롯들을 연결한 단방향 링크드 리스트(freelist)의 헤드. 서버 기동 직후에는 shared_buffers에 할당된 모든 슬롯이 이 체인으로 연결되어 있고, 버퍼가 필요할 때마다 헤드에서 슬롯을 하나씩 꺼내 쓴다. 꺼낸 슬롯은 다시 freelist로 돌아오지 않으므로, 서버를 충분히 운용하면 결국 freelist는 완전히 소진된다. 이후부터는 Clock Sweep만으로 빈 슬롯을 확보한다.
  • nextVictimBuffer: freelist가 소진된 이후 사용되는 Clock Sweep 알고리즘이 다음으로 살펴볼 버퍼 인덱스. nextVictimBuffer는 전역 원자 카운터로 관리되어, 여러 백엔드가 동시에 victim을 요청(새 페이지를 올릴 슬롯을 버퍼 매니저에 요청하는 행위)해도 CAS(Compare-And-Swap)로 각자 서로 다른 위치를 순서대로 가져가므로 락 없이 병렬 처리된다.

동작 원리

버퍼 룩업 흐름

백엔드가 특정 페이지를 읽으려 할 때 내부에서 일어나는 과정을 단계별로 따라가 보자.

BM_IO_IN_PROGRESS 플래그의 역할이 중요하다. 한 백엔드가 페이지를 디스크에서 읽는 도중 다른 백엔드가 같은 페이지를 요청하면, 두 번째 백엔드는 이 플래그를 보고 I/O가 끝날 때까지 대기한다. 같은 페이지에 대해 중복 I/O가 발생하는 것을 방지하는 핵심 메커니즘이다.

Clock Sweep 알고리즘 — 구체적 예시

nextVictimBuffer는 0부터 NBuffers-1을 순환하는 전역 원자 카운터다. 여러 백엔드가 동시에 victim을 요청하면 각자 CAS로 서로 다른 위치를 가져가 독립적으로 탐색한다.

victim 탐색 로직은 다음과 같다:

반복 {
    buf = &BufferDescriptors[nextVictimBuffer++ % NBuffers]
    state = pg_atomic_read(buf->state)

    if (RefCount(state) > 0)   → 핀 걸린 상태, 건너뜀
    if (UsageCount(state) > 0) → state CAS로 usageCount 1 감소 후 건너뜀
    else                       → victim 선정, 반환
}

구체적 예시: NBuffers = 8이고 현재 버퍼 상태가 다음과 같다고 가정하자.

buf[0]: usageCount=3, refcount=0  (dirty)
buf[1]: usageCount=1, refcount=2  (pinned)
buf[2]: usageCount=0, refcount=0  ← 즉각 victim
buf[3]: usageCount=2, refcount=0
...
nextVictimBuffer = 0

Clock Sweep이 buf[0]부터 순서대로 탐색하는 과정을 상태 변화 표로 따라가 보자.

탐색 단계 대상 조건 결과 buf usageCount
1 buf[0] usageCount=3, refcount=0 1 감소 후 건너뜀 3 → 2
2 buf[1] usageCount=1, refcount=2 핀됨, 건너뜀 1 (불변)
3 buf[2] usageCount=0, refcount=0 victim 선정 0

 

3단계에서 buf[2]가 victim으로 선정됐다. buf[0]의 usageCount는 3에서 2로 줄어들었지만, Clock Sweep이 한 번 더 돌아야만 1로 줄어들고, 또 한 번 더 돌아야 0이 된다. 이것이 자주 접근하는 페이지가 교체되지 않는 이유다.

 

victim이 dirty이면 그 전에 WAL을 먼저 플러시해야 한다. 여기서 LSN(Log Sequence Number) 이 등장한다. LSN은 WAL 스트림 내의 위치를 나타내는 단조 증가 숫자다. 각 dirty 페이지의 헤더에는 해당 페이지를 수정한 WAL 레코드의 LSN(PageGetLSN())이 기록되어 있다. 이 LSN이 현재 WAL 플러시 완료 지점보다 크다면, 그 차이만큼의 WAL 레코드를 먼저 디스크에 써야만 해당 페이지를 안전하게 플러시할 수 있다.

 

LRU와의 비교: LRU는 해시맵 + 이중 연결 리스트로 구현되며, 접근마다 해당 노드를 MRU 끝으로 이동시킨다. 노드 이동은 O(1)이지만 리스트 전체에 락을 걸어야 하므로, 수백 개의 백엔드가 동시에 버퍼에 접근하는 환경에서는 이 중앙 리스트의 락이 병목이 된다. Clock Sweep은 중앙 자료구조 없이 per-slot 원자 카운터만으로 동작하므로 이 경합이 없다. 최악의 경우 O(NBuffers × 5) 스텝이지만 모두 CPU 레지스터 수준의 원자 연산이다.

I/O 동시성 — 같은 페이지를 두 백엔드가 동시에 요청하면

버퍼 미스가 발생했을 때 두 백엔드가 거의 동시에 같은 페이지를 요청하는 시나리오를 생각해보자. 이를 잘못 처리하면 같은 디스크 블록을 두 번 읽는 중복 I/O 또는 두 백엔드가 서로 다른 슬롯에 같은 페이지를 올리는 데이터 불일치가 생긴다.

 

PostgreSQL은 BM_IO_IN_PROGRESS 플래그로 이를 방지한다. 흐름은 다음과 같다.

백엔드 A (먼저 미스 감지):
  1. BufMappingLock exclusive로 새 태그 등록, buf_id 확보
  2. state에 BM_IO_IN_PROGRESS 세트
  3. BufMappingLock 해제
  4. pread() 호출 — 디스크 I/O 진행 중

백엔드 B (A가 I/O 중일 때 같은 페이지 요청):
  1. BufTableLookup → BufTable에 이미 태그 있음 (A가 등록)
  2. 핀 획득 시도 → BM_IO_IN_PROGRESS 감지
  3. WaitIO(buf) — A의 I/O 완료를 latch 대기

백엔드 A I/O 완료:
  4. BM_IO_IN_PROGRESS 해제, BM_VALID 세트
  5. SetLatch — B 깨움

백엔드 B:
  6. 대기 해제, 이미 채워진 버퍼를 핀한 채 반환

 

결과적으로 같은 디스크 블록은 단 한 번만 읽힌다. A가 I/O를 하는 동안 같은 페이지를 요청하는 모든 백엔드는 대기 큐에 쌓였다가 A가 완료하면 일괄로 깨어난다. wait_event = 'BufferIO'pg_stat_activity에서 이 대기 상태를 보여준다.

 

-- 버퍼 관련 대기 중인 백엔드 확인
-- BufferIO: I/O 완료 대기 (BM_IO_IN_PROGRESS)
-- BufferPin: 핀 해제 대기 (BM_PIN_COUNT_WAITER)
SELECT pid, wait_event_type, wait_event, query_start, left(query, 60) AS query
FROM pg_stat_activity
WHERE wait_event IN ('BufferIO', 'BufferPin')
  AND pid <> pg_backend_pid();
 pid  | wait_event_type | wait_event | query_start             | query
------+-----------------+------------+-------------------------+------------------------------
 8421 | Buffer          | BufferIO   | 2026-05-03 10:12:33+09  | SELECT * FROM orders WHERE ..
 9031 | BufferPin       | BufferPin  | 2026-05-03 10:12:34+09  | UPDATE orders SET status = ..

BufferIO 대기가 특정 시간에 집중되거나 지속된다면, 해당 시간대에 콜드 미스(버퍼에 없는 페이지 접근)가 빈발하고 있다는 신호다. 이 경우 shared_buffers 확대, pg_prewarm 적용, 또는 I/O 서브시스템 성능 개선을 검토해야 한다.

Ring Buffer — 풀 스캔이 캐시를 오염시키지 않는 이유

대형 테이블 순차 스캔(Sequential Scan), VACUUM, bulk COPY 같은 작업은 한 번만 읽고 버릴 페이지를 대량으로 처리한다. 이런 작업이 전체 shared_buffers를 점유하면 워킹셋(자주 접근하는 페이지들)이 모조리 밀려난다.

 

PostgreSQL은 이를 방지하기 위해 이런 작업에 Ring Buffer를 배정한다. Ring Buffer는 공유 버퍼 풀 안의 제한된 슬롯 집합으로, 작업이 끝난 블록은 Ring 안에서 순환 재사용된다. 메인 버퍼 풀에는 전혀 영향을 주지 않는다.

 

Ring 크기(PostgreSQL 소스 src/backend/storage/buffer/freelist.c):

접근 종류 Ring 크기 조건
순차 스캔(Bulk Read) 256KB (32 블록) 테이블 크기 > shared_buffers / 4
VACUUM 256KB (32 블록) 항상
Bulk Write (COPY FROM) 16MB (2048 블록) 항상

 

Ring 안에서 교체 대상을 찾을 때도 같은 usageCount 기반 Clock Sweep을 사용하되, ClockHand가 Ring의 슬롯 범위 안에서만 순환한다. Ring 밖 버퍼는 건드리지 않는다.

 

Ring Buffer가 실제로 적용되고 있는지는 pg_stat_iocontext 컬럼으로 확인할 수 있다. context = 'bulkread'는 순차 스캔 Ring Buffer I/O, context = 'vacuum'은 VACUUM Ring Buffer I/O를 의미한다. context = 'normal'이 압도적으로 많다면 대부분의 접근이 주 버퍼 풀을 사용하고 있다는 뜻이다.

 

순차 스캔 Ring Buffer 조건(테이블 크기 > shared_buffers / 4)을 직접 확인하는 방법:

-- Ring Buffer가 적용되는 테이블 식별
SELECT relname,
       pg_size_pretty(pg_total_relation_size(oid))   AS total_size,
       pg_size_pretty(ring_bytes)                     AS ring_threshold
FROM pg_class,
     LATERAL (SELECT setting::bigint * 8192 / 4 AS ring_bytes
              FROM pg_settings WHERE name = 'shared_buffers') AS t
WHERE relkind = 'r'
  AND pg_total_relation_size(oid) > ring_bytes
ORDER BY pg_total_relation_size(oid) DESC
LIMIT 5;
   relname   | total_size | ring_threshold 
-------------+------------+----------------
 audit_log   | 2819 MB    | 32 MB
 order_items | 356 MB     | 32 MB
 orders      | 336 MB     | 32 MB
 
 /*
 SHOW shared_buffers;
 
 shared_buffers 
----------------
 128MB 
 */

이 쿼리 결과에 나온 테이블들은 순차 스캔 시 Ring Buffer가 적용된다. 이 테이블들의 인덱스 없는 풀 스캔 쿼리가 다른 테이블의 캐시 히트율을 떨어뜨리지 않음을 알 수 있다.

Dirty Page 처리와 bgwriter·checkpointer

victim으로 선정된 버퍼가 dirty라면 교체 전에 반드시 디스크에 써야 한다. 이 작업은 세 주체가 분담한다.

 

bgwriter (백그라운드 라이터) 는 Clock Sweep과 동일한 방향으로 버퍼를 순환하며 dirty 버퍼를 선제적으로 플러시한다. 앞으로 교체될 가능성이 높은 저-usageCount 페이지를 미리 깨끗하게 만들어두는 것이다. 한 사이클에 bgwriter_lru_maxpages를 플러시하거나 bgwriter_delay(기본 200ms)가 지나면 잠시 멈춘다. 한 사이클에 얼마나 많이 플러시할지는 bgwriter_lru_multiplier(기본 2.0)로 조절한다 (최근 사이클에서 새로 교체된 버퍼 수의 배수만큼을 목표 플러시 수로 삼는다).

 

Backend Direct Write 는 bgwriter가 처리하지 못한 dirty victim을 쿼리 중인 백엔드 자신이 직접 pwrite()로 기록하는 경우다. 쿼리 실행 중 갑자기 I/O가 발생하므로 레이턴시 스파이크의 원인이 된다. pg_stat_bgwriter.buffers_backend로 이 횟수를 확인할 수 있다. (PostgreSQL 17부터는 해당 지표가 pg_stat_io로 이전되었으며, backend_type = 'client backend'이고 context = 'normal'인 행의 writes 컬럼에서 확인한다.)

 

checkpointercheckpoint_timeout(기본 5분) 주기 또는 max_wal_size 도달 시 모든 dirty 버퍼를 체계적으로 플러시하고 WAL에 체크포인트 레코드를 기록한다. 크래시가 발생하면 PostgreSQL은 마지막 체크포인트의 LSN부터 WAL을 재실행하여 복구한다. 체크포인트 간격이 짧을수록 재실행해야 할 WAL 양이 줄어 복구 시간이 단축된다. 이 메커니즘은 08편(Checkpoint)에서 자세히 다룬다.


SQL로 직접 관찰

pg_buffercache 설치

CREATE EXTENSION IF NOT EXISTS pg_buffercache;

pg_buffercache는 contrib 모듈로, 현재 공유 버퍼 풀 전체를 행으로 노출한다. 내부적으로 BufferDesc 배열을 순회하며 스냅샷을 찍는다. 결과는 순간 스냅샷이므로 실행 시점마다 다를 수 있다. PostgreSQL 16부터는 pg_buffercache_usage_counts() 함수도 추가되어 전체를 스캔하지 않고 usageCount 요약만 빠르게 얻을 수 있다.

공유 버퍼 전체 현황

SELECT
    count(*)                                        AS total_buffers,
    count(*) FILTER (WHERE reldatabase IS NOT NULL) AS used_buffers,
    count(*) FILTER (WHERE isdirty)                 AS dirty_buffers,
    round(count(*) FILTER (WHERE isdirty)::numeric
          / nullif(count(*) FILTER (WHERE reldatabase IS NOT NULL), 0) * 100, 1)
                                                    AS dirty_pct
FROM pg_buffercache;
 total_buffers | used_buffers | dirty_buffers | dirty_pct 
---------------+--------------+---------------+-----------
         16384 |        16384 |          6725 |      41.0

total_buffersshared_buffers / 8KB와 같다. used_bufferstotal_buffers에 근접하면 버퍼 풀이 포화 상태이며 Clock Sweep이 활발히 돌고 있다는 신호다. dirty_pct가 지속적으로 15% 이상을 유지하면 bgwriter가 충분히 플러시하지 못하고 있다는 뜻으로, bgwriter_lru_maxpages를 높이거나 bgwriter_delay를 줄이는 것을 검토해야 한다.

테이블별 버퍼 점유량

SELECT
    c.relname,
    c.relkind,
    count(*)                                         AS buffers,
    pg_size_pretty(count(*) * 8192::bigint)          AS cached_size,
    round(100.0 * count(*) /
          (SELECT count(*) FROM pg_buffercache
           WHERE reldatabase IS NOT NULL), 2)        AS pct_of_used
FROM pg_buffercache b
JOIN pg_class c ON c.relfilenode = b.relfilenode
WHERE b.reldatabase = (SELECT oid FROM pg_database
                       WHERE datname = current_database())
GROUP BY c.relname, c.relkind
ORDER BY buffers DESC
LIMIT 10;
          relname          | relkind | buffers | cached_size | pct_of_used 
--------------------------+---------+---------+-------------+-------------
 audit_log                | r       |   14605 | 114 MB      |       89.14
 audit_log_pkey           | i       |     943 | 7544 kB     |        5.76
 order_items              | r       |     267 | 2136 kB     |        1.63
 orders                   | r       |     267 | 2136 kB     |        1.63

버퍼를 가장 많이 점유한 테이블을 파악할 수 있다. relkind = 'r'이 테이블, 'i'가 인덱스다. 인덱스 크기가 테이블 크기에 비해 버퍼 점유 비율이 높으면 인덱스 스캔이 활발히 사용되고 있다는 긍정적 신호다. 반대로 대형 테이블이 갑자기 상위권에 등장하면 두 가지 경우다.

  • 첫째, 해당 테이블이 크기 임계값(shared_buffers/4) 미만이어서 Ring Buffer 대상에서 제외되어 메인 풀에 직접 적재된 경우다.
  • 둘째, 임계값 이상의 대형 테이블이더라도 인덱스 스캔으로 일부 블록을 지속적으로 접근해 usageCount가 높게 유지된 경우다.

Ring Buffer가 적용된 순차 스캔이라면 스캔 후 해당 페이지가 Ring 안에서 순환 소멸하므로 메인 풀 상위권에 남지 않는다. 인덱스 스캔으로 일부 블록을 지속적으로 접근해 usageCount가 높게 유지된 경우다. Ring Buffer가 적용된 대형 스캔이라면 스캔 후 해당 페이지가 Ring 안에서 순환 소멸하므로 메인 풀 상위권에 남지 않는다.

usage_count 분포 확인

SELECT
    usagecount,
    count(*)                                    AS buffers,
    round(100.0 * count(*) /
          sum(count(*)) OVER (), 1)             AS pct,
    repeat('█', (count(*) / 200)::int)          AS bar
FROM pg_buffercache
WHERE reldatabase IS NOT NULL
GROUP BY usagecount
ORDER BY usagecount;
 usagecount | buffers | pct  |                    bar                    
------------+---------+------+-------------------------------------------
          0 |    3109 | 19.0 | ███████████████
          1 |    4524 | 27.6 | ██████████████████████
          2 |     207 |  1.3 | █
          3 |     109 |  0.7 | 
          4 |      97 |  0.6 | 
          5 |    8338 | 50.9 | █████████████████████████████████████████

usagecount = 0인 버퍼가 Clock Sweep의 즉각적 교체 후보다. 이 값의 비율이 낮으면(2~5%) 버퍼 풀에 여유가 있다는 신호다. 반대로 usagecount = 1에 과반이 몰리고 usagecount = 0이 10% 이상이면 버퍼 풀이 워킹셋을 담기에 부족해 상시 교체가 일어나는 버퍼 압박 상태다. 이때는 shared_buffers 증가, 쿼리 최적화(풀 스캔 제거), 또는 대형 배치 작업의 Ring Buffer 적용을 검토해야 한다.

 

PostgreSQL 16에서 추가된 pg_buffercache_usage_counts()를 사용하면 위 쿼리보다 훨씬 빠르게 같은 정보를 얻을 수 있다:

-- PostgreSQL 16+
SELECT * FROM pg_buffercache_usage_counts();
 usage_count | buffers | dirty | pinned 
-------------+---------+-------+--------
           0 |    3109 |     0 |      0
           1 |    4524 |     1 |      0
           2 |     207 |     0 |      0
           3 |     109 |     1 |      0
           4 |      97 |     0 |      0
           5 |    8338 |  5026 |      0

pg_buffercache의 전체 스캔 없이 집계 결과만 반환하므로 프로덕션에서 주기적으로 모니터링하기에 훨씬 적합하다.

bgwriter 성능 지표

SELECT
    checkpoints_timed,
    checkpoints_req,
    buffers_checkpoint,
    buffers_clean           AS buffers_bgwriter,
    maxwritten_clean,
    buffers_backend         AS buffers_direct,
    round(100.0 * buffers_backend /
          nullif(buffers_checkpoint + buffers_clean + buffers_backend, 0), 1)
                            AS direct_write_pct,
    stats_reset
FROM pg_stat_bgwriter;

// 17부터 체크포인트 관련 컬럼이 pg_stat_checkpointer로 분리됐고, buffers_backend도 없어져서. 두 뷰를 조인해야 해야한다
/*

SELECT
    c.num_timed                                          AS checkpoints_timed,
    c.num_requested                                      AS checkpoints_req,
    c.buffers_written                                    AS buffers_checkpoint,
    b.buffers_clean                                      AS buffers_bgwriter,
    b.maxwritten_clean,
    i.writes                                             AS buffers_direct,
    round(100.0 * i.writes /
          nullif(c.buffers_written + b.buffers_clean + i.writes, 0), 1)
                                                         AS direct_write_pct,
    b.stats_reset
FROM pg_stat_bgwriter b
CROSS JOIN pg_stat_checkpointer c
CROSS JOIN (
    SELECT sum(writes) AS writes
    FROM pg_stat_io
    WHERE backend_type = 'client backend'
      AND object = 'relation'
      AND context = 'normal'
) i;

*/
 checkpoints_timed | checkpoints_req | buffers_checkpoint | buffers_bgwriter | maxwritten_clean | buffers_direct | direct_write_pct | stats_reset
-------------------+-----------------+--------------------+------------------+------------------+----------------+------------------+-----------------------------
               843 |               2 |             124500 |            31200 |               47 |           8730 |              5.3 | 2026-04-15 09:00:00+09

핵심 지표 해석:

  • maxwritten_clean = 47: bgwriter가 47번의 사이클에서 bgwriter_lru_maxpages(기본 100개) 한도에 도달해 강제 중단됐다. 이 값이 전체 사이클 대비 5% 이상이면 bgwriter_lru_maxpages를 200이상 올리거나 100ms로 줄이는 것을 검토한다.
  • direct_write_pct = 5.3%: 전체 플러시의 5.3%가 쿼리 백엔드의 직접 I/O였다. 10% 이상이면 쿼리 레이턴시에 영향을 주기 시작한다.
  • checkpoints_req = 2: 강제 체크포인트가 거의 없다. 이 값이 checkpoints_timed와 비슷하거나 크면 WAL 생성 속도가 max_wal_size 한도를 자주 초과한다는 뜻으로 max_wal_size 증가를 고려해야 한다.

EXPLAIN ANALYZE의 버퍼 히트/미스 확인

BUFFERS 옵션을 추가하면 실행 계획의 각 노드가 얼마나 많은 버퍼를 히트·미스했는지 알 수 있다.

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.order_id, o.total_amount, c.name
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.created_at >= '2026-01-01';
Hash Join  (cost=142.00..5821.00 rows=3200 width=44)
           (actual time=1.821..25.340 rows=3187 loops=1)
  Buffers: shared hit=4821 read=142
  ->  Seq Scan on orders o  (cost=0.00..5480.00 ...)
        Buffers: shared hit=4680 read=141
  ->  Hash  (cost=98.00..98.00 rows=3523 ...)
        Buckets: 4096  Batches: 1  Memory Usage: 257kB
        Buffers: shared hit=141 read=1
Planning Time: 0.842 ms
Execution Time: 25.551 ms

shared hit=4821 read=142는 4821블록이 버퍼 히트, 142블록이 디스크(또는 OS page cache) 접근이었음을 뜻한다. 히트율 = 4821 / (4821 + 142) = 97.1%. 일반적으로 OLTP 쿼리의 히트율이 95% 미만이면 버퍼 풀 크기나 워킹셋 분석이 필요하다.

데이터베이스 전체 버퍼 히트율 — pg_stat_database

pg_buffercache는 순간 스냅샷이지만, pg_stat_database는 서버 기동 이후 누적 통계를 제공한다. 버퍼 히트율을 장기적으로 추적하기에 더 적합하다.

SELECT
    datname,
    blks_hit,
    blks_read,
    round(100.0 * blks_hit
          / nullif(blks_hit + blks_read, 0), 2)   AS hit_ratio_pct,
    xact_commit + xact_rollback                    AS total_txns,
    pg_size_pretty(pg_database_size(datname))      AS db_size
FROM pg_stat_database
WHERE datname NOT IN ('template0', 'template1', 'postgres')
ORDER BY blks_hit + blks_read DESC;
  datname  | blks_hit  | blks_read | hit_ratio_pct | total_txns |  db_size
-----------+-----------+-----------+---------------+------------+----------
 shopdb    | 184721043 |   3812041 |         97.98 |    9421830 | 12 GB
 analyticsdb|  42100912 |  18421033 |         69.57 |     210041 | 84 GB

 

해석: shopdb는 히트율 97.98%로 양호하다. analyticsdb는 69.57%로 매우 낮다. OLAP 데이터베이스는 전체 테이블 스캔이 많아 히트율이 낮을 수밖에 없는데, 이 경우 shared_buffers를 늘려도 Ring Buffer가 대형 스캔을 격리시키므로 히트율 개선 효과는 제한적이다. 워크로드 특성을 먼저 파악해야 한다.

 

blks_read는 shared buffer 미스 횟수이며, OS page cache 히트를 포함한다. 실제 디스크 I/O 횟수와 동일하지 않다. 진짜 디스크 I/O는 PostgreSQL 16+의 pg_stat_io 뷰에서 더 정확하게 확인할 수 있다.

-- PostgreSQL 16+: 실제 물리 I/O 통계
SELECT backend_type, object, context,
       reads, writes, extends,
       read_time, write_time
FROM pg_stat_io
WHERE reads > 0
ORDER BY reads DESC
LIMIT 10;
  backend_type   | object | context | reads  | writes | extends | read_time | write_time
-----------------+--------+---------+--------+--------+---------+-----------+------------
 client backend  | relation | normal | 382104 |  12041 |    4201 |   1842.12 |     201.44
 autovacuum worker| relation | vacuum |  82041 |   3101 |       0 |    412.88 |      89.12
 bgwriter        | relation | normal |      0 |  31200 |       0 |      0.00 |     891.33

pg_stat_iobackend_type별, context(normal/vacuum/bulkread/bulkwrite)별로 세분화된 I/O 통계를 제공한다. context = 'bulkread'가 Ring Buffer를 사용한 순차 스캔, context = 'vacuum'이 VACUUM의 Ring Buffer I/O다.

특정 테이블을 버퍼에 프리로드

운영 전 미리 워킹셋을 캐시에 올려두고 싶을 때 pg_prewarm 익스텐션을 사용한다.

CREATE EXTENSION IF NOT EXISTS pg_prewarm;

-- 테이블을 shared buffer에 로드
SELECT pg_prewarm('orders', 'buffer');

-- 인덱스도 함께 로드
SELECT pg_prewarm('orders_pkey', 'buffer');
 pg_prewarm
------------
       3842

 pg_prewarm
------------
        821

반환값은 로드된 블록 수다. pg_prewarm은 내부적으로 ReadBufferExtended()를 반복 호출하므로 이미 버퍼에 있는 페이지의 usageCount를 올리는 부수 효과도 있다. 주의할 점은 대규모 프리로드가 기존 워킹셋을 밀어내는 역효과가 있을 수 있다는 것이다. 운영 중인 시스템에서는 트래픽이 낮은 시간대에 점진적으로 수행하는 것이 안전하다.

 

pg_prewarm은 PostgreSQL 11부터 autoprewarm 기능도 지원한다. 서버 종료 시 현재 버퍼 상태를 파일에 저장하고, 재시작 시 자동으로 복원한다. shared_preload_libraries = 'pg_prewarm'으로 활성화할 수 있다.


shared_buffers 설정 지침

shared_bufferspostgresql.conf에서 변경 후 postmaster 재시작이 필요한 설정이다.

SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name IN ('shared_buffers', 'effective_cache_size',
               'bgwriter_lru_maxpages', 'bgwriter_delay',
               'bgwriter_lru_multiplier', 'huge_pages');
          name           | setting | unit |                               short_desc                               
-------------------------+---------+------+------------------------------------------------------------------------
 bgwriter_delay          | 200     | ms   | Background writer sleep time between rounds.
 bgwriter_lru_maxpages   | 100     |      | Background writer maximum number of LRU pages to flush per round.
 bgwriter_lru_multiplier | 2       |      | Multiple of the average buffer usage to free per round.
 effective_cache_size    | 524288  | 8kB  | Sets the planner's assumption about the total size of the data caches.
 huge_pages              | try     |      | Use of huge pages on Linux or Windows.
 shared_buffers          | 16384   | 8kB  | Sets the number of shared memory buffers used by the server.

shared_buffers 권장값: 서버 RAM의 25%가 일반적인 출발점이지만 맹목적으로 따를 필요는 없다. 핵심 원칙은 나머지 메모리를 OS page cache(effective_cache_size가 반영), 동시 연결 수 × work_mem(정렬·해시 조인 시 각 연산이 사용하는 메모리), 그리고 기타 프로세스 오버헤드에 남겨두는 것이다. 전용 DB 서버에서 OLAP 비중이 낮다면 40%까지 올리기도 한다.

 

아래 표는 전용 OLTP 서버 기준 출발점 설정값이다. OLAP 또는 혼합 워크로드라면 동시 접속 수가 적어 연결당 사용 가능한 메모리가 늘어나므로 work_mem을 높이는 것이 유리하다. 반면 대형 순차 스캔은 Ring Buffer로 격리되므로 shared_buffers를 무작정 늘리는 것보다 work_mem 확대가 OLAP 성능에 더 큰 효과를 가져오는 경우가 많다.

서버 RAM shared_buffers effective_cache_size work_mem (연결당)
8 GB 2 GB 6 GB 4–16 MB
32 GB 8 GB 24 GB 16–64 MB
64 GB 16 GB 48 GB 32–128 MB
128 GB 32 GB 96 GB 64–256 MB
256 GB 64 GB 192 GB 128–512 MB

 

work_mem의 상한은 최대 동시 쿼리 수와 쿼리당 정렬·해시 조인 연산 수에 따라 달라진다. 복잡한 분석 쿼리가 다수 실행될 경우 work_mem × 쿼리 수 × 쿼리 내 정렬·해시 연산 수가 전체 메모리를 초과하지 않도록 주의해야 한다.

 

effective_cache_size 는 실제 메모리를 할당하지 않는다. 플래너가 OS page cache를 포함한 전체 이용 가능 캐시 크기를 추정하는 데만 사용하며, 인덱스 스캔 비용 계산에 영향을 준다. 이 값이 낮으면 플래너가 인덱스 스캔보다 순차 스캔을 선호한다.

 

huge_pages = try 는 Linux의 HugeTLB(정적으로 미리 예약하는 거대 페이지)를 사용해 공유 메모리의 TLB 미스를 줄이려는 설정이다. HugeTLB는 vm.nr_hugepages로 페이지 수를 미리 예약해야 하며, 커널이 자동으로 관리하는 THP(Transparent Huge Pages)와는 다른 메커니즘이다. shared_buffers가 수 GB 이상이면 4KB 페이지 기준 TLB 엔트리가 수백만 개에 달해 TLB miss가 성능에 유의미한 영향을 준다. try는 HugeTLB 할당에 실패해도 오류 없이 일반 4KB 페이지로 fallback하므로 대부분의 환경에서 무해하게 적용 가능하다.

 

bgwriter_lru_multiplier 는 bgwriter가 한 사이클에 목표로 삼는 플러시 수를 결정한다. 직전 사이클에 새로 교체된 버퍼 수에 이 배수를 곱한 값이 다음 사이클의 플러시 목표다. 기본값 2.0은 "앞으로 교체될 것의 두 배를 미리 비워두겠다"는 의미다. I/O 용량이 충분한 NVMe 환경에서 쓰기 워크로드가 버스티하다면 3.0~4.0으로 올리는 것을 검토할 수 있다.

Linux 커널 설정과의 관계

shared_buffers가 수 GB 이상으로 커지면 Linux의 두 가지 설정이 성능에 영향을 준다.

 

Huge Pages: 기본 Linux 메모리 페이지 크기는 4KB다. shared_buffers = 8GB이면 TLB에 올려야 할 페이지 테이블 엔트리가 약 200만 개가 된다. 2MB HugePage를 사용하면 이를 4096개로 줄여 TLB miss를 대폭 줄일 수 있다. postgresql.conf에서 huge_pages = try(기본값)는 HugePage 할당을 시도하고 실패하면 일반 페이지로 fallback한다. on으로 설정하면 HugePage 할당 실패 시 postmaster 기동이 실패한다.

 

HugePage를 사용하려면 커널에 충분한 페이지가 예약되어 있어야 한다.

# 필요한 HugePage 수 계산 (shared_buffers / 2MB + 여유분)
# shared_buffers = 8GB → 8192MB / 2MB = 4096, 여유 200 추가
echo 4300 | sudo tee /proc/sys/vm/nr_hugepages

# 영구 적용 (/etc/sysctl.conf)
# vm.nr_hugepages = 4300

# 할당 결과 확인
grep -E 'HugePages_Total|HugePages_Free|Hugepagesize' /proc/meminfo
HugePages_Total:    4300
HugePages_Free:      204
Hugepagesize:       2048 kB

HugePages_Free가 너무 작으면(100 미만) shared_buffers가 예약된 HugePage를 모두 소진해 다른 프로세스가 HugePage를 사용할 수 없다는 신호다.

 

vm.overcommit_memory: PostgreSQL은 기동 시 shared_buffers 크기만큼 공유 메모리를 한 번에 예약한다. vm.overcommit_memory = 1(always allow)이면 커널이 메모리를 무제한 허용하므로 실제 메모리 부족 시 OOM killer가 임의 프로세스를 kill한다. PostgreSQL postmaster가 kill되면 모든 연결이 강제 종료된다. 전용 DB 서버에서는 vm.overcommit_memory = 2로 설정해 가용 메모리를 초과하는 할당 자체를 거부하는 편이 예측 가능한 장애 동작을 보장한다.


 

참고 자료