본문으로 건너뛰기

DuckDB v2.0 비동기 I/O

· 약 4분

지난 4월에 PostgreSQL 18의 비동기 I/O를 다루면서, DB 엔진들이 하나둘 동기 I/O와 결별하는 중이라고 썼어요. 이번엔 DuckDB 차례입니다. 7월 31일 공식 블로그 글이 가을 출시 예정인 v2.0의 비동기 I/O 구조와 벤치마크를 공개했는데, 숫자가 눈에 띄어서 정리합니다.

문제: 대역폭이 아니라 대기가 병목​

DuckDB의 기존 실행 모델은 CPU 스레드당 워커 하나입니다. 로컬 NVMe에서는 이걸로 충분합니다. 읽기 지연이 짧아서 워커가 I/O를 기다리는 시간이 얼마 안 되기 때문입니다.

S3 같은 오브젝트 스토리지에서는 사정이 다릅니다. HTTP 요청 하나의 지연이 수십 ms 단위라, 동기 방식으로는 워커가 요청을 보내고 응답을 기다리며 잠들어 있는 시간이 압도적으로 깁니다. 결과적으로 동시 요청 수가 부족해서 네트워크 대역폭을 채우지 못합니다. 측정에서 기존 버전(v1.5.5)은 25 Gbit/s를 쓸 수 있는 인스턴스에서 5 Gbit/s밖에 못 썼습니다. 인프라는 놀고 쿼리는 느린, 돈이 새는 구간입니다.

구조: 워커 풀과 I/O 풀의 분리​

v2.0은 스레드 풀을 둘로 나눕니다.

풀크기역할
REGULARCPU 스레드당 1개 (기본)디코딩, 조인, 집계 등 실제 연산
ASYNC시스템 스레드의 4배, 최대 256개 (기본)블로킹 I/O 전담

ASYNC 풀을 CPU 수보다 훨씬 크게 잡을 수 있는 이유는 이 스레드들이 대부분의 시간을 HTTP 응답 대기로 보내기 때문입니다. CPU를 거의 안 쓰니 수백 개를 띄워도 부담이 없고, 그만큼 동시 요청 수가 올라가 네트워크 대역폭이 채워집니다.

여기에 read-ahead가 얹힙니다. 정규 워커가 데이터를 소비하는 속도보다 앞서서 fetch 작업을 큐에 넣어 두는 방식입니다. 작업 단위는 Parquet이면 row group, CSV면 고정 바이트 범위입니다.

미리 받아 두는 만큼 메모리를 먹으니 제어 장치도 있습니다. read_ahead_depth 설정이 기본 -1(무제한, 메모리 예산으로만 제한)이고, 양수로 제한하거나 0으로 끌 수 있습니다. 메모리가 부족해지면 임시 메모리 관리자와 협상해 큐 크기를 스스로 줄입니다.

숫자: Parquet 3배, CSV 19배​

공개된 벤치마크는 EC2 r7i.16xlarge에서 S3의 TPC-H SF100 데이터를 읽는 구성입니다.

워크로드v1.5.5v2.0.0-dev배율
S3 Parquet 읽기8.230초2.844초2.9배
S3 CSV 읽기 (80.89GB)877.563초45.264초19.4배
로컬 디스크 (M4 MacBook Pro)1.321초0.883초1.5배

CSV의 19배가 특히 극적인데, 뒤집어 보면 기존 CSV 리더가 원격 스토리지에서 그만큼 직렬화되어 있었다는 뜻입니다. 네트워크 사용률은 5 Gbit/s에서 25 Gbit/s 포화로 올라갔습니다.

동시성 수치도 흥미롭습니다. 쿼리 4개를 동시에 돌렸을 때 v1.5.5는 평균 5.9코어(활용률 6%)를 쓰며 35.8초가 걸렸고, v2.0.0-dev는 48.1코어(75%)를 쓰며 15.6초에 끝냈습니다. I/O 대기에 묶여 있던 CPU가 풀려난 그림입니다.

제한도 명시되어 있습니다. 현재 구현된 것은 Parquet과 비압축 UTF-8 CSV뿐이고, JSON과 DuckDB 네이티브 포맷은 추후 예정입니다. row group이 거대해서 파일 안 병렬성이 부족한 경우에는 효과가 줄어듭니다.

PostgreSQL 18과 나란히 놓고 보면​

같은 문제를 두 엔진이 어떻게 다르게 푸는지 비교하면 각자의 처지가 보입니다.

PostgreSQL 18DuckDB v2.0
대상 I/O로컬 디스크 (heap 읽기)원격 오브젝트 스토리지
방식io_method (worker / io_uring)ASYNC 스레드 풀 + read-ahead
단위블록row group / 바이트 범위
배경17년 만의 아키텍처 전환분석 엔진의 클라우드 이행

PostgreSQL은 커널 인터페이스(io_uring)까지 내려가 로컬 블록 I/O를 비동기화했고, DuckDB는 HTTP 위의 원격 읽기를 스레드 물량으로 병렬화했습니다. 방식은 달라도 결론은 같습니다. 스토리지가 어디에 있든, 워커가 I/O를 기다리며 잠드는 구조는 더 이상 용납되지 않는다는 것입니다.

며칠 뒤 8월 5일 40,000 스타 기념 글에서는 DuckDB가 v2.0과 함께 가는 방향도 살짝 내비쳤습니다. 다중 동시 쓰기를 지원하는 원격 프로토콜 Quack까지 언급했는데, 임베디드 분석 엔진이라는 출발점에서 점점 멀어지는 중입니다. 오브젝트 스토리지 위의 분석 스택에서 DuckDB의 자리가 어디까지 커질지, v2.0이 나오면 직접 실행해 보고 후속으로 다루겠습니다.

참고 자료​

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이 설정돼 있어야 합니다. standby의 primary_conninfo에는 유효한 dbname이 들어 있어야 slotsync worker가 primary에 접속합니다(streaming replication에는 쓰이지 않는 값입니다). 그리고 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_conninfo = 'host=primary-host user=replicator dbname=postgres'
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가 만든 복제본입니다. sync_replication_slots를 끈 standby에서 수동으로 한 번 동기화해 보려면 SELECT pg_sync_replication_slots();를 실행합니다. PostgreSQL 17 문서에 따르면 이 함수는 주로 테스트와 디버깅용이고, 위 예시처럼 sync_replication_slots = on으로 slotsync worker가 이미 동기화 중이면 실행할 수 없습니다.

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_slots의 failover 열부터 확인하는 게 순서입니다.

참고​

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

PGSimCity 3D 도시

· 약 7분

DBA가 아닌 개발자에게 checkpoint 스파이크를 설명해 본 적 있다면 그 난감함을 알아요. WAL이 뭔지부터 시작해야 하고, dirty page가 왜 쌓이는지, max_wal_size가 왜 그 시점을 정하는지 순서대로 쌓아야 해요. 그림을 그려도 정적이라 "시간에 따라 이게 몰린다"는 감각이 전달되지 않아요.

PGSimCity는 이 문제를 풀려는 도구입니다. PostgreSQL 내부를 탐험 가능한 3D 도시로 만들어 놓고, 시간을 흘려보내며 그 안에서 무슨 일이 벌어지는지 보여줍니다. 만든 사람은 postgres.ai의 Nikolay Samokhvalov(니콜라이 사모흐발로프)입니다.

브라우저에서 바로 열립니다. nikolays.github.io/PGSimCity에 접속하면 설치 없이 도시가 뜹니다. WebGL2를 지원하는 브라우저가 필요합니다.

저장소를 직접 빌드해 봤습니다​

소개 글을 쓰려면 실제로 돌려 보는 게 맞습니다. 클론해서 테스트와 빌드를 통과시켜 보고 확인한 사실을 먼저 적습니다.

git clone https://github.com/NikolayS/PGSimCity.git
cd PGSimCity
npm install
npm run typecheck # 통과, 오류 없음
npm test # 48개 파일, 360개 테스트 통과 (10.4초)
npm run build # 2.69초
npm run dev # localhost:5173
항목확인값
버전0.12.0
라이선스Apache-2.0
TypeScript 소스107개 파일, 60,517줄
테스트48개 파일, 360개 통과
런타임 의존성three 0.185, @electric-sql/pglite 0.5.4
빌드 결과 최대 청크city 1.17MB (gzip 392KB)

README에는 테스트가 234개로 적혀 있는데 직접 실행해 보니 360개였습니다. 최근 커밋이 확인 시점과 같은 날짜였으니 문서가 코드를 못 따라가는 중입니다. 그만큼 활발하다는 뜻으로 읽으면 됩니다.

의존성이 얇은 게 눈에 띕니다. 렌더링 쪽 런타임 의존성은 Three.js 하나이고(Query Lab용으로 PGlite가 따로 들어갑니다), 시뮬레이션 코드는 Three.js를 import하지 않습니다. 시뮬레이션과 렌더링이 SimState에서만 만나는 구조라, 도식 없이도 시뮬레이션 로직만 따로 테스트할 수 있습니다. 360개 테스트가 붙어 있는 이유가 여기에 있습니다.

도시가 표현하는 것​

지구가 PostgreSQL의 구성요소에 대응합니다.

  • 클라이언트 영역: 들어오는 connection
  • backend 구역: connection 하나당 프로세스 하나, 각자의 private memory
  • buffer pool: shared_buffers를 샘플링한 프레임 격자
  • 스토리지: heap 파일, B-tree, TOAST
  • WAL 지구: 쓰기가 디스크에 먼저 도착하는 경로
  • 유지보수 구역: checkpointer, autovacuum launcher와 worker, background writer
  • replication 구역: standby와 WAL receiver

색이 상태를 나타냅니다. WAL은 호박색, dirty page는 빨강, 청소 작업은 보라색입니다. 도시를 내려다보다가 빨간 블록이 넓어지면 dirty page가 쌓이는 중이라는 걸 눈으로 압니다.

