2편에서 “요청은 병렬로, 기록은 순서대로 처리하는 큐”라는 설계까지는 정리해뒀지만, 구현은 하지 않고 미뤄뒀다. 이번 세션은 그 구현을 다시 여는 것으로 시작했다.
다시 열자마자 남아있던 문제 — wiki/Seeds 임시 노트 레이스
설계를 다시 붙잡자마자 처리 안 된 골칫거리가 하나 있었다.
/ingest는 청크를 처리할 때마다 wiki/Seeds/라는 폴더를 매번
다시 스캔한다. 이 폴더는 채팅 중에 “위키 편입” 버튼을 누르면
생기는 임시 지식 노트들이 쌓이는 곳이다. /ingest는 이 임시
노트들의 내용을 프롬프트에 같이 넣어서, 관련된 신규 원본이
들어오면 AI가 그 내용을 새 노트에 흡수하게 시키고, 흡수가
끝나면 원본 임시 노트를 지우도록 지시한다.
문제는 청크 요청을 병렬로 보내는 순간 생긴다. 두 청크가 거의 동시에 같은 임시 노트를 읽어서, 각자 다른 노트에 흡수해버릴 수 있다. 이건 이미 알고 있던 문제라 별도 이슈로 분리해서 나중에 처리하기로 해뒀었다. 그런데 이번에 그 이슈를 정말로 나중으로 미루기로 하자, “그럼 지금 당장 이 레이스는 어떻게 하나”라는 질문이 다시 튀어나왔다.
막을 방법을 찾다가, 없앨 이유를 봤다
처음 든 생각은 레이스를 완화하는 쪽이었다 — 같은 라운드 안에서는 임시 노트를 첫 번째 청크에만 몰아주면 되지 않을까 하는 식. 동작은 하겠지만, 그럴듯하게 임시방편을 하나 더 얹는 느낌이었다.
다시 생각해보니 더 근본적인 자리가 있었다. raw/ 폴더에 있는
파일들은 /ingest가 처리해도 절대 지워지지 않는다. 이미 요약이
끝났는지는 다른 위키 문서가 그 경로를 출처로 인용하고 있는지를
보고 판단하지, 원본을 지우는 방식이 아니다. 그런데 임시 지식
노트만 유일하게 “흡수되면 삭제된다”는 특수한 생애주기를 갖고
있었다. 그 특수성 자체가 매번 다시 스캔해야 하고, 흡수 여부를
추적해야 하고, 두 청크가 동시에 건드릴 수 있는 조건을 만들고
있었던 셈이다.
만약 “위키 편입” 버튼이 애초에 이 임시 폴더가 아니라 raw/
아래 일반 하위 폴더(가칭 raw/Chat/)에 저장했다면 어떨까.
/ingest 입장에서는 그냥 평소에 처리하던 원본 파일 하나일
뿐이다. 특수 스캔 로직도, 흡수 후 삭제 로직도 필요 없다. 레이스는
막을 대상이 아니라 애초에 생길 조건 자체가 없어진다.
이 방향은 저장 경로 이름까지만 잠정 정해두고 실제 구현은 이번
범위 밖으로 미뤘다 — “위키 편입” 버튼을 만드는 코드를 바꾸는
건 이번 작업과는 결이 다른 일이라서다. 대신 “지금 당장 /ingest가
임시 지식 노트를 아예 안 보게 만드는 것”만 이번 작업에 끼워
넣었다. 원래 따로 미뤄뒀던 이슈가 하려던 일의 절반을, 여기서
먼저 끝낸 셈이다. 방향 자체는 “제거”로 같지만, 그 결론에 닿은
경로가 달랐다는 게 이번 사이클에서 남는 것이었다 — 레이스를
막을 방법을 찾다가 도달한 게 아니라, 이 레이스가 애초에 왜
가능했는지(임시 노트만 특별한 삶의 주기를 갖고 있었다는 것)를
다시 짚었더니 자연스럽게 나온 결론이었다.
웨이브냐, 연속형 풀이냐
청크를 몇 개씩 묶어서 동시에 요청할지 정할 때 두 방식을 놓고 비교했다.
하나는 N개씩 웨이브로 묶어서 한 번에 동시 요청을 보내고, 응답이 다 오면 원래 순서대로 하나씩 기록한 다음 다음 웨이브로 넘어가는 방식이다. 다른 하나는 자리가 하나 비면 바로 다음 청크를 채워 넣는 연속형 워커 풀이다 — 이론상 처리량은 이쪽이 더 낫다.
웨이브를 골랐다. 연속형 풀은 완료되는 순서가 요청한 순서와 달라질 수 있다. 진행 상황을 사용자에게 원래 순서대로 보여주려면 “아직 안 돌아온 자리를 기다리는” 재정렬 버퍼 같은 상태를 따로 관리해야 한다. 웨이브 방식은 여러 개를 한 번에 요청해서 전부 기다린 다음 순서대로 처리하는 구조라, 순서 보장이 별도 장치 없이 그냥 따라온다. 기본 동시 실행 개수를 작게(2) 잡아둔 덕분에 웨이브 방식의 단점(묶음 안에서 제일 느린 응답 하나를 기다려야 한다는 것)도 실측상 영향이 크지 않았다.
그리고 이 선택을 쉽게 내릴 수 있었던 이유가 하나 더 있다. “청크 하나를 요청하는 부분”과 “응답 하나를 처리해서 기록하는 부분”을 애초에 분리해서 설계했기 때문에, 지금은 단순한 웨이브 방식으로 가고 나중에 정말 속도가 부족해지면 그 두 함수는 그대로 두고 둘을 엮는 방식만 바꾸면 된다는 확인까지 해두고 결정했다. 지금 당장 최선의 구조를 고르는 대신, 나중에 갈아끼우는 비용을 미리 낮춰두는 쪽을 택한 셈이다.
계획서 자체에 버그가 있었다 — 취소 처리 순서 버그
설계 문서를 쓰고, 그 문서를 바탕으로 구현 계획서까지 썼다. 계획서에는 취소(cancel) 처리 로직도 구체적인 코드로 못박아 뒀다. 그런데 실제 구현을 맡은 서브 에이전트가 계획서에 적힌 그대로 코드를 짠 다음, 기존 테스트 전체를 돌려보다가 하나가 깨지는 걸 발견했다 — 취소했을 때 통계가 정확해야 한다는, 이번 작업과 무관해 보이던 오래된 테스트였다.
원인은 이랬다. 계획서의 웨이브 기록 루프는 “각 청크의 결과를 기록하기 직전에 취소 여부를 확인”하도록 되어 있었다. 그런데 웨이브 안에서는 여러 청크의 요청이 동시에 나간다. 그중 어느 하나에서라도 취소 신호가 뜨면, 그 시점에는 이미 다른 청크가 성공적으로 응답을 받아온 상태라도 그걸 기록하지 않고 그냥 버려버리는 결과가 나왔다. 옛날 순차 처리 코드는 이런 손실이 없었다 — 한 청크의 요청과 기록이 완전히 끝나야 다음 청크로 넘어가면서 취소를 확인했으니, “이미 손에 들어온 걸 버리는” 상황 자체가 없었다.
여기서 눈여겨볼 부분은, 서브 에이전트가 이걸 스스로 판단해서 고치지 않았다는 것이다. “계획서가 시킨 대로 짰고, 취소 처리 로직을 제 마음대로 바꾸는 건 제 권한 밖이니 멈추고 확인부터 받겠다”고 작업을 멈추고 질문을 올렸다. 불확실하면 추측하지 말고 멈추라는 원칙이 정확히 의도한 대로 작동한 순간이었다.
확인해보니 정말로 계획 자체의 버그였다. 고친 방법은 순서를 뒤집는 것이었다 — “취소 확인 → 기록”이 아니라 “기록 → 그다음에 취소 확인”으로. 이미 손에 들어온 성공한 응답은 무조건 먼저 기록하고, 그다음 항목으로 넘어갈지 말지를 그때 가서 취소 여부로 판단하게 만들었다. 옛 순차 코드가 갖고 있던 “이미 끝난 작업은 절대 안 버린다”는 보장을 그대로 지키는 방식이다. 이 수정이 정말 맞는지는 마지막 리뷰 단계에서 시나리오를 손으로 직접 추적해서 다시 한번 확인했다.
설계 문서를 쓰고, 계획서를 쓰고, 계획서 자체를 스스로 다시 검토하는 단계까지 거쳤는데도 이 버그는 남아있었다. 결국 이걸 걸러낸 건 코드를 실제로 짜고 기존 테스트를 돌려본 순간이었다. 아무리 문서를 꼼꼼히 써도, 실행이 되기 전까지는 확인되지 않는 층이 따로 있다는 뜻이다.
마지막 리뷰가 잡은 나머지 하나
임시 지식 노트 처리 규칙을 프롬프트에서 지우면서, “임시 지식 삭제 명령을 내리라”는 지시문 한 줄이 전혀 다른 자리(시스템 권한을 설명하는 문장)에 그대로 남아있는 걸 놓쳤다. 그 지시가 정확히 뭘 가리키는지 설명하던 규칙 자체는 이미 지워졌는데, 지시 문구만 살아남은 상태였다. 작업을 하나씩 나눠서 검토할 때는 “이건 지금 이 작업 범위 밖”이라며 넘어갔던 게, 전체를 통으로 이어붙여 보는 마지막 검토에서야 걸렸다. 같이 발견된 사소한 것도 하나 정리했다 — 더 이상 아무것도 지우지 않으니 결과 보고서에 찍히던 “임시 대화 지식 정리: N개”라는 숫자가 앞으로 영원히 0으로만 나올 뻔한 것도 같이 없앴다.
남는 생각
이번엔 두 종류의 검증 실패를 다른 방식으로 마주쳤다. 하나는 전체를 조각조각 나눠서 검토할 때만 생기는 사각지대였다 — 지시문 하나가 다른 규칙에 얹혀 있어서, 그 규칙을 지운 작업의 범위 안에서는 안 보이고 전체를 다시 이어붙여야만 보였다. 다른 하나는 문서로는 절대 안 걸리고 실행이 되어야만 보이는 사각지대였다 — 계획서를 아무리 자세히 써도, 동시성이 얽힌 코드의 실제 동작은 기존 테스트를 실제로 돌려봐야만 드러났다.
그리고 이번 사이클에서 제일 남는 건 레이스 문제를 다룬 방식이다. “동시에 실행해도 안 부딪히게 막을 방법”을 찾는 대신, “애초에 왜 이게 부딪힐 수 있었나”로 한 걸음 물러났더니 답이 훨씬 단순해졌다. 임시 지식 노트가 특별했던 이유는 하나뿐이었다 — 흡수되면 사라진다는 것. 그 특별함을 없애면, 막아야 할 레이스 자체가 남지 않는다.