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

설계까지 다 거치고도, 실행해봐야 보였다 6편 — 자정을 넘긴 순간에만 걸리는 레이스였다

설계까지 다 거치고도, 실행해봐야 보였다 · 6/6편

/ingest가 만든 노트를 감사하다가 이상한 걸 발견했다. 방금 새 주 폴더에 생성된 노트인데, 본문의 Related pages 링크는 그 노트 자신의 폴더가 아니라 지난주 폴더를 가리키고 있었다. 죽은 링크로 끝나면 그나마 다행이고, 더 안 좋은 경우엔 우연히 같은 이름을 가진 지난주의 무관한 노트로 조용히 연결됐다.

처음 짐작한 원인은 틀렸다

이슈를 처음 적을 때는 “링크를 조립하는 로직이 이전 폴더 경로를 어딘가에 캐싱해두고 재사용하는 것 같다”고 추정했다. 그럴듯한 설명이었다 — 흔한 버그 패턴이고, 고치는 방법도 뻔해 보였다.

그런데 실제로 파일 쓰기 경로를 계산하는 코드를 열어보니 그런 캐싱은 어디에도 없었다. 경로는 매번 new Date()로 새로 계산되고 있었다. 짐작한 원인이 코드상 존재하지도 않는 메커니즘이었던 셈이다.

”정확히 왜 그 순간에만” 이라는 질문

원인 추정이 틀렸다는 걸 확인한 다음 막막해졌을 때, 떠 오른 질문이 방향을 바꿨다. “정확히 왜 자정 넘어가는 그 사이에 ingest 되는 경우에만 나오는지.” 캐싱 가설을 버린 다음에도 “가끔 이런 일이 생긴다”는 느슨한 설명에 머물러 있었는데, 이 질문은 그걸 “정확히 어떤 조건에서”로 좁혔다. 조건을 정확히 좁히지 않고는 답할 수 없는 질문이었고, 그 질문에 답하려고 다시 코드를 뒤지면서 진짜 메커니즘이 드러났다.

두 번 계산된 “지금”

/ingest는 노트를 쓰기까지 두 단계를 거친다. 먼저 LLM에게 보낼 프롬프트를 만들면서 “지금이 몇 년 몇 월 몇 주차인지”를 계산해 알려준다. LLM은 이 정보를 보고 어느 주 폴더에 노트를 넣을지, 어떤 경로로 링크를 걸지 결정해서 응답한다. 그 응답이 돌아오면, 실제로 파일을 쓰는 단계에서 경로를 다시 한번 계산한다.

문제는 이 두 계산이 같은 “지금”이 아니라는 데 있었다. 프롬프트를 만드는 시점과 파일을 쓰는 시점 사이에는 LLM 호출이 끼어 있고, 이 호출은 몇 초에서 몇십 초씩 걸릴 수 있다. 대부분의 경우 이 정도 시간차는 아무 문제가 안 된다. 그런데 마침 이 호출이 월요일 자정을 걸치는 순간에 일어나면 얘기가 달라진다. 프롬프트는 정직하게 “아직 지난주”라고 안내했고 모델도 그 안내를 믿고 지난주 폴더 경로로 링크를 썼는데, 실제 파일이 디스크에 쓰이는 순간엔 시계가 이미 새 주로 넘어가 있다. 노트 자신은 새 폴더에 만들어지고, 그 노트가 스스로 써넣은 링크만 방금 지나간 옛 폴더를 가리키게 되는 것이다. 자정을 걸치지 않는 대부분의 ingest에서는 두 시점의 “지금”이 우연히 같은 값이라 이 차이가 전혀 드러나지 않았을 뿐이다.

고친 방법

엔진 쪽에 새 로직을 추가하는 대신, “지금”을 딱 한 번만 계산하도록 만들었다. 프롬프트를 만드는 시점에 캡처한 시각 하나를, 그 뒤로 이어지는 호출 사슬 — 청크 처리 콜백에서 파일 쓰기 실행기까지 — 전체에 그대로 넘겨서, 파일을 실제로 쓰는 순간에도 같은 값을 쓰게 했다. 시계가 중간에 몇 번을 넘어가든 상관없이, 이 ingest 한 건 안에서는 “지금”이 하나로 고정된다.

고치면서 /refactor도 같은 경로 계산 함수를 쓴다는 게 걸렸다. 다만 /refactor는 제안을 화면에 그리는 시점에 경로를 이미 고정해버리는 구조라, /ingest와 똑같은 방식으로는 레이스가 안 생긴다 — 여기까지는 코드를 읽고 확인했다. 그런데 이 “구조가 다르다”는 확인이 “그래서 안전하다”는 뜻은 아니었다. 제안이 화면에 그려지는 시점과 사용자가 실행 버튼을 눌러 실제로 파일이 쓰이는 시점 사이에도 몇 시간에서 며칠까지 간격이 벌어질 수 있고, 애초에 이 명령이 모델에게 “지금 몇 주차인지”를 알려주긴 하는지조차 코드만 봐서는 바로 안 드러났다. 같은 함수를 공유하는 두 명령 중 하나를 고치면서 다른 하나까지 “그럼 됐다”고 넘기는 대신, 확인이 끝나지 않은 질문들을 별도 이슈로 그대로 남겨뒀다.

남겨둔 이슈를 마저 열어봤다

