기억은 있는데 왜 꺼내질 못하니 - pull을 push로
TL;DR
장기기억에 정답이 있었는데 에이전트가 recall 하지 않았다. 고칠 곳은 검색이 아니라 "언제 꺼내는가"였고, 처방은 규칙을 더 쓰는 것이 아니라 결정론적 신호로 밀어 넣는 것이었다.
- 같은 라이브러리 함정을 다시 밟은 세션에서 recall은 0회였다. 그 자리에 나올 법한 질의 셋은 모두 정답을 상위 5위에 돌려줬다. 검색하면 찾을 수 있었는데, 하지 않았다.
- "먼저 recall하라"는 규칙을 강화하지 않았다. 세션 시작 규칙조차 0회였기 때문이다. 대신 레포의 의존성·설정·경로를 태그로 바꿔 세션 시작 때 관련 기억을 주입하는 훅을 만들었다.
- 순위 공식은 실데이터에서 세 번 틀렸다. fixture 테스트를 전부 통과한 뒤에도 그랬다. 매번 실제 모양을 실패하는 테스트로 먼저 옮기고 고쳤다.
- 훅은 태그에 기대는데 태그가 비어 있었다. 과거분은 판정을 거쳐 메우고(361쌍 중 93쌍), 앞으로는 저장 시점에 동의어는 강제로 정규화하고 빠진 태그는 권고만 한다.
1. 발견 - 정답이 있는 함정을 다시 밟았다
자동 메모이제이션 컴파일러와 테이블 라이브러리를 함께 쓰는 레포에서, 정렬 헤더를 별도 컴포넌트로 뺐더니 문제가 생겼다.
데이터는 정렬되는데 헤더만 옛 상태를 보여 줬다. 원인은 컴파일러가 라이브러리 객체를 받는 쪽 컴포넌트를 평범하게 메모해서, 같은 객체를 고쳐 가며 쓰는 라이브러리의 상태 읽기를 캐시한 것이었다.
에이전트는 처음에 객체를 만드는 쪽에 옵트아웃을 붙였고 효과가 없었다. 받는 쪽으로 옮기자 고쳐졌다.
디버깅 손실 자체는 한 바퀴, 1분도 안 됐다.
문제는, 이 원리가 두 달 전 장기기억에 그대로 적혀 있었다는 것이다. "자동 스킵은 호출한 함수 한 칸에만 적용된다. 위험한 쪽은 받는 쪽이고, 만드는 쪽에 옵트아웃을 붙이는 것은 아무 일도 하지 않는다." 에이전트가 처음 한 헛손질을 정확히 예고한 문장이다.
세션 기록을 다시 보니 두 시간 동안 recall은 0회였다. 내가 물어본 뒤에야 처음 불렸다.
검색 탓인지부터 확인했다
먼저 의심한 것은 검색이다. 그 시점에 나올 법한 질의 세 개를 돌렸다. 설계할 때 물었을 법한 주제어, 증상을 서술한 문장, 라이브러리 식별자다. 세 번 모두 관련 기억 묶음이 상위 5위 안에 나왔다. 측정 직전에 그 기억을 수정해 결과가 유리해졌을 수 있어서, 수정하지 않은 형제 기억만 따로 봤다. 그쪽도 상위권이었다.
찾을 수 있었는데 묻지 않았다. 지난 사건(9월)은 "질의를 잘못 던졌다"였다. 이번에는 "질의를 던지지 않았다"였다.
잃은 것의 진짜 크기
디버깅 1분은 작다. 진짜 손실은 설계 시점에 있었다. 헤더 컴포넌트를 짜기 직전에 recall했다면, 형제 기억이 "중첩 컴포넌트가 라이브러리 객체 뒤에 상태 읽기를 숨기면 위험"이라고 그 모양 자체를 막았을 것이다.
더 아찔한 것도 있었다. 이 버그는 검증에서 테이블 컴포넌트 UI의 헤더를 두 번 눌러 봤기 때문에 잡혔다. 한 번만 눌렀다면 데이터는 정렬되니 그대로 배포되었을 것이다.
2. 판단 - 규칙을 늘리는 건 처방이 아니다
가장 쉬운 처방은 전역 지침에 "라이브러리 동작이 이상하면 먼저 recall하라"를 한 줄 더하는 것이다. 하지 않았다.
- 기존 지침에는 이미 "세션 시작 시 recall"이 있었다. 그것도 0회였다. 지켜지지 않는 규칙 위에 규칙을 쌓는 것은 처방이 아니다.
- recall 트리거 문구가 "배경·선호·과거 결정"이라 라이브러리 함정은 범위 밖으로 읽혔을 수 있다. 하지만 문구만 탓하면 시작 시 규칙 0회를 설명하지 못한다.
- 근본 원인은 이것이다. 모델은 모른다는 것을 모를 때 묻지 않는다. 원인 가설을 스스로 세울 만큼 자신 있었던 순간이 바로 기억이 가장 필요한 순간이었다. 모델이 떠올려야 작동하는 pull 방식은 이 지점을 구조적으로 받치지 못한다.
그래서 방향을 push로 틀었다. 모델이 묻지 않아도, 결정론적으로 알 수 있는 신호로 관련 기억을 눈앞에 가져다 놓는다. 레포에는 그런 신호가 이미 있다. 어떤 패키지에 의존하는지, 어떤 설정 플래그가 켜져 있는지, 어떤 디렉토리가 있는지.
처방은 세 층으로 정했다.
| 층 | 무엇 | 성격 |
|---|---|---|
| push | 세션 시작 훅이 레포 신호를 태그로 바꿔 관련 기억을 주입한다 | 모델 판단에 기대지 않는다 |
| 레포 가드 | 그 함정의 모양을 테스트로 막는다 | 기억이 없어도 막힌다 |
| 태그 정규화 | push가 기대는 태그 어휘를 정리한다 | push의 전제 |
3. 수정 1 - push 훅, 그리고 세 번 틀린 순위 공식
훅 자체는 단순하다. package.json 의존성, 설정 파일의 플래그, 특정 경로의 존재를 매핑표로 정규 태그에 대응시킨다. 그 태그로 기억 목록을 조회하고(임베딩 호출 없음), 상위 12줄을 세션 맥락에 넣는다. 어떤 실패든 출력 없이 exit 0으로 끝나서, 세션 시작을 막지 않는다.
로직 층은 fixture와 단언 스크립트로 TDD했고, 네트워크 층은 테스트에서 빼고 실제 실행 로그로 확인하기로 했다. 그런데 어려운 것은 "12줄에 무엇을 넣을까"였고, 이 공식이 실데이터에서 세 번 틀렸다.
첫 번째 - "걸린 태그 수"
처음 직관은 "여러 태그에 동시에 걸리는 기억이 강한 신호"였다. 이 레포는 컴파일러도 쓰고 테이블 라이브러리도 쓴다. 둘 다에 걸리는 기억이 가장 관련 있다는 생각이다. fixture로는 완벽하게 통과했다.
실데이터에 돌리자 정작 잡고 싶던 기억이 12줄 밖으로 밀렸다. 위를 차지한 것은 늘 함께 붙는 큰 태그 두 개(DB와 그 호스팅 서비스)의 교집합이었다. 서로 상관된 태그의 교집합은 신호가 아니다.
두 번째 - 순수 IDF
태그마다 1/(그 태그의 기억 수)를 더하는 방식으로 바꿨다. 흔한 태그는 약하게, 드문 태그는 강하게 친다. 큰 태그 교집합 문제는 풀렸다.
다시 돌리자 이번에는 10위였다. 기억이 1~3건뿐인 태그의 단독 기억이 1/2, 1/3점을 받아 위를 차지했다. IDF가 아주 작은 집합에 과하게 쏠리는 고전적인 문제다.
세 번째 - 하한, 그리고 프로젝트 몫
분모에 하한 5를 뒀다(1/max(df, 5)). 그러자 상위 5줄이 정확히 그 함정 묶음이 됐다.
그런데 다른 레포에서 돌리니 또 어긋났다. 다른 프로젝트의 테스트 러너·린터 기억이 이 레포 자체의 DB 함정을 밀어냈다. 그래서 "레포 이름과 같은 태그를 가진 기억은 이 레포에서 겪은 일이니 먼저"라는 가산점을 넣었다. 그랬더니 이번에는 프로젝트 기억이 12줄을 전부 채워, 다른 레포에서 배운 라이브러리 함정이 아예 사라졌다. 이 훅을 만든 원래 이유가 지워진 것이다.
최종 형태는 가산점이 아니라 몫 배정이다. 프로젝트 기억은 최대 절반까지만 차지하고, 나머지는 다른 레포에서 배운 라이브러리 기억 몫이다. 한쪽이 모자라면 다른 쪽이 남은 줄을 쓴다.
이 과정에서 지킨 것
- fixture 통과는 정답이 아니었다. fixture는 내가 상상한 데이터 모양이고, 세 번 모두 실데이터가 그 상상을 깼다. 실제 실행 로그를 별도 관문으로 둔 것이 이 결함들을 잡았다.
- 고치기 전에 실제 모양을 실패하는 테스트로 옮겼다. 처음 옮긴 fixture는 태그 크기가 실제(7건)가 아니라 2건이라 실패하지 않았다. 크기를 실측과 맞추자 그제야 RED가 났다. 재현 테스트는 데이터의 비율까지 맞아야 재현한다.
- 한 데이터셋으로 정한 상수는 다른 데이터셋에서 틀린다. 하한 5는 한 레포에서 정했고 다른 레포에서 바로 보완이 필요했다.
4. 수정 2 - 권고 말고 강제로: 레포 안의 가드
장기기억은 레포를 넘나드는 지식의 정본이다. 하지만 특정 레포에 그 함정이 실제로 있다면, 그 레포 안에는 강제가 있어야 한다.
처음 떠올린 것은 프로젝트 지침 문서에 한 줄 적는 것이었다. 하지만 그것도 권고다. 지금 막 "권고는 지켜지지 않았다"를 확인한 참이었다. 그래서 테스트로 만들었다.
- 소스 코드를 AST로 훑어, 테이블 라이브러리의 객체 타입을 prop으로 받는 컴포넌트를 찾는다.
- 함수 본문의 첫 문장이 옵트아웃 지시어가 아니면 실패시킨다. 지시어는 첫 문장이어야 컴파일러가 인식한다.
- 실패 메시지에 이유와 원래 사건, 장기기억 id를 적는다. 테스트를 깨뜨린 사람(또는 에이전트)이 그 자리에서 맥락을 얻는다.
- 같은 모양의 헤더가 인라인으로 아홉 번 복사돼 있어서 공용 컴포넌트 하나로 모았다. 다음 표를 만들 때 복사해 갈 정본이 생긴다.
가드에 이빨이 있는지도 확인했다. 실제 컴포넌트에서 지시어를 잠시 빼고 돌리면 파일과 컴포넌트 이름을 짚으며 실패하는지 보고, 되돌렸다. 통과하는 테스트만 보고는 그 테스트가 무언가를 잡는지 알 수 없다.
5. 두 번째 발견 - 훅이 기대는 태그가 비어 있었다
훅을 다른 레포에서도 써 보니 이상했다. 그 레포 자체의 기억이 거의 안 나왔다. 원인은 훅이 아니라 데이터였다. 그 프로젝트의 기억 대부분에 프로젝트 태그도, 라이브러리 태그도 없었다.
전체를 쟀다. 본문에 라이브러리 이름이 나오는 기억 중 그 태그가 없는 비율이 라이브러리마다 25~89% 였다. 프로젝트 축은 대체로 6~30%였지만 일부는 50~59%였다.
이 숫자는 부풀려져 있다. "본문에 나온다"에는 기술 스택 나열이나 다른 기억으로 가는 링크 제목도 들어간다. 표본을 직접 읽어 보니 본론인 것은 3분의 1 정도였다. 그래도 결론은 같다. 주제가 그 라이브러리인데 태그가 빠진 기억이 체계적으로 쌓여 있다.
원인도 분명하다. 태그는 저장할 때 모델이 그때그때 자유롭게 고른다. 정해진 어휘도, 확인하는 장치도 없다. 같은 라이브러리가 세 가지 이름으로 갈라져 있기도 했다.
6. 수정 3 - 강제할 것과 권고할 것을 가른다
과거분만 메우면 내일부터 다시 쌓인다. 그래서 저장 시점부터 고쳤다. 태그 어휘의 정본(정규 태그, 별칭, 본문 패턴)을 메모리 서버에 두고, 쓰기 경로에서 두 가지를 다르게 다뤘다.
| 처리 | 이유 | |
|---|---|---|
| 별칭 태그 (같은 라이브러리의 다른 이름) | 강제로 정규 이름으로 바꿔 저장하고 알린다 | 순수 동의어라 의미 손실이 없다. 판단이 필요 없다 |
| 본문에 나오는데 빠진 태그 | 권고만 한다 ("본론이면 붙일 것, 이름만 지나가면 둘 것") | 지나가는 언급도 걸린다. 본론인지는 문맥을 아는 쪽이 판단해야 한다 |
이 경계가 이번 작업에서 가장 쓸모 있는 판단이었다. 판단이 필요 없는 것은 강제하고, 판단이 필요한 것은 권고하되 판단에 필요한 이유를 같은 줄에 싣는다. 권고 문구에 "태그가 빠지면 세션 시작 훅이 이 기억을 그 레포에 잇지 못한다"를 넣은 것도 그래서다.
권고의 소음도 미리 쟀다. 기존 기억 716건에 같은 함수를 돌리니 61%는 권고 없음, 25%는 1개, 2개 이상은 14%였다. 실제로 이번 결정을 저장할 때 권고가 4개 붙었는데, 넷 다 이름만 지나간 것이라 붙이지 않았다. 설계대로 작동한 것이다.
과거분 메우기
후보는 저장 시점 권고와 같은 함수로 뽑았다. 그래야 과거분 정리와 앞으로의 권고가 같은 기준이 된다. 1차 범위는 훅이 실제로 조회하는 라이브러리와 누락률이 높은 프로젝트로 좁혔고, 후보는 361쌍이었다.
- 판정 기준을 글로 먼저 정했다. 라이브러리는 "그 라이브러리를 쓰는 레포에서 떠오르면 쓸모 있는 구체적 진술이 있는가", 프로젝트는 "그 레포 자체의 작업·결정·사고인가"다. "사건이 난 장소일 뿐이고 함정 자체는 일반적인" 경우는 붙이지 않는다. 애매하면 둔다. 놓치는 쪽이 소음보다 싸다.
- 판정은 셋으로 나눠 같은 기준으로 하고, 무작위 표본과 판정자들이 애매하다고 보고한 쌍을 직접 다시 봤다.
- 판정자끼리 같은 성격의 기억을 다르게 판정한 쌍이 있었다. 기준을 하나로 맞추고 그 조정을 처분안에 기록했다.
- 결과는 93쌍(26%) 붙임, 268쌍 둠이다. 실행 전후 태그 수를 비교해 처분안과 정확히 일치하는 것까지 확인했다.
효과는 바로 보였다. 처음 이상하다고 느낀 레포에서, 세션 시작 주입의 상위 6줄이 전부 그 프로젝트 자체의 기록으로 바뀌었다.
7. 작은 것들 - 숫자가 앞뒤가 안 맞으면 멈춘다
- 응답 문구가 거짓말을 했다. 별칭을 정규화하는 문구가 "바꿔 저장했다"였는데, 정규 태그가 이미 있으면 실제로는 아무것도 저장되지 않는다. 같은 문구를 "항상 저장되는 경로"와 "저장 안 될 수 있는 경로"가 함께 쓰고 있었다. 문구는 한 일만 말하고, 저장 여부는 각 경로의 본 응답에 맡기도록 고쳤다.
- 집계가 "81건 전부 변경 없음"이라고 보여 줬다. 그런데 태그 수는 계획대로 정확히 늘어 있었다. 앞뒤가 맞지 않았다. 넘어가지 않고 다시 셌더니 80건 변경, 1건 이미 있음이었고, 틀린 것은 집계 명령이었다. 데이터가 맞아 보여도 두 숫자가 서로 모순이면 둘 중 하나는 틀린 것이다.
- 셸의 함정 둘. macOS 기본 bash 3.2는 명령 치환 안의
case패턴 뒤)를 치환의 끝으로 읽는다. 패턴 앞에 여는 괄호를 붙여 피했다.cut -c는 macOS에서 바이트 단위라, 가운뎃점 같은 다바이트 문자가 들어가면 단언이 흔들린다. 정규식 추출로 바꿨다. 둘 다 "실패 경로 테스트"가 잡았다. 문법 오류는 exit 2인데, "출력 없이 exit 0"을 기대한 테스트들이 한꺼번에 깨졌다.
'TIL' 카테고리의 다른 글
| [261016 TIL] TanStack Table v9 전환기 (0) | 2026.10.16 |
|---|---|
| [261004 TIL] 우리들의 추억 2편 - 체크포인트 1 (1) | 2026.10.04 |
| [260930 TIL] vgpu로 블랙홀 세 번째 시도 (0) | 2026.09.30 |
| [260928 TIL] terraform provider 공유 캐시 사용 (0) | 2026.09.30 |
| [260917 TIL] ANALYZE는 왜 한 시간이 걸렸나 (0) | 2026.09.17 |