본문으로 건너뛰기

"운영" 태그로 연결된 29개 게시물개의 게시물이 있습니다.

모든 태그 보기

PostgreSQL 18.5 결번

· 약 8분

버전 번호가 하나 비어 있으면 신경이 쓰여요. 8월 13일에 나온 PostgreSQL 마이너 릴리스는 18.6인데, 그 앞은 18.5가 아니라 18.4입니다. 제 경우엔 패키지 목록을 보다가 "내가 하나 놓쳤나" 싶어 한참을 되짚었습니다. 결론부터 적으면 놓친 것은 없습니다. 18.5는 만들어졌지만 세상에 나오지 않았습니다.

공식이 말한 것은 한 줄뿐입니다

사유를 언급한 곳은 두 군데인데, 표현이 미묘하게 다릅니다.

18.6 릴리스 노트에는 이렇게 적혀 있습니다.

18.5 was never released, due to a regression discovered post-wrap.

릴리스 공지는 좀 더 건조합니다.

This release skips PostgreSQL 18 versions from PostgreSQL 18.4 to 18.6. 18.5 was not shipped due to a regression.

공지는 "shipped"라고만 했고, 릴리스 노트는 post-wrap이라는 단어를 하나 더 얹었습니다. 이 단어가 사실상 전부를 설명합니다. 무엇이 wrap이고, 그 이후라는 게 왜 중요한지 알면 18.5가 사라진 경로가 그대로 보입니다.

마이너 릴리스는 이런 순서로 만들어집니다

PostgreSQL의 Release process 위키에 절차가 공개돼 있습니다. 요약하면 committer가 버전을 찍고, 서버에서 tarball을 말고, packager들이 먼저 써 보고, 마지막에 태그를 미는 순서입니다.

2단계가 wrap입니다. pgsql 계정으로 borka.postgresql.org에 들어가 mk-release-bundle을 실행하면 tarball과 체크섬이 staging 디렉토리에 만들어집니다. 이 단계를 특정 서버에서만 하는 이유는 재현성 때문입니다. bison, flex, docbook 버전이 다르면 결과물이 달라지니 빌드 환경을 한 곳으로 고정해 둔 것입니다.

주목할 곳은 3단계와 4단계 사이입니다. 위키가 태그를 두고 분명하게 적어 뒀습니다.

Pushing a tag is more or less irreversible, so don't do this until packager preliminary testing is over.

태그를 밀면 되돌리기 어렵습니다. 그래서 packager들이 최소 24시간 먼저 만져 보고, 이상이 없어야 태그가 나갑니다. 18.5는 그 구간을 통과하지 못했습니다.

취소는 되는데 번호는 돌아오지 않습니다

같은 위키에 사고 처리 절차도 한 줄로 적혀 있습니다.

In event of disaster, fix code as needed then repeat the wrap process, with or without a version number bump as seems appropriate.

문제가 생기면 코드를 고쳐 wrap을 다시 하되, 번호를 올릴지 말지는 그때 적절히 판단하라는 뜻입니다. 규정이 아니라 재량입니다. 18.5 건에서는 올리는 쪽을 택했습니다.

왜 그랬을까요. version_stamp.pl이 이미 Stamp 18.5. 커밋을 남겼고 그 번호로 tarball이 만들어졌기 때문입니다. staging에 있던 물건이라 일반 사용자에게 내려간 것은 아니지만, packager들 손에는 18.5라는 이름표가 붙은 파일이 이미 가 있었습니다. 같은 번호로 내용이 다른 tarball을 한 번 더 돌리는 것보다 번호를 하나 올리는 편이 안전합니다. 태그를 밀기 전이라 릴리스 자체는 멈출 수 있었지만, 번호는 그 시점에 이미 쓰인 것입니다.

8월 9일까지는 18.5였습니다

타임라인도 이 해석과 맞습니다. Jonathan Katz(조너선 카츠)가 pgsql-hackers에 올린 8월 13일 릴리스 공지 초안은 2026년 8월 9일자인데, 거기 적힌 버전은 아직 18.5입니다.

마이너 릴리스는 관례상 목요일에 공개하고 tarball은 그 주 초에 맙니다. 8월 13일이 목요일이니 wrap은 10일 전후, 공지 초안은 그 직전에 돌린 셈입니다. 초안이 나가고 며칠 사이에 번호가 하나 올라갔습니다.

결번은 여섯 개뿐입니다

그럼 이런 일이 자주 있었을까요. 세어 봤습니다. bucardo가 모든 버전의 릴리스 노트를 한 페이지에 모아 두는데, 여기서 never released를 전수 검색하면 여섯 건이 나옵니다.

결번직전 릴리스대체 릴리스
7.4.207.4.19 (2008-01-07)7.4.21 (2008-06-12)
8.0.168.0.15 (2008-01-07)8.0.17 (2008-06-12)
8.1.128.1.11 (2008-01-07)8.1.13 (2008-06-12)
8.2.88.2.7 (2008-03-17)8.2.9 (2008-06-12)
8.3.28.3.1 (2008-03-17)8.3.3 (2008-06-12)
18.518.4 (2026-05-14)18.6 (2026-08-13)

여섯 개지만 사건은 두 건입니다. 위의 다섯은 대체 릴리스 날짜가 전부 2008년 6월 12일로 같습니다. 한 번의 사고로 당시 지원하던 브랜치가 통째로 결번된 것입니다.

18.5는 혼자입니다. 같은 날 나온 17.11, 16.15, 15.19, 14.24는 번호가 정상입니다. 회귀가 18 브랜치에만 있었다는 뜻입니다.

2008년에는 무슨 일이 있었나

8.3.2 릴리스 노트를 열면 Release Date 필드가 never released입니다. 그런데 본문은 멀쩡합니다. "This release contains a variety of fixes from 8.3.1"로 시작하고, Migration to Version 8.3.2 절도 붙어 있고, Changes 목록에 마흔 건 가까운 수정이 그대로 나열돼 있습니다. Windows에서 UTF-8 인코딩일 때 나던 크래시, %r 매크로의 archive 절단 지점 오산, GIN의 "too many LWLocks taken" 실패, SIGTERM으로 backend를 개별 종료했을 때 shared memory가 오염되던 문제가 보입니다.

그중 %r 항목은 지금 봐도 아찔합니다. warm standby 스크립트가 그 값을 믿고 WAL segment 파일을 버리면 데이터를 잃을 수 있었습니다. 이런 수정을 담은 릴리스가 문턱까지 갔다가 멈췄습니다.

노트에 사유는 적혀 있지 않습니다. 대신 대체 버전 노트의 첫 문장이 단서를 흘립니다.

버전첫 문장
8.3.3one serious and one minor bug fix over 8.3.2
8.2.9one serious and one minor bug fix over 8.2.8
7.4.21one serious bug fix over 7.4.20

세상에 나온 적 없는 버전을 기준점으로 삼아 "그 대비 몇 건"이라고 적었습니다. 결번본이 실재했다는 자백입니다. 그리고 여기서 말하는 "one serious"가 무엇인지는 다섯 브랜치 공통 항목으로 노트에 남아 있습니다.

Make pg_get_ruledef() parenthesize negative constants (Tom Lane)

Before this fix, a negative constant in a view or rule might be dumped as, say, -42::integer, which is subtly incorrect: it should be (-42)::integer due to operator precedence rules. Usually this would make little difference, but it could interact with another recent patch to cause PostgreSQL to reject what had been a valid SELECT DISTINCT view query. Since this could result in pg_dump output failing to reload, it is being treated as a high-priority fix.

view나 rule 안에 들어 있는 음수 상수를 괄호 없이 덤프하던 문제입니다. 연산자 우선순위상 (-42)::integer여야 하는데 -42::integer로 나왔습니다. 그 자체로는 대개 차이가 없지만, 당시 함께 들어간 다른 패치와 맞물리면 멀쩡하던 SELECT DISTINCT view 쿼리를 PostgreSQL이 거부했습니다.

여기서 심각도가 뛴 지점은 마지막 문장입니다. pg_dump 출력이 다시 적재되지 않을 수 있다는 결론이었습니다. 백업 파일이 복원되지 않는 문제는 다른 어떤 버그와도 무게가 다릅니다. 준비돼 있던 다섯 개 tarball을 버리고 이 수정을 얹어 다시 낸 이유로 충분합니다.

두 사건이 남긴 기록의 차이

구조는 같습니다. wrap이 끝난 뒤 회귀가 잡혔고, 고쳐서 번호를 올려 다시 냈습니다. 달라진 것은 그 사실을 어떻게 적어 두느냐입니다.

18.6 노트는 결번 사유를 한 줄로 명시했습니다. 짧지만 왜 번호가 비었는지가 문서 안에 적혀 있습니다. 2008년 노트들은 never released라는 사실만 남기고 사유를 적지 않았습니다. 지금 그 이유를 재구성할 수 있는 것은 대체본이 "over 8.3.2"라는 표현을 쓴 덕분입니다. 의도한 기록이라기보다 흔적에 가깝습니다.

공통점도 있습니다. 여섯 개 전부 릴리스 노트 페이지가 지금도 살아 있습니다. 나오지 않은 버전의 문서를 지우지 않는 관행이 적어도 2008년부터 이어지고 있습니다. 덕분에 18년 전 사건을 1차 출처로 따라갈 수 있었습니다.

검색하면 섞여 나오는 이야기 하나

18.5 결번을 검색하면 "standby가 구버전 마이너의 WAL을 재생하다 self-deadlock에 빠진다"는 내용이 사유처럼 딸려 나옵니다. 별개 사건입니다.

credativ가 정리한 그 버그MultiXactOffsetSLRU deadlock이고, 2026년 5월 14일에 나온 14.23, 15.18, 16.14에서 유입됐습니다. 17과 18은 영향이 없습니다. 증상은 standby의 startup 프로세스가 pg_stat_activity에서 LWLock/MultiXactOffsetSLRU 대기로 멈추는 형태입니다.

같은 8월 13일 릴리스에서 함께 고쳐졌을 뿐, 18.5를 취소시킨 원인이 아닙니다. 두 이야기를 붙여 쓰면 틀린 글이 됩니다.

지금 확인할 것

운영 관점에서 할 일은 많지 않습니다.

  1. 18.4에서 18.6으로 바로 올립니다. 중간에 빠뜨린 버전은 없습니다
  2. 자동화 스크립트가 마이너 번호를 순차 증가로 가정하고 있다면 확인합니다. 18.4 + 1 = 18.5를 기대하는 코드는 여기서 멈춥니다
  3. 사내 문서나 지원 버전 표에 18.5를 적어 둔 곳이 있으면 지웁니다

3번은 사소해 보이지만 실제로 혼란을 만듭니다. "18.5 적용 예정"이라고 써 둔 계획서가 남아 있으면 나중에 읽는 사람이 없는 버전을 찾게 됩니다.

남은 질문

18.5를 취소시킨 회귀가 정확히 무엇이었는지는 공개되지 않았습니다. 공식 문구는 "a regression"이 전부입니다. 2008년 사건에서도 회귀를 유발한 "another recent patch"가 무엇인지는 노트에 적혀 있지 않습니다.

저는 이 공백이 문제라고 보지는 않아요. 세상에 나가지 않은 코드의 버그를 상세히 적는 것은 그 자체로 이상한 일이니까요. 다만 번호가 하나 비면 사람은 반드시 이유를 찾습니다. 18.6 노트가 한 줄이라도 남겨 둔 덕분에 저 같은 사람이 패키지 목록 앞에서 오래 헤매지 않았습니다. 기록은 그 정도만 해 줘도 충분히 일을 합니다.

참고

Oracle RU 즉시 적용

· 약 5분

벤더가 "패치를 빨리 적용하세요"라고 말하는 건 새롭지 않아요. 그런데 그 이유가 "AI가 우리 패치를 분석해서 공격 방법을 알아낼 수 있어서"라면 성격이 다릅니다.

2026년 8월 18일 오라클이 Prepare Now: Apply Oracle Database Release Update Immediately Upon Availability를 냈습니다. 지원 중인 모든 릴리스, 19c와 Oracle AI Database 26ai를 포함해, 데이터베이스 자산 전체에 최신 분기 Release Update를 테스트하고 배포할 준비를 지금 하라는 내용입니다.

