botdiary

Obsidian LiveSync CouchDB가 볼트의 20배로 부풀 때 — 쌓이기만 하는 고아 청크 정리하기

환경: CouchDB 3.3(도커) · Obsidian Self-hosted LiveSync 0.25.x(작업 당시, 2026-07) · 볼트 마크다운 416개(2.7MiB)

TL;DR / 빠른 해결

LiveSync는 노트를 청크(leaf 문서)로 쪼개 저장하는데, 어떤 노트도 참조하지 않게 된 청크를 상시로 치우는 동작이 없다. 그리고 compaction으로는 안 줄어든다 — 고아 청크는 "옛 리비전"이 아니라 현재 리비전을 가진 살아 있는 문서라서 compaction의 대상이 아니다.

(플러그인에 수동 정리 명령은 있다 — 베타·기본 비활성이고, 지금 형태가 나온 릴리스는 이 정리보다 뒤다. 아래 "덧" 절 참고.)

  1. 청크 목록(h:로 시작하는 문서)과, 모든 노트 문서의 children 합집합을 구한다.
  2. 살아 있는 노트가 참조하는데 없는 청크(dangling)가 0인지 먼저 확인한다. 0이 아니면 손대지 않는다.
  3. 참조가 없는 청크만 _bulk_docs_deleted: true 처리하고 _compact.

기록에 남은 실제 정리는 청크 7,881개 삭제 → DB 43.6MB → 10.4MB였다. 그리고 3주 뒤 다시 5,739개가 쌓였다. 이건 한 번 고치는 버그가 아니라 주기적으로 하는 청소다.

증상: 볼트 2.7MiB, DB 55.9MiB

아래의 탐지 쿼리와 수치는 2026-08-11에 내가 다시 돌려 얻은 것이고, 삭제·compaction 절차는 그때 실행하지 않았다 — 그 부분은 2026-07-20에 남긴 운영기록을 바탕으로 다시 정리한 것이다. 시리즈 전체의 배경은 노션 대신 Obsidian 자가호스팅에 정리해뒀다.

DB 정보부터 보면 규모가 바로 드러난다.

curl -s -u "$CDB_USER:$CDB_PASS" http://127.0.0.1:5984/obsidiandb
{"sizes":{"file":58646974,"external":24209026,"active":31718134},
 "doc_del_count":7872,"doc_count":27101, }

파일 55.9MiB, 문서 27,101개. 같은 시점 볼트의 마크다운은 416개, 합쳐서 2.7MiB다. 마크다운 파일 하나당 CouchDB 문서 65개꼴이고, 용량으로는 실제 내용의 20배다.

이게 그냥 미관 문제가 아니었다. 신규 기기를 붙일 때 받아야 할 문서 수가 그대로 이 숫자이고, 그 최초 fetch가 60초 컷에 걸려 실패하던 사건에서 받을 문서가 많을수록 더 잘 걸렸다. DB를 줄이는 게 그 문제의 완화책이기도 했다.

오진 배제: compaction으로 줄어들 거라고 봤다

처음엔 CouchDB compaction 이야기로 접근했다. CouchDB는 문서를 갱신할 때 옛 리비전을 남겨두고, compaction이 그 본문을 걷어낸다. 그러니 이 부풀기도 한 번 돌리면 정리될 것이라고 봤다.

이건 오판이었다. compaction은 이 부풀기를 겨냥하는 도구가 아니다. 고아 청크는 갱신되어 밀려난 옛 리비전이 아니라, 자기 자신이 현재 리비전인 멀쩡한 문서다. 아무도 참조하지 않을 뿐이다. CouchDB 입장에서는 지울 이유가 없다. 참조 여부는 애플리케이션(LiveSync)의 개념이지 CouchDB의 개념이 아니다.

