들어가며,
내 노션 블로그에서 여러 이슈들로 인해,
노션 데이터베이스에 있는 데이터들을 내 DB에 주기적으로 옮기는 Cron job을 만들고자 한다.
가장 먼저 생각한 것은, “어떻게 배포할 것인가” 였다.
크게 다음과 같은 방법을 생각해보았다.
클라우드 서버 (AWS EC2, GCP VM, DigitalOcean 등)
- 똑같이 cron job 설정
- 장점: 안정적, 확장성
- 단점: 서버비용 발생
Serverless
cron job을 직접 안 돌리고, 클라우드 서비스에서 스케줄링을 제공해줍니다:
- GitHub Actions → schedule 트리거로 정해진 시간마다 실행
- AWS Lambda + CloudWatch EventBridge → 함수만 배포하고 주기 실행
- Google Cloud Functions + Scheduler
- Vercel / Netlify Cron Jobs → 간단한 주기성 작업 배포 가능
사실 Serverless 방법이 더 매력적으로 보인다. → 말 그대로 서버가 필요없고, 비용도 안들기 때문에
단순 cron job은 serverless 방법으로 충분히 구현이 가능할 것이다.
하지만, 요즘 Backend 분야도 궁금해져서 서버를 배포해보고자 한다.
Next.js API vs 별도 백엔드 서버
지금 나의 블로그는 next.js로 구현이 되어 있기 때문에,
Next.js의 API Routes를 이용해서 vercel에 배포할 수 있다.
하지만 vercel로 배포할시 serverless 방식으로만 가능하기 때문에,
나는 별도의 백엔드 서버를 Python으로 만들고자 한다.
왜 PostgreSQL인가?
PostgreSQL이 MySQL보다 json 데이터를 잘 처리한다고 한다.
노션 블로그 글들의 속성들은 json 형태이기 때문에, PostgreSQL을 사용하고자 한다.
위에 블로그에 나와있는
brew services status postgresql@16 말고,
brew services info postgresql@16 로 확인할 수 있다.위 블로그에 나와있는 방법이 끝나면,
sudo -u postgres psql
-- 데이터베이스 생성 CREATE DATABASE notion_blog; -- 사용자 생성 CREATE USER myuser WITH PASSWORD 'mypassword'; -- 권한 부여 GRANT ALL PRIVILEGES ON DATABASE notion_blog TO myuser; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO myuser; -- 확인 \l -- 데이터베이스 목록 \q -- 종료
그리고 내 노션 데이터들을 저장할 테이블들을 생성하는 쿼리는 다음과 같다.
노션 response 예시
한 page에 관련된 데이터가 정말 많다..
"data": [ { "object": "page", "id": "26660608-ac17-807e-9ec2-dcb687daba7d", "created_time": "2025-09-06T03:30:00.000Z", "last_edited_time": "2025-09-06T05:29:00.000Z", "created_by": { "object": "user", "id": "7443bcca-21f2-41a6-8639-895efed0149d" }, "last_edited_by": { "object": "user", "id": "7443bcca-21f2-41a6-8639-895efed0149d" }, "cover": { "type": "file", "file": { "url": "[https://prod-files-secure.s3.us-west-2.amazonaws.com/3015db7a-50ac-4a05-9ced-f48c154c06ae/88f341ea-fe95-461b-947f-10a723d6f8e2/스크린샷_2025-09-06_오후_1.01.48.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466XKEYAQQD%2F20250906%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250906T064109Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEB8aCXVzLXdlc3QtMiJIMEYCIQCZTfDLlDiiYZnwlnLmhWBPH5fiBWOo%2FFU5RMgIX5autQIhAPjDx2mBivjOXs%2BeHXf%2FJLAcl2HbmI%2F8DPAX0g4KmmCOKogECIj%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgyF5Vmz%2FYKg3EicUzUq3APvGbEY2%2BjKtaOw6uMJ%2Fy19U6Hj%2FDDfpAsFJoG1HjsMbSrX%2BLoS3qJ0TUbMwMLnh88bN9rjELO4ku6LRa98lAV7wP4juJlHINcmq4Ihs2CDE8gYHNO1Xh66ykvFE2oWyiG6v691Nhn45BR2ZjqZPtn6QsPFJebn32FaAhhv4xkFCpDAUVNTvyfjFQAZTpUvlj11dRBzdavPAL3MQuvCDEY%2FoWo%2BKmApY7tWZhF10Osx2ij31oY%2Bs7bVMCyO%2F0IyeSbQlwlK1PHZZxv%2Ba5VwywG0h9HBLSwlp4DnHCv7khcd2Tw4dz%2BQtPLQBqriHa3NiyhHUaXsIayFbb0gHp%2FB32Iv3Msb8gPG7bZKrJftw69UDOV3C7dsfXtsRTfqKTfPl5RkJt%2FDJl42HoFEksPW24%2FYLQga7mbXchZMRp93R5K0SMkOoHcsRCy6Y2OqXUxYXBZudwrrQkyVBykfGRRNQ5m4jJHYBjVDWARAS1dZm0ouQUVMkhpEosol7A7vwAcna4OI45HOGWoYZGfFlTilqmq1OdOWwUHrmg6LbiJKwlxc%2FcmfkJ48IwReaVZOkOc%2FpKc8gVmUrKAJjegtwpT8SqoSk7%2FpSdAFzTciT2TB0IyrI5bh%2BAb2ht5bosMesjCmqu%2FFBjqkAU8%2BiOSHPAKQl5yW7bHhxyDEARPfpUWmuZshsUTHHSgq0zU4UWC8sOIhHhq8zrypoMvJIsEtAcxZOEkKwgcNdrv7XiIe5jnnXv%2Bg1%2BxhM4m5BLMqMlVYzS4Z4WpizHpyyYd7aDoBM1Se57hZYYkZKhIeQLzsfxOGt5EBhx0B%2BeD%2FmlkOQkM2NO43smzCw7oJx499Z0Shsw%2FC%2B806WzX3FZjHfyLf&X-Amz-Signature=974c21e83f6886452402cd3eac9c39baff90beb4b43c07f87a444205778a5c9e&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject](https://prod-files-secure.s3.us-west-2.amazonaws.com/3015db7a-50ac-4a05-9ced-f48c154c06ae/88f341ea-fe95-461b-947f-10a723d6f8e2/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2025-09-06_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_1.01.48.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466XKEYAQQD%2F20250906%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250906T064109Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEB8aCXVzLXdlc3QtMiJIMEYCIQCZTfDLlDiiYZnwlnLmhWBPH5fiBWOo%2FFU5RMgIX5autQIhAPjDx2mBivjOXs%2BeHXf%2FJLAcl2HbmI%2F8DPAX0g4KmmCOKogECIj%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgyF5Vmz%2FYKg3EicUzUq3APvGbEY2%2BjKtaOw6uMJ%2Fy19U6Hj%2FDDfpAsFJoG1HjsMbSrX%2BLoS3qJ0TUbMwMLnh88bN9rjELO4ku6LRa98lAV7wP4juJlHINcmq4Ihs2CDE8gYHNO1Xh66ykvFE2oWyiG6v691Nhn45BR2ZjqZPtn6QsPFJebn32FaAhhv4xkFCpDAUVNTvyfjFQAZTpUvlj11dRBzdavPAL3MQuvCDEY%2FoWo%2BKmApY7tWZhF10Osx2ij31oY%2Bs7bVMCyO%2F0IyeSbQlwlK1PHZZxv%2Ba5VwywG0h9HBLSwlp4DnHCv7khcd2Tw4dz%2BQtPLQBqriHa3NiyhHUaXsIayFbb0gHp%2FB32Iv3Msb8gPG7bZKrJftw69UDOV3C7dsfXtsRTfqKTfPl5RkJt%2FDJl42HoFEksPW24%2FYLQga7mbXchZMRp93R5K0SMkOoHcsRCy6Y2OqXUxYXBZudwrrQkyVBykfGRRNQ5m4jJHYBjVDWARAS1dZm0ouQUVMkhpEosol7A7vwAcna4OI45HOGWoYZGfFlTilqmq1OdOWwUHrmg6LbiJKwlxc%2FcmfkJ48IwReaVZOkOc%2FpKc8gVmUrKAJjegtwpT8SqoSk7%2FpSdAFzTciT2TB0IyrI5bh%2BAb2ht5bosMesjCmqu%2FFBjqkAU8%2BiOSHPAKQl5yW7bHhxyDEARPfpUWmuZshsUTHHSgq0zU4UWC8sOIhHhq8zrypoMvJIsEtAcxZOEkKwgcNdrv7XiIe5jnnXv%2Bg1%2BxhM4m5BLMqMlVYzS4Z4WpizHpyyYd7aDoBM1Se57hZYYkZKhIeQLzsfxOGt5EBhx0B%2BeD%2FmlkOQkM2NO43smzCw7oJx499Z0Shsw%2FC%2B806WzX3FZjHfyLf&X-Amz-Signature=974c21e83f6886452402cd3eac9c39baff90beb4b43c07f87a444205778a5c9e&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject)", "expiry_time": "2025-09-06T07:41:09.402Z" } }, "icon": null, "parent": { "type": "database_id", "database_id": "21b60608-ac17-80cb-9399-c98acd83522c" }, "archived": false, "in_trash": false, "properties": { "태그": { "id": "Ni%3D_", "type": "multi_select", "multi_select": [ { "id": "9ab6b777-7500-46da-8edd-533e0782830c", "name": "Backend", "color": "brown" }, { "id": "2c202d12-695e-4f87-afe3-8a72fe104ecd", "name": "Infra", "color": "blue" }, { "id": "800be3f4-08bc-469e-8c4e-f78ceefa1e32", "name": "FastAPI", "color": "purple" } ] }, "PIN": { "id": "N%7DKD", "type": "checkbox", "checkbox": false }, "관련 글": { "id": "VP_%7C", "type": "relation", "relation": [], "has_more": false }, "상태": { "id": "ht%5ED", "type": "status", "status": { "id": "67580070-130f-48a3-8389-bd66827f4c55", "name": "진행 중", "color": "blue" } }, "slug": { "id": "ptzf", "type": "rich_text", "rich_text": [] }, "작성일": { "id": "xeD~", "type": "date", "date": { "start": "2025-09-06", "end": null, "time_zone": null } }, "이름": { "id": "title", "type": "title", "title": [ { "type": "text", "text": { "content": "FastAPI - Cron job", "link": null }, "annotations": { "bold": false, "italic": false, "strikethrough": false, "underline": false, "code": false, "color": "default" }, "plain_text": "FastAPI - Cron job", "href": null } ] } }, "url": "[https://www.notion.so/FastAPI-Cron-job-26660608ac17807e9ec2dcb687daba7d](https://www.notion.so/FastAPI-Cron-job-26660608ac17807e9ec2dcb687daba7d?pvs=21)", "public_url": "[https://jblog623.notion.site/FastAPI-Cron-job-26660608ac17807e9ec2dcb687daba7d](https://www.notion.so/FastAPI-Cron-job-26660608ac17807e9ec2dcb687daba7d?pvs=21)" }
보기
-- 1) 상태 테이블 CREATE TABLE IF NOT EXISTS statuses ( id text PRIMARY KEY, name text NOT NULL UNIQUE, color text ); -- 2) 태그 테이블 CREATE TABLE IF NOT EXISTS tags ( id text PRIMARY KEY, name text NOT NULL, color text ); CREATE INDEX IF NOT EXISTS idx_tags_name ON tags (name); -- 3) 페이지 테이블 CREATE TABLE IF NOT EXISTS notion_pages ( id text PRIMARY KEY, database_id text NOT NULL, url text, public_url text, created_time timestamptz NOT NULL, last_edited_time timestamptz NOT NULL, created_by_user_id text, last_edited_by_user_id text, archived boolean NOT NULL DEFAULT false, in_trash boolean NOT NULL DEFAULT false, cover_url text, cover_expiry_time timestamptz, icon text, pin boolean NOT NULL DEFAULT false, status_id text REFERENCES statuses(id) ON DELETE SET NULL, slug text, title text, written_date date, raw_properties jsonb NOT NULL DEFAULT '{}'::jsonb, synced_at timestamptz NOT NULL DEFAULT now() ); -- 자주 쓰는 검색/정렬을 위한 인덱스 CREATE INDEX IF NOT EXISTS idx_pages_parent_db ON notion_pages (database_id); CREATE INDEX IF NOT EXISTS idx_pages_status_id ON notion_pages (status_id); CREATE INDEX IF NOT EXISTS idx_pages_created_time ON notion_pages (created_time DESC); -- 같은 데이터베이스 내 slug 유니크(널은 허용) CREATE UNIQUE INDEX IF NOT EXISTS uq_pages_slug ON notion_pages (database_id, slug) WHERE slug IS NOT NULL; -- 4) 페이지-태그(M:N) CREATE TABLE IF NOT EXISTS page_tags ( page_id text NOT NULL REFERENCES notion_pages(id) ON DELETE CASCADE, tag_id text NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (page_id, tag_id) ); CREATE INDEX IF NOT EXISTS idx_page_tags_tag_id ON page_tags (tag_id); -- 5) 페이지 관계(관련 글) CREATE TABLE IF NOT EXISTS page_relations ( from_page_id text NOT NULL REFERENCES notion_pages(id) ON DELETE CASCADE, to_page_id text NOT NULL REFERENCES notion_pages(id) ON DELETE CASCADE, PRIMARY KEY (from_page_id, to_page_id) ); CREATE INDEX IF NOT EXISTS idx_page_relations_to ON page_relations (to_page_id);
왜 FastApi인가?
일단 python 백엔드 프레임워크를 사용하는 이유는,
나는 장기적으로 딥러닝 모델도 배포를 해보고 싶은데 주로 python으로 모델을 개발하므로
python 백엔드 로직을 가질 때 이점이 있을 수 있다고 생각한다.
그리고 python 백엔드 프레임워크는 주로 Django, Flask, FastAPI를 사용한다고 한다.
내 프론트 코드를 next.js로 개발이 되어있고, FastAPI가 마침 API 중심 프레임워크라서,
FastAPI를 공부해보려고 한다.
폴더 구조
fastAPI는
npx create-react-app 처럼 기본적으로 폴더 구조들을 생성해주는 명령어는 없다고 한다.fastapi-app/ ├─ app/ │ ├─ main.py # FastAPI 앱 엔트리 포인트 │ ├─ models.py # DB 모델(SQLAlchemy) │ ├─ schemas.py # Pydantic 모델 (데이터 검증) │ ├─ crud.py # DB 관련 함수들 (생성/조회/수정/삭제) │ ├─ api/ │ │ ├─ __init__.py │ │ └─ endpoints.py # 라우터들 │ └─ core/ │ ├─ config.py # 환경변수, 설정 │ └─ database.py # DB 연결 ├─ tests/ # 유닛 테스트 ├─ requirements.txt ├─ Dockerfile ├─ docker-compose.yml └─ .env
파일별 기준
app/main.py: 앱 엔트리, 라우터 include, 정적 파일 마운트, 최소 부트스트랩만 유지
app/core/database.py: 인프라 계층- DB 연결 설정(create_engine, SessionLocal, Base)
- 세션 유틸(session_scope), 테이블 조회/프린트
app/models.py: 도메인/영속 계층- SQLAlchemy 모델(Status, Tag, NotionPage, page_tags)
- DB 스키마와 직접 1:1 대응
app/schemas.py: 전송 계층(계약)- Pydantic 모델(PostTag, PostCard)로 요청/응답 스키마 정의
- DB 모델과 분리해 API 응답 모양을 안정적으로 유지
app/crud.py: 응용 서비스(쿼리/명령)- DB 세션을 받아 도메인 조회/갱신 함수 제공(list_posts)
- 비즈니스 규칙(예: is_deleted=False 필터) 캡슐화
app/api/endpoints.py: 프레젠테이션(API)- FastAPI 라우터 정의(/notion/posts, /notion/sync)
- CRUD 호출 + schemas로 직렬화
app/api/_init_.py: 패키지 마커(필요 시 라우터 재노출 용도)
app/notion.py: 외부 연동(Integration)- Notion API 호출, 데이터 변환, 로컬 커버 저장
- DB 반영은 session_scope + models 사용
Main Feature
🔽 이미지 다운 후 내 스토리지에 저장
from fastapi.staticfiles import StaticFiles from pathlib import Path # Serve static files (e.g., downloaded cover images) # Ensure base static directory exists Path("static").mkdir(parents=True, exist_ok=True) app.mount("/static", StaticFiles(directory="static"), name="static")
# Try downloading the cover locally (Notion provides time-limited signed URLs) local_cover_path = None if cover_url: try: resp = requests.get(cover_url, timeout=15) resp.raise_for_status() content_type = resp.headers.get("Content-Type", "") ext = "" if "image/" in content_type: ext = "." + content_type.split("/")[-1].split(";")[0] else: ext = ".jpg" filename = f"{page_id}{ext}" file_path = static_dir / filename with open(file_path, "wb") as f: f.write(resp.content) local_cover_path = f"/static/covers/{filename}" except Exception: local_cover_path = None
🔽 API 호출 시간 측정
@app.middleware("http") async def log_process_time(request: Request, call_next): start = perf_counter() response = await call_next(request) duration = perf_counter() - start response.headers["X-Process-Time"] = f"{duration:.3f}s" try: print(f"{request.method} {request.url.path} {response.status_code} - {duration*1000:.1f} ms") except Exception: pass return response
이
미들웨어를 이용하면, 다음과 같이 api를 호출 성공하는데 사용한 시간이 헤더에 출력된다.
지금 위 사진은 notion database와 내 로컬 DB를 동기화하는데 쓰인 시간이다.
생각보다 너무 오래 걸려서 어떻게 하면 시간을 단축할 수 있을까 고민해보았다.
🔽 API 호출 시간 단축하기
노션 데이터베이스의 last_edited_time과 내 DB의 last_edited_time이 다르면 동기화하도록 만들었다.

36.128s → 1.721s 로 단축해냈다!!
🔽 Cron Job 생성하기 → Next Step
배포하기
🔽 아래 링크에 정리해보았다.