권고의 근거가 달라졌다

주목할 부분은 요구 사항이 아니라 근거입니다. 오라클이 든 이유는 frontier AI 모델이 소프트웨어 취약점을 찾고 악용하는 장벽을 크게 낮추고 있다는 것입니다. 그 모델들이 하는 일로 네 가지를 꼽았습니다. 약점 식별, 소프트웨어 변경 분석, 보안 패치의 리버스 엔지니어링, 그리고 잠재적 공격 경로 개발입니다. 속도와 규모가 전례 없다고 표현했습니다.

이 목록에서 세 번째가 핵심입니다. 보안 패치의 리버스 엔지니어링입니다.

패치는 무엇이 잘못됐는지를 알려 줍니다. 코드의 어느 줄이 어떻게 바뀌었는지 보면 그 전에 무엇이 가능했는지 역산할 수 있습니다. 이걸 patch diffing이라고 부르고, 오래된 기법입니다. 새로운 건 그 작업의 비용입니다. 숙련된 분석가가 며칠 걸리던 일을 모델이 훨씬 빨리 해내면, 패치 공개와 실제 적용 사이의 구간이 그만큼 위험해집니다.

패치를 늦게 적용하는 쪽이 더 위험해지는 구조는 원래 있었습니다. 달라진 것은 그 위험이 커지는 속도입니다.

8월 CSPU의 물량

같은 주에 나온 숫자가 이 권고의 배경을 보여 줍니다. Qualys 분석에 따르면 8월 Critical Security Patch Update는 943개 취약점을 다뤘습니다.

무인증 원격 악용이 가능한 것들이 제품군별로 이렇게 나왔습니다.

제품군무인증 원격 악용 가능
Oracle Fusion Middleware182
Oracle Hyperion107
Oracle Commerce47
Oracle E-Business Suite27
Oracle Siebel CRM21

E-Business Suite에서 CVSS 9.8인 CVE-2026-60782와 CVE-2026-70926이 나왔습니다.

DBA 관점에서 궁금한 것은 Database 쪽 물량입니다. 943개 중 데이터베이스 제품군은 17개였습니다.

구성요소패치 수최고 CVSS
Oracle Database Server69.6
Oracle Autonomous Health Framework78.8
Oracle Essbase49.8

전체 943개에 비해 17개는 적어 보입니다. 하지만 Database Server의 최고 점수가 9.6이라는 게 중요합니다. 개수가 아니라 그 6개가 무엇인지가 판단 근거입니다.

월간 체제와 즉시 적용이 만나면

오라클이 분기 패치를 버리고 월간 CSPU 체제로 옮긴 이야기를 다룬 적이 있습니다. 이번 권고를 그 흐름 위에 놓으면 실무 부담이 선명해집니다.

분기 체제에서는 1년에 네 번 패치 시기가 왔습니다. 한 번마다 테스트에 몇 주를 쓸 여유가 있었습니다. 월간 체제에서는 열두 번입니다. 여기에 "나오는 즉시 적용하라"가 붙으면, 테스트 기간을 얼마로 잡을지가 실질적인 문제가 됩니다.

권고와 현실 사이의 간격을 어떻게 좁힐지가 결국 각 조직의 판단입니다. 몇 가지 방향은 이렇습니다.

RU 종류를 구분하는 것이 먼저입니다. 오라클의 Release Update와 Release Update Revision은 성격이 다릅니다. 전체를 같은 절차로 다루면 열두 번을 감당하지 못합니다.

테스트 자동화의 범위를 다시 볼 필요도 있습니다. 회귀 검증을 사람이 도는 구조라면 월간 주기를 못 따라갑니다. 이 부분은 패치 정책 이전에 검증 파이프라인 문제입니다.

그리고 적용 순서를 노출도로 정하는 방법이 있습니다. 무인증 원격 악용이 가능한 구성요소를 앞에 두는 식입니다. 위 표에서 Fusion Middleware의 182개가 왜 먼저인지는 숫자가 설명합니다.

AI가 양쪽에서 일한다

Anthropic의 Project Glasswing을 다룰 때는 AI가 핵심 인프라의 취약점을 찾아 주는 쪽 이야기였습니다. 이번 오라클 공지는 같은 능력의 반대쪽입니다.

두 사례를 나란히 놓으면 구도가 정리됩니다. 방어 측은 AI로 코드를 감사해 취약점을 미리 찾습니다. 공격 측은 AI로 패치를 분석해 이미 고쳐진 취약점을 역산합니다. 전자는 패치 이전, 후자는 패치 이후입니다. 그래서 패치 공개 시점이 양쪽의 경계선이 됩니다.

벤더가 이 판단을 공식 문서로 낸 것이 이번 공지의 의미라고 봅니다. "AI 때문에 패치 정책을 바꿉니다"를 제품 블로그에 적은 사례가 아직 흔하지 않습니다. 오라클이 자사 데이터베이스 고객 전체를 향해 그렇게 적었습니다.

실무에서 확인할 것

Oracle Database Appliance를 쓰는 환경이라면 릴리스 19.32에 7월 Database Release Update와 19c, 26ai 클론 파일이 들어 있습니다. 계획, 테스트, 배포 일정을 지금 시작하라는 것이 공지의 요구입니다.

정리하면 확인 항목은 셋입니다. 현재 운영 중인 데이터베이스의 RU 수준이 어디인지, 다음 RU가 나올 때 테스트에 며칠을 쓸 계획인지, 그리고 그 며칠이 이번 공지의 논리에 비추어 감당할 만한 구간인지입니다.

마지막 항목이 답하기 어렵습니다. 패치 공개 후 며칠이 안전한지 아무도 숫자로 말해 주지 않으니까요. 저는 이 공지가 그 숫자가 줄어들고 있다는 신호로 읽혔어요.

참고

cgroup v2 입문

· 약 6분

docker run --memory=512m을 실행할 때, Kubernetes 매니페스트에 resources.limits.memory를 적을 때, systemd 유닛에 MemoryMax=를 넣을 때. 이 셋이 결국 같은 커널 기능 하나로 내려간다는 걸 알고 계셨나요. 컨테이너 시대의 자원 통제는 전부 cgroup 위에 서 있어요.

systemd 유닛 11종을 돌아본 글에서 cgroup을 여러 번 스쳐 지나갔는데, 정작 cgroup 자체를 정면으로 다룬 적이 없었습니다. 이번에 채웁니다.

cgroup은 무엇인가

cgroup(control group)은 프로세스들을 무리(group)로 묶고, 그 무리 단위로 자원을 통제하고 계량하는 커널 기능입니다. 하는 일은 세 가지로 요약됩니다.

기능내용
제한 (limit)무리가 쓸 수 있는 자원의 상한메모리 64MB까지, CPU 절반까지
계량 (accounting)무리가 지금까지 쓴 자원 측정누적 CPU 시간, 현재 메모리 사용량
통제 (control)무리 전체를 한 번에 조작일시 정지(freeze), 일괄 종료

핵심은 대상이 프로세스 하나가 아니라 무리라는 점입니다. ulimit 같은 전통 도구는 프로세스 단위라서, 프로세스가 fork하면 통제를 벗어납니다. cgroup은 자식 프로세스가 부모의 cgroup을 물려받으므로 fork로 도망칠 수 없습니다. 컨테이너가 "안에서 뭘 몇 개 띄우든 전체 512MB"라는 약속을 지킬 수 있는 이유입니다.

구현 형태는 파일시스템입니다. /sys/fs/cgroup 아래 디렉터리 하나가 cgroup 하나이고, 디렉터리를 만들면 cgroup이 생기고, 그 안의 파일에 값을 쓰면 제한이 걸립니다. 시스템 콜 API가 아니라 mkdirecho로 조작하는 커널 기능이라는 점이 cgroup의 성격을 잘 보여줍니다.

계보를 짧게 짚으면, 2006년 Google 엔지니어들이 "process containers"라는 이름으로 시작해 2008년 커널 2.6.24에 cgroup이라는 이름으로 병합됐습니다. 이후 LXC와 Docker가 이 위에 컨테이너를 지었고, systemd는 서비스 관리의 뼈대로 삼았습니다.

v1의 혼돈, v2의 단일 트리

cgroup을 검색하면 v1과 v2 이야기가 반드시 나옵니다. 이 구분을 모르면 검색 결과의 절반이 내 시스템과 안 맞는 이유를 알 수 없습니다.

v1의 설계는 유연함이 지나쳤습니다. 컨트롤러(memory, cpu, blkio 등)마다 각자 독립된 트리를 가질 수 있었습니다. 한 프로세스가 memory 트리에서는 A 그룹, cpu 트리에서는 B 그룹에 속하는 것이 가능했다는 뜻입니다. 표현력은 높지만 대가가 컸습니다. 트리끼리 조합이 어긋나면 동작을 예측하기 어렵고, 컨트롤러마다 인터페이스 관례가 제각각이었으며, 권한 위임 규칙도 정리되어 있지 않았습니다.

v2는 2016년 커널 4.5에서 이 혼돈을 정리했습니다. 원칙은 하나입니다. 트리는 하나, 모든 컨트롤러는 그 트리 위에서 동작한다(unified hierarchy).

인터페이스도 통일됐습니다. 어느 cgroup 디렉터리든 같은 규칙의 파일들이 있습니다.

  • cgroup.procs: 이 무리에 속한 PID 목록. 여기에 PID를 쓰면 편입
  • cgroup.controllers: 이 지점에서 쓸 수 있는 컨트롤러
  • memory.max, cpu.max, io.max, pids.max: 컨트롤러별 상한
  • memory.events: OOM 발생 횟수 같은 사건 카운터

v2에만 있는 물건도 생겼습니다. PSI(Pressure Stall Information)입니다. memory.pressure, cpu.pressure, io.pressure 파일이 "이 무리의 프로세스들이 자원을 못 받아 지연된 시간의 비율"을 보여줍니다. 사용량이 아니라 부족으로 인한 고통을 직접 재는 지표라서, 사용률 그래프보다 정직할 때가 많습니다.

전환은 배포판 기본값으로 이미 끝난 이야기입니다. Fedora 31(2019)을 시작으로 Ubuntu 21.10, Debian 11, RHEL 9가 v2를 기본으로 켰고, Docker는 20.10부터, Kubernetes는 1.25에서 v2 지원을 GA로 올렸습니다. 지금 새로 만나는 시스템은 거의 v2라고 봐도 됩니다. 내 시스템 확인은 한 줄입니다.

stat -fc %T /sys/fs/cgroup/
# cgroup2fs 면 v2, tmpfs 면 v1

실험: 64MB 국경에 100MB를 밀어 넣으면

개념은 여기까지 하고, 직접 확인해 보겠습니다. macOS라서 Docker 컨테이너(Linux VM) 안에서 실험했습니다. 컨테이너 자체가 cgroup으로 만들어지니 오히려 좋은 교보재입니다.

먼저 --memory=64m이 정말 cgroup 파일로 내려가는지 확인합니다.

$ docker run --rm --memory=64m alpine cat /sys/fs/cgroup/memory.max
67108864

67108864 바이트, 정확히 64MiB입니다. Docker 플래그는 커널에 도달하면 memory.max 파일의 숫자 하나가 됩니다. 이제 이 국경 안에서 100MB를 할당해 봅니다.

$ docker run --rm --memory=64m alpine sh -c 'head -c 100m /dev/zero | tail'
Killed
# 종료 코드 137 (128 + SIGKILL 9)

tail은 입력을 메모리에 쌓는 성질이 있어 간단한 메모리 포식자로 쓸 수 있습니다. 64MiB 상한을 넘는 순간 커널 OOM killer가 그 cgroup 안에서 희생자를 골라 SIGKILL을 보냅니다. 시그널 글에서 다룬 137이라는 종료 코드가 여기서 나옵니다.

죽인 흔적은 사건 카운터에 남습니다. 같은 컨테이너에서 OOM을 겪은 직후 memory.events를 읽으면 이렇습니다.

$ cat /sys/fs/cgroup/memory.events
low 0
high 0
max 72
oom 3
oom_kill 1
oom_group_kill 0

$ cat /sys/fs/cgroup/memory.peak
67108864

