시스템 가이드

Notion Sync Engine의 아키텍처, 동작 방식, 사용법을 설명합니다.

1. 시스템 개요

Notion Sync Engine은 여러 Notion Database와 Page를 동적으로 등록하고, 주기적으로 폴링하여 자체 DB로 단방향 동기화(Notion → DB)하는 범용 수집 시스템입니다.

멀티 소스 관리
Notion DB/Page를 원하는 만큼 등록하고 독립적으로 수집
단방향 동기화
Notion 데이터를 읽기만 함 (Notion 원본 수정 없음)
증분 동기화
마지막 동기화 이후 변경분만 감지하여 효율적 수집
Staging 경유
Notion 원본 JSON 보존 → 변환 → 운영 테이블 적재
Property ID 기반
프로퍼티 이름이 바뀌어도 매핑이 깨지지 않음
자동 헬스체크
Notion 스키마 변경을 실시간 감지 및 알림
삭제 동기화
Notion에서 보관/삭제된 항목을 소프트 삭제로 반영
DLQ 격리
실패한 레코드만 격리하고 나머지는 정상 처리 계속

기술 스택

프레임워크Next.js 16 (App Router, TypeScript)
DB / ORMMariaDB + Prisma
Notion SDK@notionhq/client v5 (dataSources API)
검증Zod
해시Node crypto SHA-256
로깅Pino (correlation ID)
테스트Vitest
런타임Node.js v24

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     실패 큐    모니터링
Collection Sources
수집 대상 레지스트리. Notion Database 또는 Page를 동적으로 등록/관리합니다. 소스별로 폴링 주기, 커서 버퍼, 활성 상태를 독립적으로 설정합니다.
Pull Engine
소스별로 독립 실행되는 수집 엔진. 락 획득 → 헬스체크 → Notion API 페이지네이션 조회 → 해시 비교 → Staging upsert → 삭제 감지 → 커서 업데이트 순서로 동작합니다.
Staging Tables
Notion 원본 데이터를 그대로 보존하는 중간 테이블. notion_pages에 API 원본 JSON(raw_response), 파싱된 프로퍼티(properties), 블록 트리(raw_blocks), 마크다운 변환(content_markdown)을 저장합니다.
Mapping Engine
Staging → 운영 테이블 변환 레이어. Property ID 기반으로 값을 추출하고, 소스별 Transform 함수로 비즈니스 규칙을 적용한 후, Zod 검증을 거쳐 운영 테이블에 upsert합니다.
Dashboard
소스 현황, 동기화 이력, DLQ 관리, 데이터 뷰어를 제공하는 웹 UI. 서버 컴포넌트 기반으로 실시간 DB 조회 결과를 표시합니다.

3. 동기화 상세 흐름

하나의 동기화 사이클은 다음 단계를 순서대로 실행합니다.

1
동기화 시작
Correlation ID(UUID)를 생성하고, sync_logs 테이블에 status=RUNNING 레코드를 생성합니다. 이 ID로 해당 사이클의 모든 로그와 실패 기록을 추적할 수 있습니다.
2
락 획득
sync_locks 테이블에서 해당 소스의 락을 확인합니다. 이미 다른 프로세스가 동기화 중이면 실패합니다. TTL(기본 300초) 기반으로 비정상 종료 시 자동 해제됩니다.
3
매핑 헬스체크 (DATABASE 소스)
Notion DB 스키마를 조회하여 field_mappings와 비교합니다. Property ID 존재 여부, 타입 일치, 이름 변경, 새 프로퍼티 탐지 등 5가지 검증을 수행합니다. BROKEN 상태면 동기화를 중단합니다.
4
Notion API 조회
dataSources.query()로 페이지를 100건씩 페이지네이션하여 조회합니다. INCREMENTAL 모드에서는 마지막 동기화 시점 - 커서 버퍼(기본 180초)부터 조회합니다.
5
변경 감지 (Hash 비교)
각 페이지의 properties를 SHA-256 해시로 변환하고, DB에 저장된 기존 해시와 비교합니다. 해시가 동일하면 스킵(커서 버퍼 중복 제거), 다르면 변경으로 판정합니다.
6
Staging Upsert
변경이 감지된 페이지를 notion_pages 테이블에 upsert합니다. 제목 추출, 삭제/보관 상태 확인, 원본 JSON 저장을 포함합니다.
7
삭제 감지 (FULL 모드)
FULL 동기화 시, Notion에서 조회된 페이지 ID 목록과 DB의 기존 페이지를 비교합니다. Notion에 없는 페이지는 is_deleted=true, deleted_at=now()로 소프트 삭제합니다.
8
커서 업데이트
sync_cursors 테이블에 마지막 동기화 시점을 기록합니다. 다음 INCREMENTAL 동기화의 시작점으로 사용됩니다.
9
락 해제 + 로그 완료
동기화 락을 해제하고, sync_logs에 최종 결과(status, 수집/실패/삭제 건수, 소요시간)를 기록합니다.
10
Migrate (선택)
Pull 완료 후 자동으로 Staging → 운영 테이블 마이그레이션을 실행합니다. Property ID 기반으로 값을 추출하고 Zod 검증 후 운영 테이블에 upsert합니다.

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 delete

