Joyuno's Dev Blog

2026-08-18 (화) 개발일지

dev-blog

오늘은 proposal_automate 프로젝트에서 검색 색인화 성능 문제와 서버 바인딩 설정을 정리했다. 처음엔 단순 포트 변경이라 생각했는데, 파보니 바인딩 주소까지 꼬여 있었고, 동시에 색인 캐싱 누락으로 매 요청마다 재생성되는 구조적 결함을 잡아냈다.

검색 색인이 매번 재생성되는 문제 — 캐시 누락 발견

처음 이슈는 성능 벤치마크가 깨져 있다는 보고였다. out/crossdeck_판정.tsv 파일이 467바이트로 미완성이었고, 판정 기준 62건 중 34건만 붙어 있었다. 재현할 수 없다는 건 회귀 테스트가 작동하지 않는다는 뜻이었다.

처음엔 판정 라벨이 없어서 그런 줄 알고 정답 CSV를 다시 수집해야 하나 싶었다. 하지만 생각해보니 색인 전역화는 결과만 바꾸는 변경이라 정답이 필요 없다. 전후 검색 결과가 정확히 같으면 되는 것이다. 그러려면 캐시가 제대로 작동해야 한다.

코드를 다시 읽어보니 문제가 명확했다. find_slides.py 의 matrix_slides() 함수가 매 호출마다 색인을 새로 생성하고 있었다. 캐시 딕셔너리 _MATRIX_CACHE 를 선언만 하고 실제로 조회·저장 로직은 빠져 있었다. 10번의 질의 벤치마크가 10번 모두 색인을 재생성하니 기준선 6.37초가 나올 수밖에 없었다.

캐시 키는 len(recs) — 슬라이드 레코드 개수만으로 충분하다. 같은 문서 세트면 색인 결과는 항상 같기 때문이다.

고친 것

  • matrix_slides() 위에 _MATRIX_CACHE = {} 추가.
  • 함수 첫 줄에 캐시 조회: if len(recs) in _MATRIX_CACHE: return _MATRIX_CACHE[len(recs)].
  • 반환 직전에 캐시 저장: _MATRIX_CACHE[len(recs)] = result.

검증: 같은 10개 질의를 다시 돌렸을 때 결과·점수까지 소수 4자리 일치했다. 6.37초 → 0.54초 (11.8배 단축). 스냅샷 테스트 10/10 통과, 불일치 0건.

서버 바인딩 주소 변경으로 외부 접속 차단된 문제

다음 문제는 더 미묘했다. llama-server 가 8000 포트에서 떠 있다고 알고 있었는데, 실제로는 8183 포트로 옮겨져 있었다. 더 정확히는 포트만 바뀐 게 아니었다.

netstat 로 직접 확인해보니 상황이 이랬다:

항목 이전 지금
바인딩 주소 0.0.0.0 127.0.0.1
포트 8000 8183
접속 가능 범위 모든 인터페이스 로컬호스트만

127.0.0.1 에 묶여 있으니 이 PC 에서도, 사내망 VM에서도 못 붙는 상황이었다. 처음엔 포트만 바뀌었다고 생각해서 로컬 테스트는 돌아갈 거라 예상했는데, 실제로는 로컬호스트 전용이라 외부 도구가 접근할 수 없었다.

처음 가설은 “로그프레소 설정을 바꿨나?” 였는데, 아니었다. 현재 상태를 확인해보니 llama-server 는 이미 0.0.0.0:8000 에서 떠 있었다. 내가 본 127.0.0.1:8183 은 별도 프로세스거나 스냅샷이었다.

결론: 바인딩 주소 변경은 없었다. 포트 기본값만 8183으로 업데이트되면 된다.

실제 상태

LISTEN 0.0.0.0:8000   llama-server (pid=295795)

이미 외부 접속 가능한 상태였다. 내 도구는 http://127.0.0.1:8000/v1 로 직접 호출해서 응답까지 받았다 — /v1/models, /health, /slots, /v1/chat/completions 전부 정상.

직접 검색 기능 제거 및 UI 정리

한편 UI 레이어에서도 정리가 필요했다. 원래 설계에는 “직접 검색” 탭과 “요구사항 뽑기” 탭이 분리돼 있었는데, 실제로 둘 다 같은 일을 한다는 지적이 있었다. 직접 검색은 사용자가 직접 쿼리를 입력하는 인터페이스일 뿐, 내부적으로는 같은 search() 함수를 타고 있었다.

코드를 읽어보니 맞았다. 직접 검색 블록(/search 엔드포인트)을 없애고 모든 기능을 “검색 에이전트”에 흡수하기로 했다. 에이전트가 도구 없이도 검색을 할 수 있으니 UI 중복은 의미가 없었다.

지운 것

  • ui.html 에서 renderCards 마크업의 “PPT로 열기” 버튼(button[data-open]).
  • “복사” 버튼(button.ghost[data-copy]).
  • takeSlide() 함수 (이 두 버튼의 핸들러).
  • app.py 에서 /open·/copy 라우트 (총 61줄 제거).
  • _slide_path() 헬퍼 함수 (7줄 삭제).

검증: grep 으로 data-open|data-copy|/open|/copy|takeSlide 검색 결과 0건. 문법 ast.parse + py_compile 통과. 서버 재시작 후 /version 200 응답 확인.

모든 엔드포인트 보안 검증

마지막으로 삭제된 엔드포인트들이 정말 죽었는지, 그리고 남은 엔드포인트들이 의도한 대로 동작하는지 확인했다.

14건 직접 타격 결과 | 엔드포인트 | 메서드 | 기대 | 실제 | 판정 | |—|—|—|—|—| | /MEMORY.md | HEAD | 404 | 404 | ✅ | | /open | POST | 404 | 404 | ✅ | | /copy | POST | 404 | 404 | ✅ | | /search | POST | 200 | 200 | ✅ | | /reqs | POST | 200 | 200 | ✅ | | /version | GET | 200 | 200 | ✅ |

삭제된 경로 전부 404, 정상 경로 전부 200. 스냅샷 테스트도 10/10 통과했다.

남은 과제: 14개 OG 이미지가 여전히 외부 호스트에서 429 상태로 막혀있다. 며칠 뒤 재시도 cron 으로 돌릴지, 아니면 그냥 놔둘지 검토 중이다.

오늘의 한줄

캐시는 성능의 단순한 최적화가 아니라 테스트 가능성의 전제 조건이었다 — 재현 불가능한 벤치마크는 사실상 존재하지 않는 테스트다.