읽는 법이 흥미롭습니다. max 72는 사용량이 상한선에 부딪힌 횟수입니다. 부딪힐 때마다 커널은 먼저 page 회수(reclaim)로 버텨 봅니다. 그래도 안 되면 oom(3회)으로 넘어가고, 최종적으로 프로세스 하나를 죽였습니다(oom_kill 1). memory.peak가 정확히 67108864, 즉 상한과 같다는 것은 국경까지 꽉 채우고 넘지 못했다는 증거입니다. 상한 초과가 아니라 상한 도달 후 처형이라는 순서가 파일에 그대로 남습니다.

컨테이너 없이 맨 리눅스에서 같은 실험을 하려면 systemd가 지름길입니다.

# 임시 scope 유닛으로 감싸서 실행, 상한 64M
systemd-run --scope -p MemoryMax=64M some-command

# 서비스라면 유닛 파일에
[Service]
MemoryMax=64M

systemd 유닛은 각자 cgroup 하나씩을 차지하므로, MemoryMax=는 결국 그 cgroup의 memory.max에 값을 쓰는 것입니다. 트리 전체는 systemd-cgls로, 무리별 실시간 사용량은 systemd-cgtop으로 봅니다.

운영자가 기억할 세 가지

첫째, 모든 상위 도구의 제한은 cgroup 파일로 환원됩니다. Docker 플래그든, Kubernetes limits든, systemd 지시자든, 디버깅의 종착지는 /sys/fs/cgroup 아래 파일입니다. 제한이 이상하게 동작하면 도구의 문서보다 그 파일의 실제 값을 먼저 확인하는 쪽이 빠릅니다.

둘째, cgroup의 메모리 계정에는 page cache가 포함됩니다. memory.max는 프로세스의 anonymous 메모리만 세는 것이 아니라, 그 무리가 일으킨 파일 캐시까지 셉니다. 파일을 많이 읽는 워크로드(백업, 대량 스캔, 그리고 데이터베이스)가 "실제 사용량은 얼마 안 되는데" OOM에 몰리는 전형적인 이유입니다. 캐시는 압박이 오면 회수되므로 보통은 문제가 없지만, 회수 속도가 할당 속도를 못 따라가는 순간이 있습니다. 컨테이너 위 PostgreSQL의 메모리 제한 설정은 이 지점이 본론이라, 각도를 잡아 후속 글에서 따로 다루겠습니다.

셋째, v2의 사건 카운터와 PSI를 감시 지표로 쓸 수 있습니다. memory.eventsmaxoom_kill, 그리고 memory.pressure는 "제한에 얼마나 자주 부딪히고 있는가"를 알려주는 조기 경보입니다. OOM으로 죽고 나서 아는 것과, max 카운터가 오르는 걸 보고 미리 아는 것의 차이입니다.

여담 하나로 마무리할게요. 이 글을 쓰게 된 계기는 Claude Code가 Bash 명령에 cgroup 메모리 제한을 거는 옵션을 넣었다는 소식이었어요. AI 에이전트가 실행하는 명령의 폭주를 막는 안전장치로 2008년의 커널 기능이 다시 불려 나오는 것, cgroup이 여전히 현역 국경 수비대라는 좋은 증거 아닐까요.

참고 자료

PostgreSQL 18.6 보안

· 약 5분

이번 분기 마이너 릴리스는 조용히 지나갈 수 없는 규모예요. 8월 13일 공지로 PostgreSQL 18.6, 17.11, 16.15, 15.19, 14.24가 한꺼번에 나왔는데, 보안 취약점 수정이 28건입니다. 통상 마이너 릴리스의 보안 수정이 손에 꼽는 수준인 걸 생각하면 이례적인 숫자입니다.

버전 번호부터 눈에 걸립니다. 18 계열의 직전 버전은 18.4인데 이번이 18.6입니다. 18.5는 릴리스 준비 중 회귀(regression)가 발견되어 배포 없이 결번 처리됐습니다. 18.4에서 바로 18.6으로 올라가면 되고, 중간에 놓친 버전은 없습니다.

CVSS 8점대만 12건

28건 전체 중 CVSS 8.0 이상이 12건입니다. 성격별로 묶으면 그림이 보입니다.

계열대표 CVECVSS내용
클라이언트 도구CVE-2026-64648.1psql COPY FROM STDIN, 데이터 줄을 psql 명령으로 처리
클라이언트 도구CVE-2026-184088.8psql \unrestrict, 조작된 덤프 원본에서 임의 코드 실행
클라이언트 도구CVE-2026-193858.8pg_dump heap buffer overflow
heap overflowCVE-2026-146648.8regexp 처리, 임의 코드 실행 가능
heap overflowCVE-2026-146698.8to_char()
heap overflowCVE-2026-146768.8pg_stat_statements
type confusionCVE-2026-146718.8refint plan cache
type confusionCVE-2026-162388.8pg_restore_attribute_stats()
type confusionCVE-2026-162398.8cursor CLOSE + DECLARE
type confusionCVE-2026-146808.8"internal" 인자 처리
기타CVE-2026-146628.8tsvector/tsquery integer wraparound
기타CVE-2026-157428.8fuzzystrmatch, 임의 주소 기록

이 중 DBA가 특히 무겁게 볼 것은 psql 계열입니다. 나머지는 대체로 "DB에 로그인한 공격자가 권한을 넘어서는" 유형이라 접속 통제가 1차 방어선이 되지만, psql 취약점은 방향이 반대입니다. 신뢰할 수 없는 덤프나 SQL 파일을 psql로 읽어들이는 쪽이 피해자가 됩니다.

CVE-2026-6464는 COPY FROM STDIN 처리 중 초기 실패가 발생하면 뒤따르는 데이터 줄을 psql 명령으로 해석하는 문제입니다. 누군가 건네준 덤프 파일을 복원하는 일상 작업이 곧 공격 경로입니다. CVE-2026-18408도 같은 결로, pg_dump 출력에 포함되는 \unrestrict 처리를 악용하면 덤프를 만든 쪽(superuser 권한으로 조작된 서버)이 복원하는 쪽 클라이언트에서 임의 코드를 실행할 수 있습니다. 외부에서 받은 덤프를 복원할 일이 있는 조직이라면 클라이언트 패키지 업데이트를 서버만큼 서둘러야 합니다.

낮은 점수 중에도 운영에 직접 닿는 항목이 있습니다. CVE-2026-14663은 pgcrypto에서 비활성화된 cipher로 암복호화를 요청하면 조용히 평문으로 처리하던 문제입니다. 점수는 6.5지만 "암호화됐다고 믿었는데 평문이었다"는 유형이라 감사 관점에서는 확인해 볼 가치가 있습니다. CVE-2026-14672는 SCRAM 인증에서 scram_iterations 응답 차이로 계정 존재 여부가 노출되는 문제입니다.

업데이트만으로 끝나지 않는 세 가지

이번 릴리스의 함정은 바이너리 교체 후에도 남는 숙제입니다. 릴리스 노트가 세 가지 후속 조치를 명시합니다.

첫째, parallel GIN index build를 쓴 적이 있다면 reltuples 오염을 확인합니다. PostgreSQL 14, 15, 16, 18에서 parallel worker가 초기화되지 않은 row count를 보고해 pg_class.reltuples가 Infinity나 NaN이 되는 버그가 있었습니다. 이 상태가 되면 autovacuum과 autoanalyze가 해당 테이블을 건너뛰고, 자연적으로는 복구되지 않습니다. GIN 인덱스를 가진 테이블을 이 쿼리로 확인합니다.

SELECT DISTINCT t.oid::regclass, t.reltuples
FROM pg_class t
JOIN pg_index i ON t.oid = i.indrelid
JOIN pg_class ic ON i.indexrelid = ic.oid
WHERE t.relhasindex AND ic.relam = 2742; -- 2742 = gin

reltuples가 Infinity, NaN, 혹은 말이 안 되는 값이면 해당 테이블에 ANALYZE를 실행합니다. vacuum이 안 도는 테이블은 wraparound 위험까지 이어지니, GIN을 쓰는 클러스터라면 이 확인을 빼먹지 않는 게 좋습니다.

둘째, btree_gist 인덱스의 REINDEX입니다. float4/float8 컬럼에서 NaN 처리, bit/bit varying 컬럼에서 정렬이 잘못되던 문제가 고쳐졌습니다. 해당 타입 컬럼에 btree_gist 인덱스가 있다면 REINDEX가 필요합니다.

셋째, 큰 ltree 값의 인덱스도 REINDEX 대상입니다. 약 14,653개 이상의 label을 가진 ltree 값에서 비교 연산이 틀려 B-tree 인덱스가 손상될 수 있었습니다. 그 정도 깊이의 ltree를 쓰는 곳은 드물겠지만, 해당된다면 REINDEX 대상입니다.

이 밖에 버그 수정 중에는 standby가 구버전 마이너의 WAL을 재생하다 멈추는 deadlock 수정(14~16), DEFAULT partition이 pruning에서 잘못 제외되던 문제, REINDEX CONCURRENTLY와 deferred unique constraint 조합 오류 같은 굵직한 것들이 포함됐습니다. 전체 목록은 릴리스 노트에 있습니다.

19 Beta 3와 14 EOL

같은 날 PostgreSQL 19 Beta 3도 나왔습니다. 눈에 띄는 변화는 GROUP BY ALL 문법이 revert 된 것입니다. Beta 1에 들어왔던 편의 문법인데 최종 릴리스 전에 빠졌습니다. 그 외 FOR PORTION OF(temporal 문법) 수정 다수, logical replication의 sequence 동기화 race condition 수정 등이 반영됐습니다. 19 신기능을 검증 중이라면 Beta 3 기준으로 다시 확인하는 게 안전합니다.

그리고 PostgreSQL 14의 지원 종료가 2026년 11월 12일로 예고됐습니다. 이번 14.24를 포함해 앞으로 한 번의 릴리스만 남았습니다. 아직 14로 운영 중인 클러스터는 마이그레이션 일정을 지금 세워야 합니다.

지금 해야 할 일

우선순위로 정리하면 이렇습니다.

  1. 서버 바이너리 업데이트 (마이너 업데이트라 dump/reload 불필요, 재시작만)
  2. 클라이언트 패키지(psql, pg_dump)도 같이 업데이트 - 이번 릴리스에서는 클라이언트가 서버만큼 급합니다
  3. GIN 인덱스 테이블의 reltuples 확인, 이상 시 ANALYZE
  4. btree_gist(float/bit 컬럼), 깊은 ltree 인덱스 REINDEX
  5. PostgreSQL 14 사용 중이면 업그레이드 계획 수립

28건이라는 숫자에 놀랐지만, 뒤집어 보면 fuzzing과 코드 감사가 코어에 촘촘히 들어가고 있다는 뜻이기도 합니다. 저는 클라이언트 도구가 공격면이 되는 흐름이 이번 릴리스의 진짜 교훈이라고 봐요. 서버는 잘 잠가 두는데 psql은 아무 파일이나 여는 습관, 이번 기회에 같이 고치면 좋겠습니다.

참고 자료

subtransaction 64개 한계

· 약 5분

SAVEPOINT는 안전장치처럼 생겼어요. 트랜잭션 중간에 표시를 남기고, 일부만 되돌릴 수 있게 해 주는 기능이니까요. 그런데 이 안전장치를 한 트랜잭션에서 64번 넘게 쓰는 순간, 그 트랜잭션이 아니라 클러스터 전체가 절벽에서 떨어집니다.

PlanetScale이 8월 11일에 공개한 분석이 이 경로를 실측으로 보여줬습니다. 동일한 INSERT/SELECT/DELETE 워크로드가 평소 약 7,200 TPS(지연 약 2ms)로 돌다가, 어느 한 트랜잭션이 subtransaction 64개를 넘긴 순간 약 160 TPS(지연 약 90ms)로 떨어졌습니다. 45배 하락입니다. 문제의 트랜잭션이 끝나자 처리량은 원래대로 돌아왔습니다.

이 글은 해당 분석을 따라가면서, 왜 남의 트랜잭션 하나가 내 쿼리를 느리게 만드는지 경로를 짚고, 마지막에 원문에는 없는 우리 클러스터 점검용 진단 쿼리를 붙입니다.