PAGE 소스

단일 페이지의 프로퍼티와 블록 트리(본문)를 함께 수집합니다.

// 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 }
블록 수집 제한
PAGE 소스의 블록 수집은 최대 깊이 5단계, 페이지당 최대 1000블록으로 제한됩니다. 초과 시 수집된 범위까지만 저장하고 경고 로그를 남깁니다. BLOCK_MAX_DEPTH, BLOCK_MAX_COUNT 환경변수로 조정 가능합니다.

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));
}
커서 버퍼와 중복 제거
INCREMENTAL 동기화 시 커서 버퍼(기본 180초)만큼 과거 데이터를 중복 조회합니다. 이는 Notion API의 시간 정밀도 한계를 보완하기 위함입니다. 해시 비교로 실제 변경이 없는 중복 데이터는 자동으로 스킵됩니다.

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 비교

항목FULLINCREMENTAL
조회 범위전체 페이지마지막 동기화 이후 변경분
삭제 감지가능 (전수 비교)제한적 (archived/in_trash만)
API 호출량많음적음
소요 시간길음짧음
사용 시점초기 수집, 정합성 복구일상적 폴링

7. 매핑 헬스체크

동기화 시작 전, Notion DB 스키마를 조회하여 field_mappings에 등록된 매핑이 여전히 유효한지 검증합니다.Property ID 기반이므로 Notion에서 프로퍼티 이름만 변경해도 매핑이 깨지지 않습니다.

검증 항목 (5가지)

🔴
PROPERTY_MISSING (필수)
필수 매핑의 Property ID가 Notion에 없음. BROKEN 상태로 동기화 중단.
🟡
PROPERTY_MISSING (비필수)
비필수 매핑의 Property ID가 Notion에 없음. DEGRADED 경고 후 계속.
🟡
TYPE_CHANGED
Property ID는 존재하지만 타입이 변경됨 (예: select → multi_select).
🔵
PROPERTY_RENAMED
Property ID는 존재하고 타입도 일치하지만 이름이 변경됨. 자동으로 field_mappings의 이름을 업데이트.
🔵
NEW_PROPERTY
Notion에 새 프로퍼티가 추가되었지만 매핑이 없음. 정보성 알림.

상태 결정 로직

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                    // 토큰 소비
공유 풀
모든 소스의 Notion API 호출이 하나의 Rate Limiter를 공유합니다. 여러 소스가 동시에 동기화해도 초당 3회를 초과하지 않습니다.

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 sourceId
자동 해제
TTL(기본 300초) 기반이므로, 프로세스가 비정상 종료되어도 TTL이 지나면 자동으로 락이 해제됩니다. SYNC_LOCK_TTL_SECONDS 환경변수로 조정 가능합니다.

10. 실패 큐 (DLQ)

동기화 중 개별 레코드에서 오류가 발생하면 해당 레코드만 DLQ(Dead Letter Queue)에 격리하고, 나머지 레코드는 계속 처리합니다.

