본문으로 건너뛰기

Claude Code 세션 포크와 멘션

· 약 4분

터미널 두 개에 Claude Code를 띄워 놓고 한쪽 결과를 복사해서 다른 쪽에 붙여넣던 시절이 있었어요. 이번 주 릴리스로 그 복붙이 공식적으로 필요 없어졌습니다.

2.1.232 changelog의 첫 두 줄이 핵심입니다. 서브에이전트 포크가 기본 활성화됐고, 프롬프트에서 @를 입력하면 다른 세션을 이름으로 직접 부를 수 있습니다. 따로 보면 각각 작은 기능인데, 합치면 "세션 하나 = 작업 하나"라는 전제가 무너집니다.

포크: 문맥을 통째로 물려받는 서브에이전트​

기존 서브에이전트의 약점은 기억상실이었습니다. 새 에이전트는 빈 문맥에서 출발하니, 지금까지 대화에서 쌓인 맥락을 프롬프트에 꾹꾹 눌러 담아 전달해야 했습니다. 전달이 부실하면 엉뚱한 결과가 돌아왔습니다.

subagent_type: "fork"로 뜨는 포크 에이전트는 부모의 대화 전체와 프롬프트 캐시를 상속합니다. 지금까지 읽은 파일, 내린 결정, 사용자가 준 피드백을 전부 아는 상태로 출발합니다. 캐시까지 물려받으니 그 문맥을 다시 처리하는 비용도 들지 않습니다.

함께 바뀐 기본값이 하나 더 있습니다. 대화형 세션에서 띄우는 에이전트는 이제 배경 실행이 기본입니다. 에이전트가 도는 동안 메인 세션이 멈춰 기다리지 않고, 끝나면 알림으로 결과가 돌아옵니다. 에이전트의 도구 호출 로그가 메인 문맥을 차지하지도 않습니다. 정리하면 "무거운 조사나 검증은 포크로 떼어 배경에서 돌리고, 메인은 계속 진행한다"가 이제 설정 없이 되는 기본 동작입니다.

@멘션: 세션끼리 직접 대화​

두 번째 변화는 세션 간 통신입니다. 프롬프트에 @를 입력하면 같은 머신에서 돌고 있는 다른 Claude 세션 목록이 뜨고, 이름을 고르면 그 세션으로 메시지가 갑니다. 내부적으로는 SendMessage 도구를 사용합니다.

이걸 받쳐 주는 손질도 같이 들어갔습니다.

  • 이름이 정확히 하나의 살아 있는 세션과 일치하면 확인 절차 없이 바로 전달됩니다
  • 한 머신의 대화형 세션들은 고유한 이름을 유지합니다. 이미 쓰는 이름으로 시작하면 자동으로 변형된 이름을 받습니다
  • /config에 다른 세션에서 오는 메시지를 받을지(수락/보류/거부) 정하는 항목이 생겼습니다

블로그 작업으로 예를 들면 이렇게 됩니다. 포스트 A를 쓰는 세션과 포스트 B를 쓰는 세션을 나란히 띄워 두고, A 세션에서 @세션B 방금 정한 용어 표기를 너도 맞춰 줘라고 보내면 끝입니다. 사람이 두 터미널 사이에서 전서구 노릇을 할 필요가 없습니다. 지시는 각 세션에 직접 하되, 세션끼리 맞출 것은 세션끼리 맞추게 하는 구조입니다.

Opus 4.8 리뷰에서 "한 사람에게 맡기던 일을 한 팀에게 맡긴다"고 썼는데, 이번 릴리스는 그 팀에게 사내 메신저를 지급한 셈입니다. Dynamic Workflows가 한 세션 안의 수직 분업이라면, @멘션은 세션 사이의 수평 분업입니다.

2.1.233: worktree의 GitLab MR, cgroup 메모리 제한​

다음 날 나온 2.1.233에서 운영 관점의 항목 둘이 눈에 띕니다.

GitLab 지원 확대. --worktree 플래그와 claude agents 뷰가 GitLab merge request URL을 인식합니다(MR은 !N으로 표시). 2.1.232에서 이미 GitLab 토큰 계열(glpat- 등) 시크릿 마스킹과 GitLab 플러그인 마켓플레이스 지원이 들어왔으니, 이틀 사이에 GitLab 사용자 대접이 눈에 띄게 좋아졌습니다. GitHub 전용이라 망설이던 조직에는 신호가 될 만합니다.

