본문으로 건너뛰기

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 풀을 두고 세션을 받는 그림이 자연스럽게 그려집니다.

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

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

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

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

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

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

어떤 조직에 의미가 있나

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

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

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

베타답게 다듬는 중인 흔적도 보입니다. 바로 다음 릴리스(2.1.233)에서 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배

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

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

SIMD는 마지막 단계입니다. batch로 정렬된 데이터는 벡터 연산에 이상적인 모양이라, ARM64 벡터 명령으로 덧셈을 처리하면 일반 루프보다 3배 빨라집니다. 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만 현대식으로 갈아 끼우면 어디까지 가는지 보여주는 실험이라는 점. 둘, vacuum이 미운 날에서 다뤘던 것과 같은 질문, 즉 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는 마지막 몇 분치만 감당하니 부하가 거의 없습니다.

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

참고 자료

DuckDB v2.0 비동기 I/O

· 약 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를 기다리며 잠드는 구조는 더 이상 용납되지 않는다는 것입니다.

같은 글에서 DuckDB는 v2.0과 함께 가는 방향도 살짝 내비쳤습니다. 8월 5일 40,000 스타 기념 글에서는 다중 동시 쓰기를 지원하는 원격 프로토콜 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이 설정돼 있어야 합니다. 그리고 primary의 synchronized_standby_slots에 그 물리 slot 이름을 넣어, logical slot이 standby가 아직 받지 못한 지점보다 앞서 나가지 못하게 막습니다.

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

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

3사 비교

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

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

추상화 수준이 갈리는 지점

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

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

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

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

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

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

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

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

Aurora는 어떤가

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

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

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

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

# primary
wal_level = logical
synchronized_standby_slots = 'standby_phys_slot'

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

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

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

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

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

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

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

정리

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

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

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

참고

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

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 하나뿐이고, 시뮬레이션 코드는 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_activitystate = 'idle in transaction'으로 걸러 xact_start 순으로 정렬하라고 알려주고, 버려진 replication slot과 hot_standby_feedback이 켜진 standby의 장기 쿼리도 같은 메커니즘이라고 덧붙입니다.

이 대목은 어제 쓴 MVCC 비용 비교 글에서 직접 실험으로 확인한 내용과 그대로 겹칩니다. VACUUM VERBOSE0 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 페이지 단위로 시각화하면 사람 눈에는 아무것도 안 보입니다. 그래서 보여주는 층은 축척을 조정한 모델로 두고, 정확성이 중요한 실행 계획은 실물 엔진에 맡겼습니다.

한계와 쓸 자리

버전이 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 MostUber의 2016년 MySQL 전환기예요. 둘 다 틀린 말을 하지는 않아요.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

여기서 갈립니다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PostgreSQL만 하지 않는 선택 하나

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

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

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

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

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

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

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

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

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

정리

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

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

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

참고

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

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라는 기능이 내장되어 있어서, 서브에이전트를 병렬로 띄우고 각자 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를 스크립트 한 줄로 뽑는 작업이 여기 해당해요. 매번 마우스로 저장 버튼을 누르던 일이 커맨드가 되는 순간부터 제값을 해요.

참고