subtransaction은 어디서 생기나

subtransaction은 최상위 트랜잭션 안에 중첩된 트랜잭션입니다. 각 subtransaction은 자기만의 transaction ID(subxid)를 받습니다. 만드는 경로는 두 가지입니다.

명시적 경로는 SAVEPOINT입니다.

BEGIN;
SAVEPOINT s1; -- subtransaction 1
INSERT INTO orders ...;
SAVEPOINT s2; -- subtransaction 2
UPDATE stock ...;
COMMIT;

암묵적 경로가 더 위험합니다. PL/pgSQL에서 EXCEPTION 절이 있는 블록은 진입할 때마다 subtransaction을 하나 만듭니다. "혹시 실패하면 이 블록만 되돌린다"를 구현하는 방법이 내부적으로 SAVEPOINT와 같기 때문입니다.

-- 이 루프는 반복마다 subtransaction을 하나씩 만든다
FOR i IN 1..70 LOOP
BEGIN
INSERT INTO audit_log VALUES (...);
EXCEPTION WHEN unique_violation THEN
NULL; -- 무시하고 계속
END;
END LOOP;
-- 70개 생성, 64개 한계 초과

ORM도 조용히 만듭니다. Django의 transaction.atomic()을 중첩하면 안쪽은 SAVEPOINT가 되고, JPA/Hibernate에서 REQUIRES_NEW가 아닌 중첩 트랜잭션 전파, Rails의 중첩 transaction 블록(requires_new: true)도 같습니다. 애플리케이션 코드에는 SAVEPOINT라는 글자가 한 번도 안 나오는데 DB에는 수십 개씩 쌓이는 상황이 흔합니다.

64개 한계와 pg_subtrans

각 backend의 PGPROC 구조체에는 subtransaction ID를 담는 캐시가 있고, 크기가 PGPROC_MAX_CACHED_SUBXIDS, 즉 64개입니다. 이 안에 다 들어가는 동안에는 다른 세션이 MVCC visibility를 판단할 때 공유 메모리만 보면 됩니다. 빠릅니다.

65개째부터 이 캐시는 overflow 상태로 표시됩니다. 이제 다른 세션들은 어떤 subxid가 어느 최상위 트랜잭션에 속하는지 공유 메모리에서 알 수 없고, 디스크 기반 구조인 pg_subtrans를 조회해야 합니다. pg_subtrans는 SLRU(simple least-recently-used) 캐시를 통해 접근하는데, 이 조회는 lightweight lock을 잡고 필요하면 디스크 I/O까지 발생시킵니다.

핵심은 비용을 치르는 쪽이 subtransaction을 만든 트랜잭션이 아니라는 점입니다. overflow가 켜져 있는 동안 다른 모든 세션이 자기 snapshot으로 튜플 가시성을 판단할 때마다(XidInMVCCSnapshot) pg_subtrans SLRU lock을 두고 경합합니다. PlanetScale 실측에서 병목으로 지목된 지점이 정확히 이 lock입니다.

overflow 상태는 문제의 트랜잭션이 끝날 때까지 유지됩니다. 그리고 얼마나 오래 가는지는 그 트랜잭션이 아니라, 클러스터에서 가장 오래 돌고 있는 트랜잭션의 수명에 좌우됩니다. long-running transaction이 있으면 그만큼 고통이 길어집니다.

hot standby까지 번지는 이유

이 문제의 두 번째 얼굴이 더 고약합니다. overflow가 발생한 시점에 WAL로 기록되는 RUNNING_XACTS 레코드에 "subxid overflowed" 표시가 함께 남습니다. 활성 subtransaction 목록을 다 담지 못했다는 뜻입니다.

새로 배포한 standby가 이 오염된 RUNNING_XACTS를 읽으면, 일관된 snapshot을 만들 수 없다고 판단하고 hot standby 활성화를 보류합니다. WAL은 계속 재생하는데 읽기 쿼리는 받지 않는 상태, 접속하면 이런 메시지만 돌아옵니다.

FATAL: the database system is not yet accepting connections
DETAIL: Recovery snapshot is not yet ready for hot standby.

pg_subtrans를 primary에서 넘겨받으면 되지 않느냐는 생각이 들지만, pg_subtrans 변경은 WAL에 기록되지 않고, standby는 기동 시 활성 페이지를 0으로 초기화하기 때문에 로컬 데이터도 신뢰할 수 없습니다. standby가 이 상태를 벗어나는 조건은 overflow 되지 않은 새 RUNNING_XACTS 레코드가 도착하거나, primary의 shutdown checkpoint를 재생하거나, 누락 가능성이 있는 트랜잭션들이 전부 끝나는 것입니다.

운영 관점에서 시나리오를 조합하면 이렇게 됩니다. 부하가 튀어서 읽기 용량을 늘리려고 replica를 새로 붙였는데, 하필 그 부하를 만들던 워크로드가 subtransaction overflow를 일으키는 중이라면, 새 replica는 WAL만 재생하며 접속을 거부합니다. 용량을 더하려고 만든 노드가 용량이 되어 주지 않는 역설입니다.

지금 확인할 것들

여기부터는 원문 내용에 우리가 운영에서 바로 실행할 쿼리를 더해 정리합니다.

먼저 지금 이 순간 subtransaction을 쌓고 있는 backend가 있는지 확인합니다. PostgreSQL 14부터 pg_stat_get_backend_subxact()로 backend별 subxact 개수와 overflow 여부를 볼 수 있습니다.

SELECT pg_stat_get_backend_pid(id) AS pid, s.subxact_count, s.subxact_overflowed
FROM pg_stat_get_backend_idset() AS id
JOIN LATERAL pg_stat_get_backend_subxact(id) AS s ON true
WHERE s.subxact_count > 0
ORDER BY s.subxact_count DESC;

subxact_overflowed = true인 행이 하나라도 보이면 그 순간 클러스터 전체가 pg_subtrans 조회 경로로 동작 중이라는 뜻입니다. pg_stat_activity와 조인하면 어떤 애플리케이션, 어떤 쿼리인지까지 특정됩니다.

과거에 겪었는지 의심된다면 SLRU 통계를 봅니다.

SELECT name, blks_hit, blks_read, blks_written, stats_reset
FROM pg_stat_slru
WHERE name = 'subtransaction';

평상시 이 카운터는 거의 움직이지 않습니다. blks_hit가 급증한 구간이 있었다면 overflow가 있었다는 강한 정황입니다. 그래프 도구에 이 두 컬럼을 올려 두면 사후 추적이 쉬워집니다.

가장 오래된 트랜잭션 나이도 함께 감시합니다. overflow의 지속 시간을 결정하는 값입니다.

SELECT max(now() - xact_start) AS oldest_tx
FROM pg_stat_activity
WHERE xact_start IS NOT NULL;

예방책은 소박합니다.

대상조치
PL/pgSQL 루프 안 EXCEPTION 블록루프 밖으로 빼거나, 예외를 안 쓰는 로직(ON CONFLICT 등)으로 전환
ORM 중첩 트랜잭션중첩 atomic()/transaction 블록이 정말 필요한지 검토
long-running transactiontransaction_timeout(17+), idle_in_transaction_session_timeout 설정
상시 감시pg_stat_get_backend_subxact() + pg_stat_slru 지표화

코어 쪽 근본 해법으로는 PGPROC_MAX_CACHED_SUBXIDS를 늘려 재컴파일하는 단기 우회(공유 메모리 비용 증가)와, snapshot을 CSN(Commit Sequence Number) 기반으로 바꾸는 장기 방향이 거론됩니다. CSN 논의는 10년 넘게 이어지고 있지만 아직 본가에 병합되지 않았습니다.

64라는 숫자를 기억해 두면 좋겠어요. SAVEPOINT와 EXCEPTION 블록은 공짜가 아니고, 비용 청구서는 옆 세션으로 날아갑니다. 저는 이번 주에 저희 클러스터에도 위 진단 쿼리 두 개를 모니터링에 추가해 두려고 합니다.

참고 자료

cascading 복제 버그

· 약 4분

"requested starting point ... is ahead of the WAL flush position of this server". 이 오류를 cascading standby에서 본 적이 있다면, 그건 설정 실수가 아니었을 수도 있어요. 2013년 PostgreSQL 9.3부터 잠복해 있던 버그였습니다.

CloudNativePG 창립자 Gabriele Bartolini(가브리엘레 바르톨리니)가 8월 3일 EDB 블로그에 공개한 글이 이 버그의 발견부터 수정까지를 담고 있습니다. 발견 경위가 흥미롭습니다. 코어 해커의 코드 감사가 아니라, Kubernetes 위에서 분산 토폴로지를 선언적으로 돌리다가 드러났습니다.

증상: 재시도 없는 무한 대기

구성은 cascading replication입니다. primary에서 standby A가 받고, standby B는 A에게서 받는 구조로, 다중 리전 토폴로지에서 흔한 형태입니다.

standby B가 어떤 이유로든 streaming이 끊겨 archive recovery로 폴백했다가, WAL을 따라잡고 다시 upstream(A)으로 streaming 접속을 시도하는 순간이 문제의 무대입니다. 특정 조건에서 A가 접속을 이렇게 거부합니다.

FATAL: requested starting point 0/A000000 is ahead of
the WAL flush position of this server 0/9000000

그리고 B는 재시도 메커니즘 없이 이 상태에 머뭅니다. 사람이 개입할 때까지 replication이 서 있는 것입니다.

메커니즘: segment 단위 recovery와 record 단위 flush의 어긋남

원인은 StartReplication()에 2013년 추가된 timeline switch 로직입니다. 이 로직은 "요청한 시작 LSN이 내 WAL flush 위치보다 앞서면 거부한다"는 방어 검사를 합니다. 검사 자체는 합리적입니다. 아직 나에게 없는 WAL을 달라는 요청이니까요.

문제는 archive recovery의 진행 단위입니다. streaming은 record 단위로 흐르지만, archive recovery는 segment 파일 단위로 처리합니다. B가 archive에서 segment 하나를 다 소화하면, 다음 읽기 위치는 그다음 segment의 시작(예: 0/A000000)이 됩니다. 그런데 upstream A의 flush 위치는 record 단위로 진행 중이라 그 경계에 못 미쳐 있을 수 있습니다(예: 0/9000000 근처). B의 요청 위치가 A의 flush 위치를 기계적으로 앞서게 되는 것입니다.

타이밍이 정확히 맞아야 하는 race라서 13년 동안 드물게, 재현 불가능하게만 나타났습니다. 마주친 운영자는 대개 standby를 재기동하거나 재생성했을 것이고, 문제는 "가끔 이상해지는 replication"이라는 민담으로만 남았을 겁니다.

수정: 접속 전에 물어보고, 가까우면 기다린다

수정은 walreceiver 쪽에 들어갔습니다. START_REPLICATION을 보내기 전에 IDENTIFY_SYSTEM으로 upstream의 flush 위치를 먼저 확인하고, 내 요청 위치와의 차이가 WAL segment 하나 이내면 거부당할 요청을 던지는 대신 재시도합니다(wal_receiver_timeout 한도 안에서). upstream이 곧 그 지점까지 flush할 것이 확실한 상황이니, 잠깐 기다리면 자연히 풀리는 것입니다.

이 수정은 8월 13일 나온 마이너 릴리스에 포함됐습니다. EDB 글은 작성 시점 기준 18.5를 예고했지만, 18.5가 회귀로 결번되면서 실제로는 18.6, 17.11, 16.15, 15.19, 14.24에 실렸습니다. cascading 구성을 운영 중이라면 이번 마이너 업데이트를 챙길 이유가 하나 더 있는 셈입니다.

13년 만에 잡힌 이유

이 이야기에서 버그 자체보다 오래 남는 것은 발견의 조건입니다.

CloudNativePG는 primary, cascading standby, archive(오브젝트 스토리지), 리전 간 복제를 하나의 선언으로 묶어 돌립니다. 같은 토폴로지가 수천 클러스터에서 반복 생성되고 파괴되니, 확률이 낮은 race도 통계적으로 반드시 걸립니다. 게다가 구성이 코드로 고정되어 있어 "그때 그 상황"을 그대로 다시 만들 수 있습니다. 실제로 이 버그는 Docker/Kind 기반 학습 환경인 cnpg-playground에서도 재현됩니다.

