본문으로 건너뛰기

PgBouncer와 Patroni 시간대

· 약 5분

로그의 타임스탬프 절반은 맞고 절반은 UTC라는 신고가 들어오면 어디부터 볼까요. Percona Community에 올라온 사례의 답이 흥미로웠습니다. 원인이 PostgreSQL 안이 아니었습니다.

배경은 Oracle에서 Patroni로 관리하는 PostgreSQL 18 클러스터로 마이그레이션한 직후입니다. 개발자들이 "타임스탬프가 어떤 건 맞고 어떤 건 UTC로 나온다"고 알렸습니다.

계층이 넷이었다

문제를 이해하려면 timezone 값이 어디에 저장되는지 짚어야 합니다. 이 환경에서는 네 곳이었습니다.

계층처음문제 상황최종
VM (OS)UTCUTCUTC
PostgreSQLUTC (기본값)Africa/LagosAfrica/Lagos
Patroni 설정없음재시작 후 UTCAfrica/Lagos
PgBouncerUTC (캐시)UTC (낡은 캐시)갱신됨

세 번째 행과 네 번째 행이 사건의 두 단계입니다.

1단계: ALTER SYSTEM이 살아남지 못한다

처음 시도한 방법이 이것이었습니다.

ALTER SYSTEM SET TIMEZONE = 'Africa/Lagos';
SELECT pg_reload_conf();

이 명령이 어디에 쓰이는지 컨테이너로 확인했습니다.

$ psql -Atc "show timezone"
Etc/UTC

$ psql -c "alter system set timezone = 'Asia/Seoul'"
$ psql -Atc "select pg_reload_conf()"
t

$ cat $PGDATA/postgresql.auto.conf
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
timezone = 'Asia/Seoul'

새 세션에서는 바로 반영됩니다.

$ psql -Atc "show timezone; select now();"
Asia/Seoul
2026-08-24 19:02:52.265584+09

여기까지는 정상입니다. 문제는 값이 postgresql.auto.conf에 있다는 것입니다. Patroni는 클러스터 설정을 분산 설정 저장소에서 관리하고, 노드를 재시작할 때 자기가 가진 설정으로 구성 파일을 다시 씁니다. ALTER SYSTEM이 남긴 값은 그 과정에서 사라집니다. Patroni 노드가 재시작되자 timezone이 UTC로 되돌아갔습니다.

Patroni 환경에서 올바른 방법은 Patroni 쪽에 넣는 것입니다.

patronictl edit-config

여기서 설정한 값은 노드 재시작에도 유지됩니다. Patroni를 쓰는 클러스터에서 ALTER SYSTEM을 쓰지 않는다는 원칙이 이 사례의 첫 교훈입니다.

2단계: PgBouncer가 기억하고 있었다

Patroni 설정을 Africa/Lagos로 바로잡은 뒤에도 일부 세션이 UTC로 나왔습니다. 여기가 진짜 원인입니다.

PgBouncer는 세션 파라미터를 캐시합니다. 클라이언트가 연결할 때 넘기는 startup parameter를 기억해 두고, 풀에서 서버 연결을 꺼내 줄 때 그 값을 맞춰 줍니다. 그런데 Patroni 설정 변경으로 데이터베이스의 timezone이 바뀐 경우에는 이 내부 캐시를 제대로 무효화하지 않습니다. ALTER DATABASE로 바꿨을 때와 경로가 다릅니다.

그래서 낡은 UTC 기준으로 만들어진 세션들이 풀에서 계속 재활용됐습니다. 새로 만들어진 연결은 Africa/Lagos, 재활용된 연결은 UTC입니다. 로그에 두 값이 섞인 이유입니다.

startup parameter가 서버 설정을 이긴다

캐시가 왜 이렇게 강한지 궁금해서 직접 확인했습니다. 서버 설정은 Asia/Seoul인 상태에서, 클라이언트가 연결 시점에 timezone을 지정해 봤습니다.

$ psql -Atc "show timezone"
Asia/Seoul

$ PGOPTIONS="-c timezone=UTC" psql -Atc "show timezone"
UTC

같은 서버, 같은 순간인데 값이 다릅니다. 클라이언트가 연결할 때 넘긴 값이 서버의 설정을 덮어씁니다. 이게 정상 동작입니다. 세션 단위 설정이 서버 기본값보다 우선하니까요.