소스를 검사하다 예상 밖의 지구를 하나 발견했습니다. src/world/continuity.ts에 백업과 복구 쪽 오브젝트가 따로 들어 있습니다.

archive.gate archive 소유권 경계
timeline.yard timeline 전환 조차장
object.store 오브젝트 스토리지
backup.vault 백업 금고
recovery.clock recovery_target_time
restore.winch restore_command

PITR을 도시 구조물로 옮겨 놓은 것입니다. recovery_target_time이 시계로, restore_command가 권양기로 서 있습니다. Barman이나 pgBackRest로 PITR을 설명할 때 "timeline이 갈라진다"는 말이 잘 안 통했다면 이 조차장을 보여주는 편이 빠르겠습니다.

투어 14장​

T를 누르면 안내 투어가 시작됩니다. 순서가 곧 커리큘럼입니다.

  1. 클라이언트가 접속한다
  2. connection 하나에 프로세스 하나
  3. 쿼리가 plan이 된다
  4. 페이지 읽기, 캐시
  5. 페이지란 실제로 무엇인가
  6. 쓰기: 페이지보다 WAL이 먼저 디스크로
  7. commit, 그리고 fsync의 값
  8. checkpoint, 그리고 스파이크
  9. MVCC: update는 시체를 남긴다
  10. autovacuum이 치운다
  11. vacuum이 못 치울 때: horizon
  12. standby로 streaming
  13. lag, 그리고 네 개의 LSN
  14. 도시 전체를 다시

저는 9장에서 11장까지가 한 묶음으로 붙어 있는 게 좋아요. update가 옛 버전을 남기고, autovacuum이 그걸 치우고, 그런데 horizon 때문에 못 치우는 경우까지 세 장으로 이어집니다. 이 순서를 말로 설명하면 늘 세 번째에서 막히는데, 도시에서는 vacuum worker가 왔다 갔는데 빨간 블록이 그대로 남아 있는 장면으로 보여줍니다.

13장의 "네 개의 LSN"은 sent_lsn, write_lsn, flush_lsn, replay_lsn입니다. replication lag을 볼 때 어느 단계에서 밀리는지 구분해야 하는데, 이 넷의 간격을 도시 안 거리로 보여주는 방식입니다.

시나리오 13종​

투어와 별도로 상황을 직접 재현하는 시나리오가 있습니다. 소스에서 확인한 목록입니다.

시나리오재현하는 상황
steady-state평상시 부하
checkpoint-stormcheckpoint 몰림
cache-thrashbuffer pool 히트율 붕괴
bloat-and-vacuumdead tuple 누적과 회수
xmin-horizonidle 트랜잭션이 vacuum을 막음
lock-pileupACCESS EXCLUSIVE 락 뒤에 줄이 쌓임
replication-lagstandby 지연
wal-floodWAL 급증
index-vs-seqscan인덱스 스캔과 순차 스캔
no-bgwriterbackground writer 없는 상태
connection-stormconnection 폭주
logical-replicationlogical replication 흐름
full-page-writesfull page write 부하

각 시나리오는 knob 값 묶음과 시간축 해설(beats)로 되어 있습니다. xmin-horizon 하나를 열어 보면 이런 식입니다.

knobs: tps 900, writeRatio 0.8, updateRatio 0.9,
longRunningXact: true, autovacuum: true, ...

beats:
0초 누군가 BEGIN을 입력했다
14초 horizon이 얼어붙었다
30초 autovacuum은 여전히 돈다
48초 worker가 헛도는 것을 본다
66초 브레이크 없는 bloat
86초 어디를 봐야 하나
108초 풀어 준다
126초 그리고 예방한다

30초 지점 해설이 정확합니다. launcher는 여전히 worker를 보내고, worker는 테이블까지 가서 heap 전체를 스캔하고 I/O를 태우는데 회수하는 건 거의 없습니다. 모니터링에는 vacuum이 도는 것으로 보이고 테이블은 반대를 말합니다. 86초 지점에서는 pg_stat_activity를 state = 'idle in transaction'으로 걸러 xact_start 순으로 정렬하라고 알려주고, 버려진 replication slot과 hot_standby_feedback이 켜진 standby의 장기 쿼리도 같은 메커니즘이라고 덧붙입니다.

xmin horizon이 vacuum 회수를 막는 시간 흐름

이 대목은 어제 쓴 MVCC 비용 비교 글에서 직접 실험으로 확인한 내용과 그대로 겹칩니다. VACUUM VERBOSE가 0 removed, 400000 are dead but not yet removable을 뱉는 장면, 범인이 pg_stat_activity에만 있는 게 아니라 replication slot과 prepared transaction에도 있다는 지점까지 같습니다. 직접 실험으로 확인한 것을 시각 자료로 다시 설명할 수 있게 됐습니다.

모델인가 에뮬레이터인가​

저자는 이게 PostgreSQL 모델이고 에뮬레이터가 아니라고 밝힙니다. PostgreSQL 소스가 돌아가지 않고, 숫자는 사람이 눈으로 따라갈 수 있게 축척을 조정했습니다. buffer pool도 shared_buffers 전체가 아니라 1,024개 프레임 샘플입니다.

다만 빌드 결과를 보다가 청크 하나가 눈에 걸렸습니다.

dist/assets/real-postgres-runtime-BHGmqr-J.js 539.32 kB

소스를 열어 보니 @electric-sql/pglite를 import하고 pglite.wasm, initdb.wasm을 함께 번들합니다. PGlite는 PostgreSQL을 WASM으로 컴파일한 것입니다.

import { PGlite } from '@electric-sql/pglite'
import initdbWasmUrl from '.../pglite/dist/initdb.wasm?url'
import pgliteWasmUrl from '.../pglite/dist/pglite.wasm?url'

즉 구분이 필요합니다. 도시 시뮬레이션은 모델이지만, Query Lab 쪽은 브라우저 안에서 진짜 PostgreSQL을 띄워 실제 실행 계획을 받아 옵니다. accounts, orders, events, sessions 테이블에 인덱스까지 붙인 시드 스키마가 코드에 들어 있습니다. 도시의 움직임은 모형이고, 쿼리 실습은 실물입니다.

이 조합이 영리합니다. 실제 PostgreSQL을 8KB 페이지 단위로 시각화하면 사람 눈에는 아무것도 안 보입니다. 그래서 보여주는 층은 축척을 조정한 모델로 두고, 정확성이 중요한 실행 계획은 실물 엔진에 맡겼습니다.

PGSimCity의 시각 모델과 실제 엔진 역할 분담

한계와 쓸 자리​

버전이 0.12.0입니다. 저자도 0.x 초기 단계임을 명시하고, PostgreSQL 정확성은 문서와 소스로 3라운드 검토했다고 밝힙니다. 기억에 의존해 만들지 않았다는 뜻이고, 그래도 프로덕션 판단 근거로 쓸 물건은 아닙니다. 축척을 조정한 숫자를 실제 튜닝 값으로 옮기면 안 됩니다.

쓸 자리는 분명합니다. 신입 교육 첫 주에 개념 지도를 잡아 주는 용도, 장애 회고에서 "이때 이런 일이 있었다"를 비개발자에게 설명하는 용도, DB를 운영해 본 적 없는 개발자에게 idle_in_transaction_session_timeout이 왜 필수인지 납득시키는 용도입니다. 마지막 항목은 특히 말로 하면 잔소리로 들리는데, worker가 헛도는 장면을 2분간 같이 보면 설명이 끝납니다.

라이선스는 Apache-2.0이라 사내 교육 자료로 가져다 쓰기에도 걸림이 없습니다. 정적 번들이니 npm run build 결과를 사내 어디든 올려 둘 수 있습니다. 다만 PostgreSQL은 PostgreSQL Community Association of Canada의 상표이고 이 프로젝트는 공식 지원을 받지 않는 독립 교육 프로젝트라는 점, 그리고 이름과 달리 Electronic Arts와 무관하다는 점은 저자가 직접 밝혀 둔 대로 함께 알려 주는 게 좋겠습니다.

참고​

이 블로그의 관련 글로는 MVCC 비용은 어디에 숨었나와 PostgreSQL 18의 autovacuum_worker_slots가 있어요.

MVCC 엔진별 비용 비교

· 약 13분

PostgreSQL을 오래 운영하면 vacuum이 미워지는 날이 와요. dead tuple이 안 줄고, autovacuum은 계속 도는데 테이블은 커지고, VACUUM FULL은 락 때문에 걸 수 없어요. 그럴 때 검색하면 나오는 글이 CMU Andy Pavlo(앤디 파블로)의 The Part of PostgreSQL We Hate the Most와 Uber의 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

같은 10만 행 UPDATE에서 구성별 WAL 생성량과 HOT 비율 비교

두 번째 줄이 흔히 인용되는 그 비용입니다. 인덱스 컬럼을 건드리지도 않았는데 보조 인덱스 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;

ssolderrcnt가 ORA-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은 기본 설정의 primary에서는 읽기 쿼리를 취소하지 않습니다(hot standby에서는 max_standby_streaming_delay, 기본 30s를 넘기면 recovery conflict로 취소합니다). 대신 그 쿼리가 볼지도 모르는 쓰레기를 계속 들고 있습니다. 결함으로 볼 일은 아닙니다. 선택입니다.

실제로 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_slots와 autovacuum_max_workers를 분리해 재시작 없이 worker 수를 조정하게 만들었고, 19에서는 autovacuum이 카탈로그 순서 대신 테이블별 우선순위 점수로 대상을 고르도록 바뀝니다. pg_stat_autovacuum_scores 뷰와 가중치 파라미터가 함께 들어옵니다. 청소 자체를 없애지는 못해도, 언제 무엇부터 치울지는 계속 정교해지는 중입니다.