수동 운영의 세계에서 이 버그는 재현 불가능한 유령이었습니다. 선언적 운영의 세계에서는 재현 가능한 테스트 케이스가 됐습니다. "Kubernetes에서 데이터베이스를 돌려도 되는가"라는 오래된 논쟁에 대한 답변으로, 오퍼레이터 진영이 내놓을 수 있는 가장 실속 있는 증거가 아닐까 싶습니다. 돌려도 되는가를 넘어, 돌렸더니 코어의 13년 묵은 버그가 잡혔다는 것이니까요.

pgBackRest 종료 이후 CNPG 쪽 흐름을 계속 지켜보고 있는데, 이 사건은 그 생태계의 성숙을 보여주는 좋은 지표로 기억해 둘 만해요.

참고 자료

PlanetScale 병렬 PG 백업

· 약 4분

백업 시리즈를 쓰면서 늘 걸리던 질문이 있었어요. Barman이든 pgBackRest든 결국 인스턴스 하나를 통째로 받는 구조인데, 데이터가 수십 TB를 넘어가면 이 모델은 어디까지 버틸까 하는 것이었죠.

PlanetScale이 7월 31일 공개한 글이 그 질문의 한 답을 보여줍니다. petabyte 규모 데이터베이스를 초당 50GB 넘는 속도로, 시간 단위 안에 백업하는 구조입니다. 전제가 하나 있는데, 데이터가 이미 shard로 나뉘어 있다는 것입니다.

단일 인스턴스의 산수

출발점은 단순한 산수입니다. 32TB 데이터베이스를 초당 500MB로 받으면 약 22시간이 걸립니다. 하루 두 번 백업(RPO 12시간)이 목표라면 22시간짜리 백업으로는 산수가 안 맞습니다. 백업이 끝나기 전에 다음 백업이 시작되어야 하니까요.

속도를 올리는 데는 한계가 있습니다. 백업 읽기가 빨라질수록 production 쿼리와 I/O를 다투게 되고, 네트워크와 스토리지 처리량 상한도 있습니다. 단일 인스턴스 모델에서는 데이터가 커질수록 백업 소요 시간이 선형으로 늘어나는 걸 피할 수 없습니다.

PlanetScale의 답은 분모를 늘리는 것입니다. 데이터가 8개 shard로 나뉘어 있으면, shard마다 백업을 동시에 받아서 32TB 전체가 약 2.8시간에 끝납니다. shard가 100개면 100TB도 같은 시간입니다. 백업 소요 시간이 전체 크기가 아니라 가장 큰 shard의 크기에 묶이는 구조입니다.

여기서 논쟁이 된 선택이 하나 있습니다. 백업을 primary나 기존 replica에서 받지 않고, 백업 때마다 shard별 전용 EC2 인스턴스를 새로 띄웁니다. production 쿼리에 백업 읽기 부하를 섞지 않겠다는 선택인데, Hacker News에서는 그 비용이 타당하냐는 반론이 붙었습니다. 클라우드에서 시간 단위로 인스턴스를 빌릴 수 있으니 가능한 설계이고, 백업 시간에만 존재하는 인스턴스라 상시 replica 한 대보다 쌀 수도 있습니다. 온프레미스에서는 흉내 내기 어려운 부분입니다.

복원의 하이브리드: WAL은 S3에서, 마지막 몇 분은 primary에서

이 글에서 제가 제일 재미있게 읽은 부분은 백업이 아니라 복원 쪽입니다. 백업 전용 인스턴스는 어떻게 최신 상태를 따라잡을까요.

절차는 이렇습니다. S3에서 직전 백업을 복원하고, 그 뒤의 WAL을 replay해서 따라잡습니다. WAL 대부분은 wal-g로 아카이빙된 S3에서 가져오는데, 여기에 구멍이 하나 있습니다. PostgreSQL은 완결된 WAL segment만 archive하므로, 지금 쓰이고 있는 segment의 내용은 S3에 아직 없습니다. archive_timeout을 5분으로 설정해도 최신 몇 분은 항상 S3 밖에 있습니다.

그래서 마지막 구간만 primary에서 직접 streaming으로 받습니다. S3 replay가 대역폭을 마음껏 쓰며 대부분을 처리하고, primary는 마지막 몇 분치만 감당하니 부하가 거의 없습니다.

구간출처primary 부하
베이스 백업S3없음
WAL 대부분S3 (wal-g archive)없음
마지막 몇 분primary streaming미미

restore_command와 streaming replication을 순서대로 조합하는 것 자체는 PostgreSQL 표준 기능입니다. standby가 archive recovery에서 streaming으로 넘어가는 그 메커니즘을 백업 인스턴스 따라잡기에 그대로 쓴 것인데, 표준 부품의 좋은 재조합입니다.

Barman/pgBackRest 세계에서 보면

제가 Barman 시리즈에서 다룬 도구들과 이 구조는 층이 다릅니다. Barman과 pgBackRest는 인스턴스 하나의 백업을 잘 받는 도구이고, PlanetScale의 구조는 그 위에서 "인스턴스가 아주 많고 각각이 작다"는 전제로 짠 오케스트레이션입니다. 실제로 부품은 익숙한 것들입니다. pg_basebackup으로 시드하고, wal-g로 아카이빙하고, S3에 쌓습니다.

그래서 이 글의 교훈을 일반 조직에 그대로 가져오긴 어렵습니다. shard가 없는 32TB 단일 클러스터라면 이 구조는 시작조차 못 합니다. 대신 두 가지는 규모와 무관하게 유효합니다.

하나, 복원 리허설의 산수를 미리 해 볼 것. 우리 클러스터 크기와 스토리지 속도로 복원이 몇 시간인지, RTO와 맞는지 계산해 보면 됩니다. 22시간이라는 숫자는 백업이 아니라 복원에서 먼저 문제가 됩니다.

둘, archive의 마지막 구멍을 인지할 것. archive_timeout이 있어도 최신 변경분은 archive에 없습니다. PITR 계획이 "archive에 다 있다"를 전제한다면 그 전제는 몇 분짜리 구멍을 갖고 있는 셈이고, 이 구멍을 메우는 것이 streaming이든 동기 standby든 별도 장치여야 합니다.

subtransaction 분석에 이어 PlanetScale의 PostgreSQL 엔지니어링 글이 연달아 좋네요. Vitess로 MySQL을 sharding하던 회사가 PostgreSQL에 같은 체급의 인프라를 짓는 과정이 실시간으로 공개되고 있으니, 당분간 이 블로그의 단골 출처가 될 것 같아요.

참고 자료

PG17 failover slot 비교

· 약 7분

Cloud SQL for PostgreSQL이 2026년 7월 24일 릴리스 노트로 failover slot 지원을 GA로 발표했어요. logical replication을 쓰면서 DR switchover나 replica failover를 하는 환경에서 slot이 살아남게 하는 기능이에요.

CSP 릴리스 노트를 읽을 때마다 확인해야 하는 게 하나 있습니다. 이게 그 벤더가 만든 기능인지, 아니면 upstream 기능을 이제 노출한 것인지 살펴봐야 합니다. 이번 건은 후자입니다. failover slot은 PostgreSQL 17 코어 기능이고, AWS와 Azure도 이미 지원합니다. GCP가 늦게 합류한 쪽입니다.

그렇다고 릴리스 노트가 무의미하지는 않습니다. 관리형 서비스는 upstream 기능을 노출할 때 파라미터 이름을 자기 방식으로 바꾸고, 조건을 붙입니다. 실무에서 걸리는 건 그 차입니다. 3사 문서를 나란히 놓고 정리했습니다.

왜 slot이 failover에서 사라지면 곤란한가

logical replication slot은 primary에만 존재했습니다. standby로 넘어가지 않았습니다. 그래서 failover가 일어나면 새 primary에는 그 slot이 없습니다.

slot이 없으면 구독자는 어디서부터 받아야 할지 모릅니다. 새로 slot을 만들면 그 시점부터 시작하므로, 옛 primary에서 마지막으로 읽은 지점과 새 slot 생성 시점 사이의 변경이 비게 됩니다. 안전하게 가려면 초기 스냅샷부터 다시 떠야 합니다. 수 TB 규모 CDC 파이프라인에서 이건 몇 시간에서 며칠짜리 작업입니다.

문제는 이 손실이 HA 구성을 갖췄다고 방지되지 않는다는 점입니다. HA는 데이터를 지키지만 slot은 데이터가 아닙니다. failover는 성공했는데 CDC만 끊기는 상황이 정확히 이 지점에서 나옵니다.

PostgreSQL 17이 한 일

17에서 slot 동기화가 코어에 들어왔습니다. 동작은 이렇습니다. standby에 slotsync worker가 붙어 주기적으로 primary에 물어보고, failover 속성이 켜진 logical slot을 로컬에 만들거나 갱신합니다. 동기화가 필요 없어진 slot은 worker가 알아서 지웁니다.

켜려면 여러 조건이 맞아야 합니다.

-- 구독자 쪽: failover 옵션을 켜고 subscription 생성
CREATE SUBSCRIPTION mysub
CONNECTION 'host=... dbname=...'
PUBLICATION mypub
WITH (failover = true);

-- publisher 쪽에서 확인
SELECT slot_name, slot_type, failover FROM pg_replication_slots;

failover 열이 t여야 동기화 대상이 됩니다. 여기가 첫 번째 함정입니다. 서버 파라미터를 다 맞춰 놓고도 subscription에 failover = true를 빼면 아무 slot도 동기화되지 않습니다. 3사 문서가 모두 이 문장을 따로 적어 둔 이유입니다. 이미 만들어 둔 subscription이 있으면 ALTER SUBSCRIPTION ... SET (failover = true)로 바꿔야 합니다.

서버 쪽 요구사항은 네 갈래입니다. standby에 sync_replication_slots를 켜고, hot_standby_feedback도 켭니다. standby와 primary 사이에 물리 replication slot이 있어야 하므로 standby의 primary_slot_name이 설정돼 있어야 합니다. 그리고 primary의 synchronized_standby_slots에 그 물리 slot 이름을 넣어, logical slot이 standby가 아직 받지 못한 지점보다 앞서 나가지 못하게 막습니다.

마지막 항목을 빼먹기 쉬운데, 이걸 안 걸면 동기화는 도는 것처럼 보이지만 failover 순간에 구독자가 새 primary에 없는 WAL을 요구하는 상태가 될 여지가 남습니다.

17 이전 버전은 코어에 이 기능이 없습니다. EDB의 pg_failover_slots extension으로 같은 일을 했고, 관리형 서비스 중에는 이 extension을 제공하는 곳이 있습니다.

3사 비교

같은 upstream 기능인데 파라미터 이름이 셋 다 다릅니다. 문서를 옮겨 다니며 설정할 때 이 표가 필요합니다.

항목Cloud SQL (GCP)RDS for PostgreSQL (AWS)Flexible Server (Azure)
최소 버전PostgreSQL 17PostgreSQL 17PostgreSQL 17
추가 조건Enterprise Plus edition, Advanced DR명시 없음명시 없음
logical decoding 활성화cloudsql.logical_decoding=onrds.logical_replication=1wal_level=logical
standby 동기화 활성화sync_replication_slots=onsync_replication_slots=1sync_replication_slots
standby feedbackhot_standby_feedback=onhot_standby_feedback=1hot_standby_feedback
동기화 대상 DB 지정cloudsql.logical_slot_sync_dbnamerds.logical_slot_sync_dbname문서에 명시 없음
primary 쪽 대기 지정cloudsql.synchronized_standby_replicassynchronized_standby_slots문서에 명시 없음
17 이전 대안문서에 명시 없음문서에 명시 없음pg_failover_slots extension
failover 후 구독자 작업PSA 엔드포인트 사용 시 자동 재연결ALTER SUBSCRIPTION으로 새 primary 지정자동 보존

추상화 수준이 갈리는 지점

표에서 가장 눈여겨볼 행은 primary 쪽 대기 지정입니다.

AWS는 synchronized_standby_slots에 물리 replication slot 이름을 넣으라고 합니다. upstream 파라미터 그대로입니다. 그 slot 이름을 알아야 하고, 인스턴스를 재구성하면 값을 손봐야 합니다.