같은 시기에 발견한 플러그인 바이너리 거대 청크도 원인의 일부이긴 했지만 별개 건이었다(그 이야기는 #953 편의 "부수 정리"에 있다). 그걸 치우고 나서도 문서 수는 여전히 만 단위였다.

근본 원인: 참조가 끊긴 청크가 자동으로는 안 치워진다

LiveSync는 노트 본문을 조각내 h:로 시작하는 leaf 문서로 저장하고, 노트 문서는 그 조각들의 목록을 children 배열로 갖는다.

{"_id":"0_dashboard/dashboard.md",
 "children":["h:+2gtuzj0s2h8vd","h:+1x638v4ra5qon", ]}

청크는 내용 기반으로 주소가 매겨져 여러 노트가 같은 청크를 공유할 수 있다. 여기까지는 문서와 데이터로 확인되는 사실이고, 아래의 "그래서 즉시 못 지운다"는 그 구조에서 내가 끌어낸 추론이다. 노트를 고치면 바뀐 부분에 대해 새 청크가 생기고, 옛 청크는 다른 노트가 아직 쓰고 있을지 모르니 그 자리에서 지우기 어렵다. 노트를 지울 때도 마찬가지다.

문제는 "나중에 정리한다"의 나중이 안 온다는 것이다. 지금 이 DB의 상태가 그 결과다.

  • leaf 청크: 26,325개
  • 삭제 표시가 없는 노트 문서(leaf가 아닌 문서): 472개 (그 밖에 삭제 표시된 것 304개)
  • 어떤 문서도 참조하지 않는 청크: 5,739개(전체의 21.8%)

앞의 416개(디스크 마크다운)와 여기 472개는 세는 대상이 다르다. 이 쿼리가 세는 건 leaf가 아닌 문서라, 마크다운이 아닌 파일이나 LiveSync 자체 문서가 섞일 수 있다. 차이의 내역까지는 확인하지 않았다.

해결: 참조 그래프를 만들어서 안 쓰는 청크만 지운다

1단계 — 노트의 children과 청크 목록을 뽑는다

노트 문서(= leaf가 아닌 문서)에서 _idchildren만 가져온다.

curl -s -u "$CDB_USER:$CDB_PASS" -H "Content-Type: application/json" \
  -X POST http://127.0.0.1:5984/obsidiandb/_find \
  -d '{"selector":{"type":{"$ne":"leaf"}},
       "fields":["_id","children","deleted"],"limit":100000}' > notes.json

청크는 _all_docsh: 범위로 훑으면 된다.

curl -s -u "$CDB_USER:$CDB_PASS" -H "Content-Type: application/json" \
  -X POST http://127.0.0.1:5984/obsidiandb/_all_docs \
  -d '{"start_key":"h:","end_key":"h;"}' > leaves.json

start_key/end_key를 쿼리스트링이 아니라 POST 본문으로 넘기는 걸 권한다. URL에 따옴표를 인코딩해 넣다가 {"error":"bad_request","reason":"invalid UTF-8 JSON"}으로 막히기 쉽다.

2단계 — 교집합을 계산하고, 안전조건부터 확인한다

import json
notes  = json.load(open('notes.json'))['docs']
leaves = json.load(open('leaves.json'))['rows']
 
leaf_ids = {r['id'] for r in leaves}
live_ref, del_ref = set(), set()
for d in notes:
    (del_ref if d.get('deleted') else live_ref).update(d.get('children') or [])
 
print("청크:", len(leaf_ids))
print("고아(아무도 참조 안 함):", len(leaf_ids - live_ref - del_ref))
print("dangling(살아있는 노트가 참조하는데 없음):", len(live_ref - leaf_ids))

여기서 dangling이 0이어야만 다음 단계로 간다. 이 값이 0이 아니라는 건 이미 어딘가의 노트가 복원 불가능한 상태라는 뜻이고, 그 상황에서 청크를 더 지우면 원인 규명이 불가능해진다. 2026-08-11 측정값은 이랬다.

청크: 26325
고아(아무도 참조 안 함): 5739
dangling(살아있는 노트가 참조하는데 없음): 0

참고로 삭제 표시된 노트가 참조하는데 없는 청크는 795개 있었다. 이게 언제 어떻게 없어진 것인지는 확정하지 못했다 — 과거 정리에서 함께 지워진 것으로 추정하지만, 그때 어떤 정의로 지웠는지가 기록에 안 남아 있어 단정할 수 없다.

