AI 협업 개발

AI 작업 계획을 Markdown 폴더가 아닌 SQLite에 인덱싱하세요

AI 협업은 당신을 기획 문서에 파묻히게 합니다. 마크다운을 신뢰할 수 있는 단일 출처로 유지하고, FTS5로 SQLite 인덱스를 추가하고, 전체 기록을 빠르게 쿼리하세요.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

AI 어시스턴트와 함께 작업하면 많은 양의 글이 만들어집니다. 모든 간단치 않은 작업은 계획, 디자인 노트, 체크리스트로 시작하며, 이러한 파일들은 폴더에 쌓여 수백 개가 되면 필요한 파일을 찾을 수 없게 됩니다. 이 게시물은 저에게 효과가 있었던 작은 패턴을 설명합니다. 즉, 마크다운을 신뢰할 수 있는 단일 출처(source of truth)로 제자리에 그대로 두고, 그 위에 SQLite 인덱스를 구축하여 전체 기록을 밀리초 단위로 나열, 필터링 및 전체 텍스트 검색할 수 있도록 하는 것입니다. 인덱스는 파생된 캐시입니다. 언제든지 이 캐시를 버리고 파일로부터 다시 빌드할 수 있습니다.

마크다운 묘지 문제

AI와 함께 작업을 계획할 때, 계획 작성 비용이 저렴하기 때문에 많이 작성하게 됩니다. 기능별로 파일 하나, 버그별로 파일 하나, 리팩터별로 파일 하나씩 만듭니다. 각각의 파일에는 상태, 대략적인 설계, 그리고 진행 상황에 대한 실행 로그가 있습니다. 개별적으로는 유용합니다. 대량으로 쌓이면 묘지가 됩니다.

실패 양상은 구체적입니다. 3개월 후 신용카드 환불 엣지 케이스 처리 방법에 대한 결정을 내렸던 것은 기억나지만, 그 내용이 어느 계획 파일에 있었는지는 기억나지 않습니다. 그래서 grep을 사용하게 됩니다. Grep은 수백 개의 파일에서 느리게 작동하며, 어떤 문서가 최신이거나 활성 상태인지 구분하지 못하고, 주변 구조 없이 원시적인 줄 단위의 일치 결과만 반환합니다. 20개 파일에서 40개의 검색 결과를 얻게 되고, 어느 계획이었는지, 그 상태가 어땠는지, 그리고 그 결정이 여전히 유효한지 확인하기 위해 각각의 파일을 모두 열어봐야 합니다.

더 근본적인 문제는 AI도 여러분과 같은 문제를 겪는다는 것입니다. 새로운 작업을 시작할 때, 가장 가치 있는 컨텍스트는 이미 내린 관련 결정들의 집합입니다. 만약 그 컨텍스트가 여러분이나 어시스턴트 모두 쿼리할 수 없는 폴더 안에 갇혀 있다면, 모든 새로운 작업은 백지 상태에서 시작되고, 몇 달 전에 이미 결정했던 사안들을 다시 검토하게 됩니다.

마크다운을 신뢰 소스로 유지하고, 그 위에 인덱스를 추가하세요

해결책은 계획을 데이터베이스로 옮기는 것이 아닙니다. 마크다운이 올바른 저장 형식입니다. 사람이 읽을 수 있고, AI가 읽을 수 있으며, git에서 변경 사항을 비교(diff)할 수 있고, 이를 기반으로 구축하는 어떤 도구보다도 오래 살아남습니다. 테이블의 행을 위해 그것을 버리는 것은 잘못된 선택이 될 것입니다.

대신, 모든 계획을 작은 YAML 프런트매터 블록이 있는 마크다운 파일로 유지하고, 이 파일들을 유일한 신뢰 소스로 취급하십시오. 그런 다음 파일을 읽고 두 가지를 테이블에 미러링하는 SQLite 인덱스를 구축하십시오: 프런트매터의 구조화된 메타데이터와 검색을 위한 전체 본문 텍스트입니다. 인덱스는 쿼리에 응답합니다. 파일은 진실을 담고 있습니다.

계획 파일은 다음과 같습니다:

---
id: 41
status: active
project: web-api
tags: [area:billing, tech:go, type:feature, scope:minor]
created: 2026-05-02
---