정리​

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

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

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

참고​

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

AI 코딩 에이전트 도구 지도

· 약 7분

요즘 이런 도구가 쏟아진다​

최근 몇 달 사이 herdr, Orca, cmux, Claude Squad 같은 이름을 부쩍 자주 봐요. 전부 "AI 코딩 에이전트 여러 개를 한 번에 굴리는 도구"라는 소개를 달고 나오는데, Claude Code나 Codex를 하나만 돌려도 벅찬 터라 여러 개를 동시에 관리한다니 눈길이 갈 만해요.

그런데 목록을 살펴보면 이상한 질문이 머리에 떠오릅니다. "저는 Ghostty를 쓰는데, herdr로 갈아타야 하나?" "tmux를 버리고 cmux를 깔아야 하나?" 저도 처음엔 그렇게 물었습니다. 결론부터 말하면 이건 잘못 세운 질문입니다. Ghostty와 herdr는 애초에 경쟁 관계가 아니며, 서로 다른 층에서 다른 일을 합니다.

이 글은 그 착각을 푸는 지도입니다. 도구를 나열하기보다, 이 도구들이 각각 어느 층에 사는지를 먼저 나눕니다. 층을 나누고 나면 "뭘 써야 하나"라는 질문이 "지금 제 환경에서 어느 층이 비어 있나"로 바뀝니다. 그게 훨씬 답하기 쉬운 질문입니다.

왜 다들 같은 칸에 있는 것처럼 보이나​

착각의 원인은 단순합니다. 이 도구들이 대부분 터미널에서 도는 CLI 형태라서, 겉보기에 다 비슷해 보입니다. 검은 화면에 글자가 흐르고, 키보드로 조작하고, 이름도 -mux로 끝나는 게 많습니다. 그래서 한 바구니에 담아 놓고 "이 중에 뭐가 제일 좋냐"를 따지게 됩니다.

하지만 실제로는 역할이 완전히 다릅니다. 어떤 건 화면을 그리는 일을 하고, 어떤 건 그 화면을 여러 칸으로 쪼개는 일을 하고, 어떤 건 그 칸 안에서 도는 에이전트들을 관리하는 일을 합니다. 모니터와 화면 분할기와 작업 반장을 놓고 "셋 중 뭐가 제일 좋냐"를 묻는 셈이라, 질문 자체가 성립하지 않습니다.

3개 층으로 나눈다​

정리하면 층은 세 개입니다. 위에서 아래로 갈수록 더 안쪽에서 돕니다.

1층은 터미널 에뮬레이터입니다. 화면에 글자를 그리는 앱이고, 폰트와 색과 렌더링 속도가 승부처입니다. Ghostty, iTerm2, Alacritty가 여기 있습니다. 이 층은 에이전트를 "관리"하지 않습니다. 그냥 잘 보여줄 뿐입니다.

한 칸 아래가 멀티플렉서입니다. 터미널 하나 안을 여러 세션으로 쪼개고, 터미널을 닫아도 세션을 살려 둡니다. SSH가 끊겨도 작업이 죽지 않게 하는 게 이 층의 존재 이유입니다. tmux가 사실상 표준이고, Zellij가 Rust로 다시 쓴 현대판입니다. 다만 창을 나눠 줄 뿐, 그 창 속 에이전트가 일하는 중인지 끝났는지는 알지 못합니다.

가장 안쪽 3층이 이번 글의 주인공입니다. 에이전트마다 별도의 작업 공간을 만들어 서로 부딪치지 않게 격리하고, 각 에이전트의 상태를 추적하고, 결과 diff를 보여줍니다. herdr, cmux, Orca, Claude Squad가 여기 있습니다.

3층이 왜 필요한지는 한 문장으로 설명됩니다. 코딩 에이전트는 작업 디렉토리를 통째로 자기 것으로 쓸 때 가장 잘 동작합니다. 그런데 에이전트 둘이 같은 디렉토리를 동시에 만지면 서로의 편집을 뭉개고, 빌드가 깨지고, git 상태가 엉킵니다. 이런 충돌을 피하려고 3층 도구들은 하나같이 git worktree로 각 에이전트에게 독립된 체크아웃을 줍니다.

1층과 2층: 바탕이 되는 도구​

3층 이야기로 넘어가기 전에, 바탕 두 층을 짧게 정리합니다. 이 층은 3층 도구와 경쟁하지 않고 함께 씁니다. 제가 즐겨 쓰는 Ghostty도 여기 있습니다.

도구층플랫폼
Ghostty터미널 에뮬레이터macOS, Linux
iTerm2터미널 에뮬레이터macOS
Alacritty터미널 에뮬레이터전 플랫폼
tmux멀티플렉서전 플랫폼
Zellij멀티플렉서전 플랫폼

Ghostty는 GPU로 화면을 그려서 빠르고, 설정이 간결합니다. 요즘 새로 나오는 에이전트 도구 중 일부는 아예 이 Ghostty의 렌더링 엔진을 자기 기반으로 삼으며, 뒤에 나올 cmux가 그렇습니다. 이 사실 하나만 알아도 "Ghostty와 cmux 중 뭘 쓰지"라는 질문이 왜 이상한지 감이 옵니다. cmux가 Ghostty 위에 지어졌으니, 둘 중 하나를 고르는 문제가 애초에 성립하지 않습니다.

3층: 에이전트 오케스트레이터​

이제 본론입니다. 3층 도구들을 순수 사양만 표로 정리했습니다. 어느 게 더 좋은지는 표 아래 산문으로 따로 풀었습니다. 도구마다 성격이 달라서 한 줄로 우열을 매기는 게 오히려 오해를 부릅니다.

도구유형플랫폼worktree 격리상태 인지라이선스
Claude Code Agent Teams빌트인Claude Code 환경지원지원무료
cmux (manaflow-ai)터미널 앱macOS지원지원OSS
cmux (craigsc)셸 스크립트macOS, Linux지원제한OSS
herdr멀티플렉서Linux, macOS, Win(beta)지원지원OSS
Claude SquadTUImacOS, Linux지원지원OSS
dmuxCLImacOS, Linux지원제한OSS
Orca데스크톱 GUImac, Win, Linux지원지원OSS(MIT)
Parallel Code데스크톱 GUImac, Win, Linux지원지원OSS
Emdash데스크톱 GUI전 플랫폼지원지원OSS

표의 "상태 인지"는 tmux처럼 창만 나누는 데서 그치지 않고, 각 에이전트가 지금 일하는 중인지 끝났는지 입력을 기다리는지까지 도구가 안다는 뜻입니다. 이게 3층을 2층과 갈라 놓는 결정적 차입니다. herdr가 멀티플렉서라고 소개되면 tmux 대체품처럼 들리지만, 실제로는 에이전트 상태를 아는 멀티플렉서라 2층과 3층에 걸쳐 있습니다.

크게 두 갈래로 나뉩니다. 터미널 안에서 도는 가벼운 쪽(herdr, Claude Squad, dmux, cmux)과, 자체 창을 띄우는 무거운 데스크톱 쪽(Orca, Parallel Code, Emdash)입니다. 데스크톱 쪽은 내장 브라우저로 에이전트가 만든 화면을 바로 확인하거나, diff에 코멘트를 달아 에이전트에게 되돌려 주는 기능을 얹습니다. 대신 앱이 무겁고, 원격 리눅스 서버에서는 쓰기 어렵습니다. 표에 다 넣지는 않았지만 wmux(cmux의 Windows 포팅), Termdock(세션별 CPU/메모리 모니터), amux 같은 것도 같은 계보에 있습니다.

착각하기 쉬운 세 지점​

층을 나누고 나서도 개별 도구에서 헷갈리는 대목이 몇 개 남습니다.

첫째, cmux는 이름이 같은 도구가 둘입니다. 하나는 manaflow-ai가 만든 Ghostty 기반 macOS 앱이고, 다른 하나는 craigsc의 "tmux for Claude Code"라는 셸 스크립트입니다. 검색하면 둘이 섞여 나옵니다. 성격이 꽤 다르니 저장소 주소를 보고 구분하는 게 안전합니다.

둘째, Orca 같은 데스크톱 앱은 자체 터미널 렌더링을 내장합니다. 그래서 Orca를 쓰면 Ghostty를 거치지 않습니다. "Ghostty에서 Orca를 띄운다"기보다 "Orca가 Ghostty 자리를 대신한다"에 가깝습니다. 앞의 3층 도구들이 대부분 기존 터미널 위에 얹히는 것과 다른 지점입니다.

셋째, 가장 흔한 오해인데, 3층 도구가 없어도 병렬 작업은 이미 가능합니다. Claude Code에는 Agent Teams라는 실험 기능이 들어 있어 에이전트 여러 개를 병렬로 띄울 수 있고, 서브에이전트는 isolation: worktree 설정으로 각자 worktree에 격리할 수 있습니다. 새 도구를 깔기 전에 이미 손에 있는 걸 먼저 써 보는 게 순서입니다.

제 환경이라면 이렇게 고릅니다​

여기부터는 사양 비교를 넘어 선택의 문제로 들어갑니다. 제 작업 환경은 두 갈래입니다. 로컬은 macOS에 Ghostty, 원격은 리눅스 서버에 SSH로 붙습니다. 층으로 나눠 놓으니 각 환경에서 뭘 얹을지가 명확해집니다.

로컬 macOS에서는 cmux(manaflow-ai)가 가장 자연스러운 후보입니다. 이미 쓰는 Ghostty의 렌더링을 그대로 두고 그 위에 사이드바와 알림만 얹는 구조라, 기존 환경을 버릴 필요가 없습니다. 오케스트레이터라기보다 여러 에이전트를 나란히 두는 최소한의 장치라서, Claude Code를 쓰던 방식을 강제로 바꾸지 않는 점도 저는 마음에 들어요.