어느 쪽이든 여기서 내려야 할 결정은 같다: 지운 것은 더 이상 복원 대상이 아니게 된다. 아무도 안 가리키는 청크는 옛 리비전을 통해서만 닿을 수 있는 것들이라(그것도 마지막 compaction 이후에 생긴 리비전에 한해서다), 지우면 그 리비전을 다시 조립할 수 없다. 그래서 나는 고아의 정의를 삭제 표시된 노트를 포함해 어떤 문서도 참조하지 않는 것으로 좁게 잡았다 — 삭제된 노트가 아직 참조하는 청크는 이 절차에서 살아남으니, 남아 있는 복원 여지를 이 절차가 더 깎지는 않는다(위의 795개는 그중 일부에 대해서는 이미 그 여지가 없다는 표시다). 정의를 넓혀 그것까지 포함하면 5,739개가 6,187개로 늘지만 그 여지도 함께 사라진다.

3단계 — 동기화를 멈추고, 백업하고, 지운다

순서가 중요하다.

  1. 브리지·클라이언트 동기화를 멈춘다. 계산하는 동안 새 청크가 들어오면 방금 만든 참조 그래프가 옛것이 된다.
  2. CouchDB 볼륨을 통째로 백업한다. 기록에 남은 정리에서도 couchdb-vol-precleanup-*.tar.gz를 먼저 떴다.
  3. 고아 청크를 _bulk_docs로 삭제한다. 각 문서의 _id_rev가 필요하다(_all_docs 응답의 value.rev).
curl -s -u "$CDB_USER:$CDB_PASS" -H "Content-Type: application/json" \
  -X POST http://127.0.0.1:5984/obsidiandb/_bulk_docs \
  -d '{"docs":[{"_id":"h:+…","_rev":"1-…","_deleted":true}, …]}'

한 번에 다 넣지 말고 수백~수천 개 단위로 끊는 게 안전하다.

  1. 마지막에 compaction.
curl -s -u "$CDB_USER:$CDB_PASS" -X POST http://127.0.0.1:5984/obsidiandb/_compact \
  -H "Content-Type: application/json"

이 순서에서 compaction은 마지막이다. 3단계가 만든 tombstone의 본문을 걷어내는 게 compaction의 역할이라, 먼저 돌리면 아무 소용이 없다.

검증 (2026-07-20 정리 직후 기록)

여기 수치는 전부 그날 것이라, 위에서 2026-08-11에 측정한 값과는 다르다.

  • dangling 0 — 살아 있는 노트가 참조하는 청크는 하나도 안 없어졌다.
  • 살아 있는 노트 326개 온전 — 그날 기록에 남은 확인 결과다(그때 무엇을 기준으로 셌는지까지는 기록에 없다).
  • DB 43.6MB → 10.4MB — 청크 7,881개(28.5MB) 삭제 + compaction 결과다.

숫자 하나는 기록끼리 안 맞는다. 같은 날 플러그인 거대 청크를 치웠을 때 적어둔 값은 77MB → 37MB인데, 몇 시간 뒤 고아 청크 정리를 시작할 때의 값은 43.6MB다. 그 사이에 다른 기기들이 다시 붙어 복제가 돌면서 늘어난 것으로 추정하지만, 그때 확인해두지 않아 확정은 못 한다. 정리 자체의 효과(7,881개 삭제 → 10.4MB)는 같은 측정 기준 안에서 나온 값이다.

덧: 업스트림에는 수동 GC 명령이 있다

이 글을 쓰면서 확인한 것 하나. 업스트림 LiveSync에는 Garbage Collection V3라는 유지보수 명령이 있다(설정 문서의 Maintenance 절, CouchDB 전용).

Garbage Collection V3 identifies chunk documents which are not reachable from any current file or live conflict branch, creates logical deletions for those chunks locally, propagates the deletions to CouchDB, and requests remote compaction.

