Skip to content
게으른 엔지니어의 기술 블로그
Go back

고객사 뉴스 리포트 만들다가 6편 — 각주 버그를 고쳤는데, 알고 보니 처음부터 없던 버그였다

지난 글에서 LLM한테 관련성부터 물어보게 바꾼 뒤로, 이 프로젝트는 한 단계 더 나아갔다. 원래 리포트를 베이스로, 특정 팀 영업 타깃팅용으로 훨씬 무거운 버전(어카운트별 담당 조직, 산학연 네트워크, 팩트 100%, SW 솔루션 매핑까지 뽑는 리포트)을 만드는 별도 저장소로 갈라져 나온 상태였다. 그 리포트 파일 하나를 열어봤다가 이상한 걸 봤다.

요약 문장 끝에 각주 번호가 붙어 있었다

기사 제목 뒤에 [1], 표 셀 밑에 각주처럼 링크가 또 나열되는 게 보였다. 파일을 직접 grep 해보니 리터럴 [숫자] 패턴은 하나도 없었는데, 리포트를 조립하는 코드 안에 이미 \[\d+\]를 정규식으로 지우는 함수가 따로 있었다. 즉 어디선가 이 각주 번호가 생기고 있고, 누군가 이미 한 번은 그걸 눈치채서 다운스트림에서 지우는 처리를 넣어놨었다는 뜻이었다 — 그렇게 보였다.

프롬프트에는 그 얘기가 없었다

요약을 만드는 프롬프트를 열어봤다. LLM한테 회사 관련 여부, 담당조직, 산학연 정보, 팩트를 JSON으로 뽑아달라고는 시키고 있었는데, “각주나 인용 번호를 넣지 마라”는 지시는 어디에도 없었다. 다운스트림에서 정규식으로 지우는 건 증상 치료고, 애초에 LLM이 왜 각주를 붙이는지는 아무도 안 막고 있었던 것이다. 프롬프트 마지막에 한 줄 추가했다 — 각 필드 값에는 출처 인용 번호를 넣지 말라고.

캐시를 지우고 다시 돌려보려다, 옆에 있는 게 걸렸다

고친 게 진짜 먹히는지 확인하려면 다시 리포트를 생성해봐야 했다. 그런데 캐싱 로직을 다시 읽다가 이상한 줄을 하나 봤다. 요약이 끝나면 cache.save_article_cache(...)를 호출해서 결과를 저장하고 있었는데, 캐시 모듈에는 그런 이름의 함수가 없었다. 있는 건 set_article_cache 뿐이었다.

조용히 삼켜지고 있었다

이 호출은 try/except Exception 블록 안에 있었다. LLM 요약까지는 정상적으로 끝나고, 그 결과를 캐시에 저장하려는 순간 AttributeError가 나면서 예외 블록으로 떨어지고, 함수는 None을 반환하고 있었다. 그리고 None은 “이 회사랑 관련 없는 기사”라는 뜻으로 쓰이고 있었다. 즉 LLM이 정상적으로 요약까지 다 만들어낸 기사가, 캐시 저장 한 줄이 실패하는 바람에 “관련 없음”으로 둔갑해서 리포트에서 빠지고 있었던 것이다.

캐시 DB를 열어서 확인해보니 숫자로도 티가 났다. 전체 9857건 중 3138건만 요약이 채워져 있고, 나머지 6719건은 본문은 캐싱돼 있는데 요약 칸이 비어 있었다. 이미 캐시에 요약이 있던 기사만 우연히 이 버그를 안 밟고 지나간 것이었다.

고쳐서 다시 돌려봤다

함수 이름을 set_article_cache로 맞춘 다음, 실제 가이드로 리포트를 다시 생성했다. 결과를 두 가지로 확인했다. 새로 생성된 파일 8개 전체를 grep했을 때 각주 번호가 한 건도 없었고, 캐시 DB의 요약 채워진 행 수가 3138건에서 3186건으로 48건 늘어 있었다. 두 문제 다 잡았다고 생각했다.

눈으로 봐달라는 말에 다시 열어봤다

