AI 에게 디버깅을 맡겼더니, 자기가 만든 가짜 증거에 속았다
증상
옵시디언(Obsidian) 데스크톱 앱을 켜면 이렇게 됐다.
- 처음 몇 초는 멀쩡하다. 노트도 보이고 클릭도 된다
- 인덱싱(색인 작업)이 돌아가는 동안 갑자기 창 전체가 새하얗게 변한다
- 그 다음부터는 아무것도 안 된다. 클릭도, 단축키도, 메뉴도
- 껐다 켜도 똑같다
원인은 노트 파일 딱 한 개였다. 볼트가 깨진 것도, 플러그인 문제도, 그래픽 드라이버 문제도 아니었다.
이 글은 그 한 개를 찾아가는 이야기지만, 진짜 하고 싶은 이야기는 따로 있다. 나는 이 디버깅을 AI 코딩 에이전트(Claude Code)에게 맡겼고, 에이전트는 중간에 자기가 쓰는 도구의 버그 때문에 가짜 증거를 만들어냈다. 그리고 그 가짜 증거를 스스로 믿었다. 옆에서 지켜보던 나도 못 잡아냈다.
AI 에게 조사를 맡길 생각이라면, 뒤쪽 절반이 앞쪽 절반보다 중요할 것이다.
첫 단서: 멈춘 게 아니라 죽었다
앱이 하얗게 굳으면 보통 "느린가? 멈췄나?" 부터 의심한다. 하지만 이건 1분이면 갈라낼 수 있다. 프로세스 목록을 보면 된다.
옵시디언은 Electron(일렉트론)으로 만들어진 앱이다. Electron 은 크롬(Chromium)의 구조를 그대로 가져와서, 앱 하나를 여러 프로세스로 쪼개 돌린다 [1].
| 프로세스 | 하는 일 |
|---|---|
| main | 앱의 진입점. 창을 만들고 앱 생명주기를 관리한다 |
| renderer | 창 안의 내용을 실제로 그린다. 창(BrowserWindow) 하나당 하나씩 뜬다 [1:1] |
| gpu / utility | 그래픽 가속, 네트워크 등 보조 역할 |
이 구조가 중요한 이유가 있다. 창틀을 띄우는 프로세스와 창 내용을 그리는 프로세스가 서로 다른 프로세스다. 그러니 renderer 만 죽으면 창은 그 자리에 남아있는데 내용물만 사라진다. 그게 바로 하얀 화면이다.
에이전트가 프로세스를 세어봤다.
Get-CimInstance Win32_Process -Filter "Name='Obsidian.exe'"Get-CimInstance Win32_Process -Filter "Name='Obsidian.exe'"main, gpu-process, utility 는 살아있는데 --type=renderer 가 아예 없었다. 멈춘(hang) 게 아니라 죽은(crash) 것이다. 응답이 없는 게 당연했다. 응답할 주체가 없었으니까.
이 구분이 중요한 이유는, 멈춘 것과 죽은 것은 원인이 완전히 다르기 때문이다. 멈춘 거라면 무한 루프나 느린 작업을 찾아야 하고, 죽은 거라면 메모리 초과나 크래시를 찾아야 한다. 방향을 잘못 잡으면 엉뚱한 곳을 몇 시간이고 판다.
헛다리 세 개
1. 앱 로그는 아무것도 몰랐다
당연히 앱 로그부터 봤다. 옵시디언은 obsidian.log 라는 파일을 남긴다. 열어보니 이런 줄만 수백 개 있었다.
Checking for update using Github
Latest version is 1.12.7
App is up to date.Checking for update using Github
Latest version is 1.12.7
App is up to date.크래시 기록은 한 줄도 없었다. 이 로그는 업데이트 확인용이었다. 메인 프로세스가 남기는 것이라, 정작 죽은 renderer 에 대해선 아무것도 모른다.
로그가 조용하면 "문제가 없다"가 아니라 "이 로그는 그걸 안 본다"일 수 있다. 로그를 믿기 전에 그 로그가 무엇을 기록하는 로그인지부터 확인해야 한다.
2. 커널 이벤트에 낚였다
Windows 이벤트 로그를 뒤졌더니 LiveKernelEvent 가 우수수 나왔다. 코드는 141. 보통 그래픽 드라이버가 응답을 멈춰서 Windows 가 강제 복구하는 상황(TDR)을 뜻한다. 게다가 시각이 방금 전이었다. 딱 봐도 범인이었다.
아니었다. 이벤트에 첨부된 덤프 파일 이름을 열어보니 이랬다.
WATCHDOG-20260510-1655.dmp
WATCHDOG4400-20260505-1106.dmpWATCHDOG-20260510-1655.dmp
WATCHDOG4400-20260505-1106.dmp5월 10일, 5월 5일. 두 달 전 덤프다. Windows 가 밀려있던 오류 보고를 그제서야 서버로 올리면서 로그에 찍힌 것뿐이었다. 이벤트가 기록된 시각과 사건이 일어난 시각은 다르다. 시각만 보고 인과를 엮으면 이렇게 낚인다.
3. GPU 와 플러그인은 무죄였다
하얀 화면 하면 반사적으로 떠오르는 두 용의자다. 둘 다 실험으로 지웠다.
- GPU 가속 끄기 (
--disable-gpu) → 오히려 더 빨리 죽었다. GPU 아님 - 커뮤니티 플러그인 전부 끄기 → 여전히 죽었다. 플러그인 아님
로그에 특정 플러그인의 경고가 수천 줄 쏟아지고 있었기 때문에 한동안 그 플러그인이 범인처럼 보였다. 하지만 그건 가장 시끄러운 놈이었을 뿐, 죽인 놈은 아니었다. 로그에서 제일 눈에 띄는 것과 실제 원인은 자주 다르다.
진짜 사인(死因) 받아내기
앱 로그가 쓸모없다면, 프로세스가 죽으면서 뭐라고 했는지를 직접 받아내야 한다. Electron/Chromium 계열 앱은 실행할 때 로깅 옵션을 주면 표준 출력으로 내부 로그를 뱉는다.
Start-Process "Obsidian.exe" -ArgumentList '--enable-logging=stderr' `
-RedirectStandardOutput "obs-stdout.log" -RedirectStandardError "obs-stderr.log"Start-Process "Obsidian.exe" -ArgumentList '--enable-logging=stderr' `
-RedirectStandardOutput "obs-stdout.log" -RedirectStandardError "obs-stderr.log"표준 출력 맨 끝에 딱 한 줄이 있었다.
Renderer process oomRenderer process oomOOM = out of memory(메모리 초과). 추측이 아니라 확진이다. 렌더러가 메모리를 다 먹고 강제 종료된 것이다. 메모리를 재보니 실행 후 4초 만에 2.6GB, 조금 더 지켜보니 4GB 근처까지 치솟고 죽었다. 4,500개 노트를 차분히 색인하는 그래프가 아니다. 뭔가 하나가 폭주하는 모양이다.
여기까지는 순조로웠다. 문제는 그 다음이다.
에이전트가 만든 가짜 증거
원인을 좁히려고 에이전트는 테스트 볼트를 여러 개 만들어 실험했다. 빈 볼트, 의심 파일만 넣은 볼트, 전체를 복사한 볼트. 옵시디언이 어느 볼트를 열지는 설정 파일(obsidian.json)이 정하니까, 그 파일을 스크립트로 바꿔가며 실행하는 방식이었다.
결과는 이랬다.
| 테스트 볼트 | 렌더러 메모리 | 에이전트의 판정 |
|---|---|---|
| 빈 볼트 | 99 MB | 정상 |
| 의심 파일 1개만 | 99 MB | 정상 |
| 노트 전체 복사본 | 99 MB | 정상 |
에이전트는 "노트 내용은 멀쩡하다" 고 결론 내리고 다른 방향(캐시 손상, 동기화 문제)을 파기 시작했다. 전부 헛수고였다.
표를 다시 보자. 빈 볼트와 4,500개짜리 볼트가 똑같이 99 MB 다.
이게 말이 되나. 텅 빈 볼트와 꽉 찬 볼트가 같은 메모리를 쓸 리가 없다. 이 숫자 자체가 "실험이 고장났다"는 신호였다. 그런데 에이전트는 그냥 넘어갔다. 그 숫자가 자기가 원하던 결론("내용은 문제없다")과 맞아떨어졌기 때문이다. 옆에서 보고 있던 나도 못 잡아냈다. 그럴듯한 표였다.
진짜 이유는 로그 한 줄에 있었다.
Ignored: SyntaxError: Unexpected token '', "{"vaults""... is not valid JSONIgnored: SyntaxError: Unexpected token '', "{"vaults""... is not valid JSON설정 파일 맨 앞에 보이지 않는 문자가 하나 붙어 있었다. BOM(Byte Order Mark, 바이트 순서 표시)이다. 파일이 어떤 유니코드 방식으로 저장됐는지 알려주는 표식인데, JSON 파서 입장에선 정체불명의 쓰레기 문자다. 그래서 옵시디언은 이 설정 파일을 조용히 무시하고 볼트 선택 창을 띄웠다.
즉 에이전트는 테스트 볼트를 한 번도 연 적이 없었다. 텅 빈 볼트 선택 창의 메모리를 재면서 "볼트가 정상" 이라고 적고 있었던 것이다. 99 MB 는 볼트가 아니라 빈 창이었다. 어떤 볼트를 넣든 99 MB 가 나온 이유가 이거다.
범인은 에이전트가 쓴 이 한 줄이다.
'{"vaults":{...}}' | Out-File $path -Encoding utf8 # BOM 이 붙는다'{"vaults":{...}}' | Out-File $path -Encoding utf8 # BOM 이 붙는다Windows PowerShell 5.1 에서 -Encoding utf8 은 BOM 을 붙인다. 버그가 아니라 문서화된 동작이다 [2].
UTF8Uses UTF-8 (with BOM).In Windows PowerShell, any Unicode encoding, except
UTF7, always creates a BOM. PowerShell (v6 and higher) defaults toutf8NoBOMfor all text output.
PowerShell 5.1(윈도우 기본 탑재)과 PowerShell 7 이상이 정반대로 동작한다. 7 에서 utf8 은 BOM 을 안 붙인다. 인터넷에서 본 스크립트가 내 윈도우에선 다르게 동작하는 흔한 함정이 여기다.
직접 바이트를 찍어보면 명확하다.
'{"a":1}' | Out-File .\a.json -Encoding utf8 -NoNewline
[System.IO.File]::WriteAllText(".\b.json", '{"a":1}')'{"a":1}' | Out-File .\a.json -Encoding utf8 -NoNewline
[System.IO.File]::WriteAllText(".\b.json", '{"a":1}')| 파일 | 앞 5바이트 |
|---|---|
Out-File -Encoding utf8 |
239,187,191,123,34 ← EF BB BF 가 BOM |
[System.IO.File]::WriteAllText |
123,34,97,34,58 ← 바로 { 로 시작 |
설정 파일(JSON, YAML 등)을 PowerShell 로 쓸 거면 Out-File 대신 [System.IO.File]::WriteAllText 를 쓰자.
공정하게 덧붙이면, 이 오류를 결국 잡아낸 것도 에이전트 자신이다. 나중에 다른 실험을 하다가 로그에서 저 SyntaxError 줄을 발견하고 스스로 "이전 결과는 전부 무효" 라고 선언했다. 다만 그 전까지 잘못된 결론 위에서 한참을 헤맸고, 그 사이 사람인 나는 아무것도 눈치채지 못했다.
BOM 없이 실험을 다시 돌리자, 전체 복사본에서 크래시가 그대로 재현됐다. 그제서야 진짜 이분 탐색이 시작됐다.
범인: 노트 한 개
복사본에서 폴더를 하나씩 빼며 좁혔다. 전날 새로 붙인 스크립트가 만든 폴더를 통째로 빼자 크래시가 멈췄다. 폴더를 되돌리고 그 안에서 파일 하나만 뺐다. 역시 멈췄다. 범인 확정.
그 스크립트는 AI 코딩 도구의 대화 기록을 마크다운 노트로 바꿔 보관해주는 물건이었다. 문제의 노트는 이랬다.
| 항목 | 값 |
|---|---|
| 크기 | 6.73 MB |
| 줄 수 | 98,007 줄 |
| 코드 블록 표시 | 8,500개 이상 |
마크다운 노트 한 개가 10만 줄에 육박한다. 대화 한 번에 오간 수천 번의 도구 호출이 전부 그대로 박제된 결과였다.
옵시디언은 노트를 색인할 때 파일 안의 제목, 링크, 태그, 목록 항목, 섹션 등을 뽑아 메타데이터 캐시로 만든다 [3]. 평범한 노트에서야 별것 아니지만, 10만 줄짜리 파일에서는 이 항목 수가 폭발한다. 그 결과가 4초 만에 수 기가바이트다.
해결
1. 문제 파일을 볼트 밖으로 옮겼다. 삭제가 아니라 이동이라 내용은 그대로 있다. 옵시디언이 색인하지 않는 위치면 어디든 상관없다. 바로 정상으로 돌아왔다. 최대 803 MB 까지 올라갔다가 가비지 컬렉션(garbage collection, 안 쓰는 메모리 자동 회수) 후 396 MB 에서 안정됐다.
2. 스크립트에 상한을 걸었다. 이게 진짜 수정이다. 파일만 치우면 다음에 긴 대화를 하는 순간 똑같은 노트가 또 생긴다.
원래 스크립트에도 상한이 있긴 했다. 도구 호출 하나를 2,000자로 자르고 있었다. 문제는 호출 개수에는 제한이 없었다는 것. 2,000자짜리 블록이 수천 개 쌓이면 6.73 MB 가 된다.
항목별 상한이 있다고 전체 상한이 있는 게 아니다.
그래서 노트 전체에 5,000줄 상한을 걸고, 넘으면 앞뒤를 남긴 뒤 가운데를 생략하도록 고쳤다. 자를 때 아무 데서나 자르면 안 된다. 코드 블록 한가운데를 자르면 여는 표시만 남고 닫는 표시가 사라져서 그 뒤 문서 전체가 깨진다. 그래서 대화 이벤트 경계에서만 자르게 했다.
교훈 1: 옵시디언과 Electron
-
하얀 화면 = 렌더러 사망. Electron 앱이 하얗게 굳으면 프로세스 목록부터 보자.
--type=renderer가 없으면 멈춘 게 아니라 죽은 거다. -
앱 로그가 조용하다고 문제가 없는 게 아니다. 그 로그가 애초에 무엇을 기록하는지 확인하자. 여기선 앱 로그가 업데이트 확인 전용이었고, 진짜 사인은
--enable-logging=stderr로 받아낸 표준 출력에 있었다. -
로그에서 제일 시끄러운 놈이 범인은 아니다. 특정 플러그인 경고가 수천 줄 찍혔지만, 다 꺼도 앱은 죽었다.
-
자동화가 만들어낸 데이터도 입력값이다. 이 사고는 "노트를 자동으로 쌓아주는 편리한 스크립트"가 만들었다. 무언가를 자동 생성해 어딘가에 넣는 도구를 붙일 땐 최악의 경우 얼마나 커질 수 있는지 한 번은 계산해보자. 상한 없는 생성기는 언젠가 반드시 뭔가를 터뜨린다.
교훈 2: AI 에게 디버깅을 맡길 때
이쪽이 더 중요하다.
-
에이전트는 자기가 만든 증거를 의심하지 않는다. 에이전트는 실험 도구(설정 파일을 쓰는 스크립트)를 직접 만들었고, 그 도구가 고장났다는 가능성은 검사 대상에 넣지 않았다. 사람이 물어야 할 질문은 "결론이 맞나?"가 아니라 "이 결론이 나온 실험이 유효한가?"다.
-
대조군이 구별되는지 확인하라. 이번 사건의 결정적 신호는 크래시 로그가 아니라 표에 같은 숫자가 세 번 찍힌 것이었다. 빈 볼트와 꽉 찬 볼트가 같은 값을 냈다면, 그 실험은 두 경우를 구별하지 못하고 있다는 뜻이다. 값이 무엇이든 상관없다. 구별을 못 하면 그 실험은 아무것도 측정하지 않은 것이다. AI 가 표를 내밀면, 결론보다 먼저 "이 실험이 다른 조건을 실제로 구별하고 있나"를 보자.
-
확증 편향은 AI 에게도 있다. 에이전트는 "내용은 문제없다" 는 가설을 세운 직후 그 가설과 맞아떨어지는 숫자를 받았고, 그대로 넘어갔다. 가설과 맞는 결과가 나왔을 때가 가장 위험한 순간이라는 사실은 사람이든 모델이든 똑같다.
-
에이전트가 쓰는 도구도 오염원이다. 버그는 조사 대상(옵시디언)이 아니라 조사 도구(PowerShell) 에 있었다. AI 가 관측한 값이 이상하면, 관측 대상만 보지 말고 관측 장비를 의심하자.
-
그래도 결국 잡아낸 건 에이전트였다. 이걸 "AI 는 못 믿는다" 로 읽으면 곤란하다. 에이전트는 두 시간 동안 십수 개의 가설을 세우고 실험하고 스스로 폐기했고, 자기 실수도 스스로 찾아냈다. 사람이 손으로 했으면 훨씬 오래 걸렸을 일이다. 요점은 AI 를 쓰지 말라가 아니라, AI 가 내미는 증거에도 사람이 붙일 체크리스트가 필요하다는 것이다. 그 체크리스트의 1번은 이거다.
결론을 읽기 전에, 그 결론을 만든 실험이 서로 다른 조건을 구별할 수 있었는지 부터 확인한다.
참고 자료
Electron 공식 문서, Process Model - "Each Electron app spawns a separate renderer process for each open BrowserWindow." 메인 프로세스가 창을 만들고, 렌더러 프로세스가 그 창의 내용을 그린다 ↩︎ ↩︎
Microsoft Learn, about_Character_Encoding (PowerShell 5.1) - "
UTF8Uses UTF-8 (with BOM)", "In Windows PowerShell, any Unicode encoding, exceptUTF7, always creates a BOM. PowerShell (v6 and higher) defaults toutf8NoBOMfor all text output." ↩︎Obsidian 공식 API 문서, CachedMetadata - 파일별 메타데이터 캐시에
headings,links,tags,listItems,sections,embeds등이 담긴다. 파일 안의 항목 수가 많을수록 캐시도 커진다 ↩︎