원격 서버에서는 사정이 다릅니다. macOS 앱은 당연히 못 씁니다. 여기서는 herdr나, 이미 쓰고 있는 tmux 위에 Claude Squad를 얹는 쪽이 맞습니다. herdr는 SSH로 붙어서 세션을 유지하고 휴대폰에서 재접속하는 시나리오에 강합니다. 다만 tmux를 이미 손에 익혔다면 Claude Squad로 넘어가는 이행 비용이 더 낮습니다. 익숙한 2층 위에 3층만 얹는 그림이기 때문입니다.

솔직히 덧붙이면, 저는 지금 당장 급하지는 않다고 봅니다. 제 작업은 대부분 글을 쓰거나 문서를 다듬는 일이라, 에이전트를 다섯 개씩 병렬로 굴려야 하는 순간이 매일 오지는 않습니다. worktree 격리가 절실해지는 건 서로 독립적인 작업을 진짜로 동시에 여러 개 돌릴 때입니다. 그 순간이 오기 전까지는 Claude Code의 Agent Teams로 충분하고, 필요가 분명해지면 그때 로컬은 cmux, 원격은 herdr로 넘어가면 됩니다.

도구를 고르기 전에 층을 묻는다​

이 바닥은 변화가 빠릅니다. 이 글을 쓰는 지금(2026년 7월)에도 새 도구가 계속 나오고, 이름과 기능이 겹치고, 어제의 추천이 오늘 바뀝니다. 그러니 개별 도구 이름을 외우는 것보다, 새 도구를 만났을 때 "이건 몇 층 도구지"를 먼저 묻는 습관이 오래갑니다.

터미널을 바꾸고 싶은 건지, 세션 관리를 원하는 건지, 에이전트를 여러 개 굴리고 싶은 건지 생각해 봐야 해요. 이 세 가지는 서로 다른 문제이고 답도 다른 층에 있지만, 지도를 손에 쥐고 나면 쏟아지는 도구 목록이 위협이 아니라 그저 선택지로 보여요.

mmdc 입문

· 약 6분

mermaid는 렌더링해 주는 곳에서만 그림이 돼요. GitHub README와 이 블로그에서는 도식으로 보이지만, PDF로 내보내면 코드 블록 그대로 남고, 사내 위키에 붙이면 텍스트 뭉치가 돼요. 슬라이드에 넣으려면 결국 캡처 도구를 꺼내야 해요.

mmdc는 이 지점을 없애는 도구입니다. mermaid 문법을 입력받아 SVG, PNG, PDF 파일로 저장하는 공식 커맨드라인 도구이고, mermaid 개발팀이 직접 관리합니다. 2026년 7월 기준 버전은 11.16.0입니다.

무엇을 하는 도구인가​

내부는 단순합니다. Puppeteer로 headless Chromium을 띄우고, 그 안에서 mermaid 라이브러리로 다이어그램을 그린 다음, 결과 SVG를 꺼내거나 스크린샷을 찍습니다.

브라우저를 통째로 띄운다는 사실이 장점이자 단점입니다. 웹에서 보이는 그림과 결과물이 정확히 같다는 게 장점이고, 실행 한 번에 Chromium이 뜨니 가볍지 않다는 게 단점입니다.

설치​

Node 18.19 이상 또는 20 이상이 필요합니다. 전역 설치는 이렇게 합니다.

npm install -g @mermaid-js/mermaid-cli
mmdc --version

한두 번 쓸 목적이라면 설치 없이 실행해 봅니다.

npx -p @mermaid-js/mermaid-cli mmdc -i diagram.mmd -o diagram.svg

CI나 로컬 환경에 의존성을 남기고 싶지 않으면 Docker 이미지를 씁니다.

docker run --rm -u $(id -u):$(id -g) -v "$PWD:/data" \
ghcr.io/mermaid-js/mermaid-cli/mermaid-cli \
-i /data/diagram.mmd -o /data/diagram.svg

-u $(id -u):$(id -g)를 빼면 결과 파일이 root 소유로 생깁니다. 컨테이너에서 파일을 만드는 도구의 흔한 함정입니다.

Homebrew 설치는 지원이 끊겼습니다. brew install mermaid-cli로 기억하고 있다면 npm 또는 Docker로 옮겨야 합니다.

첫 렌더​

.mmd 파일 하나를 만듭니다.

flowchart TD
A[요청] --> B{캐시 있나}
B -- 있음 --> C[캐시 응답]
B -- 없음 --> D[DB 조회]
D --> E[캐시 저장]
E --> C

변환합니다.

mmdc -i flow.mmd -o flow.svg

출력 확장자가 형식을 결정합니다. flow.png로 주면 PNG, flow.pdf로 주면 PDF가 나옵니다. 확장자와 무관하게 형식을 정하려면 -e svg 같은 식으로 명시합니다.

파일을 만들지 않고 파이프로 넘기는 방식도 됩니다.

cat << 'EOF' | mmdc -i - -o out.png
sequenceDiagram
Client->>Server: 요청
Server-->>Client: 응답
EOF

주요 옵션​

옵션기본값용도
-i, --input필수입력 파일. .md는 마크다운으로 처리, -는 stdin
-o, --output입력 + .svg출력 파일. -는 stdout
-e, --outputFormat확장자에서 추론svg, png, pdf 중 선택
-t, --themedefaultdefault, forest, dark, neutral
-b, --backgroundColorwhitetransparent, #F0F0F0 등
-w, --width800페이지 폭 (px)
-H, --height600페이지 높이 (px)
-s, --scale1Puppeteer 배율. PNG 해상도를 올릴 때
-c, --configFile없음mermaid 설정 JSON
-C, --cssFile없음페이지에 주입할 CSS
-p, --puppeteerConfigFile없음Puppeteer 설정 JSON
-f, --pdfFit꺼짐PDF를 도식 크기에 맞춤
-j, --jobsCPU 절반다이어그램 여러 개를 병렬 렌더링
-q, --quiet꺼짐로그 억제

발표 자료에 넣을 PNG라면 배율과 배경을 함께 조정하는 조합이 무난합니다.

mmdc -i arch.mmd -o arch.png -t dark -b transparent -s 3

-s 3은 같은 도식을 3배 해상도로 그립니다. 슬라이드에서 확대해도 글자가 깨지지 않습니다.

마크다운 파일 일괄 변환​

.md 파일을 입력으로 주면 동작이 달라집니다. 파일 안의 mermaid 코드 블록을 모두 찾아 각각 이미지로 렌더링하고, 원본 블록을 이미지 참조로 바꾼 새 마크다운을 내놓습니다.

mmdc -i README.md -o README-rendered.md

이미지는 README-rendered-1.svg, README-rendered-2.svg 같은 이름으로 순번이 붙고, 마크다운 안에는 ![diagram](README-rendered-1.svg) 형태로 들어갑니다. 이미지를 별도 디렉토리에 모으려면 --artefacts를 지정합니다.

mmdc -i README.md -o dist/README.md -a dist/images

mermaid를 렌더링하지 못하는 목적지에 문서를 올려야 할 때 쓰는 기능입니다. 사내 위키, 정적 PDF 매뉴얼, 오래된 문서 시스템이 대표적입니다. 원본은 mermaid 코드로 관리하고 배포할 때만 이미지로 바꿔 내보내는 흐름이 만들어집니다.

마크다운 일괄 변환 흐름

설정 파일 세 가지​

옵션 플래그로 부족할 때 JSON 또는 CSS 파일을 붙입니다.

-c로 넘기는 mermaid 설정은 live editor의 Config 탭과 같은 내용입니다. 폰트, 곡선 스타일, ELK 레이아웃 사용 여부를 여기서 정합니다.

{
"theme": "neutral",
"themeVariables": { "fontFamily": "Noto Sans KR" },
"flowchart": { "curve": "basis" }
}

-C로 넘기는 CSS는 렌더링 페이지에 그대로 주입됩니다. mermaid가 테마에서 만들어 넣는 스타일을 덮어써야 하니 !important가 필요한 경우가 잦습니다.

-p로 넘기는 Puppeteer 설정은 실행 환경 문제를 푸는 열쇠입니다. 컨테이너나 CI에서 Chromium이 sandbox 때문에 죽으면 이렇게 우회합니다.

{ "args": ["--no-sandbox", "--disable-dev-shm-usage"] }

--disable-dev-shm-usage는 /dev/shm이 작게 잡힌 컨테이너에서 렌더링이 중간에 끊기는 문제를 막아 줍니다. Docker 기본값 64MB로는 큰 도식을 그리다 공유 메모리가 부족해집니다.

CI에서 문법 검증기로 쓰기​

이미지 생성이 아니라 검증 목적으로도 씁니다. mermaid 문법이 깨졌으면 mmdc는 0이 아닌 종료 코드를 남깁니다. 문서에 도식이 많은 저장소라면 PR 단계에서 걸러냅니다.

#!/usr/bin/env bash
set -euo pipefail

fail=0
while IFS= read -r f; do
if ! mmdc -q -i "$f" -o /tmp/check.svg 2>/tmp/err; then
echo "FAIL: $f"
cat /tmp/err
fail=1
fi
done < <(git ls-files '*.md' | xargs grep -l '```mermaid')

exit "$fail"

이 블로그에는 활용처가 하나 더 있습니다. mermaid 도식이 본문 폭 720px 안에 들어가는지는 브라우저를 띄워 눈으로 확인해 왔는데, -w 720으로 렌더링한 SVG의 viewBox 값을 읽으면 폭 초과를 숫자로 판정할 길이 열립니다. 노드 라벨 길이 규칙을 지켰는지 사람이 세지 않아도 되는 셈입니다.

걸리는 지점들​