정말 그런지 직접 눈으로 봐달라는 요청을 받고 터미널에서 리포트 파일을 열었다. 그런데 여전히 [1]이 보였고, 테이블 밑에 링크 목록도 그대로 있었다. grep으로는 분명 없었는데, 화면에는 있었다.

vi로 보면 없는데, glow로 보면 있었다

같은 파일을 두 가지 방법으로 열어봤다. vi로 열면 원본 그대로, 각주 없이 [제목](url) 마크다운 링크만 보였다. 그런데 glow로 열면 제목[1]처럼 각주가 붙고, 문서 끝에 그 URL들을 모은 목록이 따로 생겼다. 같은 파일인데 뷰어에 따라 다르게 보이고 있었다 — 즉 지금 눈에 보이는 각주는 파일이 아니라 뷰어에 있었다.

테이블 셀 안 링크를 통째로 각주로 바꾸고 있었다

glow -w 0(줄바꿈 폭 제한 해제)로 다시 열어봐도 똑같았다. 원인은 폭이 아니라 테이블 자체였다. glow가 쓰는 렌더링 엔진(glamour)이 테이블 셀 안에 있는 마크다운 링크는 무조건 각주로 바꾸는 걸로 보였다 — 셀 폭 안에 [제목](url) 전체를 넣으면 테이블이 깨지니까, 대신 텍스트만 남기고 URL은 번호를 매겨서 문서 끝으로 빼는 방식이었다. 이 리포트는 테이블마다 기사 링크가 잔뜩 들어 있어서, 링크 하나하나가 전부 이 규칙에 걸렸다.

각주 없는 걸 찾아 mdcat을 설치했다

클릭할 일은 없고 그냥 모양만 보면 되는 상황이라, 가벼우면서 각주로 안 도망가는 렌더러를 찾았다. bat은 렌더링을 안 하고 원본에 색만 입히는 거라 테이블 모양 자체를 못 보고, frogmouthrich는 더 무거워서 mdcat을 골랐다 — 링크를 각주로 빼는 대신 OSC8 터미널 하이퍼링크로 숨겨서, 클릭 여부와 무관하게 애초에 각주나 URL 목록이 생기지 않는 방식이었다. GitHub 릴리스에서 바이너리 받아서 ~/.local/bin에 설치했다.

mdcat도 안 예뻤다

각주는 확실히 없어졌는데, 이번엔 다른 문제가 나왔다. 이 리포트 테이블은 셀 안에 담당조직 설명이나 기사 요약처럼 문장 단위 텍스트가 들어 있는 구조라서, mdcat이 컬럼 폭을 내용 기준으로 넓게 잡아버리면서 줄바꿈이 깨지고 정렬을 알아볼 수 없어졌다. 각주로 도망가느냐(glow), 억지로 줄바꿈하다 깨지느냐(mdcat) 방식만 다를 뿐, “긴 문장이 든 넓은 테이블을 좁은 터미널에 욱여넣는다”는 근본 원인은 똑같았다.

테이블을 세로로 눕혔다

파이프라인 코드는 그대로 두고, 이미 생성된 리포트 파일 하나만 따로 변환하는 스크립트를 짰다. 어카운트별 담당조직/산학연/팩트 표는 행 하나당 컬럼을 - **컬럼명**: 값 식으로 세로로 쌓는 형태로 바꾸고, 맨 뒤에 있는 전체 기사 부록 표는 원래 테이블 그대로 남겨뒀다. 폭 제약이 없어지니 어느 렌더러로 봐도 내용이 안 깨졌다.

그래도 glow 쪽 모양이 더 나았고, 오픈소스라 소스를 봤다

세로형으로 바꾼 파일을 mdcatglow 양쪽으로 다시 비교해봤는데, 전체적인 렌더링 모양은 glow 쪽이 더 낫다는 평가가 나왔다. 다만 링크 처리(각주로 빼는 것) 하나만 여전히 마음에 안 들었다. glowglamour는 둘 다 오픈소스(MIT)라 소스를 직접 받아서 찾아봤다. glamour 안에는 이미 WithInlineTableLinks(bool)이라는 옵션이 있었다 — 켜면 테이블 안 링크를 각주로 안 빼고 그대로 인라인 렌더링한다. 그런데 glow CLI의 main.go가 렌더러를 만드는 부분을 보니, 이 옵션은 안 쓰고 있었다. 색 프로필, 줄바꿈 폭, 베이스 URL만 넘기고 있었다 — 즉 이미 라이브러리에 있는 기능인데 CLI가 플래그로도, 설정 파일로도 노출을 안 해놓은 상태였다. 이건 지금 당장 손보기엔 커서, 나중에 볼 일로 적어두고 넘어갔다.