Bash 도구 메모리 제한. Linux에서 CLAUDE_CODE_TOOL_MEMORY_LIMIT 환경변수로 Bash 명령에 cgroup 메모리 제한을 걸 수 있습니다(opt-in). 에이전트가 실행한 빌드가 폭주해 메모리를 삼키면 세션까지 같이 죽던 문제의 대응입니다. CI 러너나 공용 개발 서버에서 Claude Code를 돌리는 환경이라면 켜 둘 가치가 있습니다.

하나 더, 방향을 보여주는 항목이 있습니다. Opus 4.8과 그 이후 모델에서 todo/task 추적 도구(TodoWrite, TaskCreate 등)가 기본 비활성화됐습니다. 되살리려면 CLAUDE_CODE_ENABLE_TODO_TOOLS=1을 설정해야 합니다. 신형 모델은 할 일 목록을 도구로 관리시키지 않아도 작업을 놓치지 않는다는 판단이 깔린 변경입니다. 화면에서 todo 목록이 사라졌다면 버그가 아니라 이 변경입니다.

써 보고 느끼는 것​

이 글을 쓰는 세션도 뉴스 수집을 포크 에이전트 여섯에 나눠 맡기고, 결과를 메시지로 돌려받는 흐름으로 작업했습니다. 수집 에이전트들이 배경에서 도는 동안 메인 세션은 기존 포스트 목록을 정리했고, 끝난 에이전트부터 결과가 도착했습니다. 복붙도, 파일 경유도 없었습니다.

체감 요령을 남기면, 포크에는 "지금까지의 문맥이 필요한 일"을, 일반 서브에이전트에는 "문맥이 오히려 편견이 되는 일"(백지 검증, 독립 리뷰)을 맡기는 구분이 유효했습니다. 포크가 기본이 됐다고 전부 포크로 보낼 일은 아니에요. 문맥 상속은 힘이면서 동시에 선입견이니까요.

참고 자료​

pg_walviz WAL 시각화

· 약 3분

WAL을 공부할 때 제일 답답한 건 실체가 안 보인다는 점이었어요. record가 페이지 경계에서 쪼개진다, 세그먼트 첫 페이지에는 long header가 붙는다, 이런 문장을 문서로는 읽는데 실제 바이트가 어떻게 놓이는지는 상상에 맡겨야 했거든요.

Bertrand Drouvot(베르트랑 드루보)가 8월 13일 공개한 pg_walviz가 정확히 그 지점을 채웁니다. WAL 세그먼트 파일 하나를 읽어 브라우저에서 시각화하는 읽기 전용 도구입니다. 현재 버전은 v0.1.0-beta.1이고 저장소는 GitHub에 있습니다.

무엇을 보여주나​

화면은 서로 동기화되는 네 개의 뷰로 구성됩니다.

뷰내용
세그먼트 개요히트맵. resource manager별로 record가 세그먼트 어디에 몰려 있는지
record 조각 목록선택한 페이지에 걸친 record들, 페이지 경계에서 쪼개진 조각 포함
record 검사기선택한 record의 헤더, 물리 배치, full-page image 정보
물리 바이트색으로 구분된 원본 바이트열

히트맵에서 눈에 띄는 영역을 클릭하면 그 페이지의 record 목록이 뜨고, record를 고르면 헤더 필드와 실제 바이트가 같이 하이라이트됩니다. 페이지 번호, record 번호, 파일 오프셋, LSN을 직접 입력해서 이동할 수도 있습니다.

pg_waldump와 겹치는 도구가 아니냐는 생각이 들 수 있는데, 역할이 다릅니다. pg_waldump는 record의 논리적 내용을 사람이 읽을 텍스트로 풀어 줍니다. 어떤 rmgr가 어떤 연산을 기록했는지 보기에 좋습니다. pg_walviz는 record가 세그먼트 안에 물리적으로 어떻게 저장되는지를 보여줍니다. 페이지 헤더, record 조각, continuation record, 정렬 padding, 블록 참조가 바이트 위에 그대로 표시됩니다. 실제로 pg_walviz는 내부적으로 pg_waldump를 사용하므로 두 도구는 상하 관계에 가깝습니다.

WAL 레코드가 페이지 경계에서 나뉘는 물리 배치

실행 방법​

PostgreSQL 서버도, 데이터 디렉터리도 필요 없습니다. 세그먼트 파일 하나와 그 파일을 만든 서버 버전에 맞는 pg_waldump 바이너리만 있으면 됩니다.

