AnchorMindANCHORMIND
SIGN IN

캐시가 실패를 기억할 때: 형태소 사전의 46%가 조용히 죽어 있던 이유

에러 로그 없는 장애

이번 결함의 고약한 점은 아무 에러도 없었다는 것이다. AnchorMind의 기억 검색 엔진(memento-mcp)에는 keywords-only 검색을 보조하는 벡터 경로가 있다. 검색 키워드를 형태소로 쪼개 각 형태소의 임베딩을 구하고, 평균 벡터로 pgvector 유사도 검색을 수행하는 경로다. 형태소마다 임베딩 API를 매번 부를 수는 없으므로 morpheme_dict 테이블이 형태소-임베딩 캐시 역할을 한다.

어느 시점부터 이 경로의 회수율이 미묘하게 낮아졌다. 검색이 실패하는 것이 아니라, 나와야 할 결과가 몇 건씩 덜 나왔다. 예외도, 5xx도, 로그 한 줄도 없었다.

실측: 핵심 단어가 전부 죽어 있다

운영 DB를 직접 열어 핵심 형태소들의 캐시 상태를 확인했다.

SELECT morpheme, (embedding IS NULL) FROM agent_memory.morpheme_dict WHERE morpheme IN ('memento', 'mcp', 'arch', 'memento-mcp'); → 4행 모두 embedding NULL

이 서비스에서 가장 많이 검색될 단어 네 개가 전부 NULL이었다. 행은 존재하는데 값이 없다. 전수 조사를 돌리자 규모가 드러났다. 사전 전체의 46%가 NULL 행이었다. 캐시의 절반이 죽은 채로 몇 주를 운영한 것이다.

대조 실험이 기전을 좁혀 줬다. textToMorphemeVector('memento-mcp')는 NULL을 반환하지만, 사전에 등재된 적 없는 'memento-mcp-arch'는 같은 시점, 같은 임베딩 프로바이더에서 1536차원 벡터를 정상 반환했다. 프로바이더는 멀쩡하다. 죽은 것은 과거의 잔재다.

기전: 행의 존재를 히트로 판정했다

원인은 캐시 히트 판정 한 줄이었다. 코드는 "사전에 행이 있으면 히트"로 판정했다. embedding 컬럼이 NULL인지는 보지 않았다.

시나리오를 재구성하면 이렇다. 과거 어느 시점에 임베딩 API가 일시 장애(인증 만료, 한도 초과, 네트워크 오류 중 무엇이든)를 겪었다. 그 순간 등록된 형태소들은 embedding이 NULL인 행으로 남았다. 장애는 복구됐지만, 이후 이 형태소들이 검색될 때마다 엔진은 "행이 있으니 히트"라며 NULL을 그대로 반환하고 재계산을 건너뛰었다. 일시 실패가 영구 네거티브 캐시로 굳은 것이다.

NULL 형태소는 평균 벡터 계산에서 조용히 탈락한다. 검색 키워드 세 개 중 두 개가 죽은 형태소면 남은 하나의 벡터만으로 검색하는 셈이니, 회수율은 떨어지되 결과는 나온다. 그래서 에러가 없었다.

손상된 캐시 셀이 접근 시점에 복원되는 흐름

수정: 실패를 히트로 인정하지 않는다

수정의 골격은 세 가지다.

첫째, 접근 시 치유. 히트 판정에서 embedding IS NULL 행을 미스로 취급한다. 죽은 형태소는 다음 검색에서 재계산되고, 성공하면 그 자리에서 치유된다. 등록 쿼리는 NULL 행 한정 조건부 UPDATE로 바꿔 유효한 임베딩을 절대 덮어쓰지 않게 했다. 운영 중 임베딩 프로바이더나 차원이 바뀌어도 기존 벡터와 섞이는 사고를 막기 위해서다.

둘째, 실패 증폭 차단. NULL을 미스로 바꾸면 새 위험이 생긴다. 프로바이더가 다시 장애를 겪으면 죽은 형태소가 검색될 때마다 재계산을 시도해 호출이 폭주한다. 실측으로는 배치 1회 실패가 단건 최대 200회 호출로 증폭되는 경로였다. 그래서 오류를 인증, 한도, 서버, 네트워크로 분류하고 지수 백오프 쿨다운(30초에서 10분)을 붙였으며, 동일 형태소의 동시 중복 요청은 inFlight 맵으로 합쳤다.

셋째, 백필. 접근 시 치유는 검색되는 형태소만 살린다. 나머지 46%를 위해 커서 기반 배치로 NULL 행을 일괄 재임베딩하는 스크립트를 만들었다. dry-run 모드, 반복 실패 형태소 격리(무한 재선택 방지), 배치 전멸 시 중단 조건을 넣었다. 실행 결과 NULL 행 전량이 복구됐고, 형태소 벡터 경로의 오기 교정 랭킹이 정상으로 돌아왔다.

교훈

네거티브 캐시는 설계하는 것이지 발생하는 것이 아니다. "값이 없다"를 캐시에 저장하는 순간 그것은 네거티브 캐시인데, 의도한 적 없는 네거티브 캐시는 이번처럼 실패의 화석이 된다. 실패를 캐시할 거라면 반드시 만료를 함께 설계해야 한다.

성공과 실패는 수명이 달라야 한다. 성공한 임베딩은 영구 보존해도 되지만, 실패의 기록은 짧게 살고 죽어야 한다. 이 비대칭을 코드에 명시하지 않으면 저장소는 실패 쪽으로만 축적된다.

조용한 열화는 텔레메트리로만 보인다. 예외가 없는 장애는 로그 모니터링으로 잡히지 않는다. 회수율, 캐시 유효 비율 같은 품질 지표를 주기적으로 실측하지 않았다면 사전의 절반이 죽은 상태로 더 오래 운영했을 것이다.