에러 없이 틀리는 버그들
EP 04에서 이렇게 썼다.
"에러가 났는데 모르는 것 — 그게 진짜 두려움이었다. 에러 자체보다."
그 두려움이 여름 내내 세 번 현실이 됐다. 세 버그의 공통점은 하나다. 에러가 없다. 그냥 틀린다.
하나 — 사라진 메일들
Brief 탭의 "N개 분석"이 이상했다. 하루에 메일이 수십 통 오는데, 1~2통만 잡혔다.
에러는 없었다. 브리핑은 멀쩡히 떴다. 그냥 숫자가 작았다.
production DB를 직접 뒤졌다. 범인은 hyungbin.kim@gmail.com sub-account의 OAuth 토큰이었다. oauth_tokens.issued_at이 6월 8일에 박제돼 있었다. 정상이면 매일 갱신되며 write-back돼야 하는 값이. 즉 그날 이후 refresh가 실패해서, 이 계정의 모든 Gmail 조회가 401로 죽고 있었다.
문제는 코드가 그 죽음을 삼키고 있었다는 거다.
// getUserGoogleTokens if (!refreshed.access_token) continue // 실패한 계정은 조용히 건너뜀 // fetchGmailMessages catch (e) { return } // 실패하면 조용히 0통
continue와 return. 계정 하나가 통째로 죽어도 에러 하나 안 나고, "N개 분석"만 소리 없이 쪼그라들었다. 저트래픽 계정만 1통 잡히고, 진짜 메일 많은 gmail.com은 0통 기여.
해결은 코드 수정이 아니라 재연결이었다 — 앱에서 계정을 다시 붙이면 prompt=consent로 새 refresh_token이 강제 발급된다. 재연결하니 12통 정상, "13개 분석". 근데 진짜 결함은 남아 있다. 조용한 실패. 계정이 죽으면 누군가는 알아야 한다.
둘 — 늦게 오는 메시지
로딩 속도를 잡으려고 캐시를 넣었더니(EP 16), 이번엔 남이 보낸 메시지가 바로 안 뜨거나 늦게 떴다. 특히 백그라운드에 있다가 푸시 눌러 들어오면, 화면이 옛날 상태 그대로.
이건 원인이 세 겹이었고, 셋 다 서로 다른 층이었다.
setAuth()를 마운트 때 한 번만 호출 → access token(1시간)이 만료되면 재연결 시 채널 join이 RLS에서 거부(CHANNEL_ERROR). 만료된 JWT로 재시도해봐야 계속 거부. → "연결 끊김" 배너가 뜨는 케이스.postgres_changes는 replay가 없다 → 백그라운드로 소켓이 끊긴 구간의 메시지는 재연결해도 영영 안 온다. → 배너는 안 뜨는데 메시지만 빈 케이스.- 그 갭을 메워야 할 REST 델타가 마운트 시점에 박제된 토큰을
getSession()보다 우선해서 401 →if (!res.ok) return으로 또 무음 실패.
재현이 들쭉날쭉했던 이유가 절묘하다. 백그라운드 체류가 토큰 만료(1시간)를 넘겼는지에 달렸으니까. 특히 Family 방 ChatView는 세그먼트가 display 토글이라(EP 16) 앱 실행 내내 remount가 안 돼서, 첫 토큰으로 끝까지 버티다 가장 심하게 깨졌다.
수정은 7건이었지만 관통하는 건 하나다 — 토큰을 항상 신선하게 유지하고(onAuthStateChange로 전 채널에 전파, AppState↔autoRefresh 배선), 포그라운드 복귀 시 재구독 + REST 델타 백필로 갭을 메우고, 실패를 setConnected(false)로 노출한다. 무음 실패를 유음으로 바꾸는 것.
셋 — "저장한 적 없어요"
가장 최근이자, 가장 얄미운 버그.
"페퍼야, 내가 저장한 와인 알려줘" → "Vault에 없어요."
데이터는 있다. 임베딩도 있다. 근데 늘 없다고 한다.
전 층을 다 검증했다. triage 분류? 정상 — recall 0.95로 잘 잡는다. 임베딩? 정상 — 전 타입 100% 채워짐. 랭킹? 정상 — 와인 쿼리에 와인이 상위. 다 멀쩡한데 결과가 0건.
범인은 필터 하나였다.
vault-recall.ts SIMILARITY_THRESHOLD = 0.65
RPC에 similarity_threshold: -1을 줘서 전체 유사도 분포를 덤프했더니 — gemini-embedding-001(768차원)로 한국어 비대칭 쿼리(짧은 질문 vs 긴 문서)를 재면 유사도 천장이 ~0.66이었다. 0.65 컷은 정답까지 전부 잘라내고 있었다. 분류도 임베딩도 랭킹도 다 맞는데, 마지막 필터 숫자 하나가 전부를 0으로 만든 것.
0.65 → 0.45, limit 5 → 8
와인/레시피/맛집 recall이 살아났다. EP 09에서 자랑스럽게 소개한 그 임베딩 레이어가, 임계값 0.2 차이로 두 달간 죽어 있었다.
좋은 시스템은 시끄럽게 실패한다
세 버그 다 로그와 진단 쿼리 없이는 못 잡았다. 죽은 토큰은 DB의 박제된 issued_at이, 유실된 메시지는 채널 상태 로그가, 0건 recall은 유사도 분포 덤프가 알려줬다. EP 04에서 pepper_logs를 "페퍼의 블랙박스"라 부른 이유, EP 06에서 Claude Code가 진단 쿼리를 짜줄 때까지 원인을 몰랐던 이유가, 여기서 세 번 더 실감됐다.
그리고 세 버그의 뿌리에는 같은 안티패턴이 있었다. continue, return, .catch(() => null), if (!res.ok) return. 조용히 실패하는 코드. 비개발자에게 가장 위험한 코드가 이거다. 에러를 던지면 최소한 보이는데, 조용히 삼키면 두 달을 모른다.
만들면서 배운 게 있다. 좋은 시스템은 실패를 숨기지 않는다. 실패를 시끄럽게 만든다. 그래야 나 같은 사람도 안다.