~/pg_walviz/bin/pg_walviz \
--pg-waldump /usr/pgsql-18/bin/pg_waldump \
/archive/000000010000000000000042

실행하면 로컬 웹서버가 뜨고 브라우저가 열립니다. 원격 서버에서 실행할 때는 브라우저 자동 실행을 끄고 포트를 지정합니다.

~/pg_walviz/bin/pg_walviz --no-open --port 8765 \
--pg-waldump /usr/pgsql-18/bin/pg_waldump \
/archive/000000010000000000000042

주의사항이 둘 있습니다. 현재 쓰기가 진행 중인 세그먼트는 열지 말라는 것, 그리고 WAL에는 실데이터가 들어 있으므로 로컬 밖으로 노출하지 말라는 것입니다. 특히 두 번째는 archive에서 세그먼트를 복사해 분석용 장비에서 여는 습관과 묶어 기억해 둘 만합니다. WAL은 INSERT된 행의 내용을 그대로 담고 있어서, 세그먼트 파일 하나가 곧 데이터 유출 경로가 됩니다.

어디에 쓸 만한가​

첫째는 학습입니다. WAL record 헤더의 xl_prev가 무엇인지, full-page image가 왜 checkpoint 직후에 몰리는지 같은 주제는 글로 읽는 것보다 히트맵에서 직접 확인하는 쪽이 빠릅니다. checkpoint 직후 세그먼트를 열어 보면 FPI가 차지하는 공간이 시각적으로 드러나고, full_page_writes가 WAL 볼륨에 미치는 영향이 감으로 잡힙니다.

둘째는 장애 분석의 보조 도구입니다. 예전에 standby가 record with incorrect prev-link를 반복하며 멈춘 사건을 분석할 때는 pg_waldump 출력과 오프셋 계산을 손으로 맞춰 가며 recycled 세그먼트의 잔재를 추론했습니다. 그때 이 도구가 있었다면 문제 지점의 바이트를 바로 눈으로 확인했을 겁니다. 세그먼트 경계의 long page header(40바이트)와 첫 record의 위치 같은 것들이 화면에 그대로 보이니까요.

PostgreSQL 내부를 3D 도시로 만든 PGSimCity가 buffer와 프로세스를 보여주는 조감도였다면, pg_walviz는 WAL이라는 한 지점을 현미경으로 파는 도구입니다. 아직 beta라 큰 세그먼트에서 로딩이 느리고 단일 파일 검사가 권장되는 수준이지만, 방향이 좋습니다.

archive에 쌓여 있는 세그먼트 하나 골라서 열어 보세요. 문서 열 페이지보다 히트맵 한 화면이 WAL 구조를 빨리 가르쳐 줄 거예요.

참고 자료​

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 권한으로 조작된 서버)이 복원하는 쪽 클라이언트에서 임의 코드를 실행할 수 있습니다. 외부에서 받은 덤프를 복원할 일이 있는 조직이라면 클라이언트 패키지 업데이트를 서버만큼 서둘러야 합니다.

일반 서버 취약점과 psql 취약점의 공격 경로 비교

낮은 점수 중에도 운영에 직접 닿는 항목이 있습니다. 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만 재생하며 접속을 거부합니다. 용량을 더하려고 만든 노드가 용량이 되어 주지 않는 역설입니다.

Primary의 subtransaction overflow가 새 standby의 recovery snapshot 준비를 막는 흐름

지금 확인할 것들​

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

먼저 지금 이 순간 subtransaction을 쌓고 있는 backend가 있는지 확인합니다. PostgreSQL 16부터 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 블록은 공짜가 아니고, 비용 청구서는 옆 세션으로 날아갑니다. 저는 이번 주에 저희 클러스터에도 위 진단 쿼리 두 개를 모니터링에 추가해 두려고 합니다.

참고 자료​

Cloudflare Agents Week

· 약 4분

Cloudflare는 매년 몇 차례 "week"라는 이름으로 발표를 몰아서 내놓는데, 8월 3일부터 7일까지는 처음으로 에이전트가 주인공이었어요. 주간 회고 글 기준으로 발표를 훑으면, 개별 제품보다 그것들이 가리키는 방향이 더 흥미롭습니다.

Cloudflare OS: 에이전트를 사내 시스템에 붙이는 안전한 방법​