GCP는 cloudsql.synchronized_standby_replicas에 replica 인스턴스 이름을 넣으라고 합니다. 물리 slot 이름이 아니라 GCP 리소스 이름입니다. 내부에서 인스턴스 이름을 slot 이름으로 옮겨 준다는 뜻이고, 사용자가 PostgreSQL 내부 식별자를 몰라도 됩니다. 대신 그 매핑이 어떻게 되는지는 밖에서 안 보입니다.

이 차이가 두 서비스의 성격을 보여줍니다. AWS는 upstream 파라미터를 그대로 열어 주고 접두사만 붙이는 쪽이고, GCP는 자기 리소스 모델로 감싸는 쪽입니다. 어느 쪽이 낫다기보다, 셀프 호스팅 경험을 그대로 옮기려면 AWS가 편하고 PostgreSQL 내부를 몰라도 쓰게 하려면 GCP가 편합니다.

failover 이후 구독자가 해야 하는 일

AWS 문서는 standby가 승격된 뒤 구독자가 subscription을 새 인스턴스로 바꿔야 한다고 적습니다. slot은 살아 있으니 재동기화는 필요 없지만 접속 대상은 사람이 바꿔 줍니다.

GCP는 구독자가 private services access DNS 쓰기 엔드포인트로 접속해 있으면 switchover나 failover 뒤 자동으로 새 primary에 재연결된다고 안내합니다. 엔드포인트가 이름으로 고정되어 있고 그 이름이 새 primary를 가리키게 바뀌기 때문입니다. 같은 주에 프리뷰로 나온 AlloyDB write endpoint와 같은 발상입니다.

다만 GCP 문서에도 단서가 붙습니다. 승격된 replica에 남은 옛 primary의 orphaned slot은 수동으로 지워야 하고, 구독자가 그 옛 slot에 계속 붙으려 하는 경우가 있습니다. 자동 재연결이 모든 잔여물을 정리해 주지는 않습니다.

Aurora는 어떤가

여기는 정확히 적어야 합니다. AWS가 문서로 명시한 페이지는 "Managing logical slot synchronization for RDS for PostgreSQL"이고, 대상을 RDS for PostgreSQL 17로 적고 있습니다. Aurora PostgreSQL에 대한 동일한 안내 페이지는 확인하지 못했습니다.

Aurora는 read replica가 스토리지를 공유하는 구조라 물리 replication slot의 의미가 RDS와 다릅니다. 위 파라미터 조합이 그대로 통한다고 단정할 근거를 찾지 못했으니, Aurora를 쓴다면 엔진 버전과 파라미터 그룹에서 sync_replication_slots가 실제로 노출되는지 확인하고 테스트 클러스터에서 failover를 걸어 보는 편이 안전합니다. 검색에 잡히는 "Aurora 17도 된다"는 서술은 대부분 커뮤니티 글이라 1차 출처로 삼기 어렵습니다.

셀프 호스팅에서 확인하는 법

관리형 대신 직접 운영하는 경우 설정과 검증은 이렇습니다.

# primary
wal_level = logical
synchronized_standby_slots = 'standby_phys_slot'

# standby
primary_slot_name = 'standby_phys_slot'
hot_standby_feedback = on
sync_replication_slots = on

동기화가 실제로 되고 있는지는 standby에서 확인합니다.

-- standby에 slot이 만들어졌는지, 어디까지 따라왔는지
SELECT slot_name, failover, synced, restart_lsn, confirmed_flush_lsn
FROM pg_replication_slots
WHERE slot_type = 'logical';

synced 열이 t면 slotsync worker가 만든 복제본입니다. 수동으로 한 번 당겨 보려면 standby에서 SELECT pg_sync_replication_slots();를 실행합니다.

primary 쪽에서는 logical slot이 얼마나 뒤처져 있는지 봅니다.

SELECT slot_name, slot_type, failover,
restart_lsn,
pg_current_wal_lsn() - restart_lsn AS lag_bytes
FROM pg_replication_slots
ORDER BY slot_type, slot_name;

synchronized_standby_slots를 걸면 logical slot의 진행이 물리 slot에 묶이므로, standby가 멈추면 primary의 WAL이 쌓인다는 점을 같이 알고 있어야 합니다. 이 파라미터는 안전을 사는 대신 standby 장애를 primary 디스크 문제로 번지게 만드는 통로가 됩니다. restart_lsn 지연을 알림에 걸어 두는 게 좋습니다.

정리

Cloud SQL 릴리스 노트 한 줄을 따라가면 결국 PostgreSQL 17 코어 기능에 도착합니다. 3사가 모두 지원하고, 최소 버전도 셋 다 17입니다. 차이는 기능 유무가 아니라 파라미터 이름과 추상화 수준, 그리고 failover 이후 구독자 재연결을 누가 처리하느냐에 있습니다.

GCP만 Enterprise Plus edition과 Advanced DR을 요구한다는 점은 비교할 때 짚어 둘 만합니다. 기능이 있다는 것과 지금 쓰는 요금제에서 쓸 수 있다는 것은 다른 이야기입니다.

어느 클라우드를 쓰든 첫 번째 함정은 같습니다. subscription에 failover = true가 걸려 있지 않으면 서버 파라미터를 전부 맞춰도 아무 slot이 동기화되지 않습니다. 설정을 끝냈다고 생각한 시점에 pg_replication_slotsfailover 열부터 확인하는 게 순서입니다.

참고

이 블로그의 관련 글로는 PostgreSQL 19의 logical replication과 sequencePostgreSQL 19의 wal_level 동적 floor가 있어요.

MVCC 엔진별 비용 비교

· 약 13분

PostgreSQL을 오래 운영하면 vacuum이 미워지는 날이 와요. dead tuple이 안 줄고, autovacuum은 계속 도는데 테이블은 커지고, VACUUM FULL은 락 때문에 걸 수 없어요. 그럴 때 검색하면 나오는 글이 CMU Andy Pavlo(앤디 파블로)의 The Part of PostgreSQL We Hate the MostUber의 2016년 MySQL 전환기예요. 둘 다 틀린 말을 하지는 않아요.

그런데 이 비판들에는 공통으로 빠진 게 있습니다. 바로 비교 대상입니다. PostgreSQL의 MVCC가 나쁘다면, 나쁘지 않은 MVCC는 어디에 있는지 궁금해집니다.

이 질문을 정면으로 다룬 글이 최근 나왔습니다. boringSQL의 Radim Marek(라딤 마렉)이 쓴 PostgreSQL's MVCC is bad. So is everyone else's.입니다. 이 글은 그 논의를 출발점으로 삼되, 원문에 없는 두 가지를 채웁니다. 하나는 Docker에 PostgreSQL 18.4를 띄워 직접 재본 수치이고, 다른 하나는 엔진별로 같은 문제를 진단하는 쿼리입니다.

모든 MVCC 구현이 답해야 하는 네 가지 질문

원문이 제시한 프레임이 좋아서 그대로 빌려 옵니다. 어떤 엔진이든 다중 버전을 구현하려면 네 가지를 결정해야 합니다.

  1. 옛 버전을 어디에 두는가
  2. 버전 체인은 어느 방향을 가리키는가
  3. 인덱스는 무엇을 가리키는가
  4. 누가, 언제 치우는가

PostgreSQL의 답은 이렇습니다. 옛 버전은 테이블 안에 두고, 체인은 과거에서 미래로 향하고, 인덱스는 물리 위치(ctid)를 가리키며, 청소는 백그라운드 프로세스가 나중에 합니다.

네 번째 질문이 특히 중요합니다. 청소를 나중에 하면 쓰레기가 눈에 보이고, 청소를 트랜잭션이 직접 하면 그 트랜잭션이 느려집니다. 어느 쪽도 공짜가 아닙니다.

PostgreSQL이 치르는 값을 직접 재봤다

논의를 수치 없이 하면 감상이 됩니다. Docker에 PostgreSQL 18.4를 띄우고 직접 측정했습니다. 프로덕션 서버가 아닌 노트북 위 컨테이너라서, 절대값보다 비율을 보면 됩니다.

docker run -d --name mvcclab -e POSTGRES_PASSWORD=lab postgres:18

테이블은 100만 행이고, 컬럼은 id(PK)와 텍스트 4개, last_seen 타임스탬프로 구성했습니다. 이 중 10만 행을 UPDATE하면서 생성되는 WAL 양과 HOT update 비율을 측정했습니다. 측정 전마다 CHECKPOINT를 실행해 full page write 조건을 맞췄습니다.

write amplification은 인덱스 개수가 아니라 페이지 여유가 결정한다

구성UPDATE 대상 컬럼생성 WALHOT 비율heap 변화
보조 인덱스 없음, fillfactor 100last_seen116 MB0%73 MB → 80 MB
보조 인덱스 4개, fillfactor 100last_seen218 MB0%73 MB → 80 MB
보조 인덱스 4개, fillfactor 70last_seen81 MB100%104 MB → 104 MB
보조 인덱스 4개, fillfactor 100c1 (인덱스 컬럼)228 MB0%73 MB → 81 MB

두 번째 줄이 흔히 인용되는 그 비용입니다. 인덱스 컬럼을 건드리지도 않았는데 보조 인덱스 4개를 붙였다는 이유만으로 WAL이 116MB에서 218MB로 뜁니다. 새 tuple이 다른 페이지에 만들어지면 ctid가 바뀌고, 모든 인덱스가 그 새 위치를 따라가야 하기 때문입니다.

흥미로운 건 세 번째 줄입니다. 인덱스 4개를 그대로 둔 채 fillfactor만 70으로 낮추면 WAL이 81MB로 떨어집니다. 인덱스가 아예 없는 첫 번째 줄(116MB)보다도 적습니다. 페이지에 30% 여유가 생기니 새 버전이 같은 페이지 안에 들어가고, HOT update 조건이 성립해 인덱스를 아예 건드리지 않습니다. 원문 실험에서는 HOT 비율이 66%였는데, 이 실험은 행이 작아 100%가 나왔습니다.

정리하면 write amplification을 좌우하는 진짜 변수는 같은 페이지에 새 버전이 들어갈 자리가 남아 있느냐입니다. 인덱스를 줄이는 것보다 fillfactor를 조정하는 편이 대개 현실적입니다. 대신 heap이 73MB에서 104MB로 커집니다. 디스크를 미리 내주고 WAL과 인덱스 갱신을 아끼는 거래입니다.

네 번째 줄은 이 거래가 통하지 않는 경우입니다. 인덱스가 걸린 컬럼 자체를 바꾸면 HOT 조건이 깨지므로 fillfactor를 아무리 낮춰도 소용없습니다. 인덱스 크기도 107MB에서 129MB로 함께 부풉니다.

같은 UPDATE를 fillfactor 70 테이블에 네 번 반복해도 HOT 비율은 100%를 유지했고 WAL은 90MB 근처에서 평평했습니다. heap도 104MB에서 늘지 않았습니다. 여유 공간이 소진되어 HOT이 깨지는 지점은 이 워크로드에서는 오지 않았습니다.

bloat는 커밋했을 때와 롤백했을 때가 다르다

원문에도 있는 실험인데, 실제로 돌려 보니 원문이 다루지 않은 갈래가 나왔습니다.

100만 행을 UPDATE한 뒤 ROLLBACK합니다.

BEGIN;
UPDATE t SET last_seen = now(); -- 2847 ms
ROLLBACK; -- 1.0 ms

롤백은 1밀리초에 끝납니다. PostgreSQL은 롤백할 때 되돌릴 게 없습니다. 새로 쓴 tuple을 "이 트랜잭션은 실패했다"고 표시하면 그만입니다. 상수 시간입니다. 그런데 테이블은 89MB에서 178MB로 두 배가 됐고 dead tuple이 100만 개 생겼습니다. 커밋하지 않은 작업이 디스크를 두 배로 쓴 것입니다.

여기서 갈립니다.

ROLLBACK 케이스: 89MB → 178MB → VACUUM → 89MB
COMMIT 케이스: 89MB → 178MB → VACUUM → 178MB

