1. 문제 상황#
GitHub Actions에서 Notion-Hugo 동기화 작업을 실행하던 중 다음 오류가 발생했다.

APIResponseError: You have been rate limited. Please try again in a few minutes.
code: 'rate_limited'
status: 429
retry-after: '232'
rate_limit_reason: 'public_api_request_rate_limit'해당 오류는 GitHub Actions 자체의 실패라기보다는, Actions 실행 중 Notion API를 짧은 시간 안에 너무 많이 호출하면서 Notion 측에서 요청을 제한한 상황이다.
2. 오류의 핵심 원인#
핵심 메시지는 다음과 같다.
status: 429
code: 'rate_limited'
retry-after: '232'429 rate_limited는 API 요청이 허용량을 초과했다는 의미다.
retry-after: 232는 Notion API가 “232초 뒤에 다시 요청하라”고 응답한 것이다. 즉, 최소 약 4분 정도 기다린 뒤 다시 실행해야 한다.
이번 오류는 인증 실패나 GitHub Actions 설정 오류가 아니라, Notion API 호출량 초과 문제로 보는 것이 맞다.
3. 현재 저장소 구조에서 호출량이 많아지는 이유#
현재 notion-hugo.config.ts에서는 한 번의 동기화 작업에서 여러 Notion Database와 Page를 함께 처리한다.
databases: [
posts,
projects,
projects/ark-raiders-rl
],
pages: [
about
]즉, 한 번의 npm start 실행으로 다음 대상들이 모두 처리된다.
- posts 데이터베이스
- projects 데이터베이스
- projects/ark-raiders-rl 데이터베이스
- about 페이지
이 구조 자체가 문제는 아니지만, 각 데이터베이스 안에 글이 많거나 페이지 내부 블록, 이미지, 속성이 많으면 API 호출 수가 빠르게 증가한다.
4. 실제 처리 흐름#
현재 동기화 흐름은 대략 다음과 같다.
flowchart TD
A[GitHub Actions 실행] --> B[npm start]
B --> C[src/index.ts 실행]
C --> D[Notion Database 목록 처리]
D --> E[각 Database의 Data Source 조회]
E --> F[공개 대상 Page 목록 조회]
F --> G[각 Page 저장 처리]
G --> H[Markdown 변환]
H --> I[Page Block 조회]
H --> J[Cover 이미지 조회]
H --> K[Page Property 조회]
C --> L[개별 Notion Page 처리]이 과정에서 단순히 데이터베이스 목록만 조회하는 것이 아니라, 각 페이지의 본문 블록, 커버 이미지, 속성값까지 함께 읽게 되고, 따라서 글이 10개, 20개만 있어도 실제 API 요청 수는 훨씬 많아질 수 있다.
5. 임시 해결 방법#
가장 단순한 해결 방법은 일정 시간 기다린 뒤 GitHub Actions를 다시 실행하는 것이다.
이번 로그에서는 다음 값이 있었다.
retry-after: 232따라서 최소 232초, 즉 약 4분 이상 기다린 뒤 재실행하면 된다.
다만 이 방법은 임시 해결책이다. 동기화 대상 페이지가 많거나, GitHub Actions가 빠르게 연속 요청을 보내는 구조라면 같은 오류가 다시 발생할 수 있다.
6. 근본 해결 방향#
근본적으로는 Notion API 호출을 줄이거나, 호출 사이에 대기 시간을 넣어야 한다.
6.1 Notion API 요청 속도 제한 추가#
가장 먼저 적용할 수 있는 방법은 Notion Client에 요청 간격 제한을 넣는 것이다.
예를 들어 요청 사이에 일정 시간 대기하도록 공통 클라이언트를 만들 수 있다.
import { Client } from "@notionhq/client";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
let lastRequestAt = 0;
async function throttledFetch(input: RequestInfo | URL, init?: RequestInit) {
const minIntervalMs = Number(process.env.NOTION_REQUEST_INTERVAL_MS ?? 500);
const now = Date.now();
const waitMs = Math.max(0, lastRequestAt + minIntervalMs - now);
if (waitMs > 0) {
await sleep(waitMs);
}
lastRequestAt = Date.now();
const response = await fetch(input, init);
if (response.status === 429) {
const retryAfterSeconds = Number(response.headers.get("retry-after") ?? 60);
await sleep((retryAfterSeconds + 1) * 1000);
return fetch(input, init);
}
return response;
}
export function createNotionClient() {
return new Client({
auth: process.env.NOTION_TOKEN,
fetch: throttledFetch,
});
}이렇게 하면 Notion API 요청이 너무 빠르게 몰리는 것을 어느 정도 방지할 수 있다.
또한 429 응답이 왔을 때 Notion이 알려준 Retry-After 값을 기준으로 기다린 뒤 다시 요청할 수 있다.
6.2 불필요한 Page 재조회 줄이기#
현재 구조에서는 이미 받아온 Page 정보가 있음에도, 커버 이미지 등을 얻기 위해 같은 Page를 다시 조회하는 흐름이 있을 수 있다.
이 경우 페이지마다 API 호출이 1번씩 추가된다.
예를 들어 이미 가지고 있는 page 객체에서 cover 정보를 읽을 수 있다면, 다시 notion.pages.retrieve를 호출할 필요가 없다.
개선 방향은 다음과 같다.
export async function getCoverLinkFromPage(page: PageObjectResponse): Promise<string | null> {
if (page.cover === null) {
return null;
}
return page.cover.type === "external"
? page.cover.external.url
: await downloadAsset(page.cover.file.url, page.id, ".png");
}이렇게 하면 페이지 수만큼 발생하던 추가 조회를 줄일 수 있다.
6.3 모든 Property를 무조건 재조회하지 않기#
현재 구조에서 가장 호출량을 많이 늘릴 수 있는 부분은 Page Property 조회다.
페이지를 이미 조회하면 기본적인 property 값은 page.properties 안에 들어 있다.
그런데 모든 property에 대해 다시 API를 호출하면, 페이지 수와 property 수가 곱해져 호출량이 급격히 증가한다.
예를 들어 다음과 같은 구조는 호출량이 많아질 수 있다.
for (const property in page.properties) {
const id = page.properties[property].id;
const response = await notion.pages.properties.retrieve({
page_id: page.id,
property_id: id,
});
}글 20개에 property가 10개씩 있다면, property 조회만으로도 약 200번의 추가 요청이 발생할 수 있다.
개선 방향은 기본적으로 page.properties에서 읽을 수 있는 값은 그대로 사용하고, 별도 조회가 꼭 필요한 property만 선택적으로 요청하는 것이다.
7. Cloudflare Pages 배포 오류#
Notion API Rate Limit 문제를 수정한 뒤 GitHub Actions를 다시 실행하자, 이번에는 Cloudflare Pages 배포 단계에서 오류가 발생했다.
처음 발생한 오류는 다음과 같았다.
[ERROR] In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work.이 오류는 Notion 동기화나 Hugo 빌드 문제가 아니라, Cloudflare Pages에 배포하기 위한 인증 정보가 GitHub Actions 환경에 없어서 발생한 문제였다.
즉, Notion 콘텐츠를 가져오고 Hugo 정적 사이트를 생성하는 단계는 통과했지만, 마지막 배포 단계에서 Cloudflare에 로그인할 수 없어 실패한 것이다.
7.1 문제 상황#
GitHub Actions 로그를 보면 Cloudflare 배포 단계에서 다음 명령이 실행된다.
wrangler pages deploy public --project-name=lampseeker-blog --branch=main이 명령은 public 폴더에 생성된 Hugo 정적 파일을 Cloudflare Pages 프로젝트에 업로드하는 역할을 한다.
하지만 GitHub Actions는 사람이 직접 로그인할 수 있는 터미널 환경이 아니다.
따라서 wrangler가 Cloudflare 계정에 접근하려면 사전에 API Token이 환경변수 또는 action 입력값으로 전달되어야 한다.
처음 발생한 오류는 CLOUDFLARE_API_TOKEN이 없어서 발생했다.
7.2 원인#
Cloudflare Pages 배포에는 Cloudflare 계정 접근 권한이 필요하다.
로컬 환경에서는 wrangler login을 통해 브라우저 인증을 할 수 있지만, GitHub Actions 같은 CI 환경에서는 브라우저 로그인을 사용할 수 없다.
따라서 GitHub Actions에서는 Cloudflare API Token을 Secret으로 등록한 뒤, workflow에서 해당 Secret을 wrangler-action에 전달해야 한다.
오류 메시지의 핵심은 다음과 같다.
it's necessary to set a CLOUDFLARE_API_TOKEN environment variable즉, Cloudflare 배포 단계에서 필요한 인증 토큰이 없다는 뜻이다.
이 문제는 코드 오류가 아니라, 배포 환경 설정 문제다.
7.3 Cloudflare API Token 생성#
Cloudflare에서 Pages 배포용 Custom API Token을 생성한다.
Cloudflare Dashboard에서 다음 경로로 이동한다.
My Profile
→ API Tokens
→ Create Token
→ Custom Token토큰 설정은 다음과 같이 한다.
Token name:
lampseeker-blog-pages-deploy
Permissions:
Account / Cloudflare Pages / Edit
Account Resources:
Include / 본인 Cloudflare 계정
Client IP Address Filtering:
비워둠
TTL:
비워둠Client IP Address Filtering은 비워두는 것이 좋다.
GitHub Actions 실행 서버의 IP는 고정되어 있지 않을 수 있기 때문에, IP 제한을 걸면 오히려 배포가 실패할 수 있다.
TTL도 일단 비워둔다.
자동 배포용 토큰이므로 만료일을 따로 지정하지 않는 편이 관리하기 쉽다.
7.4 GitHub Secrets 등록#
Cloudflare에서 API Token을 생성하면 토큰 값이 한 번만 표시된다.
이 값을 복사한 뒤 GitHub 저장소의 Secret에 등록한다.
GitHub 저장소에서 다음 경로로 이동한다.
Settings
→ Secrets and variables
→ Actions
→ Secrets
→ New repository secret먼저 API Token을 다음 이름으로 등록한다.
Name:
CLOUDFLARE_API_TOKEN
Secret:
Cloudflare에서 생성한 API Token 값이후 workflow에서 accountId도 사용하고 있었기 때문에, Cloudflare Account ID도 추가로 Secret에 등록해야 했다.
Name:
CLOUDFLARE_ACCOUNT_ID
Secret:
Cloudflare Account ID 값여기서 주의할 점은 CLOUDFLARE_ACCOUNT_ID 값에 이메일을 넣으면 안 된다는 것이다.
잘못된 예시는 다음과 같다.
Rampseeker@gmail.com올바른 값은 Cloudflare Dashboard URL이나 Account 정보에서 확인할 수 있는 긴 영문/숫자 조합이다.
예를 들어 Cloudflare Dashboard URL이 다음과 같다면:
https://dash.cloudflare.com/d7374756d3a924df.../workers-and-pagesdash.cloudflare.com/ 바로 뒤에 있는 긴 문자열이 Account ID다.
7.5 GitHub Variables 등록#
이번 workflow에서는 Cloudflare 프로젝트 이름과 사이트 URL을 다음처럼 vars에서 읽고 있었다.
CLOUDFLARE_PROJECT_NAME: ${{ vars.CLOUDFLARE_PROJECT_NAME || 'lampseeker-blog' }}
SITE_BASE_URL: ${{ vars.SITE_BASE_URL || 'https://lampseeker-blog.pages.dev/' }}여기서 중요한 점은 vars를 사용한다는 것이다.
따라서 CLOUDFLARE_PROJECT_NAME과 SITE_BASE_URL은 Secrets가 아니라 Variables에 등록해야 한다.
GitHub 저장소에서 다음 경로로 이동한다.
Settings
→ Secrets and variables
→ Actions
→ Variables
→ New repository variable그리고 다음 값을 등록한다.
Name:
CLOUDFLARE_PROJECT_NAME
Value:
lampseeker-github-ioName:
SITE_BASE_URL
Value:
https://lampseeker-github-io.pages.dev/이 값들은 민감정보가 아니다.
따라서 Secret이 아니라 Variable에 등록하는 것이 맞다.
7.6 Project not found 오류#
CLOUDFLARE_API_TOKEN과 CLOUDFLARE_ACCOUNT_ID를 등록한 뒤 다시 GitHub Actions를 실행하자, 이번에는 다음 오류가 발생했다.
Project not found. The specified project name does not match any of your existing projects. [code: 8000007]이 오류는 인증 문제가 아니라, Cloudflare Pages 프로젝트 이름이 잘못되어 발생한 문제다.
GitHub Actions 로그에서는 다음 명령이 실행되고 있었다.
wrangler pages deploy public --project-name=lampseeker-blog --branch=main하지만 실제 Cloudflare Pages에 존재하는 프로젝트 이름은 다음과 같았다.
lampseeker-github-io즉, workflow는 lampseeker-blog 프로젝트에 배포하려고 했지만, Cloudflare에는 해당 이름의 프로젝트가 없었다.
그래서 Cloudflare API가 Project not found를 반환한 것이다.
7.7 Workflow 설정 수정#
Cloudflare 배포용 workflow 파일은 다음 위치에 있다.
.github/workflows/cloudflare-pages.yml해당 파일의 환경변수 부분은 다음과 같이 수정한다.
env:
HUGO_VERSION: 0.161.1
NODE_VERSION: 22
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
CLOUDFLARE_PROJECT_NAME: ${{ vars.CLOUDFLARE_PROJECT_NAME || 'lampseeker-github-io' }}
SITE_BASE_URL: ${{ vars.SITE_BASE_URL || 'https://lampseeker-github-io.pages.dev/' }}또는 GitHub Variables에 CLOUDFLARE_PROJECT_NAME, SITE_BASE_URL을 정확히 등록했다면, 기존 구조를 유지해도 된다.
배포 단계는 다음과 같은 형태가 된다.
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public --project-name=${{ env.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref_name }}이 구조에서 각 값의 역할은 다음과 같다.
CLOUDFLARE_API_TOKEN:
Cloudflare API에 접근하기 위한 인증 토큰
CLOUDFLARE_ACCOUNT_ID:
배포 대상 Cloudflare 계정 식별자
CLOUDFLARE_PROJECT_NAME:
배포할 Cloudflare Pages 프로젝트 이름
SITE_BASE_URL:
Hugo 빌드 시 사용할 사이트 기본 URL7.8 Secrets와 Variables 구분#
이번 작업에서 헷갈린 부분은 GitHub Actions의 Secrets와 Variables 구분이다.
정리하면 다음과 같다.
Secrets에 넣을 값:
- NOTION_TOKEN
- CLOUDFLARE_API_TOKEN
- CLOUDFLARE_ACCOUNT_ID
Variables에 넣을 값:
- CLOUDFLARE_PROJECT_NAME
- SITE_BASE_URLSecrets는 외부에 노출되면 안 되는 민감정보를 저장하는 곳이다.
반면 Variables는 프로젝트 이름이나 URL처럼 공개되어도 괜찮은 설정값을 저장하는 곳이다.
현재 workflow에서 vars.CLOUDFLARE_PROJECT_NAME, vars.SITE_BASE_URL을 사용하고 있다면, 해당 값은 반드시 Variables 탭에 등록해야 한다.
Secrets에 같은 이름으로 등록해도 workflow에서는 읽지 못한다.
7.9 Wrangler 경고#
배포 로그에는 다음 경고도 함께 출력되었다.
Warning: Your working directory is a git repo and has uncommitted changes
To silence this warning, pass in --commit-dirty=true이 경고는 치명적인 오류가 아니다.
Notion 동기화나 Hugo 빌드 과정에서 content/, public/ 같은 파일이 생성되거나 변경되기 때문에 Wrangler가 작업 디렉터리에 커밋되지 않은 변경사항이 있다고 알려주는 것이다.
배포 실패의 직접 원인은 이 경고가 아니라, Cloudflare Pages 프로젝트 이름 불일치였다.
필요하다면 나중에 다음 옵션을 추가해서 경고를 줄일 수 있다.
command: pages deploy public --project-name=${{ env.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref_name }} --commit-dirty=true하지만 필수 수정은 아니다.
7.10 Cloudflare Pages 배포 오류 정리#
이번 Cloudflare 배포 단계에서 확인한 문제는 총 세 가지였다.
첫 번째는 CLOUDFLARE_API_TOKEN 누락이다.
GitHub Actions에서 Cloudflare API에 접근할 수 있는 토큰이 없어서 wrangler가 실패했다.두 번째는 CLOUDFLARE_ACCOUNT_ID 누락이다.
workflow에서 accountId를 사용하고 있었지만 GitHub Secrets에 해당 값이 등록되어 있지 않았다.세 번째는 Cloudflare Pages 프로젝트 이름 불일치다.
workflow는 lampseeker-blog에 배포하려 했지만,
실제 Cloudflare Pages 프로젝트 이름은 lampseeker-github-io였다.따라서 최종 해결 방향은 다음과 같다.
1. CLOUDFLARE_API_TOKEN을 GitHub Secrets에 등록한다.
2. CLOUDFLARE_ACCOUNT_ID를 GitHub Secrets에 등록한다.
3. CLOUDFLARE_PROJECT_NAME을 GitHub Variables에 등록한다.
4. SITE_BASE_URL을 GitHub Variables에 등록한다.
5. 실제 Cloudflare Pages 프로젝트 이름과 workflow의 project-name 값을 일치시킨다.8. 권장 수정 순서#
이번 문제는 크게 두 단계로 나눌 수 있다.
첫 번째는 Notion API Rate Limit 대응이고, 두 번째는 Cloudflare Pages 배포 설정이다.
우선순위는 다음과 같다.
- Notion Client에 요청 간격 제한을 추가한다.
- Notion API에서
429가 발생하면Retry-After값을 기준으로 대기한 뒤 재시도한다. - 이미 받아온 Page 객체를 재사용하여 불필요한 Page 재조회를 줄인다.
- 모든 Page Property를 무조건
notion.pages.properties.retrieve로 다시 조회하지 않도록 수정한다. - Cloudflare에서 Pages 배포용 API Token을 생성한다.
- GitHub Actions Secrets에
CLOUDFLARE_API_TOKEN을 등록한다. - GitHub Actions Secrets에
CLOUDFLARE_ACCOUNT_ID를 등록한다. - GitHub Actions Variables에
CLOUDFLARE_PROJECT_NAME을 등록한다. - GitHub Actions Variables에
SITE_BASE_URL을 등록한다. - workflow에서
CLOUDFLARE_PROJECT_NAME이 실제 Cloudflare Pages 프로젝트 이름과 일치하는지 확인한다. - GitHub Actions를 다시 실행하여 Notion 동기화, Hugo 빌드, Cloudflare 배포가 모두 통과하는지 확인한다.
- 그래도 Notion Rate Limit이 반복되면
NOTION_REQUEST_INTERVAL_MS값을 늘린다. - 필요하다면 동기화 대상 Database를 나누거나, GitHub Actions 실행 빈도를 줄인다.
이 순서대로 처리하면 API 호출량 문제와 배포 인증 문제를 분리해서 확인할 수 있다.
9. 최종 정리#
이번 작업에서 발생한 문제는 하나가 아니라 여러 단계로 이어졌다.
- 첫 번째 문제는 Notion API Rate Limit이다.
GitHub Actions에서 Notion-Hugo 동기화를 실행하는 과정에서 Notion API 요청이 짧은 시간 안에 많이 발생했고, Notion API가 429 rate_limited 응답을 반환했다.
이를 해결하기 위해 다음과 같은 수정을 수행했다.
- Notion API 요청 사이에 일정 간격을 둔다.
- 429 응답이 오면 Retry-After 값을 기준으로 기다렸다가 재시도한다.
- 이미 가져온 Page 정보를 재사용한다.
- Page Property를 무조건 다시 조회하지 않는다.- 두 번째 문제는 Cloudflare Pages 배포 인증이다.
GitHub Actions에서 Cloudflare Pages에 배포하려고 했지만, 처음에는 CLOUDFLARE_API_TOKEN이 없어서 wrangler pages deploy 명령이 실패했다.
이후에는 CLOUDFLARE_ACCOUNT_ID도 필요하다는 점을 확인했고, 해당 값을 GitHub Secrets에 추가했다.
세 번째 문제는 Cloudflare Pages 프로젝트 이름 불일치다.
workflow에서는 lampseeker-blog라는 프로젝트에 배포하려 했지만, 실제 Cloudflare Pages 프로젝트 이름은 lampseeker-github-io였다.
그래서 Cloudflare API가 다음 오류를 반환했다.
Project not found. The specified project name does not match any of your existing projects.이를 해결하기 위해 CLOUDFLARE_PROJECT_NAME과 SITE_BASE_URL을 GitHub Actions Variables에 등록하고, 실제 Cloudflare Pages 프로젝트 이름과 일치하도록 수정했다.
전체 흐름을 정리하면 다음과 같다.
flowchart TD
A[GitHub Actions 실행] --> B[Notion 콘텐츠 동기화]
B --> C{Notion API Rate Limit 발생?}
C -- Yes --> D[Throttle 및 Retry-After 기준 재시도]
C -- No --> E[Markdown 콘텐츠 생성]
D --> E
E --> F[Hugo 정적 사이트 빌드]
F --> G[Cloudflare Pages 배포]
G --> H{CLOUDFLARE_API_TOKEN 있음?}
H -- No --> I[Cloudflare 인증 오류 발생]
H -- Yes --> J{CLOUDFLARE_ACCOUNT_ID 있음?}
J -- No --> K[Account ID 누락으로 배포 실패]
J -- Yes --> L{프로젝트 이름 일치?}
L -- No --> M[Project not found 오류 발생]
L -- Yes --> N[Cloudflare Pages 배포 성공]
I --> O[GitHub Secrets에 API Token 등록]
K --> P[GitHub Secrets에 Account ID 등록]
M --> Q[GitHub Variables의 프로젝트 이름 수정]
O --> G
P --> G
Q --> G결론적으로 이번 오류들은 저장소가 깨졌거나 Hugo 설정이 잘못되어 발생한 문제가 아니다.
문제는 다음 세 가지였다.
1. Notion API 요청량이 많아 Rate Limit에 걸렸다.
2. GitHub Actions에 Cloudflare 배포 인증 정보가 부족했다.
3. workflow의 Cloudflare Pages 프로젝트 이름이 실제 프로젝트 이름과 달랐다.따라서 안정적인 자동 배포를 위해서는 다음 두 가지가 핵심이다.
1. Notion API 요청을 천천히, 필요한 만큼만 수행한다.
2. GitHub Actions에서 외부 서비스에 접근할 때 필요한 Secret과 Variable을 정확히 구분해서 등록한다.이 두 가지를 적용하면 Notion-Hugo 기반 블로그를 GitHub Actions와 Cloudflare Pages를 통해 안정적으로 자동 배포할 수 있다.