가장 큰 화제는 Cloudflare OS였습니다. 이름과 달리 커널이 있는 운영체제가 아니라, 사내 시스템에 에이전트를 연결하는 워크스페이스 플랫폼입니다. Apache 2.0 라이선스로 GitHub에 공개됐습니다.

구조의 핵심은 권한 모델입니다. 에이전트는 권한 0에서 출발합니다. 사내 시스템(위키, 티켓, DB, 배포 도구) 각각의 앞에 Gatekeeper Worker라는 관문 코드를 두고, 에이전트는 그 관문이 허용하는 범위의 작업만 요청할 수 있습니다. "에이전트에게 사내 API 키를 통째로 주면 어디까지 뒤질지 모른다"는 공포를, 시스템별로 좁게 정의된 통로로 바꾸는 설계입니다.

인프라 회사가 사내 업무 계층까지 내려와 그걸 오픈소스로 풀었다는 점, 그리고 하필 "OS"라는 이름을 붙였다는 점 때문에 Hacker News에서 논쟁이 붙었습니다. 이름이야 어떻든, 에이전트 권한 문제를 프록시 계층으로 푸는 패턴 자체는 참고할 가치가 있습니다. 뒤에 나올 이야기지만, 이 패턴은 사내 DB에 에이전트를 붙일 때 그대로 필요해지는 물건입니다.

Wallets: 에이전트에게 용돈을 준다​

Cloudflare Wallets는 문제 정의가 직관적이라 Fortune 같은 일반 매체까지 다뤘습니다. 에이전트는 은행 계좌를 못 만든다는 것입니다. 에이전트가 유료 API를 호출하고 리소스를 사려면 결국 사람의 카드가 어딘가에 물려 있어야 하는데, 그 카드에는 한도도 범위도 걸 수 없습니다.

Wallets의 구조는 위임입니다. 사람이 보유한 Account Wallet에 스테이블코인을 담아 두고, 에이전트마다 Virtual Wallet을 만들어 지출 한도, 허용 목록, 건당 최대 금액을 걸어 위임합니다. 결제 프로토콜로는 x402를 지원합니다. 8월 4일에는 cloudflare.pay 핸들 예약이 열렸고, 실제 지갑 인프라는 몇 달에 걸쳐 나온다고 합니다.

에이전트 지출 통제를 IAM처럼 다루는 첫 대형 시도라는 점에서, 실물이 나오면 다시 볼 가치가 있습니다.

나머지 발표들: 프로토콜 계층​

한 주의 나머지를 채운 것은 프로토콜과 도구입니다.

  • WebMCP 프리뷰: 웹사이트가 자신을 MCP 서버로 노출하는 방식의 프리뷰. 에이전트가 화면을 스크래핑하는 대신 구조화된 인터페이스로 사이트와 대화합니다
  • MCPv2: MCP 프로토콜의 다음 버전 지원
  • Agent Access Model: 에이전트의 리소스 접근을 사람 사용자와 구분해 다루는 모델
  • Kitesurf: 에이전트 우선(agent-first) 브라우저

그리고 주가 끝난 뒤에도 같은 결의 발표가 이어졌습니다. 8월 14일에는 MCP 트래픽을 식별하고 보호하는 기능이 나왔습니다. 에이전트가 만드는 트래픽을 봇도 사람도 아닌 제3의 유형으로 인정하고 전용 보안 장치를 붙이기 시작한 것입니다.

관통하는 그림​

발표를 나열하면 잡다해 보이지만, 겹쳐 놓으면 한 문장이 됩니다. 에이전트를 인터넷의 1급 시민으로 만들기 위한 인프라 계층을 선점하겠다는 것입니다.

사람 사용자에게는 이미 계정(신원), 결제 수단, 브라우저, 접근 제어가 있습니다. Cloudflare는 그 네 가지의 에이전트 버전을 한 주에 걸쳐 내놨습니다. Agent Access Model이 신원, Wallets가 결제, Kitesurf가 브라우저, Cloudflare OS와 Gatekeeper가 접근 제어입니다. CDN 회사의 신사업 나열이 아니라 계층 하나를 통째로 짜는 그림입니다.

사람용 인터넷 인프라에 대응하는 Cloudflare의 에이전트 계층

DBA 관점에서 눈여겨볼 지점을 하나 꼽자면 Gatekeeper 패턴입니다. 에이전트에게 DB 접근을 열어 달라는 요청은 이미 현실에서 오고 있습니다(AlloyDB의 MCP 서버 때도 같은 이야기를 했습니다). 그때 계정과 권한을 직접 주는 대신, 좁게 정의된 관문 계층을 사이에 두는 설계가 표준이 될 가능성이 큽니다. 미리 그려 두면 요청이 왔을 때 당황하지 않을 수 있어요.