PgBouncer의 캐시가 문제가 되는 지점이 여기입니다. 서버 설정을 고쳐도 pooler가 예전 값을 세션에 계속 넣어 주면, 서버 쪽 변경은 그 세션에 닿지 않습니다. show timezone으로 확인해도 클라이언트가 보는 값은 pooler가 정한 값입니다.

어떻게 고쳤나

PgBouncer 파드를 재시작해 캐시를 비웠습니다. 이후 새 연결은 모두 Africa/Lagos를 반영했습니다.

원문이 정리한 교훈 셋입니다. Patroni에서 timezone을 바꾼 뒤에는 PgBouncer를 재시작하거나 재연결시킵니다. ALTER SYSTEM SET TIMEZONE이 아니라 patronictl edit-config를 씁니다. 그리고 Kubernetes에서는 PgBouncer를 독립 파드가 아니라 sidecar로 배치합니다.

세 번째 항목에 이 사례의 구조적 원인이 들어 있습니다. PgBouncer가 독립 파드로 떠 있었기 때문에 Patroni 노드 재시작과 PgBouncer 재시작이 서로 무관했습니다. sidecar였다면 파드 재시작이 둘을 함께 갈아 줬을 것입니다. 연결 풀이 DB 인스턴스보다 오래 사는 배치에서는 이런 종류의 불일치가 반복됩니다.

진단할 때 볼 지점

같은 증상을 만나면 확인 순서를 이렇게 잡을 만합니다.

-- 지금 이 세션이 보는 값과 그 출처
select name, setting, source, sourcefile
from pg_settings where name = 'TimeZone';

-- 접속 중인 세션별로 실제 적용된 값
select pid, application_name, backend_start
from pg_stat_activity where backend_type = 'client backend';

pg_settingssource 컬럼이 핵심입니다. 값이 configuration file이면 서버 설정에서, client면 클라이언트가 startup parameter로 넘긴 값입니다. client로 나오면서 값이 기대와 다르면 pooler를 봐야 합니다.

pooler 쪽에서는 SHOW SERVERSSHOW POOLS로 서버 연결이 언제 만들어졌는지 확인합니다. 설정을 바꾼 시각보다 오래된 연결이 남아 있으면 그게 문제의 세션입니다. RECONNECT 명령이나 파드 재시작으로 정리합니다.

남는 생각

이 사례가 좋은 이유는 원인이 PostgreSQL 밖에 있었다는 점입니다. show timezone을 아무리 확인해도, 그 값을 정한 주체가 pooler라면 서버 설정을 보는 것으로는 안 풀립니다.

Oracle에서 넘어온 직후라는 배경도 한몫했다고 봅니다. Oracle에는 연결 풀을 이런 식으로 앞에 두는 관례가 덜하고, 세션 파라미터가 계층별로 덮어써지는 구조에 익숙하지 않으면 의심 대상에 pooler가 안 들어옵니다. 저도 이 글을 읽기 전까지 PgBouncer가 startup parameter를 그렇게 오래 붙들고 있는 줄은 몰랐어요.

참고

Aurora pgvector 이진 양자화

· 약 5분

벡터 검색에서 제일 자주 부딪히는 벽이 "index가 메모리에 안 들어간다"입니다. AWS가 그 벽을 이진 양자화로 넘은 사례를 냈습니다. 1억 벡터 index가 367GB에서 약 38GB가 됐습니다.

부호만 남긴다

이진 양자화의 방식은 이름 그대로입니다. 각 차원의 float32 값을 0을 기준으로 잘라 1비트로 만듭니다. 양수면 1, 음수면 0입니다.

768차원 벡터라면 원래 768 × 4바이트 = 3,072바이트입니다. 양자화하면 768비트, 즉 96바이트입니다. 32분의 1입니다.

정보를 크게 버리는 방식인데 벡터 검색에서 통하는 이유가 있습니다. 고차원 임베딩에서는 각 차원의 정확한 크기보다 부호 패턴이 방향을 상당히 결정합니다. 768개 차원의 부호가 얼마나 겹치는지만 봐도 두 벡터가 비슷한지 대략 알 수 있습니다. 이 비교가 Hamming 거리입니다.

직접 재 봤다

pgvector 0.8.6이 들어간 PostgreSQL 18 컨테이너로 확인했습니다. 768차원 벡터 3만 개를 넣고 두 종류 index를 만들었습니다.

create table docs (id bigserial primary key, embedding vector(768));
insert into docs (embedding)
select (select array_agg(random()-0.5) from generate_series(1,768))::vector
from generate_series(1,30000);

일반 HNSW와 이진 양자화 HNSW입니다.

