목차
발단: 유튜브 영상과 찻주전자의 만남
블로그 메인 화면에 실제로 작동하는 API 테스터 기능을 넣고 싶었습니다. 마침 HTTP 상태 코드에 관한 영상을 보다가 만우절 장난 스펙에서 시작해 HTTP 표준 유산이 된 418 I'm a teapot (나는 찻주전자다) 에러 코드를 접했습니다.
“말차 코딩 블로그”라는 콘셉트에 잘 맞을 것 같았습니다. 메인 화면 API 테스터에서 ’차(Tea)’를 우려내는 블로그 서버에 ‘커피(Coffee)’ 추출 명령을 내리면, 이스터에그 경고와 함께 주전자가 보글거리는 전용 페이지로 이동하는 구조를 구상하고 Gemini와 구현 방향을 논의했습니다.
기획 및 아키텍처 설계 (Gemini & Antigravity)
이 프로젝트는 아이디어를 함께 빠르게 확장하는 Gemini, 코드 작성을 돕는 Antigravity와 함께 진행했습니다.
1. 첫 설계 논의 (Gemini)
Gemini와 418 에러의 기술적 실현 방법을 논의했습니다. 정적 사이트(Astro static) 호스팅인 Cloudflare Pages 환경에서 실제 HTTP 418 코드를 클라이언트에 전달하려면 Cloudflare Pages의 백엔드 기능인 Functions를 사용하는 것이 좋겠다는 조언을 얻었습니다. OpenAPI 스타일의 명세 페이지 설계도 정리했습니다.
2. Antigravity에게 기능 구현 요청
정리한 기획을 바탕으로 Antigravity에게 다음과 같은 프롬프트를 보냈습니다.
“홈페이지에 api 통신이 가능한 기능을 넣고 싶어. 말차를 우리는 느낌의 ai 코딩 블로그라는 느낌에 맞게, coffee 라는 api를 받으면 418 I’m a teapot 이라는 결과를 출력하고 418 이스터에그 페이지로 랜딩하는 구조를 만들고 싶어. 푸터에 open api 처럼 사용 가능한 api 통신 목록 페이지를 만들고 싶어.”
3. 네 번째 대안: Cloudflare Functions
Antigravity는 처음에 3가지 구현 계획을 제안했습니다.
- 방법 1: Cloudflare
_headers또는_redirects파일 방식 - 방법 2: 독립적인 Cloudflare Worker 프록시 배포 방식
- 방법 3: Astro를 SSR(Server-Side Rendering) 모드로 변환하는 방식
하지만 이 제안들에는 처음에 고려한 Cloudflare Pages Functions가 빠져 있었습니다. 이에 백엔드를 단순하게 유지할 수 있는 “방법 4: Cloudflare Pages Functions” 방식으로 직접 구현해 달라고 요청해 개발을 시작했습니다.
구현 및 디버깅 로그
로컬과 테스트 환경을 조율하며 5단계에 걸쳐 작업을 완성했습니다.
1단계: 다도 API 기본 구현
Antigravity가 다음 기본 구조를 구현했습니다.
- 프로덕션 Edge 백엔드:
functions/api/brew/디렉터리 내 API 파일 구성 - 로컬 Mock API: 로컬 개발 시 에러 방지용
src/pages/api/brew/Astro API 라우터 구축 - UI 컴포넌트: 메인 화면 테스터 콘솔, 문서 페이지(
/api-docs), 푸터 링크 연동 - 이스터에그: 주전자 스팀 애니메이션이 담긴 418 에러 랜딩 페이지(
/418)
개발 도중 임시로 추가된 matcha, sencha, hojicha 등의 200 OK 테스트 엔드포인트를 보았습니다. 단순 텍스트 대신 실제 사이트 데이터를 실시간으로 연동해 보여주는 통계 API로 리팩토링하기로 결정했습니다.
2단계: 실시간 블로그 메타데이터 API로의 전환
블로그 포스팅과 업데이트 릴리즈 로그를 API 출력과 매핑하기 위해 Gemini와 응답 구조를 의논했습니다. 말차 브랜드 특성에 맞춰 flavor_depth 파라미터를 추가하여 다음 형태로 출력 규격을 정했습니다.
- matcha.json: 총 블로그 글 수, 최신 글 정보 /
flavor_depth: "Deep & Heavy (100% Full Leaf)" - sencha.json: 총 업데이트 수, 최신 버전 로그 /
flavor_depth: "Light & Refreshing (75% Crisp)" - hojicha.json: 블로그의 핵심 기술 스택 정보 /
flavor_depth: "Roasted & Cozy (50% Warm)" - coffee.json: 티팟 예외 에러 /
flavor_depth: "None (0% - Teapot Exception)"
3단계: 경로 매핑 및 브라우저 캐시 버그 수정
버그 #1: 로컬 개발 서버 404 HTML 반환 오류
Astro 정적 모드에서는 확장자가 맞아야 정적 파일 매핑이 매끄럽습니다. /api/brew/matcha를 fetch할 때 Vite dev server가 404 HTML 문서를 반환해 파싱 에러가 발생했습니다. 호출 경로와 Edge 스크립트 파일 형식을 .json 확장자를 붙인 /api/brew/matcha.json 형태로 수정해 해결했습니다.
버그 #2: 브라우저 뒤로가기(bfcache) 시 UI 락 현상
coffee 호출 후 418 페이지로 갔다가 브라우저 뒤로가기를 하면 터미널 화면이 에러 상태에 멈춰 있었습니다. 브라우저 캐시 복원 시점을 감지하는 pageshow 리스너를 작성해 뒤로가기 진입 시 UI 상태를 리셋했습니다.
4단계: 트래픽 과부하 및 보안 처리 (429 & 403)
UI 비동기 락과 처리율 제한 (429)
Gemini와 API 남용 방지책을 의논한 뒤, 요청 중에는 버튼을 비활성화하고 BREWING...으로 문구를 바꾸도록 UI를 제어했습니다. 또한, Edge 단에서 동일 IP 기준 1.5초 내 연속 호출 시 429 Too Many Requests를 반환하도록 레이트 리미터를 구축했습니다.
“찻잎이 너무 뜨겁습니다! 차를 다시 우려내려면 2초간 식혀주세요. 🥵”
버그 #3: 로컬 개발 환경 429 시뮬레이션 지원 Edge 함수 기반 리미터는 로컬 dev 서버에서 작동하지 않아 연타 방지가 안 되는 것을 확인했습니다. 로컬용 Astro API 라우터에도 인메모리 맵 기반의 리미터 로직을 이식했습니다.
CORS 및 외부 호출 차단 (403)
외부 스크립트로 백엔드를 무단 호출하는 것을 막기 위해, 요청의 Referer 호스트가 본 사이트와 다를 경우 차단하는 403 Forbidden 보안을 추가했습니다.
5단계: 빌드 경고 및 로컬 403 오작동 해결
버그 #4: Astro.request.headers 정적 분석 경고 우회
Astro는 정적 빌드 시점에 API 소스코드에 request.headers 참조가 있으면 Prerender 경고를 출력합니다. 컴파일러 경고를 우회하기 위해 request['headers'] 인덱스 바인딩을 적용하고 import.meta.env.DEV 조건문으로 감쌌습니다.
버그 #5: 로컬 API 테스터 403 출력 에러 해결
로컬 개발 중에 브라우저의 localhost:4321 주소와 Vite 서버가 인식하는 루프백 IP 127.0.0.1 간의 호스트네임 차이 때문에 403 Forbidden 차단이 오작동했습니다. 로컬 환경은 안전하므로 로컬 API 파일에서는 헤더 읽기 및 Referer 검증을 제거하고, "local-user" 고정 키 기반의 429 시뮬레이션만 남겨 문제를 근본적으로 해결했습니다.
핵심 코드 구현 분석
이 기능을 자신의 정적 호스팅 프로젝트에 적용하고 싶은 개발자들을 위한 핵심 소스코드입니다.
1. Cloudflare Pages Functions 백엔드 설계
실제 프로덕션 환경의 Cloudflare Edge 단에서 구동되는 라우팅 파일입니다. Same-Origin Referer 검증(403), IP 기반 Rate Limit(429), 그리고 coffee.json 요청 시의 418 처리를 한 호스트 안에서 동적으로 처리합니다.
// functions/api/brew/[drink].json.js
const ipRequests = new Map();
export async function onRequest(context) {
const { request, params } = context;
const drink = params.drink; // e.g., "matcha"
// 1. Same-Origin Referer 검증 (403)
const referer = request.headers.get("Referer");
const urlObj = new URL(request.url);
let isSameOrigin = false;
if (referer) {
try {
const refUrl = new URL(referer);
if (refUrl.host === urlObj.host) isSameOrigin = true;
} catch (e) {}
}
if (!isSameOrigin) {
return new Response(
JSON.stringify({ error: "403 Forbidden", message: "외부 무단 호출 차단" }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
}
// 2. IP 기반 처리율 제한 (429)
const ip = request.headers.get("CF-Connecting-IP") || "anonymous";
const now = Date.now();
const lastRequestTime = ipRequests.get(ip) || 0;
if (now - lastRequestTime < 1500) {
return new Response(
JSON.stringify({ error: "429 Too Many Requests", message: "찻잎이 너무 뜨겁습니다!" }),
{ status: 429, headers: { "Content-Type": "application/json" } }
);
}
ipRequests.set(ip, now);
// 3. 418 Teapot 예외 처리
if (drink === 'coffee') {
return new Response(
JSON.stringify({ status: 418, error: "418 I'm a teapot", message: "We only brew tea!" }),
{ status: 418, headers: { "Content-Type": "application/json" } }
);
}
// 4. 정상 통과 시 Astro 빌드 타임 정적 JSON 자원 반환
try {
return await context.env.ASSETS.fetch(request);
} catch (err) {
return new Response(JSON.stringify({ error: "Internal error" }), { status: 500 });
}
}
2. Astro 빌드 타임 동적 콘텐츠 바인딩 엔드포인트
Astro 프로젝트 빌드 시 콘텐츠 컬렉션을 조회하여 통계 데이터를 가진 정적 파일로 내려주는 동시, 로컬 개발용 시뮬레이터를 포함하는 핵심 파일입니다.
// src/pages/api/brew/[drink].json.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
return [
{ params: { drink: 'matcha' } },
{ params: { drink: 'sencha' } },
{ params: { drink: 'hojicha' } },
{ params: { drink: 'coffee' } },
];
}
const ipRequests = new Map<string, number>();
export const GET: APIRoute = async ({ params, request }) => {
const drink = params.drink;
// 로컬 개발 모드용 429 시뮬레이션 (빌드 시점 실행 차단)
if (import.meta.env.DEV) {
const now = Date.now();
const lastRequest = ipRequests.get("local-user") || 0;
if (now - lastRequest < 1500) {
return new Response(
JSON.stringify({ status: 429, message: "찻잎이 너무 뜨겁습니다!" }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
);
}
ipRequests.set("local-user", now);
}
// 빌드 타임에 호출되어 생성될 실제 데이터 가공
if (drink === 'matcha') {
const allPosts = await getCollection('blog');
const sortedPosts = allPosts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const latestPost = sortedPosts[0];
return new Response(
JSON.stringify({
status: 200,
drink: "matcha",
flavor_depth: "Deep & Heavy (100% Full Leaf)",
statistics: {
totalPosts: allPosts.length,
latestPost: latestPost ? { title: latestPost.data.title, date: latestPost.data.pubDate } : null
}
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// (sencha, hojicha, coffee 코드 추가 구성...)
};
3. 비동기 UI 연타 락 및 bfcache 대응 스크립트
사용자 편의성과 클라이언트 리셋을 담당하는 브라우저 스크립트의 알짜배기 부분입니다.
// src/components/home/ApiPlayground.astro 스크립트 일부
brewBtn.addEventListener('click', async () => {
// 1. 통신 시작 시 비동기 락 처리
btnText.innerText = "BREWING...";
brewBtn.disabled = true;
brewBtn.classList.add('opacity-50');
try {
const response = await fetch(`/api/brew/${drink}.json`);
// (화면 로깅 및 리다이렉트 처리...)
} finally {
// 2. 통신 종료 시 락 해제
btnText.innerText = "BREW()";
brewBtn.disabled = false;
brewBtn.classList.remove('opacity-50');
}
});
// 3. 브라우저 뒤로가기 시 잠금 및 스크린 상태 초기화 (bfcache 리셋)
window.addEventListener('pageshow', (event) => {
if (event.persisted || (window.performance && window.performance.navigation.type === 2)) {
teapotAlert.classList.add('hidden');
btnText.innerText = "BREW()";
brewBtn.disabled = false;
brewBtn.classList.remove('opacity-50');
}
});
마치며
만우절 418 에러를 구현하겠다는 가벼운 생각에서 시작했지만, 실제 빌드 환경과 로컬 환경을 매끄럽게 연동하고 보안 필터를 거치는 과정에서 다양한 디버깅 지점들을 거쳤습니다.
Gemini와 구조적 방향을 잡고 Antigravity를 통해 세부 코드를 조립하며, 개발 과정에서 발생하는 로컬/배포 서버 환경 격차를 교차 검증하고 잡아나간 덕분에 깔끔하게 빌드를 통과시킬 수 있었습니다. 정적 빌드 환경에서 Edge 컴퓨팅과 클라이언트 스크립트를 적절히 융합해 가는 과정이 재미있었습니다.