PostgreSQL의 shared_buffers를 중심으로 8KB 페이지, 버퍼 풀 구조, 핀/사용 카운트, 클록 스윕, 더티 버퍼와 백그라운드 라이터, 링 버퍼, 로컬 버퍼, OS 페이지 캐시, effective_cache_size까지 버퍼 동작 원리를 정리한다.
RegreSQL 작업을 하면서 **버퍼(buffers)**에 대해 많이 파고들게 되었습니다. PostgreSQL을 가볍게 사용하는 분이라면 shared_buffers를 조정한다는 얘기를 들어봤을 것이고, 사용 가능한 RAM의 1/4로 맞추라는 오래된 조언도 따라 해봤을 겁니다. 그런데 최근 Postgres FM 에피소드에서 우리가 버퍼 얘기를 조금 과하게(?) 열정적으로 한 뒤로, “그게 다 무슨 이야기냐”는 질문을 받았습니다.
버퍼는 쉽게 잊히는 주제 중 하나입니다. 그리고 PostgreSQL 성능 아키텍처의 기반 블록임에도, 대부분의 사람들은 이를 블랙박스처럼 다루죠. 이 글은 그 상황을 바꿔보려는 시도입니다.
버퍼를 본격적으로 다루기 전에 꼭 짚고 넘어가야 할 개념이 하나 있습니다. 바로 8KB 페이지(8KB page) 개념입니다. PostgreSQL에서 모든 데이터는 폭이 8KB인 블록(페이지)에 저장됩니다.
PostgreSQL이 데이터를 읽을 때는 개별 행(row)을 읽지 않습니다. 페이지 전체를 읽습니다. 쓸 때도 마찬가지로 페이지 단위로 씁니다. 작은 행 하나를 가져오고 싶어도, 항상 그와 함께 더 많은 데이터를 가져오게 됩니다. 그리고 주의 깊게 보면, 쓰기에도 동일하게 적용됩니다.
sql-- block size를 확인할 수 있습니다(대부분 항상 8192 bytes일 것입니다) show block_size; block_size ------------ 8192 (1 row)
모든 테이블과 인덱스는 이런 페이지들의 모음입니다. 행이 충분히 크면 여러 페이지에 걸칠 수도 있지만, 페이지는 여전히 I/O의 원자적(atomic) 단위입니다.
흥미로운 부분은, 운영체제(OS)도 디스크 페이지를 캐시할 수 있는데 왜 PostgreSQL이 자체 버퍼 캐시 인프라를 유지해야 하느냐를 이해하는 것입니다.
답은 꽤 단순합니다. PostgreSQL은 자신이 읽는 데이터를 이해합니다. 반면 운영체제는 파일과 바이트만 볼 뿐입니다. PostgreSQL은 테이블, 인덱스, 쿼리 플랜을 보고, 의미(semantic) 지식을 바탕으로 더 빠르게 캐싱할 수 있습니다.
예를 들어 봅시다. 어떤 쿼리가 큰 테이블에 대해 순차 스캔(sequential scan)을 해야 한다고 합시다. OS는 해당 페이지들을 기꺼이 모두 캐시할 수 있지만, PostgreSQL은 이것이 일회성 작업임을 알고, 메인 캐시가 축출(eviction)되는 것을 피하기 위해 특별한 전략(링 버퍼, ring buffers)을 사용합니다.
두 번째로 중요한 측면은 ACID—정확히 말하면 WAL(write-ahead log)이 안정 저장소(stable storage)에 먼저 기록된 뒤에 데이터 페이지가 수정되어야 한다는 보장입니다. OS는 이를 구분하지 못하며, 이 내구성 요구사항을 효과적으로 보장할 수 없습니다(성능 저하를 감수한다면 모를까).
이제 우리가 흔히 PostgreSQL의 주요 “캐시”라고 알고 있는 것으로 넘어가 보죠. shared_buffers 파라미터는 모든 백엔드 프로세스가 접근할 수 있는 공유 메모리의 크기를 제어합니다. 어떤 백엔드가 페이지를 가져와야 하면 먼저 shared buffers를 확인합니다. 페이지가 있으면 히트(hit)이고, 디스크 I/O가 필요 없습니다. 미스(miss)라면 디스크(또는 OS 캐시)에서 읽어서 shared buffers에 저장해 다음번에 재사용합니다.
WAL은 자체 버퍼 영역(wal_buffers)을 가집니다. 이런 분리는 WAL 쓰기가 순차적이며, 해당 데이터 변경이 커밋으로 간주되기 전에 반드시 영속화되어야 하기 때문에 존재합니다. 기본값은 shared_buffers의 3%이며, 최대 16MB(= WAL 세그먼트 하나)로 제한됩니다.
sqlshow shared_buffers; shared_buffers ---------------- 128MB (1 row)
기본값 128MB는 매우 보수적이며, PostgreSQL 기본 설치가 제한된 RAM을 가진 시스템을 포함해 거의 모든 환경에서 잘 동작하도록 하기 위한 것입니다. 하지만 일반적인 운영 환경이라면 이 값은 보통 훨씬 더 커야 합니다.
여기서 128MB는 실제로 버퍼링되는 콘텐츠를 의미합니다. 페이지 하나가 8KB라는 점을 감안하면, 이는 데이터를 저장하는 개별 슬롯 16,384개로 상상할 수 있습니다.
하지만 버퍼 풀은 페이지 자체만 있는 게 아닙니다. PostgreSQL은 메타데이터를 추적하고 빠른 조회를 제공해야 하므로, 공유 메모리 영역은 세 구성 요소로 조직됩니다:
각 디스크립터는 슬롯에 캐시된 페이지가 무엇인지(tag), 상태에 관한 플래그(더티, 유효, I/O 진행 중), 그리고 pin/usage 카운터를 추적합니다.
O(1) = 버퍼 풀 크기와 무관한 상수 시간.
해시 테이블은 빠른 조회를 가능하게 합니다. 어떤 백엔드가 특정 페이지가 필요하면 페이지 식별자를 해싱해 바로 해당 버킷으로 점프합니다. 16,384개 슬롯을 전부 스캔할 필요가 없습니다. 이 덕분에 버퍼 조회는 풀 크기와 관계없이 O(1)을 유지합니다.
백엔드가 orders 테이블의 N번 페이지가 필요할 때, 식별자를 해싱하고 해시 테이블을 조회하며, 그 결과로 히트/미스 로직이 진행됩니다.
각 버퍼 슬롯이 어떻게 처리되는지는 위에서 언급한 두 카운터—핀(pin)과 사용(usage) 카운트—에 의해 좌우됩니다.
핀 카운트(pin count)는 활성 참조를 추적합니다. 백엔드(예: 실행 중인 쿼리)가 페이지를 적극적으로 읽거나 수정할 때, 해당 버퍼를 핀(pin)해서 축출되지 않도록 합니다. 백엔드가 작업을 마치면 언핀(unpin)합니다.
사용 카운트(usage count)는 버퍼가 얼마나 최근/자주 접근됐는지를 추적합니다. 접근할 때마다 카운트가 증가하며(최대 5로 제한), 축출 시 클록 스윕(clock sweep)이 이를 감소시킵니다. 값이 높은 버퍼는 더 오래 살아남고, 0인 버퍼는 축출됩니다.
사용 카운터는 단일 순차 스캔이 전체 버퍼 풀을 쓸어버리는 행동을 방지하는 데 중요합니다. 예를 들어 1GB 테이블 전체를 읽는다고 해봅시다. 이 보호가 없다면 shared buffers에 있는 모든 것을 축출해버려, 자주 접근되는 데이터를 무시하게 됩니다. 이 특정 동작은 뒤에서 링 버퍼에서 다시 다룹니다.
PostgreSQL은 확장(extension)인 pg_buffercache를 통해 shared buffer 캐시에서 무슨 일이 벌어지는지 실시간으로 살펴볼 수 있는 기능을 제공합니다.
이제 usage 추적을 다뤘으니, PostgreSQL이 페이지를 로드해야 하는데 모든 슬롯이 꽉 찼을 때는 어떻게 될까요? 공간을 만들어야 합니다.
단순한 LRU 체크는 유지 비용이 훨씬 큽니다. 버퍼를 로드할 때마다 연결 리스트를 업데이트해야 하며, 이는 복잡도를 크게 높입니다.
그래서 등장하는 것이 클록 스윕(clock sweep) 알고리즘입니다. 왜 “클록(clock)”일까요? 버퍼 풀을 원형 시계로 상상해 보세요. 알고리즘은 항상 앞으로 움직이며 쓸고 지나갑니다. 각 슬롯을 지날 때:
클록 스윕은 차가운(cold) 페이지는 빠르게 축출하고, 뜨거운(hot) 페이지는 여러 번의 스윕을 버티며 남게 하는 간단한 방식입니다.
변경은 항상 먼저 WAL에 기록된다는 점을 잊지 마세요.
지금까지는 디스크에서 로드된 버퍼가 shared buffers 캐시에 존재하는 것과 동일하다고 이야기했습니다. 백엔드가 페이지를 수정하면 해당 버퍼는 “더티(dirty)”가 되지만, 즉시 저장소에 기록되지는 않습니다. 더티 버퍼는 아직 수행되지 않은 I/O 작업을 나타냅니다. 즉시 쓰는 것은 비효율적이기 때문입니다. 같은 페이지가 짧은 시간에 여러 번 수정될 수 있으며, 그 사이에 했을 I/O는 불필요해질 수 있습니다.
대신 더티 페이지는 다음 이벤트 중 하나가 발생할 때까지 누적됩니다.
PostgreSQL은 체크포인트(checkpoint) 동안 모든 더티 버퍼를 디스크에 씁니다. 이는 주기적인 프로세스이며(CHECKPOINT 명령으로 강제할 수도 있습니다), 디스크상의 데이터가 일관된 상태가 되는 시점입니다. 체크포인트가 성공적으로 완료되면, 장애 복구(crash recovery)는 그 시점 이후의 WAL만 재생(replay)하면 됩니다.
두 번째 메커니즘은 백그라운드 라이터(background writer) 입니다. 이 프로세스는 더티 버퍼를 계속 스캔해서, 누군가가 축출해야 하기 전에 미리 써 둡니다. 덕분에 백엔드가 클록 스윕을 수행할 때, I/O를 기다릴 필요 없이 바로 축출 가능한 클린(clean) 버퍼를 찾을 수 있습니다. 또한 축출 압박이 있을 때의 버스티(bursty)한 스파이크 대신, 쓰기를 시간에 걸쳐 분산시킵니다.
그리고 예상했듯이, 마지막으로 더티 페이지를 디스크에 쓰는 기회는 클록 스윕이 더티 버퍼를 발견했을 때입니다. 새 페이지를 위해 재사용하려면 데이터를 디스크에 써야 하므로, 동기식 I/O로 떨어지는 최악의 경우입니다.
궁극적인 목표는 사용 가능한 클린 버퍼를 적절히 유지해, 축출 과정에서 동기식 쓰기로 백엔드가 블로킹되지 않게 하는 것입니다. 주의 깊게 보면, 흐름이나 설정이 나쁘면 문제가 생길 수 있다는 걸 알 수 있습니다. 새 버퍼를 로드할 때(I/O) 축출이 발생하고, 축출 대상이 더티 버퍼라면 또 다른 I/O를 강제하게 됩니다.
앞에서 말했듯이 특별한 종류의 버퍼도 있습니다. 큰 테이블을 스캔하는 쿼리 시나리오를 이미 살짝 언급했죠.
순진한 구현에서는 순차 스캔이 모든 데이터를 shared buffers에 로드하면서, 그 과정에서 다른 모든 것을 축출해버릴 것입니다. 따뜻하게(warmed) 데워진 캐시는 사라지고, 이후 쿼리들은 몇 분 동안 고통받게 됩니다.
PostgreSQL의 해결책은 링 버퍼(ring buffers) 입니다. 대량 작업(bulk operations)을 위한 작은 프라이빗 버퍼 풀입니다. shared buffer pool을 쓰는 대신, 특정 작업은 자신만의 제한된 링을 사용합니다.
개별 케이스는 다음과 같습니다:
큰 테이블에 대한 순차 스캔이 shared_buffers의 1/4를 초과하면, 전용 256KB 링 버퍼를 사용합니다. 페이지는 이 작은 링에서 순환하며 메인 캐시에는 닿지 않습니다.
sql-- 큰 테이블에 대해 순차 스캔 수행 EXPLAIN (ANALYZE, BUFFERS) SELECT count(1) FROM ring_buffer_test;
이 경우 hit 대신 Buffers: shared read=127285 같은 결과를 보게 될 것입니다. EXPLAIN 플랜에서 buffers를 읽는 법은 별도 글로 다루겠습니다.
대량 쓰기(COPY, CREATE TABLE AS)는 최대 16MB로 제한된 링 버퍼를 사용합니다. 효율적인 배치를 위한 충분한 크기이면서, shared buffer pool을 오염시키지 않을 만큼 작습니다.
VACUUM은 모든 페이지를 건드리며 뜨거운 데이터를 축출하면 안 되므로, 전용 링 버퍼를 사용합니다. 역사적으로는 256KB였지만, PostgreSQL 17부터는 vacuum_buffer_usage_limit로 설정할 수 있습니다(앞의 두 경우는 고정).
shared buffer pool을 쓰지 않는 두 번째 예외는, 세션 기반 임시 테이블(temporary tables) 입니다. 이 경우 동시성 문제가 없으므로, 각 백엔드는 temp_buffers(기본 8MB)로 제어되는 로컬 버퍼 풀을 가집니다.
로컬 버퍼는 잠금이 더 단순하기 때문에 shared buffers보다 빠릅니다. 메인 캐시에서 필요한 무거운 프로세스 간 조정(cross-process coordination)이 필요 없습니다.
이게 사소한 구현 디테일처럼 보일 수 있지만, 강력한 최적화 전략으로 이어질 수 있습니다. 많은 개발자가 중간 데이터를 위해 복잡한 CTE 로직을 기본으로 선택하곤 하지만, 임시 테이블을 쓰면 뚜렷한 장점이 있습니다. 임시 테이블 변경은 WAL 로깅되지 않고, 그 특성상 shared buffer pool 오염도 줄여 I/O가 더 낮아질 수 있습니다.
작업 부하가 큰 임시 테이블을 많이 포함한다면, temp_buffers를 올려 해당 작업을 순수하게 RAM에서 처리하도록 도울 수 있습니다. 다만 이 메모리는 커넥션당(per-connection)이므로, 모든 백엔드에 걸쳐 곱해진다는 점을 기억하세요.
PostgreSQL은 운영체제를 우회하지 않습니다. 모든 읽기/쓰기는 커널을 거치며, 커널은 자체 페이지 캐시를 유지합니다. 이로 인해 **이중 버퍼링(double buffering)**이 생깁니다. 같은 8KB 페이지가 PostgreSQL의 shared buffers와 OS 캐시에 동시에 존재할 수 있습니다.
OS 캐시는 “레벨 2 캐시(Level 2 cache)”처럼 동작합니다. PostgreSQL이 페이지를 축출하더라도, 그 페이지는 종종 OS 메모리에는 남아 있습니다.
이건 낭비처럼 들리지만, 실제로는 기능입니다. PostgreSQL이 공간을 만들기 위해 클린 페이지를 축출하면, 그 페이지는 사라지는 대신 OS 캐시로 내려갑니다. 잠시 후 다시 필요해지면, OS가 RAM에서 제공하므로 디스크 I/O가 필요 없습니다.
OS는 또한 리드어헤드(read-ahead) 를 제공합니다. 순차 접근 패턴을 감지해 PostgreSQL이 요청하기 전에 페이지를 미리 로드합니다.
이 관계가 “shared_buffers를 RAM의 25%로 설정하라”는 고전적인 조언을 설명해 줍니다. OS가 안전망(safety net) 역할을 하도록 공간을 일부러 남겨두는 것입니다.
sql-- RAM이 큰 전용 서버에서는 40%가 잘 맞을 때도 있습니다 -- 하지만 OS 캐시와 기타 프로세스를 위한 공간은 항상 남겨두세요 ALTER SYSTEM SET shared_buffers = '8GB';
이 파라미터는 메모리를 할당하지 않습니다. 비용 추정을 위한 힌트일 뿐입니다.
PostgreSQL은 쿼리 플래닝을 위해 이 결합 캐시(공유 버퍼 + OS)를 알아야 합니다. 여기서 effective_cache_size가 등장합니다. 이 값은 플래너에게 비용을 추정할 때 사용할 총 캐시 크기(shared buffers + OS) 를 알려줍니다.
sql-- 사용 가능한 총 캐시 추정치(shared + OS) SHOW effective_cache_size;
값이 높을수록 플래너는 데이터가 shared buffers에 없더라도 어딘가(OS 캐시 등)에 캐시되어 있을 가능성이 높다고 가정해, 인덱스 스캔을 선호하는 방향으로 계획을 세울 수 있습니다.
버퍼는 PostgreSQL 내부 구조의 핵심 축 중 하나입니다. 버퍼는 쿼리가 빠른 RAM을 칠지, 느린 디스크를 칠지를 결정하며, 동시에 더티 페이지, WAL, 내구성 사이의 취약한 균형에서도 중요한 역할을 합니다.
shared buffer pool은 단순한 캐시가 아니라, 클록 스윕 축출, 사용 카운트 감쇠, 링 버퍼 격리, 백그라운드 유지보수까지 갖춘 정교한 메모리 매니저입니다. 이 메커니즘을 이해하면 효과적으로 튜닝할 수 있고, 문제가 시작될 때 진단하는 데에도 도움이 됩니다.