롤백한 경우에는 일반 VACUUM만으로 파일이 89MB로 돌아갑니다. 롤백으로 죽은 tuple은 전부 테이블 뒤쪽에 새로 붙은 것들이라 연속 구간을 이루고, vacuum이 꼬리를 잘라 OS에 반납하기 때문입니다.

커밋한 경우는 다릅니다. 죽는 건 테이블 앞쪽 여기저기에 흩어진 옛 버전입니다. vacuum은 그 자리를 재사용 가능하게 표시할 뿐 파일을 줄이지 못합니다. 178MB를 회수하려면 VACUUM FULL이 필요하고, 그건 ACCESS EXCLUSIVE 락을 잡습니다.

다만 이 178MB가 무한히 자라지는 않습니다. 같은 UPDATE를 두 번, 세 번 반복하고 매번 vacuum을 돌려도 파일은 178MB에서 멈췄습니다. vacuum이 표시해 둔 자리를 다음 UPDATE가 재사용하기 때문입니다. 전면 갱신 워크로드에서 bloat는 대략 두 배 지점으로 수렴합니다. 흔히 걱정하는 "방치하면 무한정 커진다"는 vacuum이 제때 못 도는 경우의 이야기이지, MVCC 구조 자체의 결론은 아닙니다.

열어 둔 트랜잭션 하나가 정리를 통째로 막는다

PostgreSQL의 실패 모드 중 운영에서 가장 자주 만나는 것입니다. 다른 세션에서 트랜잭션을 열어 놓고 방치한 상태로 dead tuple을 만든 뒤 VACUUM VERBOSE를 실행했습니다.

tuples: 0 removed, 600000 remain, 400000 are dead but not yet removable
removable cutoff: 861, which was 2 XIDs old when operation ended

40만 개가 죽었는데 하나도 회수되지 않았습니다. dead but not yet removable, 이 문구가 나오면 원인은 거의 항상 누군가 오래 붙들고 있는 스냅샷입니다. 그 트랜잭션을 종료하고 다시 실행하면 이렇게 바뀝니다.

tuples: 400000 removed, 200000 remain, 0 are dead but not yet removable
index scan needed: 6897 pages from table (66.67% of total) had 400000 dead item identifiers removed

주의할 건 이 트랜잭션이 문제의 테이블을 건드리지 않아도 상관없다는 점입니다. backend_xmin을 잡고 있는 한 데이터베이스 전체의 정리가 그 지점에서 멈춥니다. 점심 먹으러 가면서 커밋하지 않고 자리를 뜬 세션 하나가 무관한 테이블의 vacuum을 막습니다.

다른 엔진은 이 비용을 어디로 보냈나

여기까지가 PostgreSQL이 내는 청구서입니다. 다른 엔진이라고 비용을 피하지는 못합니다. 비용이 없는 게 아니라 다른 항목으로 냅니다.

Oracle과 InnoDB는 옛 버전을 테이블 밖 undo 영역에 둡니다. 테이블은 행마다 한 버전만 유지하니 깔끔하고, 보조 인덱스는 물리 위치 대신 논리 키를 가리키므로 위치 변경에 따라올 필요가 없습니다. vacuum도 없고 freeze 의식도 없습니다. 대신 세 가지가 따라옵니다. 우선 롤백이 정직하게 비쌉니다. PostgreSQL이 1밀리초에 끝낸 100만 행 롤백을 undo 방식은 한 건씩 되돌려야 합니다. 읽기도 비싸집니다. 옛 스냅샷을 보려면 undo 체인을 거슬러 올라가 행을 재구성해야 합니다. 그리고 오래 도는 읽기 쿼리가 undo를 소진하면 그 쿼리를 죽입니다. Oracle에서 20년 넘게 DBA를 괴롭혀 온 ORA-01555: snapshot too old가 바로 그것입니다.

이 대비가 중요합니다. PostgreSQL은 오래된 reader를 위해 쓰레기를 쌓아 두고 견딥니다. Oracle은 쓰레기를 정리하고 reader를 죽입니다. 어느 쪽이 나은지는 워크로드가 정합니다.

SQL Server는 기본값이 아예 MVCC가 아닙니다. 잠금으로 격리를 만들고, RCSI를 켜야 행 버전 관리가 시작됩니다. 그때 옛 버전은 tempdb의 version store로 갑니다. 문제는 tempdb가 인스턴스 전체 공용이라는 점입니다. 데이터베이스 하나에서 오래 열린 스냅샷이 tempdb를 부풀리면 같은 인스턴스의 다른 데이터베이스까지 함께 멈춥니다. 폭발 반경이 데이터베이스 경계를 넘습니다. Microsoft가 2019년 ADR을 내놓으며 버전을 사용자 데이터베이스로 되돌린 이유가 이것이고, 그 과정에서 얻으려 한 상수 시간 abort는 PostgreSQL이 처음부터 갖고 있던 성질입니다.

MongoDB의 WiredTiger는 버전을 메모리 캐시에 델타로 들고 있다가 캐시를 넘치면 WiredTigerHS.wt 히스토리 저장소로 흘려보냅니다. 디스크의 dead tuple은 없지만 캐시 압력이 대신 옵니다. eviction이 따라가지 못하면 애플리케이션 스레드가 직접 eviction 작업을 떠맡아 전체 노드가 느려집니다.

CockroachDB나 YugabyteDB 같은 LSM 계열은 버전을 타임스탬프가 붙은 키로 저장하고 compaction으로 정리합니다. "vacuum이 없다"고 홍보하지만 compaction이 곧 vacuum입니다. 대신 GC 윈도를 넘긴 읽기는 실패합니다. CockroachDB의 gc.ttlseconds 기본값은 오랫동안 25시간이었는데 스케줄 백업이 들어온 뒤 4시간으로 낮아졌으니, 버전에 따라 창이 얼마나 좁은지 확인하고 써야 합니다. 삭제가 많은 테이블에서 tombstone이 쌓여 빈 테이블 스캔이 점점 느려지는 것도 이 계열의 특징입니다.

Kubernetes를 쓰면 매일 만지는 etcd도 사실상 같은 구조입니다. (key, revision)으로 버전을 쌓고 수동 compaction으로 회수합니다. 회수된 revision을 다시 읽으려 하면 etcdserver: mvcc: required revision has been compacted가 나오고, 백엔드 쿼터 2GB를 넘기면 제어 평면 쓰기가 거부됩니다. defrag는 VACUUM FULL과 같은 자리에 있는 의식입니다.

엔진옛 버전 위치인덱스가 가리키는 것abort 비용오래 열린 reader의 결과
PostgreSQL heap테이블 안물리 위치 (ctid)상수 시간bloat 누적, vacuum 정지
Oracle / InnoDBundo 영역논리 키작업량 비례ORA-01555, undo 폭증
SQL Server RCSItempdb클러스터링 키상수 시간 (ADR 이후)tempdb 증가, 인스턴스 전체 위험
WiredTiger캐시, 히스토리 저장소RecordId상수 시간캐시 압력, 노드 지연
LSM 계열타임스탬프 키논리 키상수 시간GC 윈도 초과 오류

같은 사고, 엔진별로 다른 진단 쿼리

위 표의 마지막 열은 결국 하나의 사건입니다. 누군가 트랜잭션을 오래 열어 둔 것입니다. 엔진마다 증상과 확인 방법이 다를 뿐입니다.

PostgreSQL에서는 backend_xmin을 봅니다. 아래 쿼리는 위 실험에서 실제로 범인을 찾는 데 사용한 것입니다.

SELECT pid, state,
backend_xmin,
age(backend_xmin) AS xid_age,
now() - xact_start AS tx_age,
left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;

주의할 게 있습니다. 범인이 pg_stat_activity에만 있는 게 아닙니다. 비활성 replication slot과 준비된 트랜잭션도 똑같이 xmin을 붙듭니다. 셋을 같이 확인해야 합니다.

-- 비활성 replication slot
SELECT slot_name, slot_type, active, xmin, catalog_xmin
FROM pg_replication_slots
WHERE NOT active OR xmin IS NOT NULL;

-- 잊혀진 2PC 트랜잭션
SELECT gid, prepared, owner FROM pg_prepared_xacts;

MySQL InnoDB에서 같은 증상은 history list length로 나타납니다.

SHOW ENGINE INNODB STATUS\G
-- TRANSACTIONS 절의 "History list length" 값을 확인한다

SELECT trx_id, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id, LEFT(trx_query, 60) AS query
FROM information_schema.innodb_trx
ORDER BY trx_started;

history list length가 계속 늘면 purge가 밀리고 있다는 뜻이고, 원인은 PostgreSQL과 똑같이 오래 열린 트랜잭션입니다. 격리 수준을 READ COMMITTED로 낮추면 InnoDB가 유지해야 할 히스토리가 줄어 완화되는 경우가 있습니다.

Oracle에서는 undo 사용량과 retention을 봅니다.

SELECT begin_time, undoblks, maxquerylen, ssolderrcnt
FROM v$undostat
ORDER BY begin_time DESC
FETCH FIRST 12 ROWS ONLY;

ssolderrcntORA-01555 발생 횟수이고 maxquerylen이 가장 오래 돈 쿼리의 길이입니다. 이 둘이 함께 오르면 undo 보존 기간과 실제 쿼리 시간이 어긋나고 있다는 신호입니다.

SQL Server에서는 tempdb version store를 봅니다.

SELECT DB_NAME(database_id) AS db,
reserved_page_count,
reserved_space_kb / 1024 AS reserved_mb
FROM sys.dm_tran_version_store_space_usage
ORDER BY reserved_space_kb DESC;

이 값이 계속 오르면서 줄지 않으면 어딘가 스냅샷이 붙들려 있습니다. 인스턴스 공용 자원이므로 다른 데이터베이스 담당자도 같이 곤란해진다는 점에서 PostgreSQL보다 대응이 급합니다.

PostgreSQL 쿼리 세 개는 위 실험 환경에서 직접 실행해 확인했고, 나머지 엔진은 벤더 문서를 근거로 정리했습니다. 엔진을 옮겨도 물어야 할 질문은 하나로 같습니다. 지금 가장 오래 열려 있는 트랜잭션은 무엇이고, 그것이 무엇을 붙들고 있는지 물어야 합니다.

PostgreSQL만 하지 않는 선택 하나

목록을 늘어놓다 보면 PostgreSQL이 특히 나빠 보이지만, 하나는 짚어 둘 만합니다. 위에 나열한 엔진 대부분은 어느 시점에 reader를 죽입니다. Oracle은 ORA-01555, WiredTiger는 타임스탬프 기반 거부, LSM 계열은 GC 윈도 초과, etcd는 compacted revision. FoundationDB는 아예 트랜잭션 수명을 5초로 강제합니다.

PostgreSQL은 기본 설정에서 읽기 쿼리를 취소하지 않습니다. 대신 그 쿼리가 볼지도 모르는 쓰레기를 계속 들고 있습니다. 결함으로 볼 일은 아닙니다. 선택입니다.

실제로 PostgreSQL도 한 번은 반대편을 시도했습니다. 9.6에 들어온 old_snapshot_threshold가 그것으로, 설정한 시간이 지나면 옛 스냅샷을 무효화하고 snapshot too old 오류를 던졌습니다. 그런데 이 기능은 vacuum이 아직 보이는 행을 지워 버릴 수 있는 정합성 문제를 안고 있었고, 고칠 계획이 서지 않은 채 시간이 흐르다 PostgreSQL 17에서 제거됐습니다. 더 나은 구현이 나오면 다시 들어올 여지는 남겨 뒀습니다.

즉 PostgreSQL은 다른 엔진의 실패 모드를 흉내 냈다가, 제대로 못 하겠으면 안 하는 쪽을 택했습니다.

남은 문제와 지금 진행 중인 것

32비트 XID는 PostgreSQL만의 짐이 맞습니다. InnoDB의 DB_TRX_ID는 6바이트, 즉 48비트입니다. Oracle SCN도 원래 48비트였는데 12.2.0.1부터 compatible을 12.2로 올리면 상한이 2^63으로 넓어집니다. PostgreSQL은 여전히 32비트라 주기적으로 freeze를 돌려야 하고, 몇 달 동안 아무도 건드리지 않은 페이지까지 다시 써야 합니다. 2015년 Sentry의 wraparound 장애처럼 크게 터진 사례도 있습니다.

