지난 글에서 찾은 문제 두 건을 원인까지 정확히 짚어서 Hermes에게 다시 전달했다. 남은 두 묶음(Obsidian API 가이드라인, Promise 처리)까지 끝나고 최종 보고가 왔다.
npm run lint: 1 problem (deferred chat-engine.ts fetch with AbortSignal)
794건에서 시작한 lint 문제가 1건까지 줄어 있었다. 이번에도 보고를 그냥 믿지 않고 직접 확인했다. 작업 브랜치가 최신 코드 기준으로 제대로 만들어졌는지, lint·빌드·테스트 30개가 보고 내용과 실제로 일치하는지 — 전부 정확히 맞았다. 이번 보고는 믿을 만했다.
그래서 오히려 다른 게 궁금해졌다. 왜 딱 1건만 그대로 남겨뒀을까.
남은 1건
src/engine/chat-engine.ts
43:36 error Unexpected use of 'fetch'. Use the built-in `requestUrl` function
instead of `fetch` for network requests in Obsidian no-restricted-globals
지금까지 나온 다른 fetch 관련 문제들은 다 기계적으로 Obsidian 전용 함수인 requestUrl로
바꿔서 끝났다. 이번 것도 똑같이 처리할 수 있었을 텐데, 왜 여기서만 멈췄는지 궁금해서
코드를 열어봤다.
public static async requestGeminiAPI(options: RequestGeminiAPIOptions): Promise<string | null> {
const { model, apiKey, contents, systemInstructionText, signal } = options;
// ...
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ /* ... */ }),
signal
});
이건 이 플러그인에서 가장 중요한 함수였다 — 사용자가 메시지를 보낼 때마다 매번 실행되는,
실제로 AI에게 요청을 보내는 부분이다. 그리고 signal이라는 값을 같이 넘기고 있는데,
이건 “사용자가 응답을 기다리다가 중간에 취소 버튼을 누르면 이 요청을 그만 기다린다”는
취소 기능을 위한 거다.
문제는 Obsidian이 제공하는 requestUrl 함수엔 이런 취소 기능이 아예 없다는 거였다.
그냥 이름만 requestUrl로 바꿔치기하면 코드는 문제없이 돌아가겠지만, “취소 버튼을
누르면 요청을 멈춘다”는 기능 자체가 조용히 사라진다. 지난 글에서 봤던 문제와 똑같은
함정이다 — 겉보기엔 별문제 없어 보이는 치환인데, 실제로는 기능 하나가 몰래 없어지는
경우.
이번엔 억지로 밀어붙이지 않았다
이번엔 그 함정에 안 빠지고, 그냥 멈추고 보고했다. 애초에 이 lint 작업을 맡길 때 넣어둔 지침 중에 이런 게 있었다.
- If a fix isn’t safe/obvious, stop and report rather than guessing. (고치는 게 안전하지 않거나 확실하지 않으면, 대충 추측하지 말고 멈추고 보고할 것.)
정확히 그 상황이었던 거다. 취소 기능을 살리면서 requestUrl로 바꾸려면, 별도의
“취소됐음을 알리는 신호”를 만들어서 원래 요청과 경쟁시키는 식의 우회 방법을 써야
한다 — 실제 네트워크 요청 자체를 완전히 끊지는 못해도, 사용자 입장에서는 취소 버튼을
누른 순간 바로 응답을 무시하고 멈춘 것처럼 보이게는 만들 수 있다. 다만 이건 규칙 하나
기계적으로 적용해서 될 일이 아니라, 사람이 따로 설계를 정해야 하는 문제였다.
남는 교훈
이 시리즈에서 계속 반복해온 얘기는 “AI가 보낸 완료 보고를 그대로 믿지 말라”는 쪽이었다. 첫 번째 worktree가 이미 고쳐둔 크래시를 중복으로 다시 고치고 “완료”라고 했을 때도, YAML 날짜 버그가 조용히 테스트를 깼을 때도 전부 직접 확인해서 잡아낸 문제였다.
이번엔 방향이 달랐다. Hermes는 스스로 “여기는 기계적으로 못 고친다”고 판단하고 멈췄고, 그 판단이 맞았다. 지금까지의 이야기가 전부 “AI를 의심해야 하는 이유”였다면, 이번 건 오히려 “AI가 스스로 한계를 인지했을 때는 그걸 존중해야 하는 이유”에 가깝다. 억지로 더 밀어붙여서 나머지 1건까지 없앴다면, 아마 채팅 취소 기능이 조용히 고장 난 채로 다음 배포에 나갔을 거다.
결국 이렇게 일단락했다
Hermes가 멈춘 뒤에, 이 1건은 내가 직접 판단했다. 사실 이 플러그인을 아이폰이랑 안드로이드
태블릿에서 실제로 써보고 있었는데, 지금 코드(fetch 그대로) 그대로도 아무 문제 없이 잘
돌아가고 있었다. requestUrl이 존재하는 이유가 애초에 “모바일 환경에서 fetch가 막힐 수
있으니까”인데, 정작 이 특정 API 주소에 대해서는 그 문제가 실제로 안 일어나고 있었던 거다.
그래서 억지로 Promise.race 같은 우회 설계를 새로 만드는 대신, fetch를 쓰는 이유와
“모바일에서 검증 완료”라는 사실을 코드 바로 위에 주석으로 남기고, 그 한 줄만 lint
규칙에서 제외시켰다.
// Deliberately using fetch instead of Obsidian's requestUrl: ...
// manually verified working on iOS and Android Obsidian mobile ...
// eslint-disable-next-line no-restricted-globals
const response = await fetch(url, {
이렇게 하니 npm run lint가 794건에서 시작해서 완전히 0건이 됐고, GitHub Actions도
초록불로 바뀌었다. 다만 이게 영구히 끝난 결정은 아니다. 나중에 Obsidian 모바일 쪽 동작이
바뀌거나, requestUrl이 취소 기능을 지원하게 되면 다시 들여다봐야 한다는 걸 문서에 같이
남겨뒀다. 지금은 “실제로 검증된 예외”이지 “생각 안 해도 되는 문제”는 아니라는 뜻이다.
GLM-5.2, 생각보다 괜찮았다
이번 lint 정리 작업 전체를 Hermes에서 GLM-5.2로 돌렸다. 크래시 진단부터 542건 타입 안전성 수정, 이번 262건까지 전부 이 모델로 시켰는데, 솔직히 처음엔 큰 기대는 안 했다. 그런데 실제로 써보니 생각보다 훨씬 잘했다. 회귀를 직접 잡아내진 못했지만(그건 내가 매번 검증해서 찾아낸 거였다), 문제 원인을 구체적으로 짚어서 다시 전달했을 때 정확히 그 부분만 고쳐서 돌려주는 능력은 꽤 준수했고, 이번 글에서 다룬 것처럼 “이건 기계적으로 못 고친다”고 스스로 판단하고 멈추는 것도 지시한 그대로 잘 따라줬다. 비용 대비로 보면 이 정도 반복 작업엔 충분히 쓸 만한 모델이라는 인상을 받았다.
이 1건은 처음엔 안 고쳐진 채로 남아 있었지만, 이제는 근거를 남기고 정리된 상태다. 기계적으로 처리할 수 없다는 게 확인된 다음, 사람이 직접 판단해서 마무리한 셈이다.