AI가 신뢰할 수 있는 문서를 자동으로 유지하기 — Notion → GitHub 문서 동기화 시스템을 만든 이야기
들어가며: "AI한테 시켰는데 왜 자꾸 옛날 스펙으로 짜지?"
팀에 AI 코딩 에이전트를 본격적으로 쓰기 시작하면서, 나는 "AI가 코드를 잘 짜느냐"보다 **"AI가 무엇을 근거로 코드를 짜느냐"**가 훨씬 중요하다는 걸 알게 됐다.
AI에게 "주문 취소 정책대로 구현해줘"라고 하면, AI는 어딘가의 문서를 읽고 그걸 근거로 코드를 짠다. 그런데 그 근거가 되는 문서가
•
기획자 머릿속에만 있거나,
•
Notion 어딘가에 있는데 최신인지 아무도 모르거나,
•
이미 폐기됐는데 검색에는 걸리거나
하면, AI는 아주 자신 있게 틀린 코드를 짠다. 사람이라면 "이거 옛날 문서 아니에요?"라고 물어보겠지만, AI는 주어진 문서를 진실로 믿는다.
그래서 이 문제를 이렇게 정의했다.
기획자는 Notion에서 편하게 문서를 쓴다.
개발자와 AI는 GitHub의 마크다운을 단일 기준(Single Source of Truth)으로 코드를 짠다.
이 둘 사이를, 사람이 손대지 않아도 신뢰할 수 있는 상태로 자동 연결한다.
이 글은 그 파이프라인을 만들면서 실제로 부딪히고 고민했던 지점들에 대한 기록이다.
전체 그림: 승인된 기획이 개발 기준 문서가 되기까지
먼저 큰 흐름부터. 핵심은 Notion을 원본으로, GitHub을 미러로 두는 단방향 파이프라인이라는 것이다.
Notion DB (문서 상태 = 승인 + 동기화 요청)
↓ sync:notion Notion 본문 → Markdown 변환·정제, frontmatter 부착
↓ create-pull-request 페이지당 GitHub PR 1개 생성
↓ update:notion-pr PR URL·상태(검토 대기)를 Notion에 역기록
↓ (개발자 리뷰 후 PR 머지)
↓ record-notion-merged 동기화 완료로 마킹
↓ build-specs manifest.json(문서 인덱스) + README 재생성 (bot 커밋)
GitHub specs/ ← FE/BE 레포가 consumer-sync CLI로 당겨감 → AI가 이걸 근거로 개발
Plain Text
복사
Notion DB의 각 페이지는 사업 분류(키즈/비즈/인터널/공통), 계층구조(폴더 경로), 문서 종류(PRD/정책/기획/API) 같은 속성을 갖고, 이 값들로 GitHub 경로가 자동 조립된다.
// 사람이 경로를 정하지 않는다. 속성값으로 코드가 조립한다.
return `specs/${businessUnit}/${hierarchy}/${docsType}.md`;
// 예) 키즈 + order + PRD → specs/kids/order/prd.md
TypeScript
복사
기획자는 GitHub 폴더 구조를 몰라도 되고, 개발자는 "이 도메인 문서가 어디 있지?"를 고민할 필요가 없다. 규칙이 경로를 결정하니까.
여기까지는 개요고, 진짜 이야기는 지금부터다. 만들면서 "어? 이거 이렇게 하면 안 되는데?" 했던 지점들.
고민 1. Notion 마크다운을 그대로 쓸 수 없었다
Notion API로 페이지를 마크다운으로 뽑으면 될 줄 알았다. 그런데 뽑아보니, 이걸 그대로 GitHub에 넣으면 문서가 깨지거나, 시간이 지나면 깨질 예정인 것들이 잔뜩 있었다.
그래서 normalizeNotionMarkdown()이라는 정제 레이어를 따로 만들었다. 여기서 겪은 삽질들.
(1) Notion 이미지는 시한폭탄이었다
Notion에 업로드된 이미지는 마크다운에 이런 식으로 들어온다.