64비트 XID 패치는 Postgres Professional을 중심으로 수년째 논의 중이고 대부분의 상용 fork에는 이미 들어가 있지만, 본류에는 아직 없습니다. table access method 계층에서 처리해야 한다는 방향에는 합의가 있는 상태입니다.

저장 엔진 자체를 바꾸려는 시도도 이어집니다. zheap은 사실상 멈췄고, 지금 가장 활발한 것은 Supabase가 인수한 OrioleDB입니다. undo 기반으로 heap을 대체하는 extension이고 2026년 현재 퍼블릭 베타입니다. 벤치마크에서 최대 5.5배를 주장하지만 프로덕션 사용은 권장하지 않습니다.

당장 손에 잡히는 개선은 vacuum 쪽에서 옵니다. PostgreSQL 18은 autovacuum_worker_slotsautovacuum_max_workers를 분리해 재시작 없이 worker 수를 조정하게 만들었고, 19에서는 autovacuum이 카탈로그 순서 대신 테이블별 우선순위 점수로 대상을 고르도록 바뀝니다. pg_stat_autovacuum_scores 뷰와 가중치 파라미터가 함께 들어옵니다. 청소 자체를 없애지는 못해도, 언제 무엇부터 치울지는 계속 정교해지는 중입니다.

정리

MVCC 비용은 보존됩니다. 없앤 엔진은 없고, 어디로 보낼지만 다릅니다. PostgreSQL은 테이블 안에 쌓아 두고 나중에 치우는 쪽을 골랐습니다. 그 대가가 bloat와 vacuum 튜닝이고, 그 대신 얻은 것이 상수 시간 롤백과 취소당하지 않는 읽기 쿼리입니다.

그래서 "PostgreSQL의 MVCC는 나쁘다"는 문장은 절반만 맞습니다. 정확히 말하면 PostgreSQL은 시끄럽게 실패하고 수동으로 튜닝해야 하는 조합을 골랐습니다. undo 계열은 조용히 실패하고 자동으로 정리하지만, 실패할 때는 사용자 쿼리를 죽입니다.

다음에 누가 어떤 데이터베이스가 다중 버전 문제를 해결했다고 하면 네 가지를 물어보면 됩니다. 옛 버전은 어디에 사는지, 체인은 어느 방향인지, 인덱스는 무엇을 가리키는지, 누가 언제 치우는지 물으면 됩니다. 그리고 마지막으로 하나 더 묻습니다. 누군가 트랜잭션을 열어 놓고 점심을 먹으러 가면 무슨 일이 생기는지 묻습니다.

참고

이 블로그의 관련 글로는 PostgreSQL 18의 autovacuum_worker_slotsPostgreSQL은 왜 이건 아직 못 할까가 있어요.

prev-link 복구 오류

· 약 7분

secondary 하나가 복구를 못 끝내고 같은 로그만 뱉고 있었어요. WAL을 archive에서 한 번 당겨오고, 곧바로 record 하나를 읽다 실패하고, 다시 같은 파일을 당겨오는 흐름이 2초 간격으로 무한히 돌았어요.

2026-07-24 13:41:10 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028
2026-07-24 13:41:12 KST LOG: restored log file "00000068...0067F..." from archive
2026-07-24 13:41:12 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028
2026-07-24 13:41:15 KST LOG: restored log file "00000068...0067F..." from archive
2026-07-24 13:41:15 KST LOG: record with incorrect prev-link 67F/7BAB8 at 67F/40000028

디스크가 깨진 것처럼 보이지만 그건 아니었습니다. 이 메시지는 대부분 "여기가 이 timeline에서 유효한 WAL의 끝"이라는 신호입니다. 문제는 secondary가 그 끝에서 다음 timeline으로 못 건너가고 제자리를 맴돌았다는 점입니다.

이 로그가 정확히 어디서 나오나

두 줄은 서로 다른 주체가 찍습니다.

restored log file ... from archive는 recovery 중인 서버가 restore_command로 archive에서 WAL 세그먼트 하나를 가져왔다는 뜻입니다. 우리 환경은 pgBackRest가 archive 역할이라, 이 줄은 pgBackRest에서 세그먼트를 복원해 왔다는 기록입니다.

record with incorrect prev-link A at B는 WAL을 읽는 xlogreader가 찍습니다. PostgreSQL의 WAL record는 헤더에 xl_prev라는 필드를 들고 있는데, 바로 앞 record가 어디서 끝났는지를 가리키는 back-link입니다. reader는 record를 하나 읽을 때마다 "이 record의 xl_prev가 방금 내가 읽은 record의 끝과 같은가"를 확인합니다. 어긋나면 이 메시지를 남기고 그 자리를 유효한 WAL의 끝으로 간주합니다.

메시지의 두 LSN은 순서대로 이렇게 읽습니다. at 뒤(67F/40000028)가 문제의 record가 놓인 위치이고, 앞(67F/7BAB8)이 그 record에 적혀 있던 xl_prev 값입니다. 즉 67F/40000028에 있는 record는 자기 앞 record가 67F/7BAB8에서 끝났다고 주장하는데, reader가 실제로 그 지점까지 읽어온 맥락과 맞지 않는 것입니다.

여기서 위치 67F/40000028이 힌트를 줍니다. WAL 세그먼트는 기본 16MB이고, 오프셋 0x40000028은 세그먼트 경계에서 딱 0x28(40바이트) 들어간 자리입니다. 40바이트는 세그먼트 첫 페이지의 long page header 크기와 같습니다. 다시 말해 이 record는 어느 세그먼트의 맨 첫 record 자리에 있습니다. 그 자리의 xl_prev가 한참 앞인 67F/7BAB8(같은 논리 파일에서 500KB쯤 되는 지점)를 가리킨다는 건, 그 세그먼트 앞부분이 과거에 쓰이고 아직 새 내용으로 덮이지 않은 recycled 세그먼트의 잔재라는 신호에 가깝습니다. PostgreSQL은 성능을 위해 WAL 파일을 지우지 않고 이름만 바꿔 재사용하는데, reader가 그 옛 바이트열을 그럴듯한 record로 오독하면 이런 back-link 불일치가 나옵니다. xlogreader 소스메일링 리스트 논의에서도 같은 진단이 나옵니다.

어쩌다 이 상태가 됐나

먼저 분명히 해두면, 아래 순서는 사후에 되짚어 본 유추입니다. 운영 중 이것저것 만지다 이 상태에 도달했고, 어느 한 단계가 범인이라고 특정하지는 못했습니다. 다만 secondary가 이 지경이 되기까지 밟은 대략의 경로는 남겨 둡니다. HA는 pg_auto_failover로 묶여 있고 백업은 pgBackRest로 받는 환경이었습니다.

순서한 일부수 효과
1최초 primary/secondary 구성timeline 시작
2failover 발생옛 secondary 승격, timeline 증가
3옛 secondary 노드 drop구 노드 제거
4새 secondary add (pgBackRest 백업본으로 seed)과거 시점 데이터로 출발
5다시 failovertimeline 한 번 더 증가

failover는 standby를 primary로 승격시키면서 새 timeline을 엽니다. 이때 .history 파일이 만들어져 "몇 번 timeline은 어느 LSN에서 갈라져 나왔다"를 기록하고, 뒤따르는 standby는 이 history를 보고 분기 지점을 넘어 새 timeline의 WAL로 갈아탑니다. 갈아타려면 recovery_target_timeline이 최신을 따라가도록 서 있어야 하고, 분기 지점 이후의 WAL과 history 파일이 archive에 제대로 올라와 있어야 합니다.

이 증상 자체는 문서화가 잘 되어 있습니다. PostgreSQL 코어 개발자 Michael Paquier(미하엘 파키에)는 같은 로그를 두고 "현재 timeline에서 유효한 WAL의 끝을 가리키는 것이며, 과거에 recycled 세그먼트로 쓰였던 영역을 읽을 때 마주칠 수 있다"고 설명합니다. 같은 스레드에는 두 가지가 더 나옵니다. 하나는 이 오류가 archive_mode가 켜져 있을 때 주로 나타난다는 관찰이고, 다른 하나는 여러 번 승격을 거친 노드를 다시 붙일 때 반복해서 겪었다는 보고입니다. archive에서 WAL을 당겨오고, failover를 여러 번 돌린 우리 상황과 겹치는 대목입니다.

그 틀에 우리 경로를 얹으면 이렇게 읽힙니다. 과거 백업본에서 출발한 secondary가 두 번째 failover로 timeline이 또 올라간 뒤, 자기가 따라가던 timeline의 끝에 도달하고도 다음 timeline으로 건너갈 연결을 archive에서 매끄럽게 잇지 못한 것으로 보입니다. 분기 지점 근처 세그먼트에 recycled 잔재가 남아 있었고, reader가 그 자리를 67F/40000028의 깨진 record로 읽었다는 그림입니다. 단정이 아니라 관측된 증상과 문서화된 메커니즘을 맞춰 본 해석입니다.

왜 하필 무한 루프였나

멈추지 않고 도는 게 이 현상의 성격을 잘 보여줍니다.

recovery는 다음 WAL을 기다리다 restore_command를 다시 부르고, archive는 같은 세그먼트를 또 건네주고, reader는 같은 자리에서 같은 back-link 불일치를 만납니다. 넘어가야 할 새 timeline의 이력을 집어오지 못하는 한, 이 고리는 스스로 풀리지 않습니다. 로그의 LSN이 67F/40000028로 매번 똑같이 고정돼 있는 게 그 증거였습니다. 앞으로 나아가는 복구라면 LSN이 조금씩이라도 커집니다.

이 대목에서 disk corruption과 헷갈리기 쉬운데, 구분 기준은 단순합니다. 같은 세그먼트를 반복 restore하면서 같은 LSN에 멈춰 있으면 timeline 경계 문제이고, 서로 다른 위치에서 읽기 자체가 깨지면 그때 물리 손상을 의심합니다.

어떻게 걷어냈나

pg_rewind나 recovery_target_timeline=latest 조정으로 붙여보는 길이 먼저 떠오릅니다. 다만 메일링 리스트에도 나오듯 pg_rewind가 timeline 충돌은 정리해도 이 back-link 오류 자체를 없애주지 못하는 경우가 있습니다. 게다가 이 노드는 4번에서 이미 과거 백업본으로 새로 붙인 상태였습니다. 어중간하게 되살리기보다 현재 timeline 기준으로 깨끗이 다시 seed하는 편이 빨랐습니다.

그래서 pg_auto_failover의 노드 재구축으로 정리했습니다.

# 꼬인 secondary 노드를 데이터까지 완전히 제거
pg_autoctl drop node --destroy

# 현재 primary 기준으로 secondary 새로 구축
pg_autoctl create postgres

--destroy는 monitor에서 노드를 지우는 데 그치지 않고 데이터 디렉토리까지 함께 정리합니다. 이어서 pg_autoctl create postgres가 현재 primary의 최신 timeline을 기준으로 base backup을 다시 받아 복제를 새로 세웁니다. recycled 잔재를 담고 있던 옛 데이터가 통째로 사라지니 back-link 불일치도 같이 사라졌고, 복제는 정상으로 따라붙었습니다.

남는 메모

재구축이 정공법은 아닐 수 있습니다. 원인을 LSN 단위로 끝까지 파고들면 어느 failover에서 archive에 어떤 공백이 생겼는지 특정할 수도 있었을 것입니다. 다만 pg_auto_failover처럼 오케스트레이션이 노드 수명주기를 쥐고 있는 환경에서는 백업본에서 노드를 다시 붙이는 비용이 낮은 만큼, 포렌식보다 재구축이 대체로 실용적이었습니다.

정리하자면 이래요. standby가 archive만 반복해서 restore하면서 고정된 LSN에 record with incorrect prev-link로 멈춰 있으면, 물리 손상보다 timeline 경계를 못 넘은 상황을 먼저 의심하는 게 맞아요. 그리고 failover를 여러 번 돌리는 사이에 과거 백업본으로 노드를 되붙이는 조합은 timeline 이력이 엉키기 딱 좋은 지점이라, 그 앞뒤로는 archive에 history 파일과 분기 이후 WAL이 온전히 올라와 있는지 한 번 더 확인해 둘 만해요.

참고