PostgreSQL은 행 버전을 어떻게 저장하고 정리할까¶
PostgreSQL의 내부 용어를 따로 외우지 않고, 한 번의 조회와 업데이트가 계획·메모리·저장소·복구 경로를 통과하는 순서로 이해합니다.
PostgreSQL에서 UPDATE orders SET status = 'PAID' WHERE id = 42를 실행했다고 하자. 겉으로는 한 행의 값 하나를 바꾸는 일이다. 내부에서는 먼저 어떤 경로로 그 행을 찾을지 결정하고, 메모리에 필요한 페이지를 올린 뒤, 기존 행을 지우지 않고 새 행 버전을 만든다. 장애 뒤 변경을 되살릴 기록도 남긴다. 시간이 지나면 더는 누구도 볼 수 없는 이전 버전을 정리한다.
이 흐름을 모르면 서로 다른 증상을 따로 보게 된다. 인덱스가 있는데 순차 스캔이 나오는 문제, 표 크기가 계속 커지는 문제, 긴 트랜잭션 하나가 정리를 막는 문제, 체크포인트 때 쓰기 지연이 튀는 문제가 사실은 같은 저장 구조 위에서 연결돼 있다는 점을 놓치기 쉽다.
flowchart TB
Q["SQL 요청"] --> P["실행 계획 선택<br/>통계로 읽을 경로를 예상"]
P --> B["공유 버퍼 확인<br/>필요한 페이지를 메모리에 준비"]
B --> V["보이는 행 버전 확인<br/>현재 트랜잭션의 스냅샷 적용"]
V --> U["업데이트라면 새 행 버전 생성"]
U --> W["WAL 기록<br/>장애 뒤 다시 적용할 변경을 먼저 저장"]
W --> C["커밋 성공 응답"]
U --> O["이전 행 버전"]
O --> A["VACUUM<br/>더는 보이지 않을 때 재사용 가능하게 정리"]
행을 찾기 전에 페이지를 읽는다¶
PostgreSQL은 표를 보통 8KiB 크기의 페이지로 나눠 저장한다. 저장 장치는 행 하나만 정확히 떼어 전달하지 않는다. 필요한 행이 포함된 페이지를 가져오고, PostgreSQL이 그 안에서 행을 찾는다. 작은 조회 하나도 페이지 배치와 캐시 상태의 영향을 받는 이유다.
PostgreSQL 문서와 소스 코드에서는 물리적으로 저장된 행 하나를 튜플(tuple)이라고 부른다. 튜플에는 사용자가 만든 열뿐 아니라 이 버전을 만든 트랜잭션과 더는 보이지 않게 만든 트랜잭션을 판별하는 정보도 들어간다. 따라서 VARCHAR(20) 열 몇 개의 길이만 더해서 실제 행 크기를 정확히 계산할 수는 없다.
일반 표는 힙(heap)이라는 영역에 튜플을 둔다. 여기서 힙은 우선순위 자료구조가 아니라, 행을 기본 키 순서로 강제 정렬하지 않는 표 저장 영역이라는 뜻이다. 일반 B-Tree 인덱스는 키와 함께 힙 안의 위치를 가리키는 TID(Tuple Identifier, 튜플 식별자)를 보관한다. 인덱스에서 후보를 찾은 뒤 힙 페이지로 가 실제 튜플과 가시성을 확인한다.
값이 한 페이지에 들어가기 너무 크면 TOAST(The Oversized-Attribute Storage Technique)가 압축하거나 별도 영역에 나눠 저장할 수 있다. 긴 JSON이나 본문 열을 자주 함께 조회하면 인덱스 탐색보다 이 값을 꺼내고 복원하는 비용이 더 클 수도 있다.
공유 버퍼는 디스크 앞의 작업 공간이다¶
필요한 페이지를 매번 저장 장치에서 가져오면 느리다. PostgreSQL은 여러 서버 프로세스가 함께 사용하는 메모리 영역인 공유 버퍼(shared buffers)에 페이지를 보관한다. 찾는 페이지가 있으면 바로 사용하고, 없으면 운영체제를 통해 저장소에서 읽어 빈 버퍼 칸에 올린다.
업데이트된 페이지는 곧바로 데이터 파일에 완성본으로 기록되지 않아도 된다. 메모리에서 바뀐 더티 페이지(dirty page)가 되고, 백그라운드 기록기나 체크포인트 과정이 나중에 저장한다. 여기서 중요한 질문은 “캐시 적중률이 높은가” 하나가 아니다. 자주 쓰는 데이터가 메모리에 들어오는지, 큰 순차 조회가 작은 거래 페이지를 밀어내는지, 더티 페이지가 한 시점에 몰려 쓰기 지연을 만드는지를 함께 봐야 한다.
실행 계획은 실제 실행 전에 세운 예상이다¶
SQL을 받으면 플래너(planner)가 순차 스캔과 인덱스 스캔, 조인 순서와 조인 방법의 예상 비용을 비교한다. 플래너가 그 순간 모든 행을 미리 조회하는 것은 아니다. ANALYZE가 모아 둔 행 수, 자주 나오는 값, 값의 범위, 서로 다른 값의 개수 같은 통계로 결과 규모를 추정한다.
예를 들어 전체 주문의 40%가 PENDING인데 플래너가 0.1%라고 예상하면, 많은 힙 페이지를 오가게 되는 인덱스 스캔을 선택할 수 있다. 반대로 소수의 행만 필요한데 대부분을 반환할 것으로 예상하면 표 전체를 훑을 수 있다. EXPLAIN ANALYZE를 볼 때 가장 먼저 비교할 값은 각 단계의 예상 행 수와 실제 행 수다. 둘이 처음 크게 어긋난 지점이 뒤쪽 조인과 정렬 선택까지 잘못되게 만든 출발점일 가능성이 높다.
열끼리 강하게 연관돼 있는데 각각 독립적이라고 가정한 경우에는 확장 통계가 필요할 수 있다. 통계를 더 자주 모으는 것만으로 해결되지 않는 이유다.
업데이트는 기존 행 옆에 새 버전을 만든다¶
PostgreSQL은 일반적으로 기존 튜플을 제자리에서 덮어쓰지 않는다. 변경된 값을 가진 새 튜플을 만들고, 이전 튜플에는 어느 시점부터 오래된 버전이 되었는지 판단할 정보를 남긴다. MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 각 트랜잭션의 스냅샷을 기준으로 이 여러 버전 가운데 어느 것을 보여 줄지 결정한다.
덕분에 긴 조회가 진행 중이어도 업데이트가 끝날 때까지 같은 행 잠금을 기다리지 않고, 조회가 시작할 때 유효했던 버전을 계속 볼 수 있다. 그러나 여러 버전을 보관하는 공간은 공짜가 아니다. 인덱스가 가리키는 열까지 바뀌면 새 인덱스 항목도 필요하다.
HOT(Heap-Only Tuple, 힙 전용 튜플) 업데이트는 변경된 열이 인덱스에 포함되지 않고 같은 힙 페이지에 새 버전을 둘 공간이 있을 때 인덱스를 다시 만들지 않고 버전 사슬을 잇는 최적화다. 모든 업데이트가 HOT이 되는 것은 아니다. 인덱스를 무작정 늘리면 쓰기 비용뿐 아니라 HOT이 가능한 범위도 줄어든다.
WAL은 데이터 페이지보다 먼저 남기는 복구 기록이다¶
서버가 더티 페이지를 데이터 파일에 쓰는 도중 꺼지면 페이지 일부만 반영될 수 있다. PostgreSQL은 변경 내용을 설명하는 WAL(Write-Ahead Log, 선행 기록 로그)을 데이터 페이지보다 먼저 안전하게 기록한다. 커밋 성공은 관련 WAL이 설정된 지속성 조건을 만족했다는 뜻이지, 모든 데이터 페이지가 이미 제자리에 기록됐다는 뜻은 아니다.
재시작할 때는 마지막 체크포인트 뒤의 WAL을 다시 적용해 커밋된 변경을 복원한다. 복제본도 WAL 흐름을 받아 같은 변경을 적용한다. WAL 위치를 나타내는 LSN(Log Sequence Number, 로그 순서 번호)을 비교하면 주 서버가 만든 변경을 복제본이 어디까지 받았고 적용했는지 판단할 수 있다.
체크포인트를 지나치게 자주 만들면 많은 더티 페이지 쓰기가 짧은 구간에 몰릴 수 있다. 반대로 간격이 너무 넓으면 장애 복구 때 다시 적용할 WAL 양과 보관 부담이 커진다. 운영에서는 초당 WAL 생성량, 체크포인트 소요 시간, 강제 체크포인트 수, 저장소 쓰기 지연과 복제 적용 지연을 같은 시간축에서 본다.
MVCC가 있어도 잠금은 사라지지 않는다¶
MVCC는 조회와 쓰기의 직접 충돌을 줄이지만, 같은 행을 동시에 바꾸는 요청과 스키마 변경까지 없애지는 않는다. 행 잠금은 충돌하는 업데이트의 순서를 조정하고, 표 잠금은 스키마 변경이나 특정 유지보수 작업과 다른 작업의 관계를 정한다.
두 트랜잭션이 서로 상대가 가진 잠금을 기다리는 교착 상태(deadlock)가 생기면 PostgreSQL이 대기 순환을 감지하고 하나를 중단한다. 단순히 대기 시간을 길게 늘리면 해결되는 문제가 아니다. 여러 객체를 항상 같은 순서로 변경하고, 외부 API 호출을 트랜잭션 안에서 기다리지 않으며, 실패한 전체 트랜잭션을 안전하게 재시도해야 한다.
VACUUM은 오래된 버전을 지우기보다 재사용 가능하게 만든다¶
이전 튜플 버전은 어떤 트랜잭션도 볼 가능성이 없어졌을 때 정리할 수 있다. VACUUM은 이런 공간을 PostgreSQL 내부에서 다시 쓸 수 있게 표시하고, 인덱스 정리와 트랜잭션 ID 순환 방지에 필요한 작업을 한다. 일반 VACUUM을 실행했다고 운영체제에서 보이는 파일 크기가 즉시 줄어드는 것은 아니다.
오래 실행되는 트랜잭션이나 idle in transaction 상태의 연결은 과거 스냅샷을 붙잡는다. VACUUM은 그 스냅샷이 볼 수도 있는 버전을 치울 수 없다. 자동 VACUUM이 쓰기 속도를 따라가지 못해도 오래된 버전과 인덱스 항목이 쌓인다. 그러면 같은 결과를 얻기 위해 더 많은 페이지를 조회하고, 캐시에 필요한 작업 집합도 커진다. 이 상태를 흔히 팽창(bloat)이라고 부른다.
표 크기만 보고 판단하지 말고 오래된 튜플 수, 자동 VACUUM의 마지막 실행과 소요 시간, 오래 열린 트랜잭션, 트랜잭션 ID 나이, 표·인덱스 증가 속도를 함께 확인해야 원인을 찾을 수 있다.
인덱스 종류는 연산의 모양에 맞춘다¶
PostgreSQL의 B-Tree는 등가와 범위, 정렬에 주로 쓰인다. GIN(Generalized Inverted Index)은 배열·문서·검색어처럼 한 행에서 여러 검색 키를 꺼내는 경우에, GiST(Generalized Search Tree)는 범위·공간·유사도처럼 확장 가능한 검색 규칙에, BRIN(Block Range Index)은 물리적으로 가까운 페이지 구간의 최소·최대 요약만으로 넓은 범위를 건너뛸 수 있을 때 유리하다.
중요한 것은 이름보다 어떤 연산자가 그 인덱스의 연산자 클래스와 연결되는가다. 인덱스가 있다는 사실만으로 함수가 적용된 조건이나 다른 자료형 비교가 자동으로 빨라지지 않는다. 인덱스 선택은 인덱스가 줄이는 조회와 늘리는 쓰기에서 더 자세히 다룬다.
전문 검색은 문자열 포함 검색과 다르다¶
PostgreSQL 전문 검색은 문장을 언어 규칙에 따라 검색 단위인 어휘소로 정규화하고, tsvector와 tsquery로 일치 여부와 순위를 계산한다. 단순한 LIKE '%word%'보다 단어 형태 변화와 논리 조건을 다루기 좋고 GIN 인덱스를 붙일 수 있다.
하지만 오타 교정, 복잡한 동의어 운영, 여러 필드의 정교한 순위 조정, 대규모 분산 검색이 핵심이라면 전용 검색 시스템이 더 적합할 수 있다. 그때는 원본 데이터베이스의 변경을 검색 색인에 전달하는 지연과 재색인 절차가 새 운영 비용이 된다.
확장 기능은 PostgreSQL 안에 새 능력을 넣는다¶
PostgreSQL의 확장 기능은 함수 몇 개를 묶는 데 그치지 않는다. 새 자료형, 연산자, 인덱스 접근 방식과 백그라운드 작업까지 추가할 수 있다. PostGIS가 공간 자료형과 검색을, pgvector가 벡터 자료형과 근사 최근접 이웃 검색을 제공하는 방식이 대표적이다.
이 유연성은 제품 선택 이유가 될 수 있지만, 설치 가능 여부·주 버전 업그레이드 호환성·복제본 지원·백업과 복구·관리형 서비스의 제한도 함께 확인해야 한다. 확장 기능에 중요한 데이터를 맡긴다면 “설치가 된다”보다 장애와 업그레이드 때 어떻게 복구할지를 먼저 검증해야 한다.
이 구조에서 바로 이어지는 운영 판단¶
느린 조회를 만나면 인덱스를 추가하기 전에 실행 계획의 예상과 실제 행 수가 어디서 갈라지는지 본다. 표가 빠르게 커지면 단순 데이터 증가인지, 오래 열린 트랜잭션과 자동 VACUUM 지연 때문에 이전 버전이 쌓이는지 나눈다. 쓰기 p99가 주기적으로 튀면 같은 시간의 WAL 생성량, 체크포인트, 저장소 지연을 겹쳐 본다. 복제 지연이 늘면 네트워크만 의심하지 말고 주 서버의 WAL 생성 속도와 복제본의 적용 속도를 따로 본다.
PostgreSQL을 이해한다는 것은 내부 용어를 많이 기억하는 일이 아니다. 통계가 경로를 고르고, 공유 버퍼가 페이지를 제공하며, MVCC가 보일 버전을 정하고, WAL이 복구 가능성을 만들고, VACUUM이 그 선택의 남은 공간 비용을 정리한다는 한 흐름으로 증상을 연결하는 일이다.