3개월 치 작업 기록 1만 건을 검색되게 만들었다
일을 하면서 남긴 기록이 쌓였다. 날짜별 작업 로그, 막힌 자리와 그때 잰 수치, 고친 방법. 파일로는 다 있는데 찾는 데 시간이 걸렸다. 「그때 어떻게 했더라」가 떠오를 때마다 폴더를 뒤졌고, 뒤지다 못 찾으면 그냥 다시 했다.
그래서 색인을 만들었다. 로컬에서 도는 검색 엔진 하나에 로그를 통째로 넣었다. 결과는 10,036건이었다. 3개월 치 작업 기록 전량이다. 완전 중복은 5건뿐이라 데이터도 깨끗했다.
검색도 잘 됐다. 질문 하나를 던지면 86밀리초에 관련 항목 다섯 개를 정확히 돌려줬다. 로컬이라 네트워크를 타지 않고, 그래서 빠르다. 여기까지는 계획대로였다.
무엇을 넣고 무엇을 뺐나
넣은 것은 날짜별 작업 로그다. 그날 무엇을 하려 했고, 어디서 막혔고, 무슨 값을 쟀고, 어떻게 풀었는지가 시간 순서로 적힌 글이다. 형식이 정해진 문서가 아니라 그날그날 손으로 쓴 기록이라 길이도 문체도 제각각이다. 색인에는 그게 오히려 나았다. 정형화된 요약보다 «그때 그 문장»이 나중에 더 잘 걸린다.
뺀 것은 두 가지다. 하나는 결론만 남긴 요약본이다. 이미 따로 관리하고 있어서 중복이었다. 다른 하나는 남의 자료다. 내가 쓴 기록만 넣었다. 검색해서 나온 답이 내 경험인지 어디서 읽은 것인지 섞이면, 그 답을 믿어야 할지 판단할 근거가 사라진다.
중복 판정은 문장 단위로 했다. 완전히 같은 항목이 5건이었다. 같은 내용을 두 번 적은 것이 아니라, 같은 작업을 이어서 하며 앞 문단을 그대로 옮긴 자리였다. 전체의 0.05%라 따로 지우지 않고 그대로 두었다.
그런데 누적 조회가 0회였다
상태를 재 보다가 숫자 하나를 봤다. 색인을 만든 뒤 실제로 검색한 횟수가 0회였다. 쌓기만 하고 한 번도 꺼내 쓰지 않은 것이다.
그날 나는 같은 작업에서 두 번 막혀 있었다. 전에도 겪은 적 있는 종류의 막힘이었고, 그 과정이 로그에 그대로 있었다. 색인에도 들어 있었다. 그런데 나는 색인을 부르지 않고 처음부터 다시 헤맸다.
시험 삼아 물어봤다. 그 문제의 원인이 무엇이었는지. 86밀리초 만에 답이 나왔다. 날짜와 실패한 방법 목록까지 함께였다. 물어봤으면 안 헤맸다.
더 뼈아팠던 것은 답의 «모양»이었다. 색인이 돌려준 것은 「이렇게 하면 된다」가 아니라 「그날 이 방법 셋을 시도했고 셋 다 안 됐다」였다. 그리고 그 셋 중 둘을 나는 그날 또 시도하고 있었다. 결론을 몰라서가 아니라 이미 지나온 길이라는 것을 몰라서 시간을 쓴 것이다.
이 글은 색인을 만드는 방법이 아니라, 만들어 놓고도 안 쓰게 되는 이유와 그것을 고친 구조에 관한 것이다. 검색 엔진을 고르는 이야기도, 임베딩 방식을 비교하는 이야기도 아니다. 그런 선택은 이미 끝나 있었고 결과도 충분히 좋았다. 남은 문제는 전부 그 뒤에 있었다.
색인은 «있으면 쓴다»가 아니다
검색이 안 되는 것과 검색을 안 하는 것은 다른 문제다. 앞의 것은 도구를 고치면 되고, 뒤의 것은 도구를 고쳐도 안 고쳐진다.
돌아보면 이유는 단순했다. 색인은 「필요하면 찾겠지」로는 안 쓰인다. 필요한 순간에 그것이 있다는 사실이 떠오르지 않기 때문이다. 막혔을 때 사람은 색인을 떠올리는 게 아니라 그 문제를 떠올린다. 그리고 바로 손을 움직인다.
요약본만 따로 두는 것도 답이 아니었다. 결론 목록은 이미 있었다. 그런데 막힌 자리에서 필요한 것은 결론이 아니라 그때의 과정과 수치, 그리고 이미 실패한 방법의 목록이다. 결론은 「이렇게 하면 된다」를 알려 주지만, 색인은 「이 길은 이미 가 봤고 안 됐다」를 알려 준다. 헤매는 시간을 줄이는 쪽은 뒤엣것이다.
구조가 먼저 발목을 잡았다 — 시작할 때마다 16.4MB
부르는 습관 이야기로 넘어가기 전에 고쳐야 할 것이 하나 있었다. 색인 엔진이 생성자에서 지식 파일을 통째로 읽고 있었다. 크기는 16.4MB였다.
객체를 만드는 순간 그 파일을 동기로 읽으니, 색인을 쓰든 안 쓰든 매번 그 비용을 냈다. 그리고 그 비용이 시작 자체를 무너뜨렸다. 도구가 이틀 연속 연결에 실패했고, 원인을 한참 다른 데서 찾았다.
원인을 찾는 데도 시간이 걸렸다. 증상이 「연결이 안 된다」라 네트워크와 설정을 먼저 뒤졌다. 그쪽은 멀쩡했다. 실제로는 붙기 전에 죽고 있었다. 붙는 과정에서 파일을 읽다가 시간이 다 갔고, 밖에서 보기엔 연결 실패와 구분이 안 됐다.
여기서 배운 것이 하나 있다. 「무겁다」는 대개 크기의 문제가 아니라 «언제 읽느냐»의 문제다. 16.4MB는 검색할 때 읽으면 아무 문제가 없는 크기다. 문제는 그것을 쓰지도 않을 때 읽는다는 것이었다. 그리고 이런 종류의 비용은 「가끔 느리다」가 아니라 «매번» 나가기 때문에, 어느 날 데이터가 조금 늘면 그 순간 임계를 넘어 버린다.
지연 로드로 옮기고, 그 대가를 따로 갚았다
고친 방향은 간단하다. 생성자에서 읽던 것을 실제로 검색·학습·저장을 부를 때만 읽도록 옮겼다. 안 부르면 안 읽는다. 시작이 가벼워졌고 연결 실패도 사라졌다.
그런데 지연 로드에는 값이 붙는다. 부르지 않으면 지식이 «없는 상태»가 된다. 전에는 객체만 만들어도 데이터가 메모리에 있었으니 어느 경로로 들어와도 검색이 됐다. 이제는 로더를 통과하지 않은 경로로 들어오면 빈 결과가 나온다.
그리고 빈 결과는 오류가 아니다. 「0건」으로 조용히 돌아온다. 검색이 안 되는 게 아니라 검색할 것이 없는 것처럼 보인다. 이 유형이 제일 나쁘다.
그래서 로더를 진입점 세 곳에 걸었다. 검색·학습·저장 각각의 앞이다. 어느 문으로 들어와도 데이터가 먼저 준비된다. 「한 곳에서 읽으면 되겠지」로 두면 나머지 두 문이 조용히 빈 결과를 준다.
지연 로드를 넣을 때 같이 봐야 하는 것
- 진입점을 전부 센다 — 한 곳만 걸면 나머지가 «0건»으로 답한다
- 「0건」과 「아직 안 읽음」을 구분한다 — 둘 다 빈 배열이라 겉으로는 같다
- 읽은 뒤 캐시한다 — 매 호출마다 16.4MB를 다시 읽으면 지연 로드가 아니라 반복 로드다
부를 «자리»를 네 개로 못 박았다
구조를 고쳐도 안 부르면 그대로다. 그래서 「필요하면 부른다」를 버리고, 부르는 상황을 네 개로 적어 두었다. 판단을 없애는 것이 요점이다. 그 상황이면 먼저 묻고 시작한다.
- 같은 대상에서 전에 막혔던 것 같은 작업을 시작할 때
- 「전에 어떻게 했더라」가 떠오를 때
- 수치나 날짜가 필요한데 어디 적었는지 모를 때
- 문제가 생겼을 때 — 처음인지 반복인지를 먼저 가린다. 반복이면 이미 해법이 있다는 뜻이다
넷째 항목이 가장 값이 나갔다. 문제가 생기면 곧바로 원인을 찾으려 든다. 그런데 「이게 처음인가」를 먼저 물으면 절반은 이미 기록에 답이 있다. 없으면 그때 새로 파면 된다. 순서만 바꾼 것인데 헤매는 시간이 줄었다.
네 자리를 고른 기준은 「값이 클 것 같은 상황」이 아니라 「그 상황인지 아닌지가 분명한 것」이었다. 「중요한 작업이면 물어본다」 같은 조건은 매번 판단이 끼어들어서 결국 안 지켜진다. 반대로 「문제가 생겼다」는 판단할 것이 없다. 생겼으면 생긴 것이다.
그리고 답이 없어도 손해가 아니게 만들었다. 물었는데 기록이 없으면 그건 「이건 처음이다」라는 정보다. 그 자체가 판단에 쓰인다. 처음이면 새로 파고, 판 결과를 기록에 남긴다. 그러면 다음번에는 그 자리가 «반복»이 된다. 이 되먹임이 있어야 색인이 시간이 갈수록 값이 커진다.
그래서 어디까지 됐나
지금 상태를 숫자로 적으면 이렇다.
| 항목 | 값 | 비고 |
|---|---|---|
| 색인 항목 | 10,036건 | 3개월 치 작업 기록 전량 |
| 검색 응답 | 86ms | 관련 5건 반환 |
| 완전 중복 | 5건 | 전체의 0.05% |
| 지식 파일 | 16.4MB | 생성자 → 호출 시점으로 이동 |
| 로더 진입점 | 3곳 | 검색·학습·저장 |
고친 것은 두 가지다. 구조 하나(읽는 시점을 옮겼다)와 습관 하나(부르는 자리를 넷으로 정했다). 색인 자체는 처음부터 잘 돌고 있었다.
이 표에서 눈여겨볼 것은 「검색 응답 86ms」와 「누적 조회 0회」가 한 줄 차이로 붙어 있다는 점이다. 성능은 처음부터 문제가 아니었다. 도구를 만드는 사람은 앞줄을 재고, 도구를 쓰는 사람은 뒷줄에 산다. 두 숫자를 같이 보지 않으면 「잘 만들었다」에서 멈춘다.
색인이 모르는 것
쓰다 보니 경계도 분명해졌다. 로컬 색인은 지나간 기록을 안다. 그것뿐이다.
- 지금 화면에 무엇이 떠 있는지는 모른다. 그건 지금 재야 하는 것이고, 색인에 물으면 옛날 답이 온다
- 오늘 처음 하는 일은 없다. 기록이 없으니 당연하다. 그런데 「없다」와 「못 찾았다」가 같은 모양으로 나와서, 질문을 바꿔 한 번 더 물어봐야 가려진다
- 일반 지식은 담겨 있지 않다. 이건 내 기록의 색인이지 백과사전이 아니다
그리고 기록이 틀렸으면 색인도 틀린 답을 준다. 빠르게 틀린 답을 준다. 그래서 색인이 준 수치도 지금 상태와 어긋날 수 있다고 보고, 중요한 값은 다시 잰다.
이 경계를 그어 두는 것이 실제로는 색인을 «더» 쓰게 만들었다. 무엇을 물어도 되는지 모르면 아무것도 안 묻게 된다. 반대로 「지나간 기록은 여기, 지금 상태는 직접」으로 갈라 두면 물어볼 자리가 분명해진다. 도구의 한계를 적어 두는 일이 도구를 쓰게 만드는 쪽으로 돌아온 셈이다.
자주 묻는 것
기록이 많지 않아도 로컬 색인이 값이 있나요?
양보다 «같은 문제를 두 번 겪는지»가 기준이다. 두 번째부터 값이 난다. 반대로 아무리 많이 쌓아도 부르지 않으면 0이다. 이 글의 출발점이 그것이었다.
결론만 따로 적어 두는 것과 무엇이 다른가요?
결론 목록은 「이렇게 하면 된다」를 준다. 색인은 그날의 과정과 수치, 이미 실패한 방법의 목록까지 준다. 막힌 자리에서 시간을 줄여 주는 쪽은 뒤엣것이다. 둘은 대체재가 아니라 다른 물건이다.
지연 로드로 바꾸면 첫 검색이 느려지지 않나요?
느려진다. 첫 호출에서 파일을 읽는 만큼 늦다. 대신 그 비용을 검색을 실제로 쓸 때만 낸다. 시작할 때마다 내던 것과 비교하면 총량이 준다. 읽은 뒤 캐시해 두면 두 번째부터는 원래 속도다.
검색 결과가 「0건」으로 나왔을 때 무엇을 먼저 보나요?
로더를 통과했는지부터 본다. 지연 로드에서 「0건」은 «없다»와 «아직 안 읽었다»가 같은 모양이다. 그다음이 질문을 바꿔 한 번 더 재는 것이고, 진짜 없다는 판정은 그 뒤에 한다.
부를 자리를 미리 정해 두는 것이 효과가 있나요?
있었다. 다만 효과는 「기억하자」에서 오지 않고 판단을 없앤 데서 온다. 그 상황이면 묻는다로 못 박아 두면 부를지 말지를 고민하지 않게 된다. 고민이 끼어드는 순간 손이 먼저 움직인다.
참고 자료
- MDN Web Docs. Lazy loading — 필요한 시점까지 자원 읽기를 미루는 패턴. developer.mozilla.org
- Node.js Documentation. File system — fs.readFileSync — 동기 읽기가 이벤트 루프를 막는 구간. nodejs.org
- Model Context Protocol. Specification — 도구 서버의 초기화와 호출 분리. modelcontextprotocol.io