k3s-msa-infrastructure, target-tracking-service, threat-intel-ai-service 세 리포의 docs/**.md를 Notion으로 옮기고, 이후로는 문서가 바뀔 때마다 자동으로 Notion도 갱신되게 만든 기록. (2026-09-22에 c4i-dashboard-frontend도 동일한 방식으로 추가 — 10절 참고.)

1. 배경 — 왜 필요했나

세 리포에 쌓인 docs/ 문서 48개를 Claude Code(Notion 커넥터)로 한 번에 Notion에 옮겼다. 문제는 그다음이었다 — "앞으로 문서를 추가할 때마다 Notion도 같이 최신 상태로 유지되게 하고 싶다"는 요구였는데, Claude Code 세션 안에서 "문서 추가해줘"라고 부탁할 때만 Notion을 같이 갱신하는 방식은 커밋이 다른 경로로 일어나면(예: 직접 git push, 나중에 팀원이 추가) 무력화된다. 그래서 "누가 어떤 방식으로 커밋하든" 항상 동작하는, git push 자체를 트리거로 삼는 자동화(GitHub Actions)로 만들기로 했다.

2. 전체 흐름

docs/**.md 변경 → git push (main)
        │
        ▼
GitHub Actions: Notion Doc Sync
        │
        ├─ 이전 커밋과 diff해서 바뀐 .md 파일 목록 추출
        ├─ 매핑 파일(.notion-sync-map.json)에 있으면 → 그 Notion 페이지 내용 통째 교체
        ├─ 매핑에 없는 새 파일이면 → 적절한 부모 페이지 아래 새 페이지 생성 후 매핑에 추가
        └─ 매핑 파일이 바뀌었으면 → 그 변경분을 [skip ci]로 다시 커밋 + push

3. Notion 쪽 준비 — Internal Integration

Notion API로 페이지를 읽고 쓰려면 notion.so/my-integrations에서 Internal Integration을 만들고 토큰(secret)을 발급받아야 한다. 발급받은 것만으로는 아무 페이지에도 접근이 안 되고, 연결하려는 페이지 쪽에서 그 integration을 명시적으로 "Connections"에 추가해야 한다.

이번엔 k3s-doc-sync라는 이름으로 만들고, 최상위 페이지("Defense C4I 인프라 문서") 하나에만 연결했다 — Notion의 페이지 공유/연결은 하위 페이지로 상속되므로, 그 아래 리포별 페이지·concepts 하위 페이지까지 전부 별도 연결 없이 같은 integration으로 접근 가능하다.

4. 리포별 파일 ↔ Notion 페이지 매핑

각 리포 루트에 .notion-sync-map.json을 커밋해서 "이 로컬 파일 경로가 이 Notion 페이지 ID에 대응한다"를 기록한다.

{
  "docs/architecture.md": "3e250b17-ecd1-810d-8cfe-f275b601a630",
  "docs/concepts/13-reranker.md": "3e250b17-ecd1-81fa-b5fd-ce15f1b48170"
}

최초 49개 페이지(48개 문서 + 마이그레이션 문서 하나가 분량 때문에 2페이지로 쪼개진 것 1개 추가)의 ID는 Claude Code의 Notion 커넥터로 각 부모 페이지를 순회 조회(fetch)해서 자식 페이지 목록을 뽑아 수작업으로 매핑했다. 이후로는 스크립트가 새 페이지를 만들 때마다 이 파일에 자동으로 추가한다.

분량 때문에 페이지를 둘로 쪼갰던 문서(VMware-to-Multipass-Cluster-Migration.md → 1/2, 2/2)는 매핑에 1/2 페이지만 등록해뒀다 — 원본 파일이 하나뿐이라 앞으로 그 파일이 바뀌면 1/2 페이지만 갱신되고 2/2 페이지는 그대로 남는다(고아 페이지). Claude Code로 옮길 때는 대화 단위 분량 제한 때문에 나눈 것이었지 Notion API 자체의 제약은 아니라서, 자동화 스크립트는 애초에 파일 하나당 페이지 하나로만 다룬다.

5. 동기화 스크립트 설계

Node.js + Notion 공식 SDK(@notionhq/client) + 마크다운→Notion 블록 변환 라이브러리(@tryfabric/martian, GFM 표·코드블록·목록을 Notion 블록으로 변환)로 짰다. 저장 위치는 프로젝트 루트가 아니라 .github/scripts/notion-sync/에 별도 package.json을 뒀다 — 이 세 리포는 각각 YAML/Java/Python 프로젝트라, Node 의존성을 리포 루트에 섞으면 기존 빌드 도구(Gradle, pip 등)와 무관한 잡동사니가 생긴다. Node 모듈 해석은 스크립트 파일 위치부터 상위로 찾아 올라가므로, .github/scripts/notion-sync/ 안에서만 npm install해도 그 스크립트를 실행할 땐 정상적으로 잡힌다.

5.1 "페이지 내용 교체"가 없어서 지우고 다시 쓴다

Notion API에는 "이 페이지 내용을 통째로 이걸로 바꿔라" 같은 단일 엔드포인트가 없다. 그래서 기존 페이지를 갱신할 땐 blocks.children.list로 현재 자식 블록을 전부 가져와 blocks.children.delete로 지운 뒤, 새로 변환한 블록을 blocks.children.append로 다시 채우는 2단계로 구현했다. append는 한 번에 최대 100개 블록만 받아서, 문서가 길면 90개씩 나눠서 여러 번 호출한다.

5.2 어떤 파일이 바뀌었는지 판별

const base = process.env.GITHUB_EVENT_BEFORE; // 이 push 직전 커밋 SHA
execSync(`git diff --name-status ${base} HEAD -- "docs/**/*.md"`);

GitHub Actions의 push 이벤트가 주는 github.event.before를 기준으로 diff를 떠서, 그 push에 실제로 포함된 docs/**.md 변경분만 골라낸다. 이 값이 없거나(수동 트리거 등) 커밋이 로컬에 없는 상황(얕은 클론)이면 HEAD~1로 폴백한다.