한글이 사라지는 문제가 첫 관문입니다. 공식 Docker 이미지에는 font-noto-cjk가 들어 있어서 한글이 그려지지만, 직접 만든 슬림 이미지나 폰트 없는 CI 러너에서는 한글 라벨이 빈칸이나 두부(□)로 나옵니다. 렌더링 환경에 CJK 폰트를 깔았는지부터 확인합니다.

RUN apk add --no-cache font-noto-cjk font-noto-emoji

실행 비용도 무시하기 어렵습니다. 다이어그램마다 Chromium을 띄우는 구조라 수십 개를 순차 처리하면 시간이 꽤 듭니다. 마크다운 입력에서 도식이 여러 개면 -j로 병렬 처리하니, CPU 여유가 있는 CI에서는 이 값을 올려 잡습니다.

CSP를 켜 둔 환경에서 인라인 CSS가 막히는 사례도 보고돼 있습니다. -C로 준 스타일이 적용되지 않으면 이쪽을 의심합니다.

Node와 Chromium을 동시에 요구하니 의존성이 가볍지 않다는 점은 그대로 남습니다. Python만 있는 파이프라인이라면 브라우저 없이 도는 mermaidx 같은 대안을 검토하는 편이 낫습니다. 다만 mermaid 최신 문법 지원 범위는 공식 CLI가 앞섭니다.

언제 쓰고 언제 안 쓰나​

도식 한두 개를 급하게 이미지로 받고 싶으면 mermaid live editor에서 다운로드 버튼을 누르는 편이 빠릅니다. 여러 서비스가 섞인 팀 문서 파이프라인이라면 Kroki 같은 렌더 서버를 세우는 선택지도 있습니다.

mmdc가 제 몫을 하는 자리는 반복과 자동화예요. 문서 저장소에 mermaid를 원본으로 두고 배포 때 이미지로 바꿔 내보내는 흐름, PR마다 도식 문법을 검사하는 파이프라인, 슬라이드용 고해상도 PNG를 스크립트 한 줄로 뽑는 작업이 여기 해당해요. 매번 마우스로 저장 버튼을 누르던 일이 커맨드가 되는 순간부터 제값을 해요.

참고​

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로 읽었다는 그림입니다. 단정이 아니라 관측된 증상과 문서화된 메커니즘을 맞춰 본 해석입니다.

두 번의 failover와 secondary의 timeline 이동 경로

왜 하필 무한 루프였나​

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

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이 온전히 올라와 있는지 한 번 더 확인해 둘 만해요.

참고​

Claude Code Fullscreen

· 약 7분

입력창이 더는 안 흔들린다​

Claude가 작업하는 동안 화면이 번쩍이고, 출력이 쏟아질 때 스크롤이 맨 위로 튀어 오르고, 긴 세션에서 메모리가 슬금슬금 차오르던 경험은 Claude Code를 오래 쓴 사람이면 익숙해요. Fullscreen 렌더링은 그 세 가지를 한 번에 잡으려고 나온 새 렌더링 경로로, 대화를 vim이나 htop처럼 터미널의 alternate screen buffer에 그려요.

/tui fullscreen 한 줄이면 켜집니다. 대화는 그대로 유지된 채 fullscreen으로 다시 뜹니다. 세션 중간에 켜도 맥락을 잃지 않습니다. Claude Code v2.1.89 이상에서 동작하는 리서치 프리뷰 기능입니다. (Fullscreen 렌더링 공식 문서)

주의: 이 글은 전부 리서치 프리뷰 기준입니다. 동작 방식, 키 바인딩, 설정 키 이름 모두 정식 출시 전에 바뀔 수 있습니다.

fullscreen은 창 최대화가 아닙니다​

먼저 오해부터 풀고 갑니다. 여기서 fullscreen은 터미널 창을 최대화한다는 뜻이 아닙니다. Claude Code가 터미널의 그리는 표면(drawing surface)을 vim처럼 통째로 가져간다는 뜻입니다. 창 크기와 무관하게, 작은 창에서도 똑같이 동작합니다.

기존 렌더러(classic)는 대화를 터미널의 native scrollback에 차곡차곡 쌓습니다. 매 업데이트마다 화면을 다시 그리면서 터미널로 많은 데이터를 흘려보냅니다. Fullscreen은 다릅니다. 지금 화면에 보이는 메시지만 렌더 트리에 둡니다. 터미널로 보내는 데이터량이 확 줄어듭니다.

깜빡임, 메모리, 스크롤 점프를 줄입니다​

세 가지 문제를 노립니다.

문제classic 렌더러fullscreen 렌더링
화면 깜빡임출력 스트리밍 중 번쩍임 발생alternate screen buffer로 제거
메모리대화가 길어질수록 증가보이는 메시지만 유지, 일정하게
스크롤 점프작업 중 맨 위로 튀어 오름입력창 하단 고정, 점프 없음

차이가 가장 크게 느껴지는 곳은 렌더링 처리량(throughput)이 병목인 터미널입니다. 공식 문서는 VS Code 통합 터미널, tmux, iTerm2를 콕 집습니다. 이런 환경에서 화면이 번쩍이거나 스크롤이 튀었다면 fullscreen이 정확히 그 증상을 겨냥합니다.

켜고 끄는 법​

가장 쉬운 방법은 대화 안에서 슬래시 명령을 실행하는 것입니다.

/tui fullscreen # fullscreen 렌더링으로 전환 (대화 유지된 채 재시작)
/tui default # classic 렌더러로 복귀
/tui # 인자 없이 실행하면 현재 어떤 렌더러인지 출력

/tui 명령은 tui 설정을 ~/.claude/settings.json에 저장하고 그 값으로 다시 띄웁니다. 그래서 다음 세션에도 선택이 유지됩니다.

환경 변수로 켤 수도 있습니다. /tui 명령이 생긴 v2.1.110 이전 버전에서는 이쪽이 유일한 방법이었습니다.

CLAUDE_CODE_NO_FLICKER=1 claude

tui 설정과 CLAUDE_CODE_NO_FLICKER 환경 변수는 같은 의미입니다. /tui 명령은 재시작하는 프로세스에서 CLAUDE_CODE_NO_FLICKER를 지워, 새로 쓴 설정 값이 우선 적용되게 합니다.

fullscreen이 켜졌는지 확인하는 가장 직관적인 신호는 입력창입니다. 출력이 쏟아져도 입력창이 화면 하단에 고정돼 움직이지 않으면 fullscreen이 동작 중입니다. classic 렌더러에서는 입력창이 출력을 따라 위로 밀려 올라갑니다.

두 렌더러는 어디서 갈리나​

대화가 어디에 저장되느냐가 갈림길입니다. 그 한 가지 차이에서 검색/복사 동작이 전부 달라집니다.

대화가 alternate screen buffer에 살기 때문에, 터미널의 native scrollback에 의존하던 동작이 달라집니다.

기존 (classic)fullscreen비고
Cmd+f/tmux 검색으로 텍스트 찾기Ctrl+o로 transcript 모드 진입 후 /로 검색검색 경로가 앱 안으로
터미널 native 드래그로 선택/복사앱 안에서 선택, 마우스 떼면 자동 복사클립보드는 앱이 채움
Cmd-클릭으로 URL 열기macOS는 Cmd-클릭, 그 외는 Ctrl-클릭OS별 키 차이

마우스가 붙는다​

Fullscreen은 마우스 이벤트를 캡처해 Claude Code 안에서 처리합니다. classic 렌더러에는 없던 기능입니다.

입력창을 클릭하면 입력 중인 텍스트의 원하는 위치로 커서가 옮겨집니다. / 명령이나 @ 파일 목록에서는 제안 항목을 클릭으로 바로 고르고, 호버하면 해당 행이 강조됩니다. 권한 프롬프트나 /model//config 같은 select 메뉴도 클릭으로 선택할 수 있습니다(v2.1.187 이상). 접힌 tool 결과를 클릭하면 펼쳐서 전체 출력을 보고, 다시 클릭하면 접힙니다. URL이나 파일 경로는 Cmd(macOS)/Ctrl(Linux/Windows)를 누른 채 클릭하면 링크는 브라우저로, 파일 경로는 기본 앱으로 열립니다. v2.1.181부터는 키 없이 그냥 클릭해도 링크가 열리지 않는데, native 터미널 동작에 맞춘 변경입니다. 이 밖에 드래그로 선택하고 휠로 스크롤하며, 더블클릭은 단어, 트리플클릭은 줄 단위로 선택합니다.

선택한 텍스트는 마우스를 떼는 순간 클립보드에 자동 복사됩니다. 이 동작이 거슬리면 /config에서 Copy on select를 끕니다.

스크롤과 검색​

스크롤도 앱 안에서 처리합니다. 주요 단축키는 다음과 같습니다.

단축키동작
PgUp / PgDn반 화면씩 위/아래
Ctrl+Home대화 맨 처음으로
Ctrl+End최신 메시지로 + auto-follow 재개
마우스 휠몇 줄씩 스크롤

MacBook처럼 PgUp/PgDn 전용 키가 없는 키보드는 Fn과 방향키를 조합합니다. Fn+↑이 PgUp, Fn+↓가 PgDn입니다.

위로 스크롤하면 auto-follow가 멈춰, 새 출력이 다시 맨 아래로 끌어내리지 않습니다. Ctrl+End를 누르거나 맨 아래로 내리면 다시 따라가기 시작합니다.

검색은 transcript 모드에서 합니다. Ctrl+o로 일반 프롬프트와 transcript 모드를 오갑니다. transcript 모드는 less 스타일 탐색을 지원합니다.

키동작
/검색 시작
n / N다음/이전 매치로 이동
g / G맨 위/맨 아래로
Ctrl+o, Esc, qtranscript 모드 종료