며칠 뒤 그 이슈를 다시 열었다. 먼저 확인한 건 “모델이 현재 시점을 받긴 하나”였다 — 받고 있었다. 다만 프롬프트 조립 함수가 값을 안 받으면 자체적으로 new Date()를 계산해 채워 넣는 fallback이 있었고, 이 계산은 “실행” 버튼을 누르는 바로 그 순간에 일어나고 있었다. 반면 파일이 실제로 놓일 경로는 그보다 한참 전, 제안이 화면에 그려지는 순간에 이미 고정돼 있었다. 렌더링과 실행 사이에 며칠이 지나도 이상할 게 없는 구조였다.

다행인 부분도 있었다. 형제 조각끼리 서로 링크를 걸 때는 모델이 경로를 스스로 짓지 않는다 — 렌더링 시점에 이미 정해진 형제들의 실제 경로를 번호 매긴 목록으로 통째로 프롬프트에 박아 넣고, 모델은 그 목록을 그대로 베껴 쓰도록 지시받는다. /ingest가 아직 존재하지도 않는 형제 노트의 경로를 모델이 “지금이 몇 주차인지”만 보고 스스로 지어내야 했던 것과는 다른 설계였다. 이 경로만 놓고 보면 진짜 위험은 없었다.

그런데 이 확인 자체가 새로운 문제를 드러냈다. 같은 프롬프트 한 통 안에 서로 다른 두 개의 “지금”이 동시에 들어 있었다 — 목록에 박힌, 렌더링 시점 기준의 진짜 경로 하나와, “현재 시점은 [ … ]“이라고 적힌 실행 시점 기준의 값 하나. 형제 링크는 앞엣것만 그대로 베끼니 안전했지만, 본문에서 다른 문서를 언급하거나 날짜/주차를 서술할 때 모델이 뒤엣것을 기준으로 판단하면, 파일은 지난주 폴더에 놓였는데 본문은 스스로를 이번 주 문서로 서술하는 조용한 모순이 생길 수 있었다.

고쳐서 닫았다

고치는 방법은 /ingest 때와 같은 모양이었다 — “지금”을 한 번만 계산해서 끝까지 그대로 넘기는 것. 이번엔 그 한 번을 제안이 화면에 그려지는 순간에 잡아서, 경로를 고정하는 데도 쓰고 실행 프롬프트의 “현재 시점”에도 그대로 실어 보냈다. 렌더링과 실행 사이에 시계가 몇 번을 넘어가든, 그 프롬프트 한 통 안에서는 더 이상 두 개의 “지금”이 존재하지 않는다.

고치기 전에, 렌더링 시각을 실행 시각과 일부러 다르게 고정한 테스트를 먼저 추가해서 레드부터 확인했다 — 옛날 코드로는 프롬프트가 실행 시각을 반영해 테스트가 실패했고, 고친 코드로는 렌더링 시각을 그대로 반영해 통과했다. 겪어본 적 있는 패턴이라 고치는 데는 오래 안 걸렸지만, “구조가 다르니 안전하다”는 확인과 “안전하다”는 확인은 다른 질문이라는 걸 한 번 더 확인한 셈이다.

남는 생각

이슈 본문에 적어둔 원인은 그럴듯했지만 틀렸고, 코드를 읽어서 그 틀림을 확인한 다음에도 “정확히 어떤 조건”을 말로 설명하지 못하고 있었다. 그 틈을 메운 건 다시 읽은 코드가 아니라, “왜 하필 그 순간에만”이라는 한 줄짜리 질문이었다. 설계 문서도, 코드도 이미 다 있는 상태에서 그 이상을 알아내려면 결국 실제로 그 조건을 만족하는 순간을 상상해서 실행 순서를 손으로 다시 그려봐야 했다 — 설계와 코드를 눈으로 훑는 것만으로는 안 보이던 자리였다.

그리고 /refactor를 다시 열어봤을 때도 비슷한 함정이 하나 더 있었다. “구조가 다르니 같은 레이스는 안 생긴다”까지는 코드를 읽는 것만으로 확인이 됐는데, 그게 “그러니 안전하다”는 뜻은 아니었다. 경로 하나가 안전하다는 확인과, 그 경로를 만드는 프롬프트 전체가 안전하다는 확인은 범위가 다른 질문이었다 — 이번에도 결국 실행 시점과 렌더링 시점을 손으로 나란히 놓고 봐야 드러나는 차이였다.


Share this post:

설계까지 다 거치고도, 실행해봐야 보였다

  1. 1. 설계까지 다 거치고도, 실행해봐야 보였다 1편 — 테스트가 원천적으로 못 잡는 자리에 버그를 심어놓고 있었다
  2. 2. 설계까지 다 거치고도, 실행해봐야 보였다 2편 — 병합을 빼자, 분할이 살아났다
  3. 3. 설계까지 다 거치고도, 실행해봐야 보였다 3편 — 레이스를 막을 방법이 아니라, 없앨 이유를 찾았다
  4. 4. 설계까지 다 거치고도, 실행해봐야 보였다 4편 — 없는 줄도 몰랐던 안전망을 찾아냈다
  5. 5. 설계까지 다 거치고도, 실행해봐야 보였다 5편 — 되돌리기 버튼 하나에, 버그가 세 겹으로 숨어 있었다
  6. 6. 설계까지 다 거치고도, 실행해봐야 보였다 6편 — 자정을 넘긴 순간에만 걸리는 레이스였다