create index idx_full on docs
using hnsw (embedding vector_cosine_ops)
with (m=16, ef_construction=64);

create index idx_bq on docs
using hnsw ((binary_quantize(embedding)::bit(768)) bit_hamming_ops)
with (m=16, ef_construction=64);

bit_hamming_ops가 bit 타입에 Hamming 거리를 적용하는 연산자 클래스입니다. 크기를 비교했습니다.

indexrelname | size
--------------+--------
docs_pkey | 672 kB
idx_bq | 10 MB
idx_full | 104 MB

10.4배 차이입니다. 벡터 자체는 32배 줄었는데 index는 10배 남짓입니다. 이 격차가 이 글에서 짚고 싶은 부분입니다.

32배와 10배 사이

차이의 이유는 HNSW index에 든 것이 벡터만이 아니라는 데 있습니다. HNSW는 계층 그래프입니다. 각 노드가 이웃으로 향하는 링크를 들고 있고, 그 개수를 m 파라미터가 정합니다. 위 실험에서는 m=16이었습니다.

링크는 양자화 대상이 아닙니다. 벡터를 96바이트로 줄여도 링크는 그대로입니다. 그래서 압축률이 벡터 크기 비율보다 낮게 나옵니다.

원문 수치로 확인해 봐도 같은 방향입니다. 367GB에서 38GB는 약 9.7배입니다. AWS가 "32배 압축"이라고 표현한 것은 벡터 데이터 자체의 비율이고, index 전체는 10배 안쪽입니다. 용량 계획을 세울 때 32배로 잡으면 어긋납니다.

원문이 준 벡터당 크기도 이 관점에서 읽힙니다. 1536차원에서 벡터당 약 680바이트, 768차원에서 약 400바이트입니다. 768비트는 96바이트니까 나머지 300바이트 정도가 그래프 구조입니다.

재순위화가 없으면 반쪽이다

이진 양자화만으로 검색을 끝내면 정확도가 떨어집니다. 부호만 봤으니 당연합니다. 그래서 두 단계로 나눕니다. 양자화된 index로 후보를 넉넉히 뽑고, 그 후보들만 원본 벡터로 다시 정렬합니다.

원문이 제시한 쿼리 모양입니다.

SELECT * FROM (
SELECT id, embedding <=> '[query_vector]'::vector AS exact_distance
FROM your_table
ORDER BY binary_quantize(embedding)::bit(768) <~>
binary_quantize('[query_vector]'::vector)::bit(768)
LIMIT 200
) candidates
ORDER BY exact_distance
LIMIT 10;

연산자 두 개가 각각 다른 일을 합니다. <~>가 bit 타입의 Hamming 거리로 후보 200개를 고릅니다. <=>가 원본 vector의 코사인 거리로 그 200개를 다시 정렬합니다. 최종 10개는 원본 정밀도로 판단된 결과입니다.

이 구조에서 후보 개수가 정확도와 지연의 조절 손잡이입니다. 200개를 뽑으면 100개보다 정확하고 느립니다.

수치는 어디까지 좋아졌나

원문이 낸 측정치 중 눈에 띄는 것들입니다.

OpenAI 임베딩 500만 개(1536차원)를 r8g.large에서 돌린 결과입니다. 이진 양자화 HNSW가 recall 0.951에 138 QPS, p99 지연 18.9ms였습니다. 원본 정밀도 HNSW는 78 QPS에 p99 1,356ms였습니다. 지연 차이가 70배 넘게 벌어집니다.

LAION 1억 개(768차원)를 r8g.4xlarge에서 cold cache로 돌린 쪽이 더 극적입니다. 이진 양자화가 recall 0.931에 13.5 QPS, 원본 정밀도가 3.4 QPS입니다. 원문은 원본 정밀도 index가 메모리에 들어가지 않았다고 적었습니다. 이 한 줄이 사실 이 기법의 존재 이유입니다. 압축 자체가 목적이 아니라, index를 buffer cache 안에 들여놓는 것이 목적입니다.

index 빌드 시간도 1.1시간 대 16.1시간으로 갈렸습니다.

빌드 설정은 이렇게 잡았습니다.

maintenance_work_mem = 96GB
max_parallel_maintenance_workers = 48

쿼리 쪽 설정입니다.

SET hnsw.ef_search = 800;
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.max_scan_tuples = 1400;

hnsw.iterative_scan이 여기서 역할이 있습니다. ef_search의 후보 한계인 1,000개를 넘겨 재순위화하려면 이 설정이 필요합니다.

