데이터를 노션 대시보드로 자동 적재
⓪ 이 스킬이면 뭘 할 수 있나?
손에 있는 아무 데이터(SQLite 행, JSON 배열, 크롤 결과, 주문/매출 내역…)를 노션 데이터베이스 대시보드로 한 방에 올릴 수 있어요. 핵심 매력 셋:
- 설치 0 — 파이썬 표준 라이브러리(
urllib)만.pip install안 함, 가상환경 안 만듦. - 중복 0 — 고유키 컬럼으로 멱등 UPSERT. 같은 스크립트를 100번 돌려도 행이 안 불어나고 갱신만 됨.
- MCP 불필요 — 노션 통합 토큰으로 REST를 직접 친다. 헤드리스·cron·남의 서버 어디서든 동작.
"행으로 쌓이는 데이터면 무엇이든 대시보드化" — 주문 현황, 매출, 출퇴근 기록, 수집물 목록 전부 같은 패턴.
① 한 줄 정의
NOTION_TOKEN(통합 토큰)으로 노션 REST API를 직접 호출해 DB를 1회 생성·재사용하고, 고유키로 INSERT/UPDATE를 가르는 멱등 push 스크립트.
② 언제 쓰나?
- 모아둔 데이터를 사람이 보기 좋게 노션 표/보드로 보고 싶을 때
- 주기적으로(매일·매시간) 같은 DB를 갱신해야 할 때 — 중복 없이 누적
- 설치 권한이 빡빡한 환경(시스템 파이썬에 pip 없음 등)에서 의존성 없이 돌려야 할 때
③ 사전 요구 (1회 세팅)
1. 통합 토큰 발급 — notion.so/my-integrations → 통합 생성 → Internal Integration Token 복사 (ntn_… 형태)
2. 부모 페이지에 통합 연결 — 대시보드를 둘 노션 페이지 → … 메뉴 → Connections(연결) → 만든 통합 추가. ⚠️ 이걸 안 하면 토큰이 있어도 403.
3. 부모 페이지 ID — 그 페이지 URL 끝의 32자.
4. 자격증명을 환경/.env 파일에 저장 (코드·저장소에 절대 박지 말 것):
```
NOTION_TOKEN=ntn_…
NOTION_PARENT_PAGE=<32자 page_id>
```
5. python3 (3.8+). 외부 패키지 불필요.
④ 연동·인증 방식 (정확히)
- 정식 MCP가 아니다. 통합 토큰으로 REST를 직접 호출한다.
- 엔드포인트:
https://api.notion.com/v1 - 필수 헤더 2개:
- Authorization: Bearer <NOTION_TOKEN>
- Notion-Version: 2022-06-28
- (+ 바디 있으면 Content-Type: application/json)
⑤ 단계별 절차
1. 자격증명 로드(NOTION_TOKEN, NOTION_PARENT_PAGE).
2. get_or_create_db() — .notion_db_id 캐시 파일이 있으면 GET으로 유효성 확인 후 재사용, 없으면 생성. 매번 새로 만들면 DB가 폭발하니 캐시 필수.
3. existing(dbid) — DB를 query해 고유키 → page_id 맵 확보(페이지네이션 포함).
4. 행마다: 키가 이미 있으면 PATCH /pages/{id}(갱신), 없으면 POST /pages(생성).
골격 코드 (그대로 복사 → SCHEMA·props 컬럼만 교체)
import json, urllib.request, urllib.error
from pathlib import Path
API, VER = "https://api.notion.com/v1", "2022-06-28"
TOKEN, PARENT = "{NOTION_TOKEN}", "{NOTION_PARENT_PAGE}" # ← .env에서 로드 (아래 자격증명 로드 참고)
DBID_CACHE = Path(__file__).parent / ".notion_db_id"
DB_TITLE = "내 대시보드"
def api(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{API}{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Notion-Version", VER)
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
raise RuntimeError(f"{method} {path} -> {e.code}: {e.read().decode()}")
# 컬럼 스키마 — 첫 컬럼은 반드시 title 타입 1개
SCHEMA = {
"제목": {"title": {}},
"수량": {"number": {}},
"분류": {"select": {"options": [{"name":"A","color":"green"},{"name":"B","color":"gray"}]}},
"완료": {"checkbox": {}},
"링크": {"url": {}},
"키": {"rich_text": {}}, # ← 중복 판정용 고유키
"날짜": {"date": {}},
}
def get_or_create_db():
if DBID_CACHE.exists():
dbid = DBID_CACHE.read_text().strip()
try: api("GET", f"/databases/{dbid}"); return dbid
except RuntimeError: pass
res = api("POST", "/databases", {
"parent": {"type":"page_id","page_id": PARENT},
"title": [{"type":"text","text":{"content": DB_TITLE}}],
"properties": SCHEMA,
})
DBID_CACHE.write_text(res["id"]); return res["id"]
def existing(dbid): # 키 -> page_id
out, cur = {}, None
while True:
body = {"page_size":100}
if cur: body["start_cursor"] = cur
res = api("POST", f"/databases/{dbid}/query", body)
for pg in res["results"]:
k = pg["properties"].get("키",{}).get("rich_text",[])
if k: out[k[0]["plain_text"]] = pg["id"]
if not res.get("has_more"): break
cur = res["next_cursor"]
return out
def props(row): # dict -> 노션 properties
return {
"제목": {"title":[{"text":{"content": row["title"][:90]}}]},
"수량": {"number": row.get("n",0)},
"분류": {"select":{"name": row.get("cat","A")}},
"완료": {"checkbox": bool(row.get("done"))},
"링크": {"url": row.get("url") or None},
"키": {"rich_text":[{"text":{"content": row["key"]}}]},
"날짜": {"date":{"start": row["date"][:10]}},
}
def push(rows):
dbid = get_or_create_db()
seen = existing(dbid)
new = upd = 0
for r in rows:
if r["key"] in seen:
api("PATCH", f"/pages/{seen[r['key']]}", {"properties": props(r)}); upd += 1
else:
api("POST", "/pages", {"parent":{"database_id":dbid}, "properties": props(r)}); new += 1
print(f"신규 {new} · 갱신 {upd} -> https://notion.so/{dbid.replace('-','')}")자격증명 로드 (런타임 .env 우선 → 폴백)
토큰을 코드에 박지 말고 .env에서 읽는다. 어느 머신에서 돌려도 되게 다중경로 폴백:
import os
env = {}
for sp in [Path(__file__).parent / ".env", Path("{SECRETS_ENV_PATH}")]:
if sp.exists():
for ln in sp.read_text().splitlines():
if "=" in ln and not ln.startswith("#"):
k, v = ln.split("=", 1)
env.setdefault(k.strip(), v.strip().strip('"'))
TOKEN = os.environ.get("NOTION_TOKEN") or env.get("NOTION_TOKEN")
PARENT = os.environ.get("NOTION_PARENT_PAGE") or env.get("NOTION_PARENT_PAGE")⑥ 컬럼 타입 치트시트
| 타입 | properties 값 | 스키마 |
|---|---|---|
| 제목(필수1) | {"title":[{"text":{"content":"..."}}]} | {"title":{}} |
| 텍스트 | {"rich_text":[{"text":{"content":"..."}}]} | {"rich_text":{}} |
| 숫자 | {"number": 12} | {"number":{}} |
| 선택 | {"select":{"name":"A"}} | {"select":{"options":[...]}} |
| 체크 | {"checkbox": true} | {"checkbox":{}} |
| URL | {"url":"https://..."} | {"url":{}} |
| 날짜 | {"date":{"start":"2026-06-11"}} | {"date":{}} |
⑦ 함정·기본값
1. 403 / 접근 불가 — 부모 페이지에 통합을 Connections로 추가 안 하면 토큰이 있어도 안 됨. 페이지를 옮겼다면 옮긴 위치에서도 권한 상속 확인.
2. DB 중복 생성 — .notion_db_id 캐시 파일 필수. 안 하면 실행할 때마다 새 DB가 생김.
3. title 컬럼 누락 — 모든 DB는 title 타입 컬럼이 정확히 1개 필요. 없으면 생성 400.
4. select 옵션 — 스키마에 없는 select 값도 자동 추가되긴 하지만, 색·순서를 통제하려면 스키마에 미리 선언.
5. url 빈 문자열 — ""를 보내면 400. 값이 없으면 None(null)로 보낼 것.
6. page_size 100 한계 — 행이 많으면 start_cursor로 페이지네이션(골격에 포함됨).
7. rich_text 2000자 한계 — 긴 본문은 슬라이스해서 넣을 것.
8. 컬럼(뷰) 순서 — REST는 properties 순서를 줘도 뷰 컬럼 순서를 보장하지 않는다. 순서를 강제하려면 노션 UI나 별도 뷰 설정이 필요(데이터 push 자체는 REST로 항상 가능).