앱에서 지운 노트가 서버에는 그대로 남을 때 — livesync-bridge가 삭제만 영구히 흘리는 이유
환경: livesync-bridge(Deno) 2대(미니PC·라즈베리파이) · CouchDB 3.3(미니PC 로컬) · Obsidian Self-hosted LiveSync · Docker
TL;DR / 빠른 해결
옵시디언 앱에서 지운 노트가 서버 파일시스템에는 그대로 남아 있는 경우가 있다. 그리고 한 번 그렇게 남은 파일은 저절로 사라지지 않는다. 서버 쪽에서 그 파일을 읽는 무언가가 있으면 계속 "있는 노트"로 취급하고, 그 파일이 다시 올라가 노트를 되살릴 수도 있다.
삭제가 영구히 유실되는 이유는 두 겹이다. 브리지가 죽어 있던 동안 CouchDB에서 일어난 변경은 재시작해도 재생되지 않는다 — 저장되는 since 값이 리터럴 문자열 "now"라 켤 때마다 변경 피드를 "지금부터" 다시 열기 때문이다. 그리고 오프라인 스캔은 파일시스템 → CouchDB 한 방향이라, 놓친 삭제를 되잡는 경로가 아예 없다.
- 놓친 게 수정(내용 변경)이면 티가 잘 나지 않는다. 다음에 그 노트를 또 고치면 따라잡히니까. 삭제는 아니다. 다시 일으켜줄 이벤트가 없어서 유령 파일이 영구히 남는다.
- 그래서 규칙을 뒤집었다 — 서버나 서버에서 도는 봇이 만들고 관리하는 노트는 앱에서 지우지 말고 서버 파일시스템에서
rm한다. 단 브리지가 떠 있을 때 해야 한다. 브리지가 죽어 있는 동안 지운 파일은 아래 근본 원인 3 때문에 똑같이 유실된다. - 추가로 역방향 정합 크론을 붙였다. CouchDB에서 삭제 상태인데 서버에 파일이 남아 있는 노트만 백업 후 지운다.
증상: 자동 생성 노트의 번호가 튀고, 하나가 두 개가 됐다
이 글은 지금 다시 실행해 재현한 게 아니라, 그때 남긴 운영기록과 지금도 남아 있는 브리지 로그를 바탕으로 정리한 것이다. 아래 로그 인용의 경로는 앞부분을 줄였다. 시리즈 전체의 배경은 노션 대신 Obsidian 자가호스팅에 정리해뒀다.
이 볼트에는 영상마다 노트를 자동으로 만드는 봇이 하나 있다. 새 노트 이름은 디스크에 있는 파일 중 제일 큰 번호 + 1로 붙는다.
사용자가 이상을 신고했다. rings_06에 해당하는 영상을 지우고 다시 올렸더니 새 노트가 rings_06이 아니라 rings_07로 건너뛰었고, 그 뒤로 번호가 계속 밀렸다. 확인해보니 rings_06.md와 rings_07.md가 같은 영상을 가리키는 중복 노트였다.
오진 배제: 봇의 번호 매기기 로직
첫 가설은 봇이었다. 번호를 "디스크 최댓값 + 1"로만 정하고 유령 파일에 대한 방어가 없으니, 여기서 틀린 게 맞아 보였다.
절반만 맞았다. 그 로직에 방어가 없는 건 사실이고 나중에 보강도 했다. 하지만 입력이 이미 틀려 있었다. 사용자가 앱에서 지운 노트가 서버 파일시스템에는 남아 있었고, 봇은 그 남은 파일을 보고 정확하게 "다음 번호"를 계산한 것이다. 번호 로직만 고쳤으면 유령 파일은 그대로 남고 증상만 다른 모양으로 다시 나왔을 것이다.
여기서 방향을 틀어, "지운 파일이 왜 서버에 남아 있나"를 봤다.
진단: 로그에 죽는 순간이 그대로 남아 있었다
아래 로그는 CouchDB와 같은 호스트에 있는 허브 쪽 브리지 것이다. 근거는 두 가지다 — 이 로그를 그 허브 기기의 컨테이너에서 직접 뽑았고, 그 기기 dat/config.json의 couchdb 피어 URL이 컨테이너 네트워크 안(같은 호스트)을 가리킨다. 로그의 [pi-vault]는 기기 이름이 아니라 storage 피어 이름인데, 허브를 라즈베리파이에서 옮길 때 설정을 그대로 가져와 두 기기가 같은 피어 이름을 쓴다 — 이 이름만으로는 어느 기기인지 안 갈린다.
브리지 로그는 어느 방향으로 무엇이 흘렀는지를 남긴다. 삭제가 CouchDB에서 파일시스템으로 내려온 순간은 이렇게 찍힌다.
7/22/2026, 8:44:54 AM 32 [obsidiandb] --> …/videos/rings_06.md delete detected
7/22/2026, 8:44:54 AM 32 [pi-vault] <-- /app/data/vault/…/videos/rings_06.md deleted
여기까지는 정상이다. 문제는 몇 시간 뒤의 이 구간이었다.
1:06:39 PM 32 [obsidiandb] --> …/videos/rings_07.md delete detected
1:06:39 PM 32 [pi-vault] <-- /app/data/vault/…/videos/rings_07.md deleted
1:06:41 PM 16 WATCH: PROCESSING: …/videos/rings_06.md
1:06:41 PM 64 Missing document content!, could not read …/videos/rings_06.md(1_projec) from database.
error: Uncaught (in promise) Error: Corrupted document: …/videos/rings_06.md
throw new Error(`Corrupted document: ${doc.path}`);
^
at DirectFileManipulator.getByMeta (file:///app/lib/src/API/DirectFileManipulatorV2.ts:313:19)
Task run deno run -A main.ts
LiveSync Bridge is now starting...
삭제를 연달아 처리하던 도중에 프로세스가 통째로 죽고 재시작했다. 청크가 없는 문서를 읽으려다 던진 예외가 아무 데서도 잡히지 않은 것이다.
여기서 경계를 그어둔다. 아래에서 설명할 메커니즘(예외가 프로세스를 죽이는 것, 저장된 since가 "now"인 것, 오프라인 스캔이 한 방향인 것)은 코드와 저장값으로 확인한 사실이다. 반면 이 사건의 유령 파일이 정확히 어느 크래시 창에서 생겼는지는 로그로 특정하지 못했다 — 위 인용에 보이는 두 삭제는 둘 다 파일 삭제까지 성공한 것들이다. 구조가 그런 유실을 만들 수 있다는 것까지가 확정이고, 이 사건과의 연결은 정황이다.
근본 원인 1: getByMeta는 try 바깥에 있다
변경 피드 핸들러의 구조를 보면 이유가 명확하다.
Logger(`WATCH: PROCESSING: ${doc.path}`, LEVEL_VERBOSE, "watch");
const docX = await this.getByMeta(doc); // ← try 바깥
try {
await callback(docX, change.seq);
Logger(`WATCH: PROCESS DONE: ${doc.path}`, LEVEL_INFO, "watch");
} catch (ex) {
Logger(`WATCH: PROCESS FAILED`, LEVEL_INFO, "watch");
}callback은 try로 감쌌는데, 그 앞에서 문서를 조립하는 getByMeta는 밖에 있다. 그리고 getByMeta는 청크가 하나라도 빠지면 던진다.
async getByMeta(doc: MetaEntry): Promise<ReadyEntry> {
const docX = await this.liveSyncLocalDB.getDBEntryFromMeta(doc as LoadedEntry);
if (!isReadyEntry(docX)) {
throw new Error(`Corrupted document: ${doc.path}`);
}
return docX;
}async 핸들러 안에서 던진 예외라 잡히지 않은 프로미스 거부(unhandled rejection)가 되고, Deno는 프로세스를 종료한다. 문서 하나가 손상되면 브리지 전체가 죽는다.
근본 원인 2: 저장된 since가 리터럴 "now"다
죽는 것 자체는 재시작으로 넘길 수 있다. 진짜 문제는 재시작이 잃어버린 구간을 안 되돌린다는 쪽이다.
브리지는 이렇게 시작 지점을 정한다.
// Fetch remote since.
this.man.since = this.getSetting("since") || "now";그리고 감시를 걸면서 그 값을 다시 저장한다.
this.man.beginWatch(async (entry) => { /* … */ }, (entry) => {
this.setSetting("since", this.man.since);
// …
});이름만 보면 "마지막으로 처리한 위치를 저장한다"로 읽힌다. 그런데 라이브 감시 중에 this.man.since를 갱신하는 코드가 없다. 매번 저장되는 값은 감시를 시작할 때의 그 값 그대로다.
실제로 무엇이 저장돼 있는지 확인해봤다. 이 설정은 Deno의 localStorage(컨테이너 안의 SQLite 파일)에 들어간다.
'obsidiandb-couchdb--since' = 'now'
리터럴 now다. 즉 브리지는 켜질 때마다 변경 피드를 "지금부터" 연다. 죽어 있던 시간이 3초든 3시간이든, 그 사이 CouchDB에서 일어난 변경은 재생되지 않는다.
근본 원인 3: 오프라인 스캔은 한 방향뿐이다
그래도 자가교정이 있으면 되지 않나 싶은데, 이 자리를 메워야 할 scanOfflineChanges가 반대 방향을 본다.
if (this.config.scanOfflineChanges) {
for await (const entry of walk(lP)) {
if (entry.isFile) {
const ePath = this.toPosixPath(relative(this.toLocalPath("."), entry.path));
if (await this.isChanged(ePath)) {
await this.dispatch(entry.path);
}
}
}
}파일시스템을 걸어다니며 바뀐 파일을 CouchDB로 올린다. 여기서 "바뀌었나"는 브리지가 따로 기억해둔 파일 stat과 비교해 판정하므로, 크래시로 못 올린 쓰기가 항상 여기에 걸리는 건 아니다(앞 편에서 본 것처럼 그때는 파일을 다시 건드려야 올라갔다). 어느 쪽이든 CouchDB 기준으로 파일시스템을 훑어 맞추는 스캔은 없다. 라이브 감시가 도는 동안에는 CouchDB의 삭제가 파일로 내려오지만(위 로그가 그 장면이다), 놓친 것을 나중에 되짚는 경로가 없다는 뜻이다. 그래서 그림이 완성된다.
- 놓친 게 수정이면 티가 잘 나지 않는다. 다음에 그 노트를 또 고치면 그때 올라가거나 내려오면서 따라잡힌다.
- 놓친 게 삭제면 되잡을 이벤트가 없다. 지워졌어야 할 파일이 남아 있고, 그 파일은 스캔 입장에서는 그냥 존재하는 파일이다. 오히려 변경으로 감지되면 PUSH로 다시 올라가 노트를 되살릴 수도 있다.
참고로 LiveSync에서 노트 삭제는 CouchDB의 tombstone(_deleted)이 아니라 문서에 deleted: true 필드가 붙은 살아 있는 문서다. 지금도 DB에서 이렇게 보인다.
{"_id":"…","path":"…","ctime":…,"mtime":…,"size":16,"type":"plain","deleted":true}이건 나중에 청소부를 만들 때 그대로 판정 기준이 된다.
해결 1: 삭제 방향을 뒤집었다
CouchDB와 같은 호스트에 있는 허브 서버에서 PUSH(파일시스템 → CouchDB)는 이 사건에서 잘 동작했다. 서버에서 rings_06.md를 rm 하자 브리지가 그걸 감지해 CouchDB 문서를 삭제 상태로 갱신했다(rev 11 → 12로 올라가며 삭제 반영, 부활 없음). 다만 이건 관측 1건이지 안정성 증명은 아니다.
그래서 규칙을 이렇게 뒤집었다.
서버/봇이 관리하는 노트의 삭제는 앱이 아니라 서버 파일시스템에서 한다. 단, 브리지가 떠 있는 것을 확인하고 한다.
뒤 조건이 중요하다. 근본 원인 3에서 본 대로 오프라인 스캔은 있는 파일만 훑는다. 브리지가 죽어 있는 동안 지운 파일은 스캔이 볼 대상 자체가 없어서, 재시작해도 그 삭제가 CouchDB로 올라가지 않는다. 방향만 바꾸면 되는 게 아니라 타이밍도 조건이다.
이게 뒤집기인 이유는, 운영 노트에 정확히 반대로 적혀 있었기 때문이다. 그 낡은 문장을 근거로 내가 사용자에게 앱에서 지우라고 안내한 적도 있다. 방향에 따라 신뢰도가 다르다는 걸 알기 전이었다.
해결 2: 역방향 정합 크론(청소부)
규칙만으로는 부족하다. 사람이 앱에서 지우는 일은 계속 생긴다. 그래서 하트비트 감시와 같은 패턴으로 크론 하나를 붙였다. CouchDB에서 삭제 상태인데 서버에 파일이 남은 노트만 정리한다.
핵심 판정은 이렇다.
for r in rows:
doc = r.get("doc") or {}
if not (doc.get("deleted") or doc.get("_deleted")):
continue
path = doc.get("path")
if not path or not path.endswith(".md"): # 노트만(청크 h:* 등 제외)
continue
fp = os.path.join(VAULT, path)
if not os.path.isfile(fp): # 이미 없음 = 정상
continue
if int(os.path.getmtime(fp) * 1000) > int(doc.get("mtime") or 0) + GRACE_MS:
skipped.append(path) # 파일이 더 최신 → 재생성 의심, 보류
else:
to_del.append((path, fp))안전장치를 네 겹 뒀다.
- 삭제 상태 문서만 본다. 그 외에는 아무것도 안 건드린다.
- 파일이 삭제 시각보다 5분 이상 최신이면 스킵한다. 지운 뒤에 다시 만든 파일을 청소부가 도로 지우면 안 되기 때문이다.
- 지우기 전에 백업 디렉토리로 복사한다.
- 한 번에 25개 상한. 넘으면 삭제하지 않고 목록만 알린다. 뭔가 크게 어긋난 상황에서 자동 삭제가 폭주하는 게 제일 무섭다.
크론은 두 개다. 봇 노트 폴더만 10분마다, 볼트 전체는 하루 한 번.
*/10 * * * * /path/to/scripts/reconcile-vault-deletions.py videos >> /path/to/reconcile.log
50 3 * * * /path/to/scripts/reconcile-vault-deletions.py all >> /path/to/reconcile.log
함정 하나. CouchDB 자격증명을 컨테이너 안 환경변수로 쓰는 경우, docker exec … sh -c 에 넘기는 URL은 큰따옴표여야 한다.
'curl -s "http://$COUCHDB_USER:$COUCHDB_PASSWORD@127.0.0.1:5984/obsidiandb/%s"' % qs여기서 따옴표는 URL을 감싼 쪽이 중요하다. sh -c로 넘기는 명령 문자열 안에서 URL을 '(작은따옴표)로 감싸면 컨테이너 셸이 $COUCHDB_USER를 그대로 문자로 받아 unauthorized가 되고, 응답에 rows가 없으니 조회 결과 0행 = "정리할 게 없음"으로 조용히 통과한다. "(큰따옴표)여야 컨테이너 안에서 변수가 펼쳐진다. 고장 났는데 정상처럼 보이는 종류라 특히 나쁘다.
또 하나. CouchDB의 _id는 소문자화된 경로다. 파일을 찾을 때는 _id가 아니라 문서의 path 필드를 써야 실제 대소문자와 맞는다.
검증 — 그리고 검증하지 못한 것
지금까지의 실적은 이렇다.
[2026-07-23 17:20:01] (videos) 변화 없음 (삭제0, 스킵0)
…
[2026-08-11 03:50:01] (all) 변화 없음 (삭제0, 스킵0)
18일 동안 2,675회 실행, 삭제 0건, 스킵 0건. 백업 디렉토리도 아직 안 생겼다.
이걸 어떻게 읽어야 할지는 솔직히 애매하다. 확실한 건 지금까지 살아 있는 노트를 지운 적이 없다는 것이다. 다만 삭제 0건은 삭제 분기가 한 번도 실행되지 않았다는 뜻이기도 하다 — mtime 유예도, 25개 상한도, 백업 복사도 실전에서 아직 발동한 적이 없다. 그리고 드리프트를 실제로 잡아낸 적도 없다. 도입 당시에는 별도 볼트를 만들어 유령 파일·재생성 파일·정상 노트를 넣고 동작을 확인했지만, 그건 시험대 위에서였다. 그러니 이 청소부는 "검증된 안전망"이 아니라 "아직 안 울린 알람"에 가깝다.
같은 기간에 브리지를 죽이던 크래시 원인 하나를 따로 고쳤으니, 재발이 없는 게 그 덕분일 수도 있다. 다만 그건 추정이고, 삭제 유실을 만드는 구조(since: "now" + 단방향 스캔)는 그대로 남아 있다.
덧붙여, 헤드리스 파일시스템 동기화는 이 생태계에서 아직 정리되지 않은 영역이다. CouchDB 볼트를 파일시스템에 동기화하는 헤드리스 클라이언트는 플러그인 본체 기능이 아니라 기능 요청 #815로 열려 있다(2026년 8월 기준). 이 시리즈가 쓰는 livesync-bridge는 플러그인과 같은 저자가 따로 만든 별도 프로젝트다. 같은 자리를 다르게 메우는 것으로는 예를 들어 obsidian-livesync-headless가 있다. 정해진 표준이 없다는 게 지금의 상태라, 나는 스택을 갈아엎는 대신 self-heal(청소부)로 대응하는 쪽을 골랐다.
교훈 / 체크리스트
- "마지막 위치를 저장한다"고 보이는 코드가 실제로 저장하는 값을 확인한다. 여기서는 커서 이름의 변수에 리터럴
"now"가 들어 있었고, 저장 코드는 매번 같은 값을 다시 썼다. 이름만 보고 넘어가면 절대 안 보인다. - 삭제는 수정과 다른 등급의 이벤트다. 놓친 수정은 다음 수정이 덮어주지만, 놓친 삭제는 스스로 다시 일어나지 않는다. 동기화를 설계할 때 삭제만 따로 생각해야 한다.
- 자가교정 스캔의 방향을 확인한다. 한 방향 스캔은 반대 방향의 유실을 못 고치고, 심지어 유실된 상태를 정본으로 밀어올릴 수 있다.
- 파이프라인 끝에서 난 증상의 원인은 대체로 그 앞에 있다. 번호 로직에도 손볼 데가 있었지만, 번호 로직만 고쳐서는 유령 파일이 그대로 남았을 것이다.
- 자동 정리에는 상한과 백업을 건다. 자동 삭제가 폭주하는 것보다 "상한 초과, 손으로 확인 요망" 알림을 받는 게 낫다.