Markdown
복사
문제는 이 URL이 1시간 만료 presigned URL이라는 것. 동기화한 순간엔 보이지만, 1시간 뒤면 죽는다. 즉 동기화 문서에 이걸 남기면 깨질 게 확정된 링크를 심는 셈이다.
다운로드해서 재호스팅하는 것도 고려했지만, 문서의 본질은 텍스트 스펙이고 이미지는 보조라는 판단으로 본문에서 통째로 제거하기로 했다.
// Notion 업로드 이미지는 1시간 만료 presigned URL이라 동기화 문서에서 깨진다.
// 다운로드/재호스팅 대신 본문에서 통째로 제거한다.
const IMAGE_RE =/!\[[^\]]*\]\([^)]*\)/g;
out = out.replace(IMAGE_RE, "");
TypeScript
복사
(2) 리스트 안에 중첩된 callout이 자꾸 깨졌다
Notion의 callout(
이런 강조 박스)을 GitHub의 GFM alert(> [!TIP])로 변환했는데, 리스트 안에 들어간 callout이 문제였다. 본문 줄마다 선행 들여쓰기(공백·탭)가 남아서 > \t\t내용처럼 blockquote가 깨졌다.
그래서 alert로 렌더링할 때 각 줄의 선행 공백을 벗겨서 blockquote 안을 평탄하게 만들었다.
// 리스트 안 중첩 callout은 본문 줄마다 선행 들여쓰기가 남아 `> \t\t내용`처럼 깨진다.
// 각 줄의 선행 공백/탭을 제거해 blockquote 안을 평탄하게 만든다.
const lines = trimmed.split("\n").map((line) => line.replace(/^[ \t]+/, ""));
TypeScript
복사
이런 게 이모지별로 alert 종류를 매핑하는 로직(
→WARNING,
→TIP,
→IMPORTANT…), 2단 컬럼 레이아웃 평탄화, 목차 블록 제거까지 줄줄이 붙었다.
(3) "이 문서 깨진 거 아니야?"를 사람 대신 잡아주기
정제를 아무리 해도 예상 못 한 Notion 태그가 남을 수 있다. 그래서 정제 후에도 잔여 태그나 비정상적으로 긴 한 줄(마크다운이 한 줄로 뭉개진 collapse 의심)을 경고로 뱉게 했다.
여기서 한 번 오탐으로 고생했다. 처음엔 "긴 줄"을 단순 길이로 판단했는데, 긴 presigned URL 한 줄이나 정상적인 표를 collapse로 오탐했다. 그래서 URL·이미지·코드펜스를 길이 계산에서 빼고 순수 산문 길이만 재도록 고쳤다.
// 링크/이미지 타겟(presigned S3 URL 등)은 산문이 아니므로 길이 계산에서 제외.
const prose = line
.replace(/!?\[[^\]]*\]\([^)]*\)/g, "")
.replace(/https?:\/\/\S+/g, "")
.trim();
if (prose.length > LONG_PARAGRAPH_CHARS) { /* collapse 의심 경고 */ }
TypeScript
복사
여기서 배운 것. "Notion → Markdown 변환"은 한 줄짜리 작업처럼 보이지만, 실제로는 한쪽 도구의 방언을 다른 쪽 표준으로 번역하는 일이었다. 그리고 번역의 난이도는 "잘 되는 케이스"가 아니라 "깨지는 엣지 케이스"에 전부 몰려 있었다.
고민 2. 폐기된 문서를 왜 지우지 않고 "묘비"로 남겼나
문서가 폐기(deprecated)되면, 처음엔 그냥 파일을 지우면 된다고 생각했다. 그런데 AI가 이 문서들을 검색·RAG로 참조한다는 걸 생각하니 다른 결론이 나왔다.
파일을 그냥 지우면 상호 참조가 깨지고, 그렇다고 옛 본문을 남겨두면 AI가 폐기된 스펙을 근거로 코드를 짤 위험이 있다. 최악의 조합이다.
그래서 폐기 문서는 본문을 아예 가져오지 않고, 파일·경로·frontmatter는 유지하되 내용을 tombstone(묘비) 공지로 대체했다.
if (isDeprecated) {
// 폐기 문서는 본문을 가져오지 않고 tombstone 공지로 대체한다.
// 파일·경로·상호 참조는 유지하되, 옛 본문이 grep·RAG에 노출되지 않게 한다.
normalizedBody = `> [!WARNING]\n> 이 문서는 폐기되었습니다 (${date}). 더 이상 참조하지 마세요.`;
}
TypeScript
복사
경로는 살아 있으니 링크가 깨지지 않고, 본문은 비었으니 AI가 옛 스펙을 주워 담을 수 없다. **"AI 시대의 문서는 지우는 것보다 무엇을 남기지 않을지가 더 중요하다"**는 걸 여기서 처음 체감했다.
고민 3. 생성 산출물을 PR에 넣지 않은 이유 (동시성)
문서 인덱스(manifest.json)와 목록 README는 specs/ 전체를 스캔해서 만드는 생성 산출물이다. 처음엔 이걸 sync PR에 같이 넣었다. 그랬더니 문제가 생겼다.
여러 기획 문서가 동시에 각자의 PR로 열리면, 이 PR들이 전부 같은 manifest.json을 건드린다. → 머지할 때마다 컨플릭.
그래서 규칙을 바꿨다. 개별 sync PR에는 해당 문서 파일만 넣고, 생성 산출물은 PR에 넣지 않는다. 대신 PR이 main에 머지된 뒤 build-specs 워크플로우가 main에서 재생성해서 봇 커밋한다.
sync PR → specs/kids/order/prd.md 만 포함 (생성물 제외)
main 머지 후 → build-specs가 manifest.json/README 재생성 → bot 커밋
Plain Text
복사
이렇게 하니 여러 PR이 동시에 열려도 생성 파일 컨플릭이 원천적으로 사라졌다. "누가 만드는가"와 "언제 만드는가"를 분리한 셈이다. 동시성 문제는 락으로 푸는 것만 답이 아니라, 애초에 경합하는 자원을 공유하지 않게 설계하는 게 더 깔끔할 때가 있다는 걸 배웠다.
고민 4. manifest.json은 사람이 아니라 AI를 위한 인덱스다
이 시스템에서 개인적으로 제일 재밌었던 설계다.
AI 에이전트가 "주문 취소 관련 문서 찾아줘"라고 자연어로 물었을 때, 40개 도메인·120여 개 문서를 다 읽게 할 순 없다. 그래서 각 문서의 summary(요약)와 keywords(동의어)를 뽑아 manifest.json을 검색 인덱스로 만들었다.
핵심은 keywords를 동의어·약칭으로 채운 것이다. 기획자가 Notion에 "오더, order, 결제"처럼 적어두면, AI가 "결제 취소"로 물어도 "주문(order)" 문서를 후보로 좁힐 수 있다. 이걸 나는 의역 매칭이라고 불렀다.
문제는 기획자가 요약을 안 적는 경우였다. 그래서 요약이 비면 본문 첫 단락에서 자동 추출하도록 하고, 그 출처를 summary_source로 기록했다(notion / body / none).
if (!summary) {
const fallback = extractSummaryFromBody(normalizedBody);
if (fallback) { summary = fallback; summarySource = "body"; }
}
TypeScript
복사
그런데 자동 추출은 품질이 들쭉날쭉하다. 그래서 이걸 강제로 막기보단, 품질 사이드카(build-manifest-quality)를 따로 만들어 "요약이 없다 / 너무 짧다(80자 미만) / 너무 길다(200자 초과) / 본문에서 자동 채워졌다"를 리포트하게 했다.
// 강제로 실패시키지 않고, 품질 이슈를 별도 리포트로 가시화한다.
{ path, type: "summary_auto_filled", detail: "Notion 요약이 비어 본문에서 자동 추출됨" }
TypeScript
복사
여기서의 판단. 메타데이터 품질을 하드 게이트로 막을지, 소프트 리포트로 드러낼지를 두고 고민했다. 요약이 부실하다고 sync를 막으면 기획 흐름 전체가 멈춘다. 그래서 "막지는 않되, 품질을 눈에 보이게" 하는 쪽을 택했다. 문서는 흐르게 두고, 품질은 관측 가능하게.
고민 5. 개발자의 로컬 문서를 절대 건드리지 않기 (소비 CLI)
FE/BE 레포는 consumer-sync CLI로 specs/를 자기 docs/로 당겨간다. 여기서 제일 신경 쓴 건 **"싱크가 개발자가 직접 쓴 로컬 문서를 덮어쓰면 안 된다"**는 것이었다.
같은 docs/ 폴더에 싱크 문서와 로컬 문서가 섞여 사는데, 단방향 덮어쓰기를 하면 개발자가 손으로 쓴 decisions.md 같은 게 날아갈 수 있다. 그래서 manifest로 "싱크가 소유한 파일"만 추적하고, 소유가 아닌 파일은 존재하면 건드리지 않고 skip한다.
// 로컬 문서 보존: 이미 있는데 이전 manifest 소유가 아니면 덮어쓰지 않음
if (exists && !wasManaged) {
skipped.push({ rel: destRel, reason: "로컬 파일 존재(싱크 소유 아님) — 보존" });
continue;
}
TypeScript
복사
여기에 경로 탈출 방지(../로 레포 밖을 못 가리키게), upstream에서 사라진 파일 정리까지 붙였다.
그리고 아주 사소하지만 오래 잡았던 삽질 하나. 싱크 파일에는 "직접 수정 금지" 마커를 넣는데, 이걸 파일 맨 앞에 넣었더니 소비 레포의 frontmatter 파서가 ---로 시작하지 않는 파일을 frontmatter 없는 문서로 오인해서 메타데이터가 전부 유실됐다. 그래서 마커를 frontmatter 블록 아래로 옮겨, 파일이 항상 ---로 시작하도록 했다.
// 마커를 맨 앞에 두면 표준 파서가 frontmatter를 인식하지 못해 메타데이터가 유실된다.
// frontmatter가 있으면 그 "아래"에 마커를 넣어 파일이 항상 `---`로 시작하게 한다.
const injectMarker = (body, marker) => {
const match = body.match(FRONTMATTER_RE);
if (!match) return marker + body;
return `${match[1]}\n${marker}${body.slice(match[1].length)}`;
};
TypeScript
복사
•
-- 한 줄 위치 때문에 AI가 문서의 도메인·태그를 못 읽는다니. AI가 소비할 문서일수록, 사람 눈엔 안 보이는 구조가 결과를 가른다.
고민 6. 기획자가 GitHub를 몰라도 되게
마지막으로, 이 모든 걸 기획자가 트리거하려면 GitHub Actions를 눌러야 한다면 아무도 안 쓴다. 그래서 Notion 버튼 → Lambda relay → GitHub repository_dispatch 경로를 뒀다. 기획자는 Notion에서 버튼 하나 누르면, 뒤에서 GitHub Actions가 돈다. GitHub 계정도, PR이 뭔지도 몰라도 된다.
그리고 각 페이지 sync는 GitHub Actions matrix로 독립 실행(fail-fast: false)한다. 한 문서 sync가 실패해도 다른 문서는 계속 처리되고, 실패한 문서는 Notion에 실패 + 한글 사유로 역기록된다. (앞선 인수증 시스템에서 배운 "장애 격리"가 여기서도 그대로 적용됐다.)
회고: AI를 "코드 생성기"가 아니라 "기준을 자동으로 유지하는 시스템"으로
처음 이 시스템을 만들 때는 "Notion 문서를 GitHub에 자동으로 넣는 스크립트" 정도라고 생각했다. 그런데 만들면서 계속 부딪힌 질문은 전부 하나로 수렴했다.
AI가 이 문서를 근거로 코드를 짤 텐데, 이걸 믿어도 되나?
•
이미지 만료 URL을 남기면 → 깨진 근거
•
폐기 문서 본문을 남기면 → 틀린 근거
•
요약·동의어가 부실하면 → AI가 못 찾는 근거
•
마커 위치가 틀리면 → 메타데이터를 못 읽는 근거
결국 이 프로젝트의 본질은 **"AI가 신뢰할 수 있는 단일 기준 문서를, 사람이 손대지 않아도 유지되게 만드는 것"**이었다. AI에게 좋은 프롬프트를 주는 것보다, AI가 딛고 설 바닥을 자동으로 깨끗하게 유지하는 것이 팀 단위 생산성에는 훨씬 크게 작용했다.
문서 작성 시간은 크게 줄었지만, 나에게 더 크게 남은 건 이 관점의 전환이다. AI 시대의 백엔드 개발자는 "AI가 무엇을 근거로 삼는가"를 설계하는 사람이기도 하다는 것.