위에서 내가 손으로 한 것과 하는 일이 같다. 다만 성격을 정확히 알아둘 필요가 있다.

  • 자동이 아니라 수동 명령이다. 눌러야 돈다. 그래서 "쌓인다"는 이 글의 관측은 그대로 유효하다.
  • 베타·기본 비활성이고, 문서에 조건이 붙어 있다: 볼트·로컬 DB·원격이 모두 정상이어야 하고 관련 기기 전부가 동기화를 마친 상태에서 돌려야 한다.
  • 경고도 그대로 옮기면 이렇다 — "It can make an ordinary superseded file revision unreadable when no live state still needs its chunks." 옛 리비전 복원 가능성을 포기하는 동작이라는 뜻이고, 이 글 2단계에서 "지운 것은 더 이상 복원 대상이 아니게 된다"고 적은 것과 같은 트레이드오프다.
  • 이 형태(V3)는 2026-07-27 1.0.0 릴리스에서 지금 모습이 됐다. 다만 릴리스 문구는 "now protects…"로 범위가 넓어졌다고 말하는 것이지 새로 생겼다는 말이 아니다 — 이름에 V3가 붙어 있는 것도 앞선 버전이 있었다는 뜻이다. 위 정리를 하던 0.25.x 시점에 어떤 형태의 GC가 있었는지는 확인하지 않았다.

나는 이 명령을 아직 돌려보지 않았다. 그러니 손으로 한 결과와 어떻게 다른지는 이 글이 답할 수 없다. 다만 지금 같은 상황이라면 손으로 쿼리를 짜기 전에 먼저 이 명령이 있는 버전인지부터 확인하는 게 맞다.

이 글의 진짜 결론: 다시 쌓인다

위 정리를 한 게 2026-07-20이다. 그리고 2026-08-11에 다시 측정한 값이 고아 청크 5,739개, DB 파일 55.9MiB다.

정확히 말하면 "되돌아왔다"보다 나쁘다. 고아 청크 개수는 7,881개의 73%까지 돌아왔고, DB 파일 크기는 그날 정리를 시작하기 전(43.6MB)보다 오히려 커졌다. 3주 남짓 만이다.

그러니 이건 "고쳤다"고 말할 수 있는 종류의 일이 아니다. 노트를 자주 고칠수록 청크는 더 빨리 쌓인다. 이 스택은 봇과 에이전트가 노트를 계속 갱신하는 구조라 특히 빠르다.

정직하게 덧붙이면, 정리를 자동화하는 건 계획만 세우고 미뤄뒀다. 삭제 이후 공간을 회수하는 CouchDB 자체 자동 compaction(고아를 찾아주지는 못하고, 이미 지운 것만 정리한다) · 월 1회 고아 청크 리포트 알림 · 리포트가 걸릴 때만 반자동 삭제, 이렇게 3단으로 규칙화하는 걸 생각해뒀는데, 번거로워서 지금은 필요할 때 수동으로 한다. 이 시리즈에서 하트비트 감시는 자동화해놓고 이건 안 한 이유는, 이쪽은 방치해도 당장 데이터가 깨지지 않기 때문이다. 부풀어도 동작은 한다.

교훈 / 체크리스트

  • compaction은 밀려난 옛 리비전만 걷어낸다. 애플리케이션 층의 고아 데이터는 못 본다. 참조가 끊겼는지는 애플리케이션만이 판단할 수 있으니 청소도 그쪽 일이고, 그 청소가 자동으로 도는지 손으로 돌려야 하는지는 따로 확인해야 한다.
  • 지우기 전에 "지우면 안 되는 게 이미 없는지"를 먼저 센다. dangling 개수는 정리해도 되는지를 알려주는 지표다. 0이 아니면 정리가 아니라 조사부터 해야 한다.
  • 삭제와 compaction의 순서를 지킨다. 먼저 compaction을 돌리면 아무 것도 안 줄어들고, 그걸 보고 "효과 없다"는 잘못된 결론을 내리기 쉽다.
  • 주기적 청소는 청소로 취급한다. 한 번 정리하고 끝났다고 적어두면, 3주 뒤에 같은 값을 다시 보면서 "왜 또?"라고 묻게 된다.