시스템 가이드
Notion Sync Engine의 아키텍처, 동작 방식, 사용법을 설명합니다.
1. 시스템 개요
Notion Sync Engine은 여러 Notion Database와 Page를 동적으로 등록하고, 주기적으로 폴링하여 자체 DB로 단방향 동기화(Notion → DB)하는 범용 수집 시스템입니다.
기술 스택
2. 전체 아키텍처
시스템은 크게 5개 레이어로 구성됩니다.
┌─────────────────────────────────────────────────────────────┐
│ Notion Workspace │
│ (Databases, Pages, Blocks, Comments) │
└────────────────────────┬────────────────────────────────────┘
│ Notion SDK v5 (dataSources API)
▼
┌─────────────────────────────────────────────────────────────┐
│ Collection Sources │
│ 소스 레지스트리 (DATABASE / PAGE 동적 등록) │
│ 소스별 폴링 주기, 커서 버퍼, 활성 상태 관리 │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────────┐
│ Pull Engine │
│ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ Lock │ │HealthChk │ │ RateLimit│ │ Hash │ │
│ │ (TTL)│ │(PropertyID│ │(Token │ │(SHA-256)│ │
│ │ │ │ 5종 검증) │ │ Bucket) │ │ 변경감지│ │
│ └──────┘ └──────────┘ └──────────┘ └─────────┘ │
│ DB소스: 페이지네이션 조회 + 프로퍼티 해시 비교 │
│ Page소스: 페이지 + 블록 트리 재귀 수집 + 마크다운 변환 │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────────┐
│ Staging Tables (notion_pages) │
│ ┌────────────┐ ┌────────────┐ ┌───────────┐ ┌───────────┐ │
│ │raw_response│ │ properties │ │ raw_blocks│ │ content │ │
│ │(API 원본) │ │ (파싱 JSON)│ │(블록 트리) │ │ _markdown │ │
│ └────────────┘ └────────────┘ └───────────┘ └───────────┘ │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────────┐
│ Mapping Engine (Migrate) │
│ Property ID 기반 값 추출 → Transform 함수 → Zod 검증 │
│ 실패 시 DLQ 격리 (MAPPING 카테고리) │
└────────────────────────┬────────────────────────────────────┘
│
┌─────────┼─────────┐
▼ ▼ ▼
[운영 테이블] [DLQ] [Dashboard]
upsert 실패 큐 모니터링3. 동기화 상세 흐름
하나의 동기화 사이클은 다음 단계를 순서대로 실행합니다.
4. Pull 엔진 (수집)
Pull 엔진은 소스 타입에 따라 두 가지 방식으로 동작합니다.
DATABASE 소스
// dataSources.query() 페이지네이션
do {
response = queryDataSource(sourceId, { startCursor, pageSize: 100, filter, sorts })
for (page of response.results) {
syncHash = SHA-256(properties) // 프로퍼티 해시
existing = DB에서 기존 해시 조회
if (existing.syncHash === syncHash)
continue // 변경 없음 → 스킵
notion_pages에 upsert { // 변경 있음 → 저장
rawResponse, properties, syncHash,
title, isDeleted, isArchived,
notionCreatedAt, notionUpdatedAt
}
}
startCursor = response.nextCursor
} while (startCursor)
// FULL 모드: 삭제 감지
notionPageIds - dbPageIds → soft deletePAGE 소스
단일 페이지의 프로퍼티와 블록 트리(본문)를 함께 수집합니다.
// 1. 페이지 프로퍼티 조회
page = getPage(sourceNotionId)
syncHash = SHA-256(properties)
// 2. 블록 트리 재귀 수집 (최대 깊이 5, 최대 1000블록)
blocks = getBlocks(sourceNotionId, { maxDepth: 5, maxCount: 1000 })
contentHash = SHA-256(blocks)
// 3. 마크다운 변환
contentMarkdown = blocksToMarkdown(blocks)
// 4. 두 해시 모두 비교
if (existing.syncHash === syncHash && existing.contentHash === contentHash)
→ 변경 없음, 스킵
// 5. 변경 감지되면 upsert
notion_pages에 { rawResponse, properties, rawBlocks, contentMarkdown, syncHash, contentHash }5. 변경 감지 (Hash)
Notion API는 페이지의 프로퍼티 변경뿐 아니라 본문(블록) 수정 시에도 last_edited_time이 갱신됩니다. 이 때문에 단순히 시간 비교만으로는 실제 어떤 부분이 변경되었는지 알 수 없습니다.
이 시스템은 두 종류의 SHA-256 해시를 분리하여 정밀한 변경 감지를 수행합니다.
syncHash (프로퍼티)
페이지의 properties 객체를 키 알파벳순 정렬 후 JSON.stringify → SHA-256. 제목, 날짜, 참석자, 태그 등 프로퍼티 값 변경을 감지합니다.
contentHash (본문)
블록 트리 전체를 JSON.stringify → SHA-256. 본문 텍스트, 이미지, 테이블 등 블록 구조 변경을 감지합니다. PAGE 소스에서만 사용됩니다.
// 해시 계산 (hash.ts)
function computeSyncHash(properties) {
// 키를 알파벳순으로 정렬하여 결정론적 직렬화 보장
const sorted = Object.fromEntries(
Object.entries(properties).sort(([a], [b]) => a.localeCompare(b))
);
return SHA-256(JSON.stringify(sorted));
}
function computeContentHash(blocks) {
return SHA-256(JSON.stringify(blocks));
}6. 증분 동기화 (Incremental)
FULL 모드는 전체 데이터를 재조회하므로 안전하지만 느립니다. INCREMENTAL 모드는 마지막 동기화 이후 변경분만 조회하여 효율을 높입니다.
동작 원리
// 1. 마지막 동기화 시점 조회
cursor = sync_cursors에서 source별 lastSyncedAt 조회
// 2. 커서 버퍼링 적용 (기본 180초)
// Notion의 last_edited_time 정밀도 한계를 보완
bufferedTime = lastSyncedAt - cursorBufferSeconds
// 3. 필터 생성
filter = {
timestamp: "last_edited_time",
last_edited_time: { after: bufferedTime }
}
// 4. 필터를 적용하여 Notion API 조회
// → 버퍼 구간의 중복 데이터는 syncHash 비교로 자동 제거FULL vs INCREMENTAL 비교
| 항목 | FULL | INCREMENTAL |
|---|---|---|
| 조회 범위 | 전체 페이지 | 마지막 동기화 이후 변경분 |
| 삭제 감지 | 가능 (전수 비교) | 제한적 (archived/in_trash만) |
| API 호출량 | 많음 | 적음 |
| 소요 시간 | 길음 | 짧음 |
| 사용 시점 | 초기 수집, 정합성 복구 | 일상적 폴링 |
7. 매핑 헬스체크
동기화 시작 전, Notion DB 스키마를 조회하여 field_mappings에 등록된 매핑이 여전히 유효한지 검증합니다.Property ID 기반이므로 Notion에서 프로퍼티 이름만 변경해도 매핑이 깨지지 않습니다.
검증 항목 (5가지)
상태 결정 로직
CRITICAL 이슈 존재 → status = BROKEN (동기화 중단) WARNING 이슈 존재 → status = DEGRADED (경고 후 계속) 전부 INFO/없음 → status = HEALTHY (정상)
8. Rate Limit 관리
Notion API는 초당 요청 수를 제한합니다 (429 Too Many Requests). 이 시스템은 Token Bucket 알고리즘으로 모든 소스가 하나의 풀을 공유하여 제한을 준수합니다.
// Token Bucket (rate-limiter.ts)
maxTokens = 3 // 최대 토큰 수 (NOTION_RATE_LIMIT_PER_SECOND)
refillRate = 3 tokens/second // 초당 토큰 충전 속도
acquire():
while (tokens < 1) // 토큰이 없으면 대기
sleep(100ms)
tokens += elapsed * refillRate // 시간에 비례해 충전
tokens -= 1 // 토큰 소비9. 동기화 락 (Lock)
같은 소스의 동기화가 동시에 실행되면 데이터 충돌이 발생합니다. DB 기반 TTL 락으로 소스당 하나의 동기화만 실행되도록 보장합니다.
// lock.ts
acquireLock(sourceId):
1. 만료된 락 정리 (expiresAt < now)
2. INSERT sync_locks { sourceId, lockedBy: "{pid}-{uuid}", expiresAt: now + TTL }
3. 성공 → true / 중복키 에러 → false (이미 다른 프로세스가 실행 중)
renewLock(sourceId): // 긴 동기화 시 5페이지마다 갱신
UPDATE expiresAt = now + TTL WHERE sourceId AND lockedBy
releaseLock(sourceId):
DELETE sync_locks WHERE sourceId10. 실패 큐 (DLQ)
동기화 중 개별 레코드에서 오류가 발생하면 해당 레코드만 DLQ(Dead Letter Queue)에 격리하고, 나머지 레코드는 계속 처리합니다.
에러 3단계 분류
DLQ 상태 흐름
PENDING → (재시도) → RETRYING → 성공 → RESOLVED
→ 실패 → retryCount < 5: PENDING (재진입)
→ 실패 → retryCount >= 5: ABANDONED (수동 확인)
분류 (failureCategory):
INGESTION: API 호출, 네트워크, 파싱 오류
MAPPING: 변환, 검증, 타입 불일치 오류11. Migrate (Staging → 운영 테이블)
Staging의 notion_pages 데이터를 운영 테이블로 변환합니다. 소스별 Transform 함수를 분리하여 비즈니스 규칙을 적용합니다.
// migrate.ts 실행 흐름 1. getTransform(sourceId) → 소스별 변환 함수 조회 2. 활성 field_mappings 조회 3. notion_pages 순회: a. Property ID로 프로퍼티 값 추출 b. Transform 함수 호출 (비즈니스 규칙) c. Zod 스키마 검증 d. 운영 테이블에 upsert (notion_id 기준) 4. is_deleted 페이지 → 운영 테이블 soft delete 5. 실패 → DLQ (category: MAPPING) // 소스별 Transform 분리 구조 src/lib/sync/transforms/ ├── base.ts // 공통 변환 로직 ├── index.ts // sourceId → transform 라우팅 └── [소스명].ts // 소스별 비즈니스 규칙
12. 대시보드 사용법
개요 (메인 페이지)
/dashboard- -활성 소스 수, 수집된 페이지 수, 실패 대기 건수, 최근 동기화 수를 요약 카드로 표시
- -소스별 현황 테이블: 헬스 상태(🟢🟡🔴), 마지막 동기화 시간/결과, 페이지 수, 실패 건수
- -최근 10건의 동기화 이력: 시간, 소스, 모드, 결과, 수집/실패 건수, 소요시간
소스 관리
/dashboard/sources- -"+ 소스 추가" 버튼으로 Notion Database 또는 Page를 등록
- -Notion ID(UUID)를 입력하면 Notion API에서 이름을 자동으로 가져옴
- -소스별 폴링 주기, 커서 버퍼를 개별 설정 가능
- -각 소스의 "동기화" 버튼으로 수동 FULL/INCREMENTAL 동기화 트리거
소스 상세 + 매핑
/dashboard/sources/[id]- -소스 정보(타입, Notion ID, 폴링 주기 등)와 매핑 설정을 확인
- -"Notion 자동 매핑" 버튼: Notion DB 스키마를 조회하여 field_mappings를 자동 생성
- -Property ID 기반 매핑이므로 Notion에서 프로퍼티 이름을 바꿔도 안전
- -매핑 헬스 이력 페이지에서 시간별 검증 결과 확인
동기화 로그
/dashboard/logs- -전체 동기화 이력을 시간순으로 조회 (최대 100건)
- -각 로그: 시간, 소스, 모드(FULL/INCREMENTAL), 상태, 수집/마이그레이션/실패/삭제 건수
- -Correlation ID로 하나의 동기화 사이클을 추적
- -에러 메시지가 있으면 함께 표시
실패 큐 (DLQ)
/dashboard/failures- -수집 오류(INGESTION) 탭과 매핑 오류(MAPPING) 탭으로 분리
- -각 실패: 상태 배지, 소스, 페이지 ID, 에러 메시지, 재시도 횟수
- -"재시도" 버튼으로 개별 또는 일괄 재처리 가능
- -ABANDONED 상태(5회 초과)는 수동 확인 필요
데이터 뷰어
/dashboard/data- -소스별 필터링으로 수집된 notion_pages 데이터를 조회
- -목록: 소스, 제목, 프로퍼티 요약, Notion 수정일, 동기화일
- -상세 페이지: 메타 정보(Notion ID, URL, 해시, 상태) + 3개 탭
- -프로퍼티 탭: 17가지 Notion 프로퍼티 타입을 테이블로 렌더링
- -본문 탭: 마크다운 변환 결과 표시 (PAGE 소스)
- -원본 JSON 탭: Notion API 원본 응답을 그대로 표시
13. API 레퍼런스
소스 관리
| GET | /api/sources | 활성 소스 목록 조회 |
| POST | /api/sources | 새 소스 등록 (notionId, sourceType, name, pollIntervalSeconds, cursorBufferSeconds) |
| GET | /api/sources/:id | 소스 상세 조회 (매핑 수, 페이지 수 포함) |
| PUT | /api/sources/:id | 소스 정보 수정 |
| DELETE | /api/sources/:id | 소스 비활성화 (소프트 삭제) |
| GET | /api/sources/:id/mappings | 소스별 필드 매핑 목록 |
| POST | /api/sources/:id/mappings | 매핑 일괄 등록/수정 (트랜잭션) |
| GET | /api/sources/:id/health | 매핑 헬스체크 이력 (최근 20건) |
Notion 탐색
| GET | /api/notion/search?q=...&filter=database|page | 워크스페이스 DB/Page 검색 |
| GET | /api/notion/databases/:id/schema | DB 프로퍼티 스키마 조회 (ID, 이름, 타입, 옵션) |
동기화
| POST | /api/sync/trigger | 수동 동기화 실행 (body: { sourceId, mode: "FULL"|"INCREMENTAL" }) |
| GET | /api/sync/status | 활성 락 + 소스별 동기화 상태 |
| GET | /api/sync/logs | 동기화 이력 (sourceId, status, limit, offset 필터) |
| POST | /api/sync/migrate | Staging → 운영 테이블 수동 마이그레이션 |
실패 큐 (DLQ)
| GET | /api/sync/failures | 실패 목록 (category, status 필터) |
| POST | /api/sync/failures/:id/retry | 개별 실패 재처리 |
| POST | /api/sync/failures/retry-all | PENDING 실패 일괄 재처리 |
시스템
| GET | /api/health | DB 연결 헬스체크 |
| GET | /api/metrics | 관측 지표 (소스 상태, 에러율, DLQ 건수, 동기화 통계) |
14. DB 스키마
Prisma 스키마(prisma/schema.prisma)가 스키마의 정본(Ground Truth)입니다.
collection_sources수집 대상 레지스트리field_mappingsProperty ID 기반 매핑 정의mapping_health매핑 헬스체크 결과notion_pagesStaging - 페이지 원본notion_commentsStaging - 댓글sync_logs동기화 이력sync_cursors동기화 커서 (소스당 1개)sync_failures실패 큐 (DLQ)sync_locksTTL 기반 동기화 락15. 환경 설정
.env.local 파일에 설정합니다. 모든 환경변수는 Zod로 시작 시 검증됩니다.
데이터베이스
| DATABASE_URL | PostgreSQL 연결 문자열 (모노레포 공용) | postgresql://user:pass@host:5432/dhub |
Notion
| NOTION_API_TOKEN | Notion Integration 토큰 (절대 하드코딩 금지) | secret_... |
| NOTION_MCP_URL | MCP 서버 주소 (선택) | http://localhost:3001/mcp |
동기화 기본값 (소스별 override 가능)
| DEFAULT_POLL_INTERVAL_SECONDS | 폴링 주기 | 3600 |
| DEFAULT_CURSOR_BUFFER_SECONDS | 커서 버퍼 | 180 |
| SYNC_LOCK_TTL_SECONDS | 락 TTL | 300 |
블록 수집 제한
| BLOCK_MAX_DEPTH | 최대 중첩 깊이 | 5 |
| BLOCK_MAX_COUNT | 페이지당 최대 블록 수 | 1000 |
기타
| NOTION_RATE_LIMIT_PER_SECOND | API 초당 제한 | 3 |
| RETENTION_DAYS | 데이터 보존 기간 (일) | 30 |
| LOG_LEVEL | 로깅 레벨 | info |