# Credit refund on subscription downgrade

## Decision

Refunds are prorated by remaining days, not by unused credits...

인덱스는 파생된 것이므로 .gitignore에 포함됩니다. 데이터베이스가 아닌 마크다운을 커밋합니다. 레포를 클론하는 사람은 누구나 로컬에서 인덱스를 다시 빌드하는 명령 하나를 실행하며, 만약 동기화가 어긋나면 삭제하고 다시 생성하면 됩니다. 이것이 전체 패턴을 안전하게 만드는 속성입니다: 데이터베이스는 설계상 폐기 가능하기 때문에 보호해야 할 두 번째 신뢰 소스가 될 수 없습니다.

스키마: 메타데이터와 전체 텍스트

인덱스의 핵심은 두 개의 테이블입니다. 하나는 각 플랜별로 행을 가지며, frontmatter 필드가 열로 승격되어 저장됩니다. 다른 하나는 태그를 저장하며, 태그당 하나의 행을 가지므로 필터링하고 조인할 수 있습니다.

CREATE TABLE plans (
  id          INTEGER PRIMARY KEY,
  status      TEXT NOT NULL,        -- backlog, active, completed
  project     TEXT NOT NULL,
  title       TEXT NOT NULL,
  created_at  TEXT NOT NULL,
  updated_at  TEXT NOT NULL,
  body        TEXT NOT NULL
);

CREATE TABLE tags (
  plan_id  INTEGER NOT NULL REFERENCES plans(id),
  axis     TEXT NOT NULL,           -- area, tech, type, scope
  value    TEXT NOT NULL,
  PRIMARY KEY (plan_id, axis, value)
);

frontmatter를 열로 승격시키는 것은 모호한 폴더 탐색을 정밀한 쿼리로 바꾸는 역할을 합니다. "웹 API에 대한 활성 결제 플랜을 보여줘"는 더 이상 수동 스캔이 아니라 WHERE 절이 됩니다. body 열은 전체 마크다운을 담고 있어 검색 인덱스가 읽을 내용이 있고, show가 파일을 두 번 읽지 않고도 플랜을 출력할 수 있게 해줍니다.

작업 중에 일어난 일에 대한 실행 로그(running log)인 노트는 자체 테이블을 가집니다. 이를 통해 플랜의 본문을 다시 작성하지 않고도 내용을 추가할 수 있습니다:

CREATE TABLE notes (
  plan_id     INTEGER NOT NULL REFERENCES plans(id),
  created_at  TEXT NOT NULL,
  text        TEXT NOT NULL
);

각 노트는 타임스탬프가 찍힌 한 줄입니다. 세션을 마치면 변경된 내용에 대해 한 문장을 추가합니다. 나중에 노트 기록은 해당 결정에 대한 변경 로그(changelog)처럼 읽히며, 작업을 이어받는 AI에게 가장 먼저 보여줄 가치가 있는 것입니다.

FTS5로 즉각적인 검색 구현

SQLite에는 전체 텍스트 검색 확장 기능인 FTS5가 내장되어 제공됩니다. 아무것도 설치할 필요가 없습니다. 검색하려는 열을 인덱싱하는 가상 테이블을 만들고, 실제 테이블을 가리키도록 한 다음, MATCH 절로 쿼리하면 됩니다.

CREATE VIRTUAL TABLE plans_fts USING fts5(
  title,
  body,
  content='plans',
  content_rowid='id',
  tokenize='unicode61'
);

content='plans' 옵션은 이를 외부 콘텐츠 인덱스로 만듭니다. FTS5는 검색 구조만 저장하고 실제 텍스트는 plans 테이블에서 읽어오므로, 모든 plan 본문의 두 번째 사본을 유지하지 않아도 됩니다. 검색 시에는 표시하려는 열에 대해 기본 테이블과 다시 조인합니다.

SELECT p.id, p.title, p.status
FROM plans_fts f
JOIN plans p ON p.id = f.rowid
WHERE plans_fts MATCH 'credit AND refund'
ORDER BY rank
LIMIT 10;