참고 자료​

DuckDB MySQL 500GB 실험

· 약 4분

MySQL에서 분석 쿼리를 억지로 버텨 본 경험이 있다면, 이 숫자들이 남 일 같지 않을 거예요. InnoDB로 28시간 넘게 돌리고도 다 못 끝낸 TPC-H 22개 쿼리를, 같은 MySQL 프로세스 안의 DuckDB 엔진이 185.6초에 끝냈습니다.

Percona가 8월 7일 공개한 실험입니다. DuckDB를 MySQL의 스토리지 엔진으로 붙인 실험 프로젝트를 TPC-H 스케일팩터 500, 그러니까 원본 CSV 500GB(약 30억 행) 규모에서 검증했습니다. MySQL은 스토리지 엔진을 갈아 끼울 수 있는 구조라는 걸 다들 알지만, 그 자리에 컬럼 지향 분석 엔진을 꽂는 발상을 실제 수치로 검증한 것은 드문 일입니다.

숫자부터​

테스트 환경은 80코어, 187.5GB RAM 서버 한 대입니다. 같은 데이터를 InnoDB, MySQL+DuckDB 엔진, 순수 DuckDB 세 가지로 적재하고 비교했습니다.

항목InnoDBMySQL+DuckDB 엔진순수 DuckDB
적재 시간15시간 21분36분 5초(동일 계열)
디스크 사용673.2 GB132.4 GB132.4 GB
TPC-H 22개 쿼리28시간+ (4개 미완주)185.6초152.7초

쿼리별로 보면 격차가 더 생생합니다. Q1은 InnoDB 11,864초가 11.1초로, Q6는 3,539초가 1.3초로 줄었습니다. InnoDB는 쿼리당 2시간 제한을 두었는데도 4개를 완주하지 못했습니다.

디스크 방향도 반대입니다. 원본 CSV 500GB가 InnoDB에서는 673GB로 늘어났고, DuckDB에서는 132GB로 압축됐습니다. 행 지향 저장과 인덱스 오버헤드 대 컬럼 저장과 압축의 차이가 그대로 드러납니다.

정확성 검증도 곁들여져 있습니다. 22개 쿼리 중 21개가 순수 DuckDB 결과와 소수점 4자리까지 일치했습니다.

어떻게 동작하나​

핵심은 MySQL의 스토리지 엔진 인터페이스입니다. 애플리케이션은 여전히 MySQL 프로토콜로 접속해 SQL을 실행하고, 해당 테이블의 저장과 스캔을 DuckDB가 담당합니다. 적재가 25.5배 빨랐던 비결도 여기 있는데, 엔진이 LOAD DATA를 행 단위 삽입으로 처리하지 않고 DuckDB의 COPY로 직접 전달해 배치 적재로 바꿉니다.

시도해 보는 것도 쉽게 만들어 놨습니다. Docker 이미지 한 줄로 실행됩니다.

docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secret \
perconalab/ducksdb-mysql-engine:latest

냉정하게 볼 부분​

Percona 스스로 못박은 제약들이 있습니다.

첫째, 이것은 실험 소프트웨어입니다. 운영 투입 대상이 아니라고 글에서 반복해 강조합니다. 둘째, 분석 전용입니다. 포인트 조회 같은 OLTP 패턴은 여전히 행 경로가 낫고, 이 엔진의 목적이 아닙니다. 셋째, 메모리 관리가 아직 거칩니다. 메모리 제한을 넘는 쿼리는 디스크 스필 설정을 조정해야 합니다. 넷째, 서버 한 대의 특정 워크로드만 검증한 결과입니다.

그리고 벤치마크 자체의 성격도 감안해야 합니다. TPC-H는 컬럼 스토어에 유리한 순수 분석 워크로드입니다. InnoDB가 28시간 걸렸다는 것은 InnoDB가 나쁘다는 뜻이 아니라, 애초에 이 일을 시키면 안 되는 엔진에 이 일을 시켰다는 뜻에 가깝습니다. 문제는 현실의 많은 조직이 정확히 그렇게 쓰고 있다는 점이고, 이 실험의 가치는 그 간극을 숫자로 보여준 데 있습니다.