요구 사항은 Aurora PostgreSQL 16.8 이상 또는 17.x이고 pgvector 0.8.0 이상입니다.

제 실측의 한계

위에서 크기는 재 봤지만 recall은 재지 않았습니다. 이유를 밝혀 두는 게 맞습니다.

제가 넣은 벡터는 random()-0.5로 만든 균등 난수입니다. 실제 임베딩과 분포가 다릅니다. 임베딩은 의미가 비슷한 것끼리 방향이 모이는 구조인데, 균등 난수는 모든 벡터가 서로 비슷하게 멀리 있습니다. 이런 데이터에서 recall을 재면 이진 양자화의 정보 손실이 실제와 다르게 나옵니다.

index 크기는 데이터 분포와 무관한 구조적 값이라 그대로 쓸 수 있고, 정확도는 실제 임베딩으로 재야 의미가 있습니다. 그 부분은 원문 수치를 인용하는 쪽이 정직합니다.

언제 쓸까

기준이 비교적 선명합니다. index가 메모리에 들어가느냐입니다.

들어가는 규모라면 이진 양자화의 이득이 작습니다. 원본 정밀도 HNSW가 이미 충분히 빠르고, 재순위화 단계가 붙지 않아 쿼리도 단순합니다.

들어가지 않는 규모라면 계산이 완전히 달라집니다. index를 디스크에서 읽는 순간 지연이 자리수 단위로 뛰기 때문에, recall을 0.95 근처로 유지하면서 index를 10분의 1로 줄이는 거래가 압도적으로 유리해집니다. 위 LAION 사례에서 QPS가 4배 차이 난 것이 그 지점입니다.

AlloyDB가 AI 에이전트에 문을 연 이야기를 다룰 때도 느낀 건데, 클라우드 3사가 벡터 쪽에서 경쟁하는 방향이 새 엔진이 아니라 PostgreSQL 위에 얹는 최적화로 모이고 있어요. 이번 것도 pgvector의 기존 기능을 조합한 결과입니다. binary_quantizebit_hamming_ops는 pgvector 0.8.0부터 있던 것이고, AWS가 한 일은 그것을 1억 벡터 규모에서 검증하고 설정값을 찾은 것입니다.

참고

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이 여전히 현역 국경 수비대라는 좋은 증거 아닐까요.

참고 자료

Claude Code 토큰 절약

· 약 4분

같은 작업을 시켰는데 어떤 날은 사용량이 순식간에 닳고 어떤 날은 널널했던 경험, 있으실 거예요. 저도 한도에 일찍 닿은 날마다 모델 탓을 했는데, Anthropic이 8월 14일에 낸 공식 가이드를 읽고 나니 범인은 따로 있었습니다. 프롬프트 캐시를 깨는 습관입니다.

요금의 실체는 캐시 히트율

Claude Code는 매 턴마다 지금까지의 대화 전체를 모델에 보냅니다. 대화가 길어질수록 매 요청의 입력이 커지는 구조인데, 이게 감당되는 이유가 프롬프트 캐시입니다. 직전 요청과 같은 prefix는 캐시에서 읽고, 캐시 읽기 요금은 정상 입력 가격의 0.1배입니다. 반대로 캐시에 새로 쓰는 비용은 최대 2배입니다.

즉 20배짜리 요금 격차가 대화 내내 작동합니다. 캐시가 살아 있으면 긴 대화도 턴당 비용이 낮게 유지되고, 캐시가 깨지면 대화 전체를 정상 가격으로 다시 보내며 다시 캐시 쓰기 할증까지 뭅니다. Claude Code의 캐시 유효 시간은 1시간입니다(API 직접 호출 기본은 5분).

캐시를 깨는 행동들

가이드가 지목하는 캐시 무효화 요인은 네 가지입니다.

행동왜 캐시가 깨지나
/model 변경모델마다 캐시가 따로 있다
/effort 조정추론 강도가 캐시 키의 일부다
fast mode 전환요청 키가 바뀐다
1시간 이상 방치캐시 만료

여기서 실무 결론이 나옵니다. 모델과 effort는 세션 시작 시점이나 /clear 직후에 정하고, 대화 중간에는 건드리지 않는 것입니다. 긴 대화 한가운데서 /model을 바꾸면 그 시점까지의 대화 전체가 새 모델 기준으로 재처리되고 재캐싱됩니다. "이 질문만 가볍게 다른 모델로" 하려던 절약이 실제로는 가장 비싼 행동이 되는 역설입니다.