grep과의 차이점은 속도뿐만이 아닙니다. 물론 수백 개의 문서에서도 엔터 키에서 손가락을 떼기도 전에 쿼리가 반환되기는 합니다. 결과가 구조화되어 있다는 점이 다릅니다. 모든 검색 결과는 관련성 순으로 순위가 매겨져 id, 제목, 상태와 함께 반환되므로, 활성 plan이 동일한 단어를 언급하는 세 개의 폐기된 plan보다 위에 표시됩니다. snippet()을 추가하여 일치 항목 주변의 강조 표시된 발췌문을 얻을 수 있고, 본문보다 제목에 가중치를 두고 싶다면 bm25()로 순위를 매길 수 있습니다.

FTS5는 또한 grep이 잘 처리하지 못하는 쿼리, 즉 여러 단어로 된 구문, 불리언 ANDOR, refund*를 사용한 접두사 일치, 근접 일치 등을 처리합니다. unicode61 토크나이저는 대소문자를 구분하지 않고 구두점을 기준으로 분리하는데, 이는 일반적으로 혼합된 기술 텍스트에서 원하는 방식입니다.

일관성을 유지하는 태그 지정 체계

자유 형식 태그는 시간이 지나면 쓸모없어집니다. 그대로 두면, 어떤 계획에는 billing이, 다른 계획에는 payments가, 세 번째 계획에는 credits가 붙게 되어, 이제 단일 필터로는 이 세 가지를 모두 찾을 수 없습니다. 해결책은 약간의 구조를 추가하는 것입니다. 즉, 모든 태그가 축(axis)을 명명하는 네임스페이스 접두사를 갖도록 하는 것입니다.

area:billing      area:auth      area:worker
tech:go           tech:sqlite    tech:redis
type:feature      type:bugfix    type:refactor
scope:patch       scope:minor    scope:major

각 축은 서로 다른 질문에 답합니다. area는 제품의 부분, tech는 스택, type은 작업의 종류, scope는 크기입니다. 하나의 계획은 축마다 한두 개의 태그를 가집니다. 접두사는 두 가지 유용한 일을 합니다. 한 축의 모든 값을 나열하여 전체 제어 어휘를 한눈에 볼 수 있으므로, 어휘가 자체적으로 문서화됩니다. 그리고 area:billing에 대한 쿼리가 tech:billing-lib로 태그된 계획과 우연히 일치할 수 없으므로, 필터가 정직하게 유지됩니다.

태그를 쉼표로 구분된 문자열이 아닌 (axis, value) 행으로 저장하는 것이 이것을 쿼리 가능하게 만듭니다. 하나의 태그로 필터링하는 것은 조인(join)입니다. 두 개로 필터링하는 것은 두 번의 조인입니다. 한 축의 어휘를 나열하는 것은 SELECT DISTINCT value FROM tags WHERE axis = 'area'입니다. 태그가 단일 텍스트 필드에 있는 경우 이 중 어느 것도 불가능합니다.

매일 실행하는 명령어

이것을 사용하기 위해 SQL을 작성해야 한다면 이 모든 것은 별 가치가 없습니다. 인덱스는 일반적인 쿼리를 래핑하는 작은 명령줄 도구 뒤에서 그 가치를 발휘합니다. 제가 항상 사용하는 명령어는 다음과 같습니다.

명령어 기능
plan list 활성 및 백로그 계획, 최신순
plan list --status active 진행 중인 것만
plan list --tag area:billing 하나의 제품 영역에 있는 모든 것
plan show 41 하나의 계획에 대한 메타데이터, 요약 및 최근 노트
plan search "credit refund" 모든 본문에 대한 FTS5 전체 텍스트 검색
plan note 41 "switched to cursor pagination" 타임스탬프가 찍힌 노트 추가
plan index --full 마크다운 파일에서 전체 인덱스 다시 빌드

AI 협업에서 가장 중요한 패턴은 모든 작업의 첫 번째 움직임입니다. 코드를 읽거나 파일을 열기 전에 plan search 또는 plan list --tag를 실행하여 관련된 과거 작업을 찾은 다음, plan show로 결정 사항을 불러옵니다. 이는 콜드 스타트 대신 한 단계 만에 어시스턴트에게 실제 컨텍스트를 제공합니다. 이는 "우리는 계획 41에서 이렇게 결정했고, 그 이유는 이렇습니다"라고 말하는 것과 처음부터 동일한 답을 다시 도출하는 것의 차이입니다.