그런데, 애초에 각주가 진짜 있었던 적이 있었나?

여기서 한 번 멈췄다. glow 하나로 지금까지 본 각주와 링크 목록이 전부 설명됐다. 그러면 맨 처음에 프롬프트를 고치게 만들었던 그 각주는 — 정말 LLM이 붙인 게 맞았을까, 아니면 처음 그 리포트를 열어본 것도 사실 glow였던 걸까? 근거로 삼았던 건 reporter.py에 있던 \[\d+\] 제거 코드뿐이었다. 그 코드가 실제로 관찰된 문제 때문에 생긴 건지, 아니면 그것도 예전에 누군가 지금 우리처럼 glow 렌더링을 콘텐츠 문제로 오진하고 방어적으로 넣어둔 건지는 확인한 적이 없었다.

막던 걸 다 빼고 다시 돌려봤다

확인할 방법은 하나였다. 각주를 막고 있던 걸 전부 빼고, 실제로 다시 돌려서 진짜 나오는지 보는 것. 프롬프트에 넣었던 “각주 넣지 마라” 지시 한 줄과, reporter.py\[\d+\] 제거 정규식 둘 다 빼고, 기간을 1주일로 좁혀서(내용을 보려는 게 아니라 각주 발생 여부만 보면 되니까) 실제 가이드로 다시 돌렸다.

결과 리포트 파일 8개를 전부 grep했더니 [숫자] 패턴이 딱 1건 나왔는데, “삼성전자[005930]“처럼 주식 종목코드였다. 각주가 아니었다. 이번에 새로 캐싱된 LLM 원본 응답 38건도 전부 뒤져봤는데 각주 패턴은 0건. 기존에 쌓여 있던 캐시 3186건까지 다시 확인해보니, 거기도 각주로 보이는 건 종목코드 하나뿐이었다. 지금까지 LLM이 실제로 각주 스타일 인용 번호를 붙인 적은 한 번도 없었다.

배운 점

증상 하나([1] 각주, 하단 링크 목록)에 원인 후보가 두 개 있을 때, 그럴듯한 쪽(코드에 이미 방어 로직이 있다는 것)을 증거로 삼고 넘어가면 틀릴 수 있다. viglow로 같은 파일을 나란히 열어본 것 한 번으로 파일과 렌더러를 분리할 수 있었고, 그다음엔 거기서 멈추지 않고 “고친 걸 다시 빼고 진짜 재현되는지” 직접 실험해서 확인했다. 고쳤다고 믿는 것과, 그게 실제로 원래 있던 문제였는지 확인하는 것은 다른 일이다.

그 과정에서 얻어걸린 것도 있었다. 각주를 쫓아 들어갔다가 캐시 저장 함수 이름이 애초에 틀려 있던, 완전히 별개의 버그를 만났다. 증상을 고치러 코드를 읽다 보면 바로 옆 줄에 있는 진짜 문제를 우연히 보게 될 때가 있는데, “이 한 줄만 고치면 끝”이라고 생각하지 않고 관련 코드를 한 번 더 읽어야 눈에 띈다. 그리고 넓은 except Exception은 에러를 숨기는 것에서 그치지 않는다 — 실패를 정상적인 비즈니스 로직(“관련 없음”)으로 둔갑시켜 버리면, 로그를 안 보는 이상 뭐가 새고 있는지 알아챌 방법이 아예 없어진다. 이건 각주와 달리 진짜 있던 버그였고, 그대로 고쳐진 채로 남았다.


Share this post:

Previous Part
고객사 뉴스 리포트 만들다가 5편 — 도메인 키워드로도 안 되길래, 결국 LLM한테 물어봤다
Next Part
고객사 뉴스 리포트 만들다가 7편 — 다섯 시간 넘게 멈춰 있었는데, 원인은 알고리즘이었다