이어지는 실험: 복제로 OLTP와 분석 분리​

Percona는 이 실험을 한 단계 더 밀고 나가는 중입니다. 8월 13일 후속 글 Replicating from InnoDB into a DuckDB storage engine에서는 primary의 InnoDB 테이블을 replica의 DuckDB 엔진 테이블로 복제하는 구성을 다뤘습니다. 쓰기는 InnoDB가 받고, 분석은 DuckDB replica가 받는 그림입니다.

InnoDB primary의 쓰기와 DuckDB replica의 분석을 MySQL 복제로 분리하는 구조

이 방향이 흥미로운 이유는 기존 선택지와의 비교 때문입니다. MySQL의 분석 부하를 분리하는 전통적인 답은 별도 웨어하우스(ClickHouse, BigQuery 등)로의 CDC 파이프라인인데, 그 순간 스키마 동기화, 지연, 운영 부담이 따라옵니다. replica 한 대의 스토리지 엔진만 바꿔서 같은 효과를 얻는다면, MySQL 프로토콜과 권한 체계 안에서 문제가 끝납니다. PostgreSQL 진영에서 pg_duckdb가 겨냥하는 자리와 정확히 같은 자리입니다.

아직 붙일 이름은 실험이지만, 방향은 뚜렷해 보여요. 분석 엔진을 밖에 두고 데이터를 나르는 대신, 익숙한 DB 안으로 분석 엔진을 들여오는 흐름입니다. Docker 이미지가 공개되어 있으니 가벼운 데이터로 직접 실행해 보고, 결과가 재미있으면 후속으로 다루겠습니다.

참고 자료​

Claude Code 자체 호스팅

· 약 3분

코딩 에이전트 도입 논의가 보안 검토에서 멈추는 조직, 많죠. 코드는 사내망 밖으로 못 나간다는 원칙과, 에이전트 세션은 벤더 클라우드에서 돈다는 현실이 부딪히는 지점이에요.

Anthropic이 8월 6일 퍼블릭 베타로 공개한 self-hosted environments는 정확히 그 지점을 겨냥합니다. 웹, 모바일, 데스크톱에서 시작하는 Claude Code 클라우드 세션의 실행 환경을 조직이 제공한 머신으로 바꿉니다. 저장소 체크아웃, 빌드 산출물, 시크릿, 세션이 만들거나 수정하는 모든 파일이 조직 인프라 안에 남습니다.

구조: runner라는 상주 프로세스​

동작 단위는 runner입니다. claude self-hosted-runner로 띄우는 장수(long-lived) 프로세스로, 세션 하나가 시작되면 runner 하나가 그 세션의 Claude Code 실행을 맡습니다. 운영 모드는 두 가지입니다.

모드동작
Fixed정해진 수의 runner를 상시 유지, 세션을 분배
On-demandorchestrator가 대기 중인 세션 수에 맞춰 runner 수명 관리

CI 러너를 운영해 본 조직이라면 구조가 낯설지 않을 겁니다. GitHub Actions의 self-hosted runner와 개념이 같고, 이름도 같습니다. 사내 Kubernetes에 runner 풀을 두고 세션을 받는 그림이 자연스럽게 그려집니다.

Fixed와 On-demand runner 운영 방식 비교

기존 Remote Control과의 차이도 여기 있습니다. Remote Control은 특정 개인의 머신에 세션을 묶는 기능입니다. self-hosted environments는 조직 공용 인프라에 세션을 올리고, 권한 있는 누구든 쓸 수 있습니다. 개인의 원격 제어와 조직의 실행 기반이라는 층위 차이입니다.

무엇이 남고, 무엇이 나가나​

이 기능을 검토할 보안 담당자가 볼 핵심은 경계선입니다.

사내에 남는 것: 저장소 체크아웃, 빌드 산출물, 시크릿, 세션이 만들고 수정하는 파일 전부. 코드가 Anthropic의 실행 환경에 복제되지 않습니다.

여전히 Anthropic으로 가는 것: 대화 데이터입니다. 프롬프트, 응답, 도구 실행 결과는 모델 추론을 위해 전송되고, 세션 transcript는 여러 기기에서 이어 쓰기 위해 저장됩니다. 도구 실행 결과에는 코드 조각이 포함될 수 있으니, "코드가 한 줄도 안 나간다"는 이해는 정확하지 않습니다. 나가는 것은 모델이 보는 문맥이고, 남는 것은 파일시스템과 실행 환경입니다.

