PouchDB `changes` 피드가 에러도 없이 영원히 멈출 때 — `heartbeat`는 빠진 적이 없었다
환경: livesync-bridge(Deno) 2대(라즈베리파이·미니PC) · CouchDB 3.3(미니PC 로컬) · pouchdb-core / pouchdb-adapter-http / pouchdb-replication 9.0.0 · tailscale HTTPS
TL;DR / 빠른 해결
.changes({live: true})로 연 피드가 에러도 완료도 없이 그냥 멈춘다면, heartbeat에 간격 값을 넣거나 늘리는 건 답이 아니다.
live: true는 내부적으로continuous가 되고,pouchdb-adapter-http는 continuous 피드에heartbeat=10000을 기본으로 붙인다. 즉 그 옵션은 애초에 빠진 적이 없다.- 같은 어댑터의 changes 코드가
requestTimeout을 계산해두지만 그 값을 그 뒤로 쓰지 않는다. 이 경로의 fetch에는 마감시한이 안 붙는다는 뜻이다. 그래서 소켓이 조용히 죽으면 에러 없이 영원히 대기한다. .on("error")에 걸어둔 재접속은 그래서 영영 안 돈다. 실제로 필요한 건 "연결이 살아 있나"가 아니라 "최근에 뭔가 들어왔나"를 바깥에서 재는 장치다.- 그리고
retry는changes()의 옵션이 아니다(복제 쪽 옵션이다). 내가 넣은 두 줄 중 한 줄은 처음부터 무시되고 있었다. - 그리고 그 두 줄을 넣은 뒤에도 pull은 계속 멈췄다. 감시 크론 로그에 재시작 기록이 그대로 남아 있다(아래 검증 절).
이 글은 내가 내렸던 진단이 틀렸다는 걸 확인하는 글이다. 아래에 그 과정을 그대로 적었다.
증상: 자가복구가 붙었는데도 계속 죽었다
이 글의 사건 경위는 그때 남긴 운영기록을 바탕으로 정리했고, 소스 인용과 아래 감시 로그는 오늘 내가 두 기기에서 직접 읽었으며, 해석도 오늘 한 것이다. 아래에서 "허브 기기"는 CouchDB와 같은 호스트인 미니PC, "문제가 났던 기기"는 라즈베리파이다. 시리즈 전체의 배경은 노션 대신 Obsidian 자가호스팅에 정리해뒀다.
앞선 편에서 pull이 에러 한 줄 없이 죽는 사건을 겪었고, 그걸 잡으려고 하트비트 감시 크론을 붙였다. 감시는 잘 돌았다. 하트비트가 25분보다 낡으면 죽은 것으로 보고 컨테이너를 재시작하는데, 검사 주기가 10분이고 하트비트도 10분마다 갱신되니, 실제로는 죽고 15~35분 뒤에 잡힌다.
그런데 재시작 알림이 계속 왔다. 감시를 붙인 첫 이틀만 봐도 07-14에 2회, 07-15에 4회다. 두 숫자 모두 감시 크론이 보낸 알림을 센 값이고, 감시를 붙이기 전에는 이 고장을 셀 수단 자체가 없었다. 그러니 "원래보다 잦아졌다"고는 말할 수 없고, 이 이틀치만으로 추세를 말하기도 어렵다 — 실제로 뒤에 나오는 로그를 보면 재시작이 걸린 날만 놓고 볼 때 하루 1~2회가 흔하다. 그때 나를 움직인 건 추세라기보다 하루에도 몇 번씩 알림이 온다는 사실 자체였다.
감시가 붙어 있으니 노출 창은 며칠 단위에서 한 시간 안쪽으로 줄었지만, 그 창이 안전한 건 아니었다. 앞 편에서 확인한 대로 pull이 죽은 기기는 그동안 옛 파일을 최신인 척 밀어 올린다. 그러니 알림이 잦다는 건 그 위험에 자주 노출된다는 뜻이기도 했다. 그래서 이번엔 재시작 말고 원인을 봤다.
로그가 말해준 것: 죽는 순간에 아무 일도 안 일어난다
브리지 로그를 죽음 직전 구간부터 훑었다.
- 마지막 하트비트 기록까지 pull이 정상으로 들어왔다.
- 그 뒤로 아무것도 없다. 에러도, 재접속 시도도, 스트림 종료 로그도 없다.
이게 핵심 단서였다. 이 코드는 스트림이 정상 종료하면 WATCH: FINISHED를 남기고, 에러가 나면 에러를 남긴다. 둘 다 없었다. 즉 코드 입장에서는 아직 연결이 살아 있는 상태고, 그래서 아무 조치도 하지 않은 것이다.
내가 내린 진단(그리고 그게 왜 틀렸는지)
브리지가 CouchDB에서 변경을 받아오는 코드는 DirectFileManipulatorV2.ts의 이 부분이다. 아래는 문제가 났던 기기에 남아 있는 패치 전 백업(DirectFileManipulatorV2.ts.bak.20260716)에서 오늘 읽은 것이다.
.changes({
include_docs: true,
since: this.since,
selector: { type: { $ne: "leaf" } },
live: true,
})
.on("change", async (change) => { /* … */ })여기엔 heartbeat도 retry도 없고, 재접속 로직은 .on("error")에만 걸려 있다.
헷갈리기 쉬운 지점을 먼저 적어둔다. 여기서 코드 옵션으로 말하는 heartbeat는 감시 크론이 쓰는 하트비트 파일이 아니다. 이건 PouchDB가 CouchDB로 보내는 _changes 요청의 파라미터로, "몇 밀리초 간격으로 살아 있다는 신호를 보내달라"는 값이다(기본값이 10000, 즉 10초). 이름만 같고 층이 다르다.
어쨌든 나는 그때 이렇게 정리했다.
heartbeat가 없어서 연결이 끊겨도 클라이언트가 모른다 → 에러가 안 나니
.on("error")재접속이 안 돈다.
그리고 두 줄을 넣었다. 아래는 그 기기의 파일에서 오늘 오전에 읽은 것이다(같은 날 저녁에 이 자리를 한 번 더 바꾸는데, 그 얘기는 뒤에 나온다).
live: true,
retry: true, // 추가
heartbeat: 30000, // 추가이 진단은 틀렸다. 글을 쓰면서 근거를 다시 대려고 허브 기기의 컨테이너에 캐시된 pouchdb-adapter-http 9.0.0 소스를 열었는데, 세 가지가 나왔다.
첫째, heartbeat는 빠진 적이 없다. pouchdb-core는 live를 continuous로 옮기고,
if ('live' in opts && !('continuous' in opts)) {
opts.continuous = opts.live;
}HTTP 어댑터는 continuous 피드에 기본 heartbeat를 붙인다.
const DEFAULT_HEARTBEAT = 10000;
// …
if (opts.continuous && !('heartbeat' in opts)) {
opts.heartbeat = DEFAULT_HEARTBEAT;
}즉 원래 요청에도 heartbeat=10000이 들어가 있었으니, CouchDB는 10초마다 keep-alive를 보내는 설정이었다(요청 파라미터에서 끌어낸 것이지 패킷을 본 건 아니다). 내가 넣은 heartbeat: 30000이 한 일은 그 간격을 10초에서 30초로 늘린 것뿐이다.
둘째, retry는 changes()의 옵션이 아니다. pouchdb-core와 pouchdb-adapter-http 소스 전체에서 retry라는 문자열이 안 나온다. 그건 replicate/sync 쪽 옵션이다. 내가 근거로 삼았던 LiveSyncReplicator.ts의 retry: true도 복제 옵션이지 changes 옵션이 아니었다. 복제 코드는 자기가 아는 키만 골라서 changes 호출로 넘긴다 — 같은 자리에서 읽은 pouchdb-replication 9.0.0 소스를 보면 heartbeat와 timeout은 명시적으로 복사해 넣는데 retry는 그 목록에 없다. retry는 복제 층이 직접 쓰는 옵션이고, 복사 목록에도 없어서 changes()까지 내려가지 않는다. 같은 이름이 다른 API에 있는 걸 보고 "여기도 되겠지" 한 것이다. 그 줄은 처음부터 무시되고 있었다.
셋째, 진짜 문제는 따로 있었다. 어댑터는 요청 마감시한을 이렇게 계산한다.
let requestTimeout = ('timeout' in opts) ? opts.timeout : 30 * 1000;
// …
if ('heartbeat' in opts && opts.heartbeat &&
(requestTimeout - opts.heartbeat) < CHANGES_TIMEOUT_BUFFER) {
requestTimeout = opts.heartbeat + CHANGES_TIMEOUT_BUFFER;
}그리고 이게 이 글에서 제일 중요한 한 줄이다.
이 파일에서
requestTimeout은 계산된 뒤로 한 번도 쓰이지 않는다.
세 갈래로 정교하게 값을 구해놓고 아무 데도 넘기지 않는다. fetch에는 AbortController의 signal만 붙고, 그 컨트롤러를 시간에 맞춰 자르는 타이머가 없다. 즉 이 경로에는 클라이언트 마감시한이 아예 존재하지 않는다.
코드가 말하는 것: 아무도 마감시한을 걸지 않는다
세 조각을 합치면 이렇게 된다.
- CouchDB는
heartbeat가 지정된 요청에 대해 연결을 무기한 열어두고 그 간격으로 keep-alive만 보낸다. 서버가 먼저 끊지 않는다. - 클라이언트에는 그 fetch를 자를 타임아웃이 적용되지 않는다.
- 그러니 중간 경로에서 TCP 연결이 조용히 사라지면, 고장을 알리는 신호가 양쪽 어디에서도 나오지 않는다. 소켓은 열려 있는 것처럼 보이고 데이터만 영영 안 온다.
.on("error")는 이 상황에서 절대 안 불린다. 그래서 재접속도 안 돈다. 이 그림이면 로그가 조용했던 것이 설명된다 — 다만 이번 사건이 실제로 이 경로였는지는 확인하지 못했다(아래 검증 절의 로그 해석 범위, 그리고 "검증하지 못한 것"을 함께 보라).
여기서 heartbeat의 역할을 오해하기 쉽다. keep-alive는 "받는 쪽이 마감시한을 재고 있을 때만" 고장을 드러낸다. 재는 사람이 없으면 신호가 끊겨도 아무 일도 안 일어난다. 그리고 이 스택에서 마감시한을 재는 코드가 없었다.
플러그인에는 이 축을 그대로 노출한 설정이 하나 있다. "Use timeouts instead of heartbeats" 이고, 설명 원문은 이렇다.
If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.
두 가지를 짚어둔다.
- 이 설명이 내세우는 용도는 "프록시가 요청 시간을 제한할 때"다. 죽은 연결을 감지하려고 만든 옵션이라고 이 문서가 말하지는 않는다. 내 주장의 근거로 이 문장을 끌어오면 그건 과장이다.
- "PouchDB가 60초 붙들었다가 닫는다"는 표현도 곧이곧대로는 아니다. 코드에서 이 설정이 하는 일은 복제 옵션에
heartbeat: setting.useTimeouts ? false : 30000을 넣는 것뿐이다. 이 옵션을 켜면 heartbeat는 꺼지는데,timeout옵션도 아무도 넘기지 않는다(허브 기기 사본의 플러그인 복제 경로와 어댑터를 찾아봤다). 어댑터는timeout이 주어졌을 때만 그걸 요청 파라미터로 실으므로, 결과적으로 요청에는heartbeat도timeout도 안 실린다. 그러면 60초를 재는 건 CouchDB 쪽 기본값이다 — 클라이언트 타이머가 아니라 서버가 끊는 것이다.
그래서 정리하면, 끊김을 드러내는 축은 heartbeat가 아니라 "연결을 주기적으로 끊는" 쪽이다. 그리고 그 축을 켰을 때 시간을 재는 쪽은 클라이언트가 아니라 서버다 — 이 스택의 클라이언트에는 잴 수단 자체가 없다. 내가 고른 옵션은 그 축의 반대편이었다.
(같은 시리즈의 #953 편은 "heartbeat 없이 연 스트리밍 연결"을 원인으로 다뤘다. 서로 부정하는 게 아니라 코드 경로가 다르다 — #953 편에 적어둔 대로 그쪽은 플러그인의 fast fetch가 여는 연결이고, 이 글은 브리지가 PouchDB를 통해 여는 changes 피드다. 후자에는 어댑터가 기본 heartbeat를 채워 넣는다.)
검증: 두 줄을 넣은 뒤에도 계속 멈췄다
여기까지는 소스를 읽고 내린 결론이다. 그래서 실제로 어떻게 됐는지를 확인했다.
먼저 패치는 문제가 났던 기기에 실제로 들어가 있었다. 그 기기의 DirectFileManipulatorV2.ts에 두 줄이 주석과 함께 남아 있었고, 같은 디렉토리에 DirectFileManipulatorV2.ts.bak.20260716 백업도 그대로 있었다. 그 기기의 deno.jsonc도 pouchdb-core·pouchdb-adapter-http 모두 9.0.0으로, 위에서 읽은 소스와 같은 버전이다.
그리고 감시 크론 로그가 남아 있었다. 하트비트가 임계(25분)를 넘겨 낡은 걸 보고 실제로 재시작을 건 순간마다 한 줄씩 찍힌다.
2026-07-14 02:10:01 STALE age=1800s > 1500s -> restarting bridge
패치를 넣은 게 2026-07-16이다. 그 앞뒤로 세보면 이렇다.
- 패치 전: 07-14 2회, 07-15 4회
- 패치 후: 07-18 2회, 07-19 2회, 07-21 1회, 07-22 2회, 07-24 2회, 07-27 1회, 08-07 2회, 08-08 2회, 08-11 1회 (집계 구간은 2026-08-11까지다)
(패치 날짜는 그 기기에 남은 백업 파일명 DirectFileManipulatorV2.ts.bak.20260716과 운영기록이 같이 가리킨다.)
증상은 멈추지 않았다. 2026-08-11에도 한 번 찍혔다. 즉 "두 줄로 고쳤다"는 기록은 관측으로 반증된다.
다만 이 로그가 말해주는 범위는 정확히 해두자. 크론은 하트비트가 낡았다는 것만 안다. 그 15회가 전부 위에서 설명한 무한 대기 경로였는지, 아니면 크래시·네트워크 단절·컨테이너 문제가 섞여 있는지는 이 로그로는 구분되지 않는다. 확정할 수 있는 건 "pull이 멈춰 재시작이 걸린 것이 패치 후에도 15번"까지다. 이게 서로 다른 고장 15건이라는 뜻도 아니다 — 이 크론에는 30분 쿨다운이 있어서, 한 번의 긴 정지가 한 줄로 압축될 수도 있고 쿨다운이 풀린 뒤 다시 걸려 두 줄이 될 수도 있다.
그 "섞여 있을지 모르는 것" 중 하나는 나중에 재현됐다. 2026-08-12에 문제가 났던 바로 그 기기의 브리지를 리빌드하자, 켜지자마자 Corrupted document 예외로 두 번 연속 죽었다. 청크가 빠진 문서를 조립하려다 던진 예외가 try 밖이라 잡히지 않고 프로세스를 통째로 내리는 구조인데, 그 메커니즘은 같은 시리즈의 다른 편에서 따로 다뤘다(그쪽 사례는 허브 기기 것이다).
그렇다고 과거 15회 중 몇이 그거였다고 말할 수는 없다. 그때 로그에 그걸 구분할 정보가 없다.
빈도만 보면 줄어든 것처럼 보이기는 한다. 패치 후 26일 동안 15회니 하루 0.6회꼴이고, 그 앞 이틀은 하루 3회꼴이었다. 다만 이걸 패치의 효과라고 말할 근거는 없다. 비교 대상인 "패치 전"이 감시를 막 붙인 이틀치뿐이고, 패치 후에도 07-27과 08-07 사이 11일 간격이 있다가 다시 몰려 나오는 식이라 애초에 고르지 않다. 그 사이 이 스택에는 다른 변경도 여럿 들어갔다.
말할 수 있는 건 하나다. pull이 멈추는 사건은 지금도 난다.
그럼 뭘 해야 하나
솔직히 이 글에는 "이걸 넣었더니 해결됐다"가 없다. 지금 시점에서 말할 수 있는 건 두 가지다.
하나 — 코드가 가리키는 방향은 heartbeat를 끄는 쪽이다. heartbeat: false를 주면 어댑터가 그 파라미터를 아예 안 보내고,
if ('heartbeat' in opts) {
// If the heartbeat value is false, it disables the default heartbeat
if (opts.heartbeat) {
params.heartbeat = opts.heartbeat;
}
}그러면 CouchDB 쪽 기본 timeout(60초)이 적용돼 새 변경이 60초 동안 없으면 서버가 스트림을 닫는다.
닫힌 다음이 중요한데, 이건 어댑터 소스에서 확인된다. continuous 피드는 응답을 받을 때마다 마지막 시퀀스로 곧바로 다음 요청을 다시 건다.
if ((opts.continuous && !(limit && leftToFetch <= 0)) || !finished) {
// Queue a call to fetch again with the newest sequence number
pouchdbUtils.nextTick(function () { fetchData(lastFetchedSeq, fetched); });
}같은 콜백 안 몇 줄 위에는 이런 주석까지 달려 있다 — The changes feed may have timed out with no results / if so reuse last update sequence. 즉 타임아웃으로 끊기는 걸 정상 흐름으로 상정한 코드고, 마지막 시퀀스에서 이어받으니 그 사이 변경을 흘리지도 않는다.
요청 형태만 보면 #953 편에서 실패하던 것과 비슷해진다. 차이는 위 루프에 있다. #953 편에서 관측된 대로 그쪽은 일회성 초기 fetch라 스트림이 끊기면 "다 받았다"로 오인됐고, 여기는 continuous라 끊기면 마지막 시퀀스로 다시 요청을 건다.
다만 이게 만능은 아니다. 갈라서 봐야 한다.
- 연결이 요청과 요청 사이에 죽었거나, 다시 거는 요청이 곧바로 실패하는 상황이면 → 그때 에러가 나고 기존 재접속이 돈다.
- 진행 중인 요청 자체가 블랙홀이 되면 → 서버가 60초 뒤 닫는다는 신호도 그 죽은 경로로 와야 하므로 도착하지 않는다. 클라이언트 쪽 마감시한이 여전히 없으니 결과는 똑같은 무한 대기다.
즉 구조적으로 맞는 답은 클라이언트에 마감시한을 거는 것인데, 이 버전에서는 두 옵션 중 어느 쪽도 그걸 주지 않는다. heartbeat: false가 기대는 60초도 서버가 재는 값이지 클라이언트 타이머가 아니고, 클라이언트 쪽 값은 어댑터가 계산해두고 쓰지 않는다.
그리고 초고를 쓸 때는 heartbeat: false를 넣어보지 않은 상태였는데, 같은 날 저녁에 문제가 났던 그 기기에 실제로 넣었다. 위에서 인용한 heartbeat: 30000이 있던 바로 그 자리이고(백업은 .bak.20260812로 남겼다), 그래서 이 글의 스니펫과 로그는 전부 그 변경 이전 상태다. 다만 결과는 아직 모른다 — 판정하려면 같은 감시 로그를 몇 주 더 세야 하고, 그건 따로 쓴다. 하나 더 적어두면, 그날 같은 기기의 같은 재기동 사이클에 다른 변경도 하나 같이 들어갔다(위에서 크래시를 낸, 청크 참조가 끊긴 옛 문서들을 정리했다). 그래서 앞으로 빈도가 줄어도 어느 쪽 덕인지 깔끔하게 가르기 어렵다.
둘 — 실제로 이 스택을 굴러가게 한 건 바깥에서 재는 장치였다. 하트비트 감시 크론은 "연결이 살아 있나"를 묻지 않는다. 최근에 파일이 실제로 내려왔는지를 묻는다. 라이브러리 안쪽이 어떤 상태든 상관없이 판정이 서는 게 이 방식의 장점이고, 결과적으로 그게 이 문제를 계속 덮어준 유일한 장치였다.
그래서 지금 이 스택의 운용 상태는 이렇게 정리된다. 원인은 미확정이고, 이 버전의 라이브러리 안에는 걸 수 있는 마감시한이 없고, 바깥의 크론이 안전망 역할을 하고 있고, 다음 시도는 이미 들어갔지만 판정은 몇 주 뒤다. 좋은 상태는 아니지만 적어도 각 항목이 어느 칸에 있는지는 안다.
검증하지 못한 것
- 연결이 왜 끊기는지는 여전히 미확정이다. 위에서 확정한 건 "코드에 마감시한이 없다"까지고, 실제 죽음의 순간에 무엇이 끊었는지는 재현으로 확인하지 못했다.
- 빈도가 줄어든 것처럼 보이는 게 무엇 때문인지 모른다. 위에 적은 대로 표본이 얇고 교란 요인이 많다.
heartbeat: false가 재발을 줄이는지는 아직 모른다. 로그를 몇 주 더 세야 하고, 같은 사이클에 다른 변경이 함께 들어가서 그때도 원인을 깔끔히 가르기는 어렵다.- 여기서 만진 옵션 변경들은 상류 저장소에 올린 게 아니라 이 스택의 사본에 직접 넣은 것이라
git pull한 번이면 전부 사라진다.
교훈 / 체크리스트
- 옵션 이름을 보고 동작을 추정하지 말고, 쓰는 어댑터의 소스를 연다.
heartbeat가 인자 목록에 없다고 해서 요청에 안 붙는 건 아니었다. 기본값을 채워 넣는 코드가 한 겹 아래에 있었다. - 한 API에서 본 옵션이 다른 API에도 있으리라 가정하지 않는다.
retry는 복제에는 있고 changes에는 없다. 조용히 무시되는 옵션은 틀렸다는 신호조차 안 준다. - 계산해놓고 안 쓰는 변수는 그 자체로 단서다.
requestTimeout은 여러 갈래로 정교하게 계산된 뒤 아무 데도 안 쓰인다. 그게 "여기엔 마감시한이 없다"는 증거였다. - keep-alive는 마감시한을 재는 쪽이 있을 때만 의미가 있다. 신호를 더 보내게 하는 것보다, 안 오면 자르는 규칙이 먼저다.
- "고쳤다"고 적기 전에, 이미 쌓이고 있던 로그부터 세본다. 이 사건에서 재발 기록은 처음부터 있었다 — 감시 크론이 pull이 멈춰 재시작을 걸 때마다 한 줄씩 남기고 있었으니까. 나는 그걸 세보지 않은 채로 "근본수정 완료"를 기록에 박았고, 그 문장은 한 달 가까이 그대로 인용됐다.