에러 3단계 분류

즉시 중단 (CRITICAL)
DB 커넥션 실패, 인증 오류(401), 락 획득 실패, 매핑 BROKEN → 전체 동기화 중단, sync_log status=FAILED
재시도 (RETRYABLE)
Notion API 429/5xx, 네트워크 타임아웃 → Exponential Backoff (1초→2초→4초, 최대 5회) → 실패 시 DLQ
스킵 + 격리 (ISOLATABLE)
개별 페이지 변환 실패, Zod 검증 오류, 프로퍼티 타입 불일치 → DLQ에 원본 payload 보존, 나머지 계속 → status=PARTIAL_SUCCESS

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   // 소스별 비즈니스 규칙
새 소스 추가
새 Notion 소스를 추가할 때: 1) transforms/ 디렉토리에 변환 파일 추가 2) index.ts에 등록. Pull/Migrate 엔진 코어는 수정하지 않습니다.

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/schemaDB 프로퍼티 스키마 조회 (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/migrateStaging → 운영 테이블 수동 마이그레이션

실패 큐 (DLQ)

GET/api/sync/failures실패 목록 (category, status 필터)
POST/api/sync/failures/:id/retry개별 실패 재처리
POST/api/sync/failures/retry-allPENDING 실패 일괄 재처리

시스템

GET/api/healthDB 연결 헬스체크
GET/api/metrics관측 지표 (소스 상태, 에러율, DLQ 건수, 동기화 통계)

14. DB 스키마

Prisma 스키마(prisma/schema.prisma)가 스키마의 정본(Ground Truth)입니다.

collection_sources수집 대상 레지스트리
notionId, sourceType(DATABASE|PAGE), pollIntervalSeconds, cursorBufferSeconds, lastSyncStatus, isActive
field_mappingsProperty ID 기반 매핑 정의
propertyId(UK), notionPropertyName, notionPropertyType, targetTable, targetColumn, transformRule(JSON), isRequired
mapping_health매핑 헬스체크 결과
status(HEALTHY|DEGRADED|BROKEN), issues(JSON), checkedAt
notion_pagesStaging - 페이지 원본
notionId(UK+sourceId), rawResponse(JSON), properties(JSON), rawBlocks(JSON), contentMarkdown, syncHash, contentHash, isDeleted, isArchived
notion_commentsStaging - 댓글
notionId, discussionId, authorId, authorName, content
sync_logs동기화 이력
correlationId, syncMode, status, recordsPulled/Migrated/Failed/Deleted, durationMs, errorMessage
sync_cursors동기화 커서 (소스당 1개)
sourceId(UK), lastSyncedAt, nextCursor
sync_failures실패 큐 (DLQ)
failureCategory(INGESTION|MAPPING), status, retryCount, maxRetries(5), rawPayload(JSON), errorMessage
sync_locksTTL 기반 동기화 락
sourceId(UK), lockedBy, expiresAt

15. 환경 설정

.env.local 파일에 설정합니다. 모든 환경변수는 Zod로 시작 시 검증됩니다.

데이터베이스

DATABASE_URLPostgreSQL 연결 문자열 (모노레포 공용)postgresql://user:pass@host:5432/dhub

Notion

NOTION_API_TOKENNotion Integration 토큰 (절대 하드코딩 금지)secret_...
NOTION_MCP_URLMCP 서버 주소 (선택)http://localhost:3001/mcp

동기화 기본값 (소스별 override 가능)

DEFAULT_POLL_INTERVAL_SECONDS폴링 주기3600
DEFAULT_CURSOR_BUFFER_SECONDS커서 버퍼180
SYNC_LOCK_TTL_SECONDS락 TTL300

블록 수집 제한

BLOCK_MAX_DEPTH최대 중첩 깊이5
BLOCK_MAX_COUNT페이지당 최대 블록 수1000

기타

NOTION_RATE_LIMIT_PER_SECONDAPI 초당 제한3
RETENTION_DAYS데이터 보존 기간 (일)30
LOG_LEVEL로깅 레벨info
Notion Sync Engine Guide