조건도 명확합니다. Team과 Enterprise 플랜 전용이고, ZDR(zero data retention) 계약 조직은 현재 쓸 수 없습니다. transcript 저장이 기능의 전제라서 생기는 제약으로 보입니다. 그리고 공식 문서가 셋업과 유지보수에 전담 엔지니어링 인력이 필요하다고 미리 말해 둡니다. 켜면 되는 토글이 아니라 운영해야 하는 인프라입니다.

어떤 조직에 의미가 있나​

이 기능이 풀어 주는 매듭은 두 종류입니다.

첫째, 컴플라이언스 매듭입니다. 소스코드의 물리적 위치를 통제해야 하는 조직(금융, 공공, 계약상 제약이 있는 곳)은 지금까지 클라우드 세션 자체가 선택지 밖이었습니다. 실행 환경이 사내로 들어오면 검토의 성격이 "코드를 외부에 두어도 되는가"에서 "대화 데이터 전송을 허용하는가"로 좁혀집니다. 후자는 이미 API 사용 승인과 같은 범주라 통과 경로가 있는 조직이 많습니다.

둘째, 환경 매듭입니다. 빌드에 사내 아티팩트 저장소, 내부 DNS, VPN 안쪽 서비스가 필요한 프로젝트는 벤더가 제공하는 격리 환경에서 애초에 빌드가 안 됩니다. runner가 사내망에 있으면 이 문제가 사라집니다.

베타답게 다듬는 중인 흔적도 보입니다. 이 글을 게시한 뒤 나온 2.1.233(2026-08-14)에서 runner의 세션 시작 시간을 줄이는 개선(working tree 재작성 없는 브랜치 생성, 서버 왕복 2회 제거)이 들어왔습니다.

개인 사용자에게는 당장 해당 없는 기능이지만, 방향은 기억해 둘 만해요. 에이전트가 개인 도구에서 조직 인프라로 올라가는 단계마다 이런 부품이 하나씩 채워지고 있고, self-hosted runner는 그중 꽤 큰 조각입니다.

참고 자료​

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 쪽 흐름을 계속 지켜보고 있는데, 이 사건은 그 생태계의 성숙을 보여주는 좋은 지표로 기억해 둘 만해요.

참고 자료​

pgrust 쿼리 엔진

· 약 4분

PostgreSQL로 분석 쿼리를 돌려 본 사람은 다들 한 번쯤 궁금해했을 거예요. 같은 데이터를 두고 왜 컬럼 스토어 엔진들과 이렇게까지 차이가 날까요. 인덱스나 통계의 문제가 아니라 더 아래층의 문제라는 감은 오는데, 그 아래층이 정확히 뭘까요.

7월 초 regression test 전체 통과 소식으로 화제가 됐던 Rust 재작성 프로젝트 pgrust가 7월 30일 v0.2를 릴리스하면서 성능 주장을 들고 나왔고, 며칠 뒤 저자가 그 성능이 어디서 나왔는지 해설한 글을 올렸습니다. 해설 글이 Hacker News 339점을 받으며 이 기간 PostgreSQL 관련 글 중 가장 크게 회자됐는데, pgrust 홍보를 걷어내고 봐도 PostgreSQL executor가 어디서 시간을 쓰는지 보여주는 교재로 훌륭합니다.

Volcano 모델: 우아함의 청구서​

PostgreSQL의 executor는 Volcano 모델(iterator 모델)입니다. 실행 계획의 각 노드가 next()를 구현하고, 부모 노드가 자식에게 next()를 호출하면 행이 한 번에 하나씩 위로 올라옵니다.

이 모델의 장점은 우아함입니다. 어떤 연산자든 같은 인터페이스로 조립되고, 중간 결과를 통째로 메모리에 들 필요가 없습니다. OLTP처럼 행 몇 개를 만지는 쿼리에서는 완벽합니다.

문제는 5억 행을 집계할 때입니다. 행 하나마다 함수 호출 체인이 반복되니 호출 오버헤드가 5억 번 쌓이고, CPU 입장에서는 분기가 많고 예측이 어려운 코드가 됩니다. 파이프라이닝과 캐시가 일을 못 합니다. 저자의 측정에서 5억 행 합계 쿼리가 PostgreSQL에서 약 20초 걸렸는데, 이 시간 대부분이 데이터가 아니라 행을 나르는 절차에 쓰인 셈입니다.

행 단위 실행기와 배치 실행기의 데이터 경로 비교