# Start a task by loading context, not by grepping
plan list --tag area:billing --status active
plan show 41
plan notes 41 --last 5

왜 더 큰 데이터베이스가 아닌 SQLite인가

SQLite는 이 작업에 거의 지나치게 잘 맞습니다. 단일 파일이므로 인덱스는 삭제하고 다시 생성할 수 있는 하나의 아티팩트이며, 그 하나의 경로 외에는 gitignore할 것이 없습니다. 실행할 서버도, 관리할 포트도, 계획을 나열하기 전에 살아 있어야 하는 데몬도 없습니다. FTS5가 내장되어 있어 검색은 CREATE VIRTUAL TABLE 외에는 아무 비용도 들지 않습니다. 그리고 트랜잭션을 지원하므로 재인덱싱은 완료되거나 기존 인덱스를 건드리지 않은 채로 남습니다.

더 큰 요점은 이 패턴이 두 가지 형식의 장점을 동시에 취한다는 것입니다. Markdown은 인간과 AI 어시스턴트가 잘 읽고 쓰는 것이며, git이 추적하는 것입니다. 관계형 인덱스는 기계가 잘 쿼리하는 것입니다. 선택할 필요가 없습니다. Markdown으로 쓰고 읽고, SQL로 쿼리하며, 재구축 단계가 후자를 전자와 동기화된 상태로 유지합니다. 더 무거운 데이터베이스는 데이터셋이 작고, 로컬이며, 단일 작성자이고, 폐기 가능하기 때문에 작업에 필요한 어떤 것도 추가하지 않으면서 운영상의 부담만 더할 것입니다.

트레이드오프, 그리고 언제 폴더만으로 충분한가

이것은 공짜가 아니라 실제 비용이 따릅니다. 인덱스는 동기화된 상태를 유지해야 합니다. 마크다운이 변경될 때마다, 데이터베이스는 재인덱싱할 때까지 오래된(stale) 상태가 됩니다. 파일 감시자(file-watcher)나 커밋 시 재인덱싱하는 git hook 뒤에 대부분을 숨길 수 있지만, 결합(coupling)은 존재하며, 조용히 오래된 결과를 반환하는 오래된 인덱스는 아예 인덱스가 없는 것보다 더 나쁩니다. 재구축은 저렴하고, 이상적으로는 자동이어야 합니다.

구축 및 유지보수 비용도 있습니다. 누군가는 파서, 스키마, 그리고 커맨드 라인 래퍼를 작성해야 하고, frontmatter가 발전함에 따라 그것들이 계속 작동하도록 유지해야 합니다. 그것은 한 번 지불하는 고정 비용이며, 특정 규모를 넘어서야만 그 가치를 합니다.

그러니 규모에 대해 솔직해져야 합니다. 계획이 수십 개 정도라면, 이것은 과도한 설계(over-engineering)입니다. 마크다운 폴더와 퍼지 파일 검색(fuzzy file search) 기능이 있는 편집기나 일반 grep만으로도 쿼리를 입력하는 시간 안에 필요한 모든 것을 찾을 수 있으며, 이 모든 장치를 건너뛸 수 있습니다. 인덱스는 그 수가 수백 개를 넘어설 때, grep이 답변 대신 노이즈를 반환하기 시작할 때, 그리고 과거의 결정을 찾지 못하는 비용이 그 결정을 다르게 다시 내려 버그를 만드는 것일 때 제 역할을 합니다. 바로 그 지점이 쿼리 가능한 인덱스가 장난감이기를 멈추고 모든 작업에서 실제 시간을 절약해주기 시작하는 때입니다.

제가 정한 규칙은 간단합니다. 계획이 100개 미만이면 폴더를 사용합니다. 그 이상이면 폴더 위에 SQLite 인덱스를 둡니다. 어느 쪽이든 마크다운을 신뢰할 수 있는 단일 출처(source of truth)로 유지하여, 폴더의 규모를 넘어서는 날이 왔을 때 인덱스를 추가하는 것이 마이그레이션이 아닌 빌드 단계가 되도록 합니다.

관련 글