한 시간 자리를 비울 때도 마찬가지입니다. 돌아와서 이어 쓰면 만료된 캐시를 처음부터 다시 씁니다. 가이드는 장시간 중단 전에 /compact를 실행하라고 권합니다. 캐시가 아직 유효할 때 요약해 두는 쪽이 싸기 때문입니다.

문맥 관리 명령 셋의 역할 분담

/clear, /compact, /rewind가 비슷해 보여도 캐시 관점에서 역할이 다릅니다.

  • /clear: 새 작업을 시작할 때. 이전 작업의 문맥은 다음 작업에서 전부 "돈 내고 실어 나르는 짐"이 되므로, 작업 사이에는 비우고 시작합니다
  • /compact: 문맥은 이어가야 하는데 대화가 너무 길어졌거나, 장시간 자리를 비우기 직전에
  • /rewind: 마지막 몇 턴만 무르고 싶을 때. 캐시를 무효화하지 않는 것이 장점입니다

저처럼 한 세션에서 이 일 저 일 이어 하던 사람에게는 /clear가 제일 아픈 항목입니다. "혹시 아까 맥락이 필요할지 몰라서" 남겨 둔 문맥이 매 턴 입력 토큰으로 청구되고 있었으니까요.

입력을 줄이는 잔기술

캐시 다음으로 효과가 큰 항목은 문맥에 들어오는 양 자체를 줄이는 것입니다.

파일은 @멘션으로 첨부합니다. src/main.py 읽어 봐라고 쓰면 모델이 Read 도구를 호출하는 왕복이 생기는데, @src/main.py로 멘션하면 파일이 첫 요청에 바로 첨부됩니다. 도구 호출 한 번과 그 결과 처리가 통째로 절약됩니다.

시끄러운 명령 출력은 서브에이전트로 격리합니다. 30,000자를 넘는 명령 출력은 자동으로 파일로 저장되고 미리보기만 문맥에 남습니다만, 그 미만의 장황한 출력(테스트 로그, 빌드 로그)은 고스란히 문맥을 차지합니다. 로그를 뒤져야 하는 작업은 독립 문맥에서 도는 서브에이전트에 맡기면 메인 세션이 가벼워집니다.

자주 쓰는 명령은 CLAUDE.md에 조용한 플래그와 함께 적어 둡니다. 예를 들어 테스트를 verbose로 돌리는 습관이 있다면, CLAUDE.md에 quiet 플래그가 붙은 명령을 기록해 모델이 처음부터 조용한 버전을 실행하게 하는 식입니다.

가이드가 제시한 우선순위는 이렇습니다. 불필요한 파일 읽기 방지, 긴 세션 분할, 모델/effort 고정, 명령 출력 최소화 순입니다.

이 가이드가 나온 맥락

읽다 보면 이 가이드는 절약 팁 모음이라기보다 과금 구조 설명서에 가깝습니다. 에이전트형 코딩 도구의 비용은 "몇 마디 나눴나"가 아니라 "문맥 몇 토큰을 몇 번 실어 날랐나"로 결정되는데, 사용자 대부분은 전자로 체감합니다. 그 간극에서 "왜 벌써 한도냐"는 불만이 나오고, 이 문서는 거기에 대한 공식 답변으로 보입니다.

도구 쪽 기본값도 같은 방향으로 움직이고 있습니다. 최근 릴리스에서 서브에이전트 포크가 캐시를 상속하게 된 것도, 포크할 때마다 문맥을 다시 캐싱하는 비용을 없애는 변경입니다. 캐시를 아끼는 방향으로 도구가 정렬되고 있으니, 사용자 습관만 따라가면 됩니다.

오늘부터 바꿀 것 하나만 고르라면 저는 "작업 끝나면 /clear"를 고르겠어요. 제일 쉽고, 제일 큽니다.

참고 자료

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를 사용하므로 두 도구는 상하 관계에 가깝습니다.

실행 방법

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

낮은 점수 중에도 운영에 직접 닿는 항목이 있습니다. 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 블록은 공짜가 아니고, 비용 청구서는 옆 세션으로 날아갑니다. 저는 이번 주에 저희 클러스터에도 위 진단 쿼리 두 개를 모니터링에 추가해 두려고 합니다.

참고 자료

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 회사의 신사업 나열이 아니라 계층 하나를 통째로 짜는 그림입니다.

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가 받는 그림입니다.

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

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

참고 자료