하나의 필드로 "언제"와 "깨울까"를 동시에 표현하기까지 네 번 실패했다

26년 07월 28일

네 번의 실패

이날은 메시지 배달 API를 설계하면서 네 번 실패한 날이다.

“언제 배달할지”와 “깨울지 말지”를 하나의 필드로 표현하고 싶었다.

근데 두 개의 직교하는 축을 하나로 합치는 게 어려웠다.

다섯 번째에서 정답을 찾았다.

그리고 Bun 런타임 마이그레이션을 측정 데이터로 거절한 것도 있었다.

메시지 배달 API가 필요했다.

확장이 메시지를 보낼 때, “지금 바로”인지 “다음 단계에”인지 “쉬고 있을 때”인지 지정할 수 있어야 했다.

그리고 “메인 에이전트를 깨울지 말지”도 지정해야 했다.

첫 번째 시도: 이름으로 시점 표현

“실행 중”, “실행 후”, “다음 턴”이라는 이름을 썼다.

근데 “midRun이 정확히 언젠지” 이름만 봐서는 알 수 없었다.

이름이 시점을 명확히 표현하지 못했다.

기각.

두 번째 시도: 4개 값

“이번 단계”, “다음 단계”, “대기”, “다음 사용자 요청” 4개 값으로 정리했다.

근데 “대기”에 문제가 있었다.

“쉬고 있을 때”인데, “쉴 때 깨워라”와 “쉴 때 깨우지 마라”를 구분할 수 없었다.

별도의 깨움 여부 설정 축을 추가해서 해결하려 했다.

근데 이러면 값이 2개 축의 조합이 된다.

하나의 필드로 표현하려는 목표에 어긋난다.

기각.

세 번째 시도: 그래도 4개 값

깨움 여부 설정을 유지하면서 4개 값을 개선했다.

근데 여전히 두 축이 혼재됐다.

호출하는 쪽이 두 개의 필드를 동시에 설정해야 했다.

복잡도가 줄지 않았다.

기각.

네 번째 시도: 여전히 막힘

3번째와 비슷한 방식으로 또 시도했다.

결과는 같았다.

두 축을 하나로 합칠 수 없었다.

다섯 번째: idle을 둘로 쪼갰다

정답은 idle을 둘로 쪼개는 거였다.

“쉴 때 깨우기”는 “쉴 때 깨워라”다.

“대기”는 “쉴 때 깨우지 마라”다.

하나의 값이 “언제”와 “깨울지”를 동시에 표현한다.

왜 이게 정답인가

“대기”를 둘로 쪼개면, 깨움 여부 설정 축이 필요 없어진다.

“쉴 때 깨우기”가 자체적으로 “깨운다”를 포함하고 “대기”가 자체적으로 “안 깨운다”를 포함한다.

두 축이 하나의 값으로 합쳐진다.

다른 값들(“이번 단계”, “다음 단계”, “다음 사용자 요청”)은 깨움 여부가 자명하다.

“이번 단계”은 당연히 깨운다.

“다음 사용자 요청”는 당연히 안 깨운다.

깨움 여부가 모호한 건 “대기”뿐이었다.

그래서 “대기”만 쪼개면 된다.

설계의 핵심: 모호한 지점만 쪼갠다

전부를 쪼갤 필요가 없다.

모호한 지점만 쪼개면 된다.

나머지는 자명하니까 그대로 둔다.

“전부 쪼개서 8개 값”이 아니라 “하나만 쪼개서 5개 값”이 더 단순하다.

13개 호출 지점 검증

5개 값으로 확정한 후, 6개 확장의 13개 호출 지점을 전부 매핑했다.

기존의 깨움 설정: true는 “쉴 때 깨우기”으로, 깨움 설정: false는 “대기” 또는 “다음 단계”으로 변환됐다.

100% 호환됐다.

번역 계층으로 구현했다

pi 코어를 수정하지 않았다.

확장 내부에서 5개 값을 pi의 내부 프리미티브로 번역한다.

코어를 건드리지 않으니까 런타임 호환성 부담이 없다.

Bun 런타임 마이그레이션: 측정 데이터로 거절

Bun 런타임으로 마이그레이션할지 검토했다.

성능을 측정했다.

Node 22.16은 웜 중앙값 659밀리초. Bun 1.3.14는 541밀리초.

Bun이 18% 빠르다.

이 18%는 의미가 없다

pi는 LLM API 호출과 네트워크 입출력에 병목이 있는 장기 실행 프로그램이다.

시작 속도 120밀리초 차이는 노이즈다.

LLM 응답은 수 초가 걸린다.

시작 속도가 120밀리초 빨라도 전체 실행 시간에는 의미가 없다.

진짜 장벽: code-index

Bun에서 code-index 확장이 치명적으로 실패한다.

code-index가 node

import하는데, Bun은 이를 지원하지 않는다.

node

bun
포팅할 수는 있지만 그다음 node-llama-cpp라는 네이티브 애드온이 또다시 벽이다.

네이티브 네이티브 애드온 파일 파일은 Bun에서 로드할 수 없다.

Bun으로 마이그레이션하면 code-index를 포기해야 한다.

code-index는 코드 의미 검색의 핵심이다.

포기할 수 없다.

결론: Node에 머문다

측정 데이터가 말해준다.

시작 속도는 빨라지지만 의미 없다.

code-index가 안 된다.

Node에 머무른다.

바이트 동일 깃 히스토리 재구성

이날 가장 즉시 사용 가능한 기법이었다.

63개의 지저분한 커밋을 5개의 논리적 커밋으로 재구성했다.

최종 트리는 원본과 바이트 단위로 동일했다.

핵심 원칙: 코드를 다시 입력하지 마라

논리적 단위마다 git checkout <source> -- <files>로 파일을 가져온다.

코드를 다시 입력하지 않는다.

드리프트가 불가능하다.

가져온 후 git diff HEAD <source> -- <fileset>으로 검증한다.

차이가 비어 있어야 한다.

비어 있으면 바이트 동일이다.

교차 파일 처리

한 파일이 두 개 이상의 논리적 단위에 걸쳐 있으면, 통째로 한 단위에 할당한다.

아니면 git checkout -p로 헝크 단위로 가져온다.

헝크 단위는 검증 비용이 높으니까 최소한으로 쓴다.

마무리

이날의 핵심은 두 가지였다.

첫째, 설계에서 모호한 지점만 쪼개라. 전부를 쪼갤 필요가 없다.

“대기”만 모호했으니까 “대기”만 쪼갰다.

4개 값에서 8개 값이 아니라 5개 값이 됐다.

모호한 걸 쪼개고 자명한 건 그대로 둔다.

둘째, 측정 데이터로 결정해라. Bun이 18% 빠르다.

근데 그 18%가 의미 있는 차이인가? pi는 LLM 병목 프로그램이다.

시작 속도는 노이즈다.

그리고 code-index가 안 된다.

측정 데이터가 “아니오”라고 말하면, 거절해야 한다.

그리고 깃 히스토리 재구성은 “다시 입력하지 마라”가 핵심이다.

코드를 다시 입력하면 드리프트가 생긴다.

체크아웃으로 가져오면 바이트 동일이 보장된다.