세 단계 처방​

해설 글은 같은 쿼리를 세 단계로 빠르게 만듭니다. 측정 환경은 AWS c8g.4xlarge(Graviton4, 16 vCPU), PostgreSQL 18.4 대비, 병렬 실행은 꺼서 엔진 구조 차이만 비교했습니다.

단계실행 시간직전 대비
Volcano 방식 재구현1.3초기준
+ batching (1024행)480ms2.7배
+ operator fusion358ms1.3배
+ SIMD135ms2.7배

pgrust의 배치·연산자 결합·SIMD 적용에 따른 실행 시간 변화

Batching은 next()를 next_batch()로 바꿔 1024행씩 나르는 것입니다. 함수 호출 오버헤드가 1/1024로 줄고, 루프가 단순해져 CPU가 파이프라이닝할 수 있는 코드가 됩니다. 이 한 가지만으로 2.7배입니다.

Operator fusion은 인접한 노드를 하나로 합치는 것입니다. sequential scan과 aggregation이 별도 노드면 batch를 만들어 넘기고 다시 읽는 복사가 생기는데, 합치면 스캔하면서 바로 집계합니다. 중간 버퍼가 사라집니다.

SIMD는 마지막 단계입니다. batch로 정렬된 데이터는 벡터 연산에 이상적인 모양이라, ARM64 벡터 명령으로 덧셈을 처리하면 일반 루프보다 약 2.7배 빨라집니다. batching이 먼저 있었기에 가능한 최적화입니다. 행 단위로 흩어진 데이터에는 SIMD를 적용할 자리가 없습니다.

결과적으로 20초가 135ms로, 약 150배입니다. 릴리스 노트는 여기에 columnar 저장 포맷까지 얹어 ClickBench 종합에서 ClickHouse를 앞선다고 주장합니다. 이 주장에는 유보를 달아 두는 게 좋겠습니다. 자체 벤치마크이고, 독립 검증이 쌓이려면 시간이 필요합니다. OLTP 쪽 30% 향상 주장도 마찬가지입니다.

PostgreSQL은 이걸 몰랐을까​

당연히 알고 있습니다. 이 글이 보여준 처방은 학계와 업계에서 오래된 레시피입니다. MonetDB/X100 계열 연구가 20년 전에 벡터화 실행을 정립했고, DuckDB와 ClickHouse가 그 위에 서 있습니다.

PostgreSQL 본가에도 부분 부분 들어와 있습니다. JIT 컴파일이 expression 평가를 통째로 컴파일해 호출 오버헤드를 줄이고, executor 내부에 부분적인 batch 처리가 도입되는 흐름도 있습니다. 다만 35년 된 C 코드베이스에서 executor의 기본 단위를 행에서 batch로 바꾸는 것은, 백지에서 Rust로 다시 짜는 것과는 전혀 다른 난이도입니다. pgrust가 유리한 것은 Rust여서라기보다 백지여서입니다.

그래서 이 프로젝트를 보는 제 관점은 "PostgreSQL의 대체재가 나타났다"가 아닙니다. 재미있는 것은 두 가지입니다. 하나, PostgreSQL 호환(문법, 프로토콜, regression test)을 유지하면서 executor만 현대식으로 갈아 끼우면 어디까지 가는지 보여주는 실험이라는 점. 둘, MVCC 엔진별 비용 비교에서 다뤘던 것과 같은 질문, 즉 PostgreSQL의 설계 결정들이 어떤 트레이드오프였는지를 실물로 보여준다는 점입니다. Volcano 모델은 게으름이 아니라 1980년대의 합리적 선택이었고, 그 청구서가 분석 워크로드에서 날아오고 있을 뿐입니다.

실무 관점의 결론은 소박하게 남겨 둘게요. 오늘 PostgreSQL에서 느린 집계를 빠르게 하는 검증된 길은 여전히 병렬 쿼리, JIT, 사전 집계, 그리고 필요하면 분석 전용 엔진의 병행입니다. pgrust는 북마크해 두고, regression test 통과라는 출발점이 성능 주장 이후에도 유지되는지 지켜보겠습니다.

참고 자료​

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는 마지막 몇 분치만 감당하니 부하가 거의 없습니다.

S3 복원과 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에 같은 체급의 인프라를 짓는 과정이 실시간으로 공개되고 있으니, 당분간 이 블로그의 단골 출처가 될 것 같아요.

참고 자료​