터미널의 Cmd+f나 tmux 검색은 대화를 보지 못합니다. 대화가 native scrollback이 아니라 alternate screen buffer에 있기 때문입니다. 내용을 터미널로 다시 넘기려면 Ctrl+o로 transcript 모드에 들어간 뒤 [를 누릅니다. 전체 대화가 native scrollback에 평범한 텍스트로 다시 쓰이고, 그때부터 Cmd+f/tmux copy mode가 다시 통합니다.

tmux와 함께 쓸 때​

Fullscreen은 tmux 안에서도 동작합니다. 세 가지만 주의합니다.

첫째, 마우스 휠 스크롤은 tmux의 mouse mode가 필요합니다. ~/.tmux.conf에 아래 한 줄을 넣고 설정을 다시 읽습니다. 없으면 휠 이벤트가 Claude Code 대신 tmux로 갑니다.

set -g mouse on

둘째, iTerm2의 tmux 통합 모드(tmux -CC)와는 호환되지 않습니다. 이 모드에서는 alternate screen buffer와 마우스 추적이 제대로 동작하지 않고, 더블클릭이 터미널 상태를 깨뜨릴 수 있습니다. tmux -CC 세션에서는 fullscreen을 켜지 않는 편이 좋습니다. -CC 없는 일반 tmux는 문제없습니다.

셋째, tmux는 synchronized output을 지원하지 않아, Claude Code를 터미널에서 바로 실행할 때보다 redraw 중 깜빡임이 더 보일 수 있습니다. SSH 환경에서 특히 거슬리면 tmux 밖 별도 탭에서 실행하는 편이 낫습니다.

native 선택을 그대로 두고 싶다면​

마우스 캡처는 가장 흔한 마찰 지점입니다. SSH나 tmux 안에서 특히 그렇습니다. Claude Code가 마우스 이벤트를 가져가면 터미널의 native copy-on-select가 멈춥니다. 드래그로 만든 선택은 Claude Code 안에만 있고, 터미널의 선택 버퍼에는 없습니다.

한 번만 native 선택을 쓰고 싶으면 터미널별 지정 키를 누른 채 드래그합니다. Terminal.app은 Fn, iTerm2는 Option, VS Code/Cursor는 Shift입니다.

마우스 캡처 자체가 늘 거슬린다면, 깜빡임 제거와 일정한 메모리는 유지하면서 마우스 캡처만 끄는 방법도 있습니다.

CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_MOUSE=1 claude

이렇게 하면 PgUp, PgDn, Ctrl+Home, Ctrl+End 키보드 스크롤은 그대로 살고, 선택은 터미널이 native로 처리합니다. 대신 클릭으로 커서 옮기기, tool 결과 펼치기, URL 클릭, 앱 안 휠 스크롤은 포기해야 합니다.

DBA/개발자가 지금 해 볼 것​

긴 작업을 자주 돌리는 사람일수록 효과가 큽니다. 대형 리팩터링이나 수십 개 파일을 읽는 세션에서 classic 렌더러는 출력이 쌓이며 메모리가 늘고 스크롤이 튀지만, fullscreen은 일정하게 버팁니다.

일단 /tui fullscreen을 실행하고, 출력이 쏟아질 때 입력창이 고정되는지 봅니다. 안 맞으면 /tui default로 즉시 돌아오면 됩니다. VS Code, tmux, iTerm2 사용자라면 우선순위를 높게 둘 만한데, 깜빡임/스크롤 점프가 가장 심하던 환경이라 체감 차이가 큽니다. tmux를 쓴다면 set -g mouse on을 먼저 넣어야 휠 스크롤이 tmux로 새어 나가지 않습니다. 마지막으로 검색 습관을 바꿔야 합니다. Cmd+f 대신 Ctrl+o로 transcript 모드에 들어가 /로 찾고, 내용을 터미널로 넘기려면 그 안에서 [를 누릅니다.

복귀 경로가 명확한 것도 저는 안심돼요. /tui default로 돌아가거나, 저장된 설정과 무관하게 classic 렌더러를 강제하려면 CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1을 둡니다. agent view나 claude attach로 여는 백그라운드 세션은 항상 fullscreen으로 뜬다는 점만 기억하면 됩니다.

정리​

Fullscreen 렌더링은 Claude Code의 TUI를 vim/htop 계열로 한 단계 올립니다. alternate screen buffer를 써서 깜빡임이 사라지고 메모리가 일정해지며 마우스가 붙는 대신, 검색/복사는 앱 안 동작으로 바뀝니다. 손에 익히는 데 며칠 걸리지만, 긴 세션을 자주 돌리는 사람에게는 그만한 값을 합니다.

아직 리서치 프리뷰라서 흔치 않은 터미널이나 특이한 설정에서는 렌더링 문제가 생길 수 있고, 그럴 땐 /feedback이나 claude-code GitHub 저장소로 터미널 이름/버전과 함께 알리면 돼요. 동작이 바뀔 여지도 남아 있지만, 한 줄(/tui fullscreen)로 켜고 한 줄(/tui default)로 끄는 비용이면 한 번 켜 보고 판단하기에 충분히 싸요.

Slack의 Claude Tag

· 약 6분

무엇이 나왔나​

2026년 6월 23일, Anthropic이 Slack 채널 안에서 한 명의 팀원처럼 일하는 Claude Tag를 베타로 공개했고, 쓰는 법은 단순해요. 채널에서 @Claude로 태그하고 할 일을 말로 던지면, Claude가 작업을 단계로 쪼개 도구를 써가며 처리한 뒤 같은 Slack 스레드에 결과를 보고해요. Anthropic은 이를 사람이 매번 묻는 챗봇이 아니라, 채널에 상주하며 문맥을 쌓는 팀원으로 설명해요.

지난 글에서 정리했듯 그동안 Claude Code의 무대는 터미널이었습니다. slash command가 skills로 통합되고, Opus 4.8이 수백 개의 subagent를 굴리는 식으로 위임의 단위가 커졌지만, 어디까지나 개발자의 CLI 안에서 벌어진 일이었습니다. Claude Tag는 그 흐름을 협업 도구로 끌어냅니다. 개발자가 아닌 사람도 매일 머무는 Slack 채널에서 같은 Claude를 부릅니다.

어떻게 동작하나​

Claude Tag의 성격은 세 가지로 압축됩니다. 공유 정체성, 문맥 학습, 알아서 끼어드는 ambient 모드입니다.

채널마다 하나의 공유 Claude​

기존 챗봇이 사람마다 별도의 1:1 대화였다면, Claude Tag는 채널 단위로 하나의 Claude가 모두를 상대합니다. 같은 채널에 있는 사람은 Claude가 지금 무슨 작업을 하는지 볼 수 있고, 앞사람이 멈춘 지점에서 대화를 이어받을 수 있습니다. 작업이 한 사람의 DM에 갇히지 않고 팀의 공동 작업으로 굴러간다는 뜻입니다.

채널을 따라가며 문맥을 쌓는다​

Claude는 배정된 채널의 대화를 따라가며 일에 대한 문맥을 축적합니다. Anthropic의 표현으로는 "채널을 따라갈수록 그 일에 대해 점점 더 많이 알게 된다". 권한을 주면 다른 Slack 채널이나 연결된 데이터 소스에서도 사실을 끌어올 수 있습니다. 매번 처음부터 배경을 설명하지 않아도 된다는 게 핵심입니다. 단, 비공개 채널은 학습 대상에서 빠집니다.

ambient 모드는 먼저 끼어든다​

ambient 동작을 켜면, Claude는 호출을 기다리지 않습니다. 연결된 채널과 도구를 살피다가 팀이 알면 좋겠다 싶은 정보를 먼저 꺼내고, 방치된 스레드나 멈춘 작업을 따라가 챙깁니다. 작업을 던져두면 사람이 다른 일을 하는 동안 비동기로 처리하고, 길게는 수 시간에서 며칠에 걸쳐 자율적으로 일을 진행하기도 합니다.

호출에서 보고까지의 흐름은 다음과 같습니다.

기존 Slack 통합과 뭐가 다른가​

Claude Tag는 기존 "Claude in Slack" 앱을 대체합니다. 둘 다 Slack 안에서 Claude를 부른다는 점은 같지만, 동작 모델이 다릅니다.

기존 Claude in SlackClaude Tag
대화 단위사람별 1:1채널 단위 공유 Claude
문맥 유지세션 한정채널을 따라가며 지속 축적
능동성호출해야 응답ambient 모드로 먼저 제안
비동기 작업제한적수 시간~수일 자율 진행
권한 관리제한적관리자가 채널별 도구/데이터 범위 지정
작업 가시성개인 DM채널 구성원이 함께 관찰

가장 큰 차이는 정체성의 단위입니다. 기존 통합이 "제가 부르는 비서"였다면, Claude Tag는 "채널에 함께 있는 팀원"에 가깝습니다.

CLI 쪽 Claude Code와 비교하면 무대가 옮겨졌다는 점이 분명해집니다.

Claude Code (CLI)Claude Tag (Slack)
무대개발자 터미널팀 협업 채널
주 사용자개발자채널의 모든 구성원
호출터미널 세션@Claude 태그
협업개인 작업 위주다중 사용자 핸드오프
문맥프로젝트 파일/세션채널 대화/연결된 데이터

권한과 거버넌스​

조직에 들이는 도구인 만큼 통제 장치가 함께 설계됐습니다. 관리자는 Claude가 채널별로 어떤 도구와 데이터에 접근할지 정하고, 용도에 따라 별도의 Claude 정체성을 분리해 만듭니다. 예를 들어 법무용 Claude가 쌓은 메모리는 엔지니어링 채널로 새어 들어가지 못합니다. 누적된 기억을 포함한 모든 것이 지정된 경계 안에 머뭅니다.

여기에 조직/채널별 토큰 지출 한도를 걸 수 있고, Claude의 활동과 요청자를 남기는 감사 로그도 제공됩니다. 데이터가 부서 경계를 넘지 않도록 막는 장치와 비용/이력을 추적하는 장치도 함께 들어갔습니다.

Claude Tag의 채널별 정체성, 도구, 데이터 경계

실무 활용과 한계/요금​

Anthropic은 사내 활용 수치를 함께 내놨습니다. 자사 제품팀 코드의 65%를 내부판 Claude Tag가 작성한다는 것입니다. The Decoder에 따르면 용도는 엔지니어링에 그치지 않고 제품 지표 추적, 지원 티켓, 버그 진단까지 걸쳐 있습니다. 채널에 던지는 일상적인 요청을 Claude가 받아 처리하는 그림입니다.

엔진은 Opus 4.8입니다. 수백 개의 subagent를 굴리는 동적 워크플로우를 얹은 그 모델이, 이번에는 Slack 채널이라는 새 무대 위에서 돌아갑니다.

가용성과 요금 관련 사실은 다음과 같습니다.

항목내용
상태베타
제공 플랜Claude Enterprise / Claude Team
엔진Opus 4.8
기존 앱"Claude in Slack" 대체, 마이그레이션 기간 30일
비용 통제조직/채널별 토큰 지출 한도 설정
확장 계획Slack 외 다른 플랫폼으로 확대 예정

출시와 함께 적격 조직에는 도입용 크레딧이 지급됩니다. VentureBeat를 비롯한 일부 매체는 Enterprise 25,000달러, 일정 좌석 수 이상의 Team 2,500달러 수준으로 전했는데, 구체적 금액은 매체 보도이므로 실제 조건은 공식 안내로 확인하는 편이 안전합니다.

한계도 분명합니다. 우선 Enterprise와 Team 플랜에 묶인 베타라 아무나 바로 쓸 수 있는 기능이 아닙니다. 채널 대화를 따라가며 문맥을 쌓는 구조이므로, 어떤 채널에 들이고 어떤 데이터에 접근시킬지를 처음부터 신중하게 설계해야 합니다. ambient 모드로 먼저 끼어드는 동작은 편리한 만큼, 켜는 순간 Claude가 채널을 들여다보는 범위가 넓어진다는 뜻이기도 합니다. 도입에 앞서 권한 범위와 토큰 한도를 정해둘 필요가 있습니다.

정리​

Claude Tag는 Claude를 터미널 밖으로 한 걸음 더 꺼냈습니다. Claude Code가 개발자의 CLI에 자리 잡고, Opus 4.8이 위임의 규모를 키웠다면, 이번에는 위임의 장소가 팀이 매일 머무는 협업 채널로 옮겨졌습니다.

요약하면 다음과 같습니다.

  • 채널에서 @Claude 태그로 호출하면 작업을 쪼개 처리하고 스레드에 보고합니다.
  • 관리자가 채널별 도구/데이터 범위와 토큰 한도를 통제합니다.
  • 현재 Enterprise/Team 베타이며, 엔진은 Opus 4.8이고, Slack 외 플랫폼으로 확대가 예고됐습니다.

비서를 부르는 단계에서 팀원을 합류시키는 단계로의 이동이에요. Slack에서 시작했지만, "다른 플랫폼으로 넓힌다"는 예고가 붙은 이상 Claude가 협업 도구 곳곳으로 번지는 흐름의 시작점으로 보는 게 맞아요.

참고 자료​

pg_statistic 통계 구조

· 약 10분

PostgreSQL planner는 테이블의 실제 데이터를 들여다보지 않아요. 100만 건짜리 테이블에 조건을 걸어도, planner는 그 100만 건을 세어 보는 대신 pg_statistic에 저장된 통계 요약만 읽고 "이 조건이면 대략 몇 row가 나오겠다"고 추정해요. 이 추정값이 맞으면 좋은 계획이 나오고, 틀어지면 seq scan을 해야 할 자리에 index scan을 고르거나 nested loop가 터지는 계획이 나와요. 그래서 쿼리가 느릴 때 인덱스보다 먼저 의심해야 할 게 통계예요.

이 글은 그 통계가 어디서 와서 어디에 저장되고 planner가 어떻게 읽는지를 따라갑니다. 시드 출처(richyen.com)와 PostgreSQL 공식 문서를 교차 확인하며 한국어 실무 관점으로 다시 엮었습니다.

왜 통계가 실행계획을 좌우하나​

planner의 일은 같은 쿼리를 실행하는 여러 방법(seq scan vs index scan, nested loop vs hash join, 조인 순서) 중 비용이 가장 싼 것을 고르는 것입니다. 비용을 계산하려면 각 단계에서 몇 row가 흘러갈지를 알아야 합니다. 그런데 쿼리를 실제로 돌려 보고 정할 수는 없으니, 미리 떠둔 통계로 추정합니다.

추정이 틀어지는 전형적인 예는 이렇습니다. 어떤 조건이 실제로는 4000 row를 반환하는데 planner가 8 row로 추정했다면, planner는 "8 row니까 index scan으로 한 건씩 찾아오는 게 싸겠다"고 판단합니다. 막상 실행하면 4000번을 random access로 긁느라 seq scan보다 훨씬 느려집니다. 계획 자체는 통계가 맞다는 가정 아래 합리적이었습니다. 통계가 틀렸습니다.

그래서 row 추정(row estimation)이 쿼리 성능의 출발점입니다. 시드 글의 표현을 빌리면 "planner는 쥐여 준 통계만큼만 똑똑하다".

ANALYZE가 통계를 만드는 과정​

통계를 채우는 명령은 ANALYZE입니다. autovacuum이 백그라운드에서 알아서 돌려 주지만, 대량 적재나 마이그레이션 직후에는 통계가 옛날 값이라 직접 실행해 주는 게 안전합니다.

ANALYZE customers; -- 테이블 전체 컬럼 통계 갱신
ANALYZE customers (state); -- 특정 컬럼만

여기서 주목할 점은 ANALYZE가 테이블 전체를 읽지 않는다는 사실입니다. 큰 테이블을 매번 통째로 스캔하면 비용이 감당되지 않으니, 무작위 샘플을 떠서 그걸로 분포를 추정합니다.

샘플 크기는 통계 타깃(statistics target)에 비례합니다. default_statistics_target이 기본값 100일 때 ANALYZE는 약 30,000개의 row를 샘플로 뽑습니다(테이블이 작으면 그보다 적게 뽑습니다). 무작위성을 보장하기 위해 Vitter의 reservoir sampling 알고리즘을 씁니다. 샘플을 다 모으면 컬럼별로 분포를 계산해 pg_statistic에 저장합니다.

이때 테이블 전체 규모를 나타내는 reltuples(행 수)와 relpages(블록 수)는 pg_class에 따로 저장됩니다. 이 값들은 실시간으로 갱신되지 않고 VACUUM, ANALYZE, 일부 DDL 시점에만 갱신되며, 전체를 스캔하지 않은 경우 스캔한 부분으로부터 근사값을 추정해 갱신합니다.

pg_statistic은 슬롯 구조라 사람이 직접 읽기 까다롭습니다. 그래서 PostgreSQL은 같은 내용을 사람이 읽기 좋게 펼친 pg_stats 뷰를 제공하고, 일반 사용자도 자기 권한 안에서 조회할 수 있습니다. 실무에서는 거의 항상 pg_stats를 봅니다.

아래는 ANALYZE가 통계를 만들어 planner에 닿기까지의 흐름입니다.

pg_stats 주요 컬럼 읽는 법​

pg_stats 한 행은 한 컬럼의 통계 요약입니다. 컬럼이 여럿이지만 실무에서 손이 가는 건 다섯 개 정도입니다.

컬럼의미무엇을 말해 주나
null_fracNULL인 행의 비율NULL이 얼마나 흔한가
avg_width값의 평균 바이트 폭행 크기/메모리 추정
n_distinct서로 다른 값의 수(또는 비율)카디널리티
most_common_vals가장 흔한 값 목록(MCV)편향된 값
most_common_freqsMCV 각 값의 빈도그 값의 점유율
histogram_boundsMCV를 뺀 나머지 분포의 경계범위 조건 추정
correlation물리 순서와 논리 순서의 상관index scan 효율

조회는 이렇게 합니다.

SELECT attname, n_distinct, null_frac,
most_common_vals, most_common_freqs,
correlation
FROM pg_stats
WHERE tablename = 'customers' AND attname = 'state';

n_distinct​

n_distinct에서 눈여겨볼 것은 음수의 의미입니다. 이 값은 서로 다른 값의 추정 개수를 나타내지만 별도의 부호 규칙이 있습니다. 공식 문서 정의는 이렇습니다.

0보다 크면 컬럼의 distinct 값 추정 개수. 0보다 작으면 distinct 값 개수를 행 수로 나눈 값의 음수. (음수 형태는 테이블이 커질수록 distinct 값도 늘어날 것으로 ANALYZE가 판단할 때 쓰이고, 양수 형태는 가능한 값의 수가 고정돼 보일 때 쓰인다.) 예를 들어 -1은 distinct 값 수가 행 수와 같은 unique 컬럼을 뜻한다.

즉 state 컬럼이 50으로 나오면 "값이 50종이고 테이블이 커져도 50종일 것"이라는 뜻이고, 기본키처럼 -1이면 "모든 값이 다 다르다"는 뜻입니다. -0.5라면 "행 두 개당 distinct 값 하나꼴"입니다.

이 값이 실제와 어긋나면 추정이 통째로 흔들립니다. 실제로는 값이 만 종인데 통계가 100종으로 잡혀 있으면 planner는 100분의 1만 걸러질 조건을 만 분의 1로 착각합니다.

most_common_vals와 most_common_freqs​

여기서는 편향된 값을 읽습니다 — 분포가 한쪽으로 쏠린 컬럼에서는 흔한 값 몇 개가 통계를 지배합니다. most_common_vals(MCV)는 그 흔한 값들의 목록이고, most_common_freqs는 각 값이 차지하는 비율입니다. 두 배열은 같은 순서로 짝을 이룹니다.

예를 들어 state 컬럼에서 CA가 0.174, TX가 0.116으로 나온다면, planner는 WHERE state = 'CA'에 대해 "전체의 17.4%"라고 정확히 추정할 수 있습니다. 흔한 값은 빈도를 직접 들고 있으니 추측할 필요가 없습니다.

histogram_bounds​

histogram_bounds는 MCV에 들지 못한 나머지 값의 분포를 담습니다. 공식 문서 정의는 "컬럼 값들을 거의 같은 개수의 그룹으로 나누는 경계값 목록"입니다. 즉 equi-depth histogram이라, 각 구간(bucket)이 데이터의 거의 같은 비율을 담습니다. 기본 타깃 100이면 경계가 약 101개 잡혀 구간마다 전체의 약 1%를 덮습니다.

중요한 성질이 두 가지 있습니다. 첫째, MCV에 들어간 값은 histogram 계산에서 빠집니다. 흔한 값은 MCV가 맡고 나머지는 histogram이 맡습니다. 둘째, 컬럼 타입에 < 연산자가 없거나 MCV가 전체를 다 덮으면 이 값은 NULL입니다.

WHERE signup_date < '2026-03-01' 같은 범위 조건의 추정이 여기서 나옵니다. 경계가 촘촘할수록 범위 추정이 정확해집니다.

MCV와 히스토그램이 컬럼 분포를 나누어 요약하는 구조

correlation​

correlation은 물리적 행 순서와 논리적 값 순서가 얼마나 일치하는지를 -1에서 +1로 나타내며, index scan이 쌀지 가늠하는 기준이 됩니다. 공식 문서는 "값이 -1이나 +1에 가까우면 그 컬럼의 index scan이 0에 가까울 때보다 싸게 추정됩니다. random access가 줄어들기 때문"이라고 설명합니다.

값이 디스크에 정렬된 순서로 쌓여 있으면(예: 시간순 append) correlation이 1에 가깝고, index로 범위를 긁어도 디스크를 거의 순차로 읽습니다. 값이 무작위로 흩어져 있으면 0에 가깝고, index scan은 매번 다른 블록으로 점프해야 해서 비싸집니다. 같은 인덱스라도 이 값에 따라 planner의 선택이 갈립니다.

planner가 통계로 row를 추정하는 예​

추정의 뼈대 공식은 하나입니다.

추정 row = reltuples × selectivity

reltuples는 테이블 전체 행 수, selectivity는 조건이 걸러 내는 비율(0~1)입니다. selectivity를 어떻게 구하느냐가 통계가 쓰이는 지점입니다. 공식 문서의 Row Estimation Examples에 나온 tenk1(1만 행) 예시로 봅니다.

MCV에 있는 등치 조건​

조건 값이 most_common_vals에 있으면 그 빈도를 그대로 selectivity로 씁니다.

SELECT * FROM tenk1 WHERE stringu1 = 'CRAAAA';

'CRAAAA'의 most_common_freqs가 0.003이면 selectivity는 0.003, 추정 row는 10000 × 0.003 = 30입니다.

MCV에 없는 등치 조건​

조건 값이 MCV 목록에 없으면, MCV가 차지하지 않은 나머지를 남은 distinct 값들이 고르게 나눠 가진다고 가정합니다.

selectivity = (1 - sum(mcv_freqs)) / (n_distinct - num_mcv)
SELECT * FROM tenk1 WHERE stringu1 = 'xxx';

MCV 빈도 합이 0.03033, n_distinct가 676, MCV 개수가 10이면,

selectivity = (1 - 0.03033) / (676 - 10) = 0.0014559
추정 row = 10000 × 0.0014559 ≈ 15

n_distinct가 추정에 직접 들어가는 게 여기서 보입니다. 이 값이 틀리면 비-MCV 등치 조건이 통째로 빗나갑니다.

histogram을 사용하는 범위 조건​

<, > 같은 범위 조건은 histogram_bounds로 값이 어느 구간에 떨어지는지를 보고 비율을 보간합니다.

SELECT * FROM tenk1 WHERE unique1 < 1000;

histogram 경계가 {0, 993, 1997, 3050, ...}(10구간)이고 1000이 993~1997 구간 안에 있다면,

selectivity = (1 + (1000 - 993) / (1997 - 993)) / 10 = 0.100697
추정 row = 10000 × 0.100697 ≈ 1007

독립을 가정하는 AND 결합​

여러 조건이 AND로 묶이면 planner는 기본적으로 각 조건이 서로 독립이라 보고 selectivity를 곱합니다.

SELECT * FROM tenk1 WHERE unique1 < 1000 AND stringu1 = 'xxx';
selectivity = 0.100697 × 0.0014559 = 0.0001466
추정 row = 10000 × 0.0001466 ≈ 1

이 독립 가정이 다음 절의 함정으로 이어집니다. 추정값과 실제값은 EXPLAIN으로 바로 대볼 수 있습니다.

EXPLAIN ANALYZE
SELECT * FROM customers WHERE state = 'CA';
-- Seq Scan ... (rows=1740 ...) (actual ... rows=1736 ...)
-- ↑ 추정 ↑ 실제

추정(rows=)과 실제(actual ... rows=)가 크게 벌어지는 노드가 통계 문제의 출발점입니다.

통계가 틀어질 때의 운영 점검​

default_statistics_target​

pg_statistic에 담기는 MCV/histogram 배열의 최대 길이는 컬럼별 ALTER TABLE ... SET STATISTICS로, 또는 전역 default_statistics_target으로 정합니다. 공식 문서 기준 기본 한도는 100입니다. 값을 키우면 샘플이 늘고 배열이 길어져 분포가 불규칙한 컬럼에서 추정이 정밀해지지만, pg_statistic 공간과 ANALYZE 시간을 더 씁니다.

-- 특정 컬럼만 정밀하게
ALTER TABLE customers ALTER COLUMN signup_date SET STATISTICS 1000;
ANALYZE customers;

특정 컬럼의 단일 추정이 계속 빗나가면, 전역값을 올리기 전에 그 컬럼만 타깃을 올리는 게 비용 대비 효과가 좋습니다.

조정 대상효과비용
전역 default_statistics_target 상향모든 컬럼 추정 정밀ANALYZE 시간/공간 전반 증가
컬럼 SET STATISTICS 상향해당 컬럼만 정밀그 컬럼만 비용 증가
컬럼별 다중 통계상관된 컬럼 조합 추정ANALYZE 시 추가 계산

다중 컬럼 상관과 extended statistics​

AND 결합의 독립 가정은 컬럼들이 실제로 상관돼 있으면 깨집니다. 시드 글의 예가 명확합니다. WHERE city = 'Cheyenne' AND state = 'WY'는 도시가 정해지면 주는 사실상 결정되는데, planner는 둘을 독립으로 보고 곱해 8 row로 추정합니다. 실제는 4012 row로, 약 500배 차이가 납니다.

PostgreSQL 10부터 도입된 extended statistics가 이 문제를 풉니다. CREATE STATISTICS로 관심 컬럼 조합을 등록하면 ANALYZE가 그 조합의 통계를 함께 모읍니다.

CREATE STATISTICS customers_city_state (dependencies, ndistinct)
ON city, state FROM customers;
ANALYZE customers;

이후 같은 쿼리의 추정은 4087 row로, 실제(4012)에 거의 붙습니다. 단, dependencies(함수 종속성)는 컬럼을 상수와 비교하는 단순 등치 조건과 상수 IN 절에만 적용되고, 두 컬럼끼리 비교하거나 범위/LIKE 조건에는 쓰이지 않는다는 한계가 있습니다. 한 가지 더, extended statistics도 일반 단일 컬럼 통계와 같은 샘플로 계산되므로, 통계 타깃을 올리면 extended statistics도 함께 정밀해집니다.

통계가 의심될 때의 점검 순서​

추정과 실제가 벌어지는 노드를 찾았다면 순서대로 봅니다.

  1. EXPLAIN ANALYZE로 추정 row와 실제 row의 차이를 짚습니다.
  2. 문제 컬럼의 pg_stats에서 n_distinct와 MCV를 확인합니다.
  3. 통계가 낡았으면 ANALYZE를 먼저 실행합니다.
  4. 단일 컬럼 추정이 계속 빗나가면 그 컬럼의 SET STATISTICS를 올립니다.
  5. 다중 컬럼 상관이 원인이면 extended statistics를 만듭니다.
  6. 그래도 안 되면 그때 쿼리 재작성을 검토합니다.

대량 적재나 마이그레이션 직후, 파티션 추가 직후처럼 데이터가 급변한 시점에는 autovacuum의 ANALYZE를 기다리지 말고 직접 실행해 두는 습관이 추정 사고를 가장 많이 막아 줍니다.

정리​

planner는 데이터를 보지 않고 통계를 봐요. ANALYZE가 무작위 샘플로 분포를 떠서 pg_statistic에 채우고, pg_stats가 그걸 읽기 좋게 펼칩니다. 등치 조건은 MCV의 빈도나 n_distinct로, 범위 조건은 histogram_bounds로, index scan 여부는 correlation으로 추정합니다. 추정이 빗나가면 인덱스를 의심하기 전에 pg_stats를 먼저 열어 보고, 단일 컬럼이면 통계 타깃을, 상관된 컬럼이면 extended statistics를 손봅니다. 이 흐름만 손에 익으면 "왜 이 계획이 나왔지"의 절반은 통계 한 군데에서 답이 나와요.


참고한 출처: