
Claude Code와 Codex의 작업 이력을 모두 SQLite에 저장하려다 Claude Code 본체가 무거워진 이야기 (268ms →
요약
Claude Code와 Codex의 작업 이력을 SQLite에 자동 저장하는 CLI 도구 개발 과정과 성능 최적화 경험을 다룹니다. HDD 환경에서 발생한 I/O 병목 문제를 분석하고, 배치(Batch) 처리 방식을 도입하여 성능을 개선한 기술적 사례를 공유합니다.
핵심 포인트
- AI 코딩 과정의 맥락을 보존하기 위해 SQLite 기반의 이력 저장 도구 개발
- HDD 환경의 fsync로 인한 Hook 실행 지연(268ms) 문제 식별
- 쓰기 작업을 크리티컬 패스에서 제외하는 2단계 배치 구조로 성능 개선(p95 17ms)
- 물리적 한계(프로세스 기동 시간)로 인한 목표 성능 달성 불가능 확인
- AI와의 작업 이력을 로컬 SQLite에 저장하는 CLI (
agent-history)를 만들고, Claude Code / Codex의 Hook을 통해 자동으로 수집하도록 설정함 - 실제 환경에서 활성화했더니 Claude Code가 체감될 정도로 무거워짐
- 측정 결과 Hook 1회당 268ms 소요. 그중
commit의fsync가 153ms,close시의 WAL 체크포인트가 66ms - 원인은 "개발기의 디스크가 HDD라서, 생(raw)
fsync가 걸린다"는 것이었음.fsync1회당 83ms PRAGMA synchronous조정으로는 해결되지 않음. 쓰기 작업을 크리티컬 패스(Critical Path)에서 제외하는 수밖에 없었음- Hook은 spool 파일을 1개 두기만 하고, 상주하는 worker가 배치(Batch)로 SQLite에 넣는 2단계 구성으로 변경 → p95 17ms
- 단, 당초 목표였던 p95 10ms는 물리적으로 달성할 수 없었음. 이 기계에서는 프로세스 기동 그 자체에 11.8ms가 걸리기 때문
측정값은 모두 본인의 개발기(Ubuntu / ext4 / HDD / Python 3.14)에서의 실측치입니다. SSD라면 이야기가 완전히 달라집니다. 그 부분도 포함하여 작성하겠습니다.
전반부는 "애초에 Hook이란 무엇인가? Worker란 무엇인가?"부터 작성하므로, 데이터베이스나 시스템 프로그래밍에 익숙하지 않아도 읽을 수 있을 것입니다. 후반부는 실측치와 설계 판단에 관한 이야기입니다.
이런 경험 없으신가요?
- 어제 어떻게 고쳤는지 기억나지 않는다
- Claude와 Codex에 서로 다른 안을 내보게 했는데, 비교 결과가 남아있지 않다
- 글을 쓰려고 하니 조사 경위를 잊어버렸다
- "그때 HogeHoge 설정을 어떻게 했더라"가 터미널 스크롤백에도 Git 이력에도 남아있지 않다
AI 코딩은 편리하지만, "생각한 과정"이 남기 어렵다는 문제가 있습니다. 커밋에는 결론만 남습니다. 3주 후에 똑같은 것을 다시 조사하고 있는 것이 현실이었습니다.
이를 어떻게든 해보고 싶어서 만든 것이 agent-history입니다.
처음에 생각한 것은 "Hook이 뱉는 JSON을 그대로 파일에 저장하면 되지 않을까"였습니다.
하지만 매번 JSON을 다시 읽어서 grep 하는 것은 생각보다 힘듭니다. SQLite라면,
- 검색
- 정렬 (Sort)
- 세션 단위의 집계
- 전문 검색 (FTS5)
을 전부 그대로 사용할 수 있습니다. 게다가 서버도 데몬(Daemon)도 필요 없는 파일 1개입니다.
즉 "Docker 빌드 에러"로 검색하면, 그때 무엇을 조사했고 어디서 실패했으며 어떻게 해결했는지까지 한꺼번에 찾아낼 수 있습니다. 이것이 제가 하고 싶었던 것입니다.
저장하는 것은 관측 가능한 입출력뿐입니다. AI의 내부 추론이나 숨겨진 사고 과정은 저장 대상으로 하지 않습니다.
| 항목 | 내용 |
|---|---|
session_id | 세션 |
source | claude / codex |
event_type | 시작·종료·프롬프트 전송 등 |
prompt | 사용자 입력 |
response | AI의 답변 |
model | 사용 모델 |
cwd | 작업 디렉토리 |
timestamp | 시각 |
비밀 정보는 저장 전에 마스킹합니다. ghp_xxxxxxxxx와 같은 토큰은 <REDACTED_SECRET>으로 교체됩니다.
설계에서 한 가지만 처음에 결정한 것은, 1차 단위를 project가 아니라 session으로 한다는 것이었습니다.
한 번의 작업은 여러 리포지토리, 호스트, 클라우드 리소스에 걸쳐 있습니다. Cloudflare나 Azure처럼 로컬 디렉토리를 가지지 않는 대상도 있습니다. 로컬 경로에 묶어버리면 애초에 입구에서 분류에 실패합니다.
sessions ── 작업의 시간적 묶음 (1차 단위)
events ── prompt / 커맨드 / 툴 결과 / 에러 등 관측 가능한 입출력
targets ── repository / service / cloud resource 등 작업 대상
...
session_targets
다대다(Many-to-Many) 관계로 설정되어 있기 때문에, "이 작업이 어느 리포지토리의 것인지" 분류에 실패하더라도 event 자체는 유실되지 않습니다. 분류는 나중에 수정할 수 있지만, 기록하지 못한 이벤트는 되돌릴 수 없기 때문입니다.
Stage 1은 CLI에서 수동으로 session-start / event-add를 하는 방식으로 동작했습니다. 당연히 수동으로 등록하는 방식은 지속 가능하지 않습니다. 그래서 Stage 2로서, Hook을 통해 자동으로 수집하기로 했습니다.
이 부분이 가장 중요하므로, 먼저 용어를 정리하겠습니다.
Hook은 **"○○이 발생하면 호출되는 프로그램"**입니다.
Windows에서도 USB를 꽂았을 때나 파일을 저장했을 때 무언가가 동작하죠. 그것과 같은 발상의 메커니즘이 Claude Code나 Codex에도 있습니다.
예를 들어,
SessionStart
→ "세션이 시작되었어"UserPromptSubmit
→ "사용자가 프롬프트를 전송했어"
라는 알림이 날아옵니다. Hook은 그 알림을 받아 우리가 지정한 명령을 실행해 줍니다.
Worker는 한마디로 말하면, 백그라운드에서 묵묵히 일하는 프로그램입니다. 계속 켜져 있는 상태로 주기적으로 작업 보관함을 확인합니다.
음식점에 비유하면 다음과 같습니다.
주문을 받는 사람 (Hook)
↓
주문서를 놓음 (Spool)
...
주문을 받는 사람이 주방에 들어가 요리까지 하기 시작하면 가게는 돌아가지 않습니다. 접수는 접수대로, 조리는 조리대로 나누는 것. 이것이 이번 설계의 본질입니다.
Claude Code / Codex
│
│ "이벤트 발생!"
...
……하지만, 처음부터 이런 형태였던 것은 아닙니다. 처음에는 Hook이 직접 SQLite에 기록하고 있었습니다. 거기서부터 어떻게 이렇게 되었는지가 아래 본론의 내용입니다.
Claude Code에는 라이프사이클의 각 단계에서 외부 명령을 실행하는 Hook 메커니즘이 있습니다. ~/.claude/settings.json에 다음과 같이 작성합니다.
{
"hooks": {
"UserPromptSubmit": [
...
]
}
}
이벤트 발생 시 stdin으로부터 JSON이 전달됩니다. 이를 받아 SQLite에 INSERT 하면 됩니다. 구현 자체는 단순합니다.
가지고 있는 Claude Code (2.1.220)가 가진 Hook 이벤트 정의는 31종류였습니다. SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Notification / Stop / PreCompact
기왕 하는 김에 전부 수집하는 설정을 작성하여 --apply로 설치했습니다.
그리고 Claude Code를 재시작하여 사용하기 시작한 지, 10분 만에 위화감을 느꼈습니다.
체감상 프롬프트를 보내고 반응이 돌아오기까지의 지연, 도구 실행 시마다 발생하는 버벅임이 명확하게 느껴졌습니다.
"왠지 무겁다"는 주관적인 느낌이므로, 먼저 측정했습니다.
| 항목 | 실측 |
|---|---|
| Hook 1회 (전체) | 268 ms |
└ sqlite3.commit (commit 시 fsync) | 153 ms |
└ sqlite3.close (WAL 체크포인트 시 fsync) | 66 ms |
| └ Python 실행 + import | 57 ms |
그리고 애초에 이 기계의 생(raw) fsync는 1회당 83ms였습니다.
$ cat /sys/block/sda/queue/rotational
1
HDD였습니다.
개발용 PC의 시스템 디스크가 회전식 디스크(HDD)라는 사실을 깜빡했습니다. SSD라면 fsync는 1ms 내외이므로 전혀 문제가 되지 않았을 것입니다. 역설적으로 말하면, SSD를 전제로 작성한 코드는 HDD에서 100배 느려지는 구간을 아무렇지 않게 포함할 수 있습니다.
여기에, 약 30종류의 이벤트 전부를 Hook으로 등록한 설정이 얹어집니다. PreToolUse / PostToolUse / FileChanged / MessageDisplay
이러한 이벤트는 한 턴에 수백 번씩 발생합니다. Claude Code의 크리티컬 패스 (Critical Path) 상에서 268ms가 수백 번 직렬로 실행되는 것입니다. 무거워지는 것이 당연했습니다.
SQLite에 관한 이야기이므로, 가장 먼저 떠올린 것은 당연히 PRAGMA synchronous였습니다. 직접 측정해 보았습니다 (실제 ext4 환경, 1개 이벤트 INSERT).
| PRAGMA | 실측 |
|---|---|
synchronous=FULL | 170.8 ms |
synchronous=NORMAL + wal_autocheckpoint=0 | 190.3 ms |
synchronous=OFF | 0.9 ms |
NORMAL로 설정해도 개선되지 않는다 (오히려 오차 범위 내에서 악화됨)는 점이 중요한 포인트였습니다.
그 이유는 이 훅 (Hook)이 한 번의 실행마다 1개의 연결을 열고 닫는 프로세스이기 때문입니다. SQLite는 연결을 닫을 (close) 때 WAL 체크포인트 (Checkpoint)를 실행하며, 그 과정에서 fsync를 수행합니다. synchronous=NORMAL은 커밋 (commit) 시의 fsync를 생략하는 설정일 뿐, 체크포인트의 fsync는 사라지지 않습니다. 프로세스가 매번 종료되는 이상, close는 반드시 발생합니다.
OFF로 설정하면 0.9ms가 나옵니다. 하지만 이력을 쌓는 도구가 DB 파일을 망가뜨리는 것은 본말전도이기에 이 방법은 채택하지 않았습니다. synchronous=OFF는 전원 차단 시 DB 자체가 손상될 수 있는 설정입니다. "이벤트를 몇 개 놓치는 것"과 "DB가 깨지는 것"은 완전히 다른 장애 클래스입니다.
즉, PRAGMA 조정만으로는 근본적인 해결이 불가능합니다. fsync를 크리티컬 패스 (Critical Path)에서 물리적으로 분리하는 수밖에 없다는 결론에 도달했습니다.
수행한 작업은 요컨대 쓰기 작업의 비동기화입니다.
Claude Code / Codex ──> bin/agent-history-*-hook ──> data/spool/pending/ (약 12ms, fsync 없음)
│
agent-history worker-start ─────┘
...
- Hook: stdin을 읽어 spool 파일을 1개 생성하고 즉시 종료한다. SQLite를 열지 않는다. JSON을 파싱하지 않는다. 새니타이즈 (Sanitize)도 하지 않는다.
- Worker: 단일 프로세스 (
flock으로 보장)가 spool을 배치 (Batch)로 가져온다. JSON 파싱, 새니타이즈, 트리밍 (Trimming), FTS 업데이트는 모두 여기서 수행한다.
배치화를 통해 fsync는 최대 50개 이벤트당 1회로 상쇄됩니다. Hook 측의 fsync는 제로(0)입니다.
spool 파일의 이름은 다음과 같이 정했습니다.
{ts_ns:019d}-{pid}-{rand8hex}-{event}.spool
예: 1785652440916697000-23037-9f3c1a20-SessionStart.spool
19자리의 0 채움 나노초 (nanoseconds)를 사용함으로써, 사전순 = 시간순이 됩니다. worker는 scandir를 통해 정렬하기만 하면 되며, 파일명만으로 순서를 결정할 수 있습니다. 파일 내용을 열어볼 필요가 없습니다.
pid와 4바이트 난수를 조합해 충돌을 방지하고, tmp/ 디렉토리에 O_CREAT|O_EXCL 플래그로 생성한 뒤 os.replace를 통해 pending/으로 원자적 (Atomic)으로 이동시킵니다. 이렇게 하면 쓰기 중인 파일이 읽히는 일은 발생하지 않습니다.
spool에 두는 방식으로 바꿨으니 빨라졌을 것이라 생각했지만... 측정해 보니 여전히 30ms대였습니다. 이번에는 fsync가 아니라 Python의 import가 병목이 되고 있었습니다.
python3 -S로 20회 평균을 내어 본 결과는 다음과 같습니다.
| import | 실측 |
|---|---|
os, sys만 포함 | 11.8 ms |
+ json | 19.4 ms |
+ agent_history.sanitizer (re 포함) | 36.2 ms |
json을 import 하는 것만으로 7.6ms, 새니타이저(실체는 re)에서 24ms가 소요됩니다. 25ms의 예산(Budget)을 고려할 때, 이는 감당할 수 없는 수치입니다.
그래서 Hook에서 이 두 가지를 제거했습니다.
Hook은 이벤트 이름을 알아야 합니다 (파일명에 넣기 위해). 하지만 payload의 hook_event_name을 읽으려면 json이 필요합니다.
그래서, 인스톨러가 등록하는 커맨드에 이벤트 이름을 인자(argument)로 임베딩하도록 했습니다.
"command": ".../bin/agent-history-claude-hook SessionStart"
Hook은 argv[1]만 확인합니다. payload의 값과 대조하는 검증은 worker 측에서 수행합니다.
spool 파일의 첫 번째 줄에는 메타 정보 헤더를 두고 싶었습니다. 하지만 json.dumps는 사용할 수 없습니다.
이는 이스케이프(escape)가 필요한 값을 헤더에 전혀 넣지 않음으로써 해결했습니다.
def build_header(uid, event_name, size, pid=None, truncated=False, source=None):
"""`json`을 임포트하지 않고 엔벨로프(envelope) 헤더 라인을 생성합니다.
모든 보간된 값은 머신 생성(machine-generated)이며 숫자,
..."
safe_label은 영숫자와 _.- 이외의 문자를 _로 치환하는 함수이며, 이 또한 re를 사용하지 않고 작성되었습니다.
def safe_label(value, limit=64):
"""`re`를 임포트하지 않고 값을 파일명에 안전한 문자로 축소합니다."""
if not value:
...
헤더에 들어가는 것은 숫자, 16진수, 그리고 화이트리스트를 통과한 이벤트 이름뿐이므로, 문자열 결합만으로 올바른 JSON이 됩니다. payload(원시 stdin)는 개행(newline) 뒤에 통째로 배치하며, worker는 "첫 번째 개행 이후의 모든 내용"을 payload로 취급합니다. payload 자체에 개행이 포함되어 있어도 문제없습니다.
참고로 python3 -E는 PYTHONPATH를 무시하기 때문에 사용할 수 없으며, -S (site 억제, 6.6ms 절약)만 사용하고 있습니다.
worker를 실행하는 것을 잊으면 pending/ 디렉토리가 무한히 늘어나 디스크를 가득 채우게 됩니다. 이것만큼은 방어하고 싶었습니다. 하지만 매번 scandir을 호출하면 그 자체가 비용이 됩니다.
그래서 1/128의 확률로만 체크합니다.
def should_check_pending(sample=PENDING_CHECK_SAMPLE):
return os.urandom(1)[0] < max(1, 256 // sample)
상한선(50,000건)을 초과하면 Hook은 쓰기를 중단합니다. 카운트 역시 cap에 도달하는 시점에 조기 종료합니다. 이는 "디스크를 채우지 않는다"라는 단 하나의 목적만을 지키기 위한 가드이며, 그 외의 견고함(robustness)은 추가하지 않았습니다.
| 변경 전 | 변경 후 |
|---|---|
| Hook 레이턴시 (p95) | 268 ms |
| ... |
실측치는 p50 16.5ms / p95 17.2ms (컨테이너 내 200회)입니다.
하지만, 당초 목표였던 p95 10ms는 달성하지 못했습니다. 그리고 이것은 튜닝으로 도달할 수 있는 거리가 아니었습니다. 이 환경에서는:
- Python의
os와sys만으로 실행 시 11.8ms cat과mv만 사용하는/bin/sh스크립트에서도 11.4ms
즉, fork + exec 방식의 Hook인 한, 10ms는 물리적으로 도달 불가능합니다. Python이 느린 것이 아니라, 프로세스 실행에 11ms가 소요되는 환경인 것입니다.
그래서 수용 기준을 p95 25ms 미만으로 변경했습니다. 10ms 미달을 허용한 근거는 두 가지입니다.
- 268ms로부터의 개선 폭이 충분히 크다.
- 크리티컬 패스(critical path) 상의 SQLite 오픈과 fsync가 모두 제로(zero)가 되었다. 남아있는 비용은 프로세스 실행이 지배적이며, 이는 디스크 성능에 의존하지 않는다.
두 번째가 중요했습니다. 단순히 "빨라졌다"가 아니라, "느린 디스크에서도 느려지지 않는 구조가 되었다"는 점이 본질이라고 생각합니다. 벤치마크 숫자가 좋아졌느냐가 아니라, 그 숫자가 무엇에 의존하고 있는가가 바뀌었느냐가 이런 종류의 개선에서 진정한 합격 여부를 가르는 기준이라고 생각합니다.
이 설계의 장점은 에이전트(agent)마다 변경해야 하는 부분이 거의 없다는 점입니다.
Claude Code ─┐
├─> Hook ─> Spool ─> Worker ─> SQLite
Codex ───────┘
Hook의 바이너리와 임베딩(embedding)할 이벤트 이름이 다를 뿐, spool의 형식부터 그 이후는 완전히 동일합니다. 이벤트에는 source를 포함하고 있으므로,
source = claude만 보기source = codex만 보기- 양쪽 모두를 합쳐서 시계열로 보기
위 작업들을 동일한 쿼리로 작성할 수 있습니다. "같은 과제를 Claude와 Codex에 던져서 비교했다"와 같은 기록을 나중에 횡단적으로 조회할 수 있게 되었습니다.
속도를 위해 명시적으로 포기한 것들이 있습니다. README에도 적어두었습니다.
agent-history는 Claude Code를 블로킹(blocking)하지 않는 최선형(best-effort) 이력 수집 도구입니다. 이벤트 누락이 발생할 수 있습니다.
구체적으로는,
- Hook은
fsync를 전혀 수행하지 않으므로, OS 크래시나 전원 차단 시 직전 몇 초간의 이벤트가 유실될 수 있음 - worker를 실행하지 않는 동안에도 spool에는 쌓이지만, 상한선을 초과하면 새로운 이벤트는 버림
- 손상된 이벤트는 재시도(retry)하지 않고
failed/디렉토리에 격리할 뿐
반면, SQLite 본체는 synchronous=FULL을 유지합니다. DB 파일의 정합성(integrity)은 보장됩니다. 이 부분은 타협하지 않았습니다.
processing/ 디렉토리에서의 소유권 관리, 고아(orphan) 회수, 재시도와 백오프(backoff), 독성(toxic) 이벤트 분리, 백프레셔(backpressure) 우선순위 제어 — 이러한 "제대로 된 큐(queue)" 기능은 의도적으로 구현하지 않았습니다. 이력 수집 도구에게는 이벤트를 3건 놓치는 것보다, 복잡성을 안고 동작을 멈춰버리는 것이 훨씬 치명적이기 때문입니다.
순서에 대해서도 보장하는 것은 단 하나뿐입니다.
- 보장하는 것: 동일 세션 내에서
sequence_no가 spool 파일명(Hook이 stdin 읽기를 마친 시각)의 오름차순이 됨 - 보장하지 않는 것: "이벤트가 논리적으로 발생한 순서"일 것 (Hook은 병렬로 실행될 수 있기 때문), 여러 세션 간의 상대적 순서, NTP 조정으로 벽시계(wall clock)가 되감겼을 경우의 단조성(monotonicity)
포기한 것이 많은 대신, 망가지지 않는다는 점은 구조로 담보했습니다.
- spool 파일은
tmp/에 쓴 다음os.replace를 통해pending/으로 이동 → 쓰기 도중인 파일이 읽히는 일은 없음 - worker는 커밋(commit) 성공 후에 파일을 삭제 → 그 사이에 크래시가 발생해도 파일은
pending/에 남아 다음 재처리 시 다시 불러와짐 - 재처리로 인한 중복은
UNIQUE(session_id, dedup_key)와INSERT ... ON CONFLICT DO NOTHING이 흡수
"재시도하지 않는데도" "크래시가 나도 유실되지 않는 것"은 삭제 순서와 멱등성(idempotency)만으로 성립합니다. 재시도 메커니즘을 작성하는 것보다 이 방식이 더 짧고 확실했습니다.
중복 방지 키를 어떻게 만들 것인가에 대해 한 가지 함정이 있었습니다.
Claude Code의 페이로드(payload)에는 prompt_id라는 ID가 들어있습니다. 언뜻 보면 이것이 이벤트 ID처럼 보입니다. 하지만 공식 스키마상 이것은 "다음 프롬프트까지의 모든 이벤트가 공유하는 턴 상관 ID(turn correlation ID)"였습니다.
이를 이벤트 ID로 사용하면, 한 턴 내의 FileChanged, Notification, SubagentStart, TaskCreated 등이 모두 1건으로 뭉쳐버립니다.
따라서 다음과 같이 결정했습니다.
- 이벤트 고유 ID가 있다면 그것을 사용:
tool_use_id(PreToolUse / PostToolUse 등),agent_id,task_id,elicitation_id,message_id+index - 고유 ID가 없다면: 새니타이즈(sanitized)된 페이로드 전체 +
prompt_id의 해시
이렇게 하면, 동일한 Bash 명령을 연속해서 실행하더라도 tool_use_id
가 다르기 때문에 별개의 이벤트로 남습니다. 반대로, 동일한 Hook을 user와 project 양쪽 스코프에 등록하여 이중 전달된 경우에는 1건으로 통합됩니다.
(고유 ID를 가지지 않는 이벤트가 동일 턴 내에서 완전히 동일한 내용으로 발생한 경우에는 1건으로 통합되어 버립니다. 이 부분은 허용했습니다.)
성능 문제가 해결되었으니 총 30개의 이벤트를 수집해도 된다……는 상황이 되지는 않았습니다. full 설정은 1턴에 수백~수천 개의 이벤트가 발생합니다. 이벤트 1개당 17ms라고 해도, 500개라면 8.5초입니다.
그래서 프리셋(preset)을 준비하여 기본값을 balanced로 설정했습니다.
| preset | 이벤트 수 | 예상 빈도 |
|---|---|---|
minimal | 4 | 턴당 몇 건 |
balanced (기본값) | 24 | 턴당 십수 건 |
full | 30 | 턴당 수백~수천 건 |
full에만 포함되는 고빈도 이벤트: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, FileChanged, MessageDisplay
full은 명시적인 opt-in 방식으로 만들고, 회전식 디스크(HDD)가 감지되면 설치 시 경고를 띄우도록 했습니다. 이번 교훈을 그대로 도구에 심어놓은 형태입니다.
참고로 balanced에는 도구 실행 이력(PreToolUse/PostToolUse)이 포함되지 않습니다. 본 프로젝트의 핵심 데이터이긴 하므로, 실제 운용 시의 측정치를 보고 balanced로 승격시킬지를 나중에 판단하겠다는 숙제를 남겨두었습니다.
본론은 아니지만, 같은 프로젝트를 진행하며 겪은 일이기에 메모로 남깁니다.
FTS5 표준의 unicode61 tokenizer는 일본어를 형태소 단위로 분리(分かち書き)하지 않습니다. 그래서 CockpitをCloudflare Tunnelで公開したい는 1개의 토큰으로 취급되어, MATCH 'Cockpit'으로는 검색되지 않습니다.
현재는 FTS5의 MATCH와 LIKE 부분 일치(partial match)의 합집합으로 임시방편 처리를 하고 있습니다. 부작용으로는:
- 단어 중간에서도 히트함 (
cat이concatenate에 히트함) - **LIKE 쪽은 전체 테이블 스캔(full table scan)**을 수행하므로, 이벤트 수가 늘어나면 느려짐
FTS5의 trigram tokenizer 도입이 다음 개선 후보입니다.
솔직히 말해서, 로그를 남기는 것 자체가 목적은 아닙니다.
SQLite에 쌓인 정보를 바탕으로, 글의 초안을 만드는 것이 목적입니다.
「Docker 빌드 에러」로 검색하면:
- 무엇을 조사했는지
- 어디서 실패했는지
- 어떻게 해결했는지
가 시계열로 전부 나옵니다. 이것을 Markdown으로 변환하면 Qiita / Zenn / 개인 Wiki의 초안이 됩니다. 실제로 이 글도 그런 방향으로 작성하고 있습니다.
SQLite를 '데이터베이스'라기보다, **AI 에이전트 전용의 '작업 기억(working memory)'**으로 사용하는 포지션입니다.
앞으로는:
- 기사 초안의 반자동 생성
- 과거 작업의 횡단 검색
- Claude Code와 Codex를 넘나드는 비교
까지 만들어 나갈 예정입니다.
여기까지 읽고, "그거 MCP로 하면 되지 않아?"라고 생각하시는 분이 계실지도 모르겠습니다. 절반은 정답입니다. 정리하자면, 쓰기(write)는 MCP로 대체할 수 없고, 읽기(read)는 MCP가 본체라는 구분입니다.
MCP 도구는 모델이 스스로 호출하기로 결정해야 비로소 동작하는(pull 방식) 것입니다. 따라서 이력 수집을 MCP로 하려면 "매 턴마다 record_event를 호출해줘"라고 모델에게 부탁하는 형태가 되는데, 이는 효율적이지 않습니다.
- 모델이 호출을 잊어버리면 기록되지 않음. 망라성(comprehensiveness)을 구조적으로 보장할 수 없음
- 수집하고 싶은
PostToolUse의 실패나 에러는 모델이 "굳이 기록해야지"라고 생각하지 않을 종류의 이벤트임 - 기록할 때마다 모델의 턴과 토큰을 소비함
반면 Hook은 모델의 의사와 상관없이, 라이프사이클 이벤트에 따라 기계적으로 발화합니다. "관측 가능한 입출력을 전부 잡는다"라는 요구사항에는 이러한 수동적인 포착이 본질적으로 필요하며, 이 부분은 MCP로 대체할 수 없습니다. 따라서 쓰기 측면은 Hook 방식을 유지합니다.
반면에, 저장된 SQLite를 Claude에게 돌려주는 통로로서는 MCP가 아주 딱 맞습니다. 이 글에서 언급한 "정말로 하고 싶었던 것"—과거 작업 검색, 기사 초안 생성—이 바로 여기에 해당합니다. SQLite 위에 MCP 서버를 한 겹 씌워서,
search_history(query)
도구(FTS5 검색을 그대로 공개) - 세션 단위를 resource로서 읽을 수 있도록
해 두면, Claude Code로부터 "Cloudflare Tunnel 관련해서 전에 뭐 했었지?"라고 묻는 것만으로, 자신의 과거 이력을 스스로 가져올 수 있게 됩니다. 지금은 Markdown 변환을 거치는 것을 전제로 하고 있지만, 그것을 Claude가 직접 수행하게 할 수 있습니다.
게다가 성능 특성이 깔끔하게 일치합니다. 이번 268ms 문제는 "모든 라이프사이클 이벤트에서 동기식 쓰기(synchronous write)를 수행했기" 때문에 발생한 것이었습니다. MCP 검색 도구는 매 이벤트마다 발화되는 것이 아니므로, 그 고빈도·크리티컬 패스(critical path) 문제는 발생하지 않습니다. 고빈도의 수동적인 것(쓰기)은 Hook, 저빈도의 온디맨드(on-demand) 방식인 것(검색)은 MCP라는 역할 분담이 그대로 성능의 차이로 이어집니다.
쓰기: Hook → spool → worker → SQLite (망라적·수동적·고빈도)
읽기: SQLite → MCP 서버 → Claude (온디맨드·저빈도)
대체가 아니라 합성입니다. Stage 3는 아마 이것이 될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기