백엔드 서버 0대로 만든 서비스 — 프론트엔드만으론 막히는 두 순간, Cloudflare Workers로 뚫기
결론 먼저: 이 사이트(블로그와 도구들)엔 백엔드 서버가 한 대도 없습니다. EC2도, 상시 켜둔 서버도, 배포 파이프라인도 0 — 순수 정적 프론트엔드예요. 그런데도 정확한 방문 통계를 재고, CORS로 막힌 남의 API를 가져다 씁니다. 서버 0대로 어떻게? 프론트엔드만으로 서비스를 만들다 보면 딱 두 지점에서 벽을 만나는데 — 서버를 통째로 세우긴 과하고, 브라우저만으론 안 되는 자리 — 저는 거기에 Cloudflare Worker를 끼웠습니다. 서버 없이 '서버 기능'만 몰래 붙이는 셈이죠. 이 글은 프론트엔드만으로 만들다 막힌 두 순간과, 그걸 뚫은 실제 코드입니다.
Cloudflare Worker가 뭔데
한 줄로 말하면 요청이 내 서버에 닿기 전에 거치는, 엣지에서 도는 함수입니다. 서버를 상시 켜둘 필요가 없고(요청 올 때만 실행), 배포는 명령어 한 줄, 무료 티어만으로도 하루 10만 요청이 됩니다. 데이터가 필요하면 D1(Cloudflare가 주는 SQLite)을 붙이면 되고요.
정리하면 이런 자리에 맞습니다.
"풀 백엔드는 과하고, 프론트만으로는 안 되는" 딱 그 중간.
서버 프로비저닝·상시 가동·배포 인프라 없이, 요청 앞단에 로직 하나를 끼우고 싶을 때.
아래 두 벽이, 프론트엔드만으로 서비스를 만들다 제가 가장 자주 부딪힌 지점입니다.
벽 ① 프론트엔드만으론 정확한 통계가 안 잡힌다
프론트(정적 사이트)만으로는 안 되는 일들이 있습니다. 대표적으로 정확한 방문 통계가 그렇습니다. 브라우저에서 자바스크립트로 재는 애널리틱스(GA4·GoatCounter 등)는 애드블록·프라이버시 브라우저·봇에 상당수를 놓쳐요. 서버에서 재야 정확한데, 그렇다고 통계 하나 때문에 백엔드 서버를 세우는 건 배보다 배꼽입니다.
이게 Worker의 자리였습니다. 요청을 서버(엣지)에서 가로채 D1에 기록하면, 백엔드 서버 없이 서버사이드 통계가 됩니다. 실제로 이 사이트에 그렇게 붙였어요.
핵심은 응답을 먼저 그대로 돌려주고, 기록은 그 뒤에 비동기로 한다는 점(ctx.waitUntil). 방문자 체감 속도는 1밀리초도 안 느려집니다.
const response = await fetch(request); // 원본 그대로 프록시
if (request.method === 'GET' && !ASSET_RE.test(url.pathname) && isHtml) {
ctx.waitUntil(logHit(request, env, url, ua, bot, botName)); // 응답 후 비동기 기록
}
return response;
직접 붙여보면서 배운 세 가지가 있는데, 이게 이 글의 알맹이입니다.
(1) 관측 코드가 서비스보다 위에 있으면 안 된다 (fail-open)
Worker는 모든 요청 앞단에 끼는 코드예요. 로깅 로직에 버그가 나서 예외가 터지면 최악의 경우 사이트 전체가 500을 뱉을 수 있습니다. 통계 만들려다 사이트를 내리는 건 말이 안 되죠. 그래서 맨 앞에 안전장치를 깔았습니다.
async fetch(request, env, ctx) {
ctx.passThroughOnException(); // Worker가 예외를 던지면 origin 응답을 그대로 서빙
...
}
passThroughOnException()은 무슨 에러가 나든 원본 응답을 그냥 통과시켜라는 뜻입니다. 로깅이 통째로 터져도 방문자는 멀쩡한 페이지를 받고, 통계만 그 요청 하나를 놓칠 뿐이에요. 저는 이걸 fail-open(고장 나면 열린 채로)이라 부르는데, 관측(통계)은 없어도 되지만 서비스(사이트)는 떠 있어야 한다는 원칙입니다. 배포 후 일부러 강제 예외를 던져보는 테스트로 확인했어요.
(2) 개인정보는 저장하지 않으면서 방문자는 구분하기
서버사이드로 가면 IP·UA가 다 손에 들어오지만, 그대로 저장하면 개인정보를 쌓는 셈입니다. 그래서 쿠키 없이, IP 원본도 저장하지 않으면서 방문자를 구분했어요. IP·UA를 그날의 소금과 합쳐 해시만 남기는 방식입니다.
const salt = env.HASH_SALT + '|' + day; // 소금에 "날짜"를 섞음 → 매일 값이 바뀜
const buf = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(ip + '|' + ua + '|' + salt));
const visitorHash = hex(new Uint8Array(buf).slice(0, 8)); // 앞 8바이트만
소금에 날짜를 섞는 게 포인트입니다. 같은 사람도 날짜가 바뀌면 해시가 완전히 달라져서, 하루 안에서는 방문자를 구분하되 어제와 오늘을 이어 추적하는 건 불가능해요. 원본 IP는 어디에도 남지 않습니다(Plausible 같은 프라이버시 도구와 같은 계열). 대가로 "며칠에 걸친 순수 유니크"는 못 세지만, 저희는 방문자를 "하루 단위 유니크의 합"으로 정의하고 그 트레이드오프를 받아들였습니다.
(3) "미국이 한국보다 많다"는 봇이었다
배포 며칠 뒤 데이터가 이상했어요. 한국 사용자용 사이트인데 미국 41% > 한국 31%. 데이터센터에서 오는 봇이 사람으로 잡히고 있었습니다. 요즘 크롤러는 브라우저인 척 UA를 위장해서 UA만으론 구분이 안 돼요. Worker에 붙는 request.cf.asOrganization(접속 네트워크 소속)으로 걸러냈습니다.
// UA는 브라우저인데 접속처가 데이터센터(AWS/GCP/Azure...)면 위장 봇
const org = cf?.asOrganization || '';
if (org && !HUMAN_ASN_RE.test(org) && DC_RE.test(org)) return '데이터센터: ' + org;
단, 한국 통신사(SK·KT·LGU+)는 예외로 무조건 사람 처리했어요. 안 그러면 통신사 망을 탄 진짜 사용자까지 봇으로 오해하거든요. 이 예외를 넣고 국가 비율이 정상화됐습니다.
그리고 — 롤업은 cron으로 충분했다 (Workflows를 안 쓴 이유)
원본(hits)은 계속 쌓이니 하루 한 번 롤업으로 날짜×페이지별 집계 테이블을 만들고 오래된 원본을 정리합니다. 이건 그냥 cron 트리거로 처리했어요.
[triggers]
crons = ["0 19 * * *"] # 매일 19:00 UTC = 04:00 KST
여기서 한 번 고민한 게, Cloudflare에 Workflows(다단계·재시도·재개가 되는 durable execution)가 있어서 "롤업도 이걸로 짜야 하나?" 였습니다. 결론은 안 썼습니다.
Workflows가 바꾸는 건 "어떻게 실행되느냐"지 "얼마나 집계하느냐"가 아닙니다. 롤업이 무거워지는 건 데이터가 커져 훑는 양이 느는 문제인데, Workflows로 감싼다고 그 양이 줄지 않아요.
저희 규모(하루 수백~수천 조회)엔 cron + SQL 한 방이면 충분하고 더 단순합니다. durable execution이 진짜 값하는 자리는 몇 년치를 하루씩 끊어 재집계하는 백필 같은, cron 한 번으론 시간이 부족한 작업이에요. 지금 그럴 일이 없어 도입하지 않았습니다.
"좋은 도구가 있으니 써보자"가 아니라 지금 문제에 그게 필요한가를 먼저 보는 것. 이게 ①에서 얻은 제일 큰 교훈이었습니다.
벽 ② 프론트엔드에서 남의 API가 CORS로 막힌다
프론트에서 외부 API나 리소스를 fetch 하다 보면 이 벽을 만납니다.
Access to fetch at 'https://api.foo.com/...' from origin 'https://mysite.com'
has been blocked by CORS policy
CORS는 "이 응답을 다른 출처(origin)의 브라우저에서 읽어도 된다"고 서버가 허락 헤더(Access-Control-Allow-Origin)를 붙여줘야 통과합니다. 문제는 그 허락을 붙일지 말지는 상대 서버가 정한다는 것. 상대가 안 붙여주면 브라우저에선 방법이 없어요.
내 API면 — 헤더 한 줄
만약 그 API가 내 Worker라면 얘기가 간단합니다. 응답에 헤더 한 줄만 붙이면 끝이에요. 실제로 이 사이트 통계 대시보드가 다른 출처에서 통계 API를 부르는데, Worker가 이렇게 열어줍니다.
// 내 stats API 응답
headers: {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*', // ← 이 한 줄이 CORS 허락
}
남의 API면 — 프록시 Worker를 세운다
진짜 문제는 내가 못 고치는 남의 API입니다. 이때 Worker를 중간 프록시로 세웁니다. 브라우저 → (내 Worker) → 남의 API 로 우회하고, Worker가 돌아온 응답에 CORS 헤더를 붙여 브라우저에 줍니다. 서버-대-서버 호출엔 CORS가 없으니 통하는 거예요.
여기서 대부분의 튜토리얼이 위험하게 알려주는 부분이 있습니다. 아무 URL이나 대신 열어주는 "오픈 프록시"로 만들면, 그 순간 전 세계 누구나 쓰는 무료 우회 서버 + SSRF 표적이 됩니다. 그래서 저는 allowlist(허용 호스트만) + GET만 + 호출 출처 제한 + 쿠키 제거로 잠가서 만들었어요.
const ALLOW_HOSTS = ['api.foo.com']; // 대신 열어줄 호스트만
const ALLOW_ORIGINS = ['https://mysite.com']; // 이 프록시를 부를 수 있는 내 사이트만
export default {
async fetch(request) {
const origin = pickOrigin(request);
if (request.method === 'OPTIONS') // 프리플라이트
return new Response(null, { status: 204, headers: corsHeaders(origin) });
if (request.method !== 'GET' && request.method !== 'HEAD')
return json({ error: 'method not allowed' }, 405, origin);
const t = new URL(new URL(request.url).searchParams.get('url'));
if (t.protocol !== 'https:') return json({ error: 'https only' }, 400, origin);
if (!ALLOW_HOSTS.includes(t.hostname)) return json({ error: 'host not allowed' }, 403, origin);
const upstream = await fetch(t.toString(), { // 쿠키·인증 안 넘김
headers: { accept: request.headers.get('accept') || '*/*' },
cf: { cacheTtl: 300, cacheEverything: true }, // 5분 엣지 캐시
});
const headers = new Headers(upstream.headers);
Object.entries(corsHeaders(origin)).forEach(([k, v]) => headers.set(k, v));
headers.delete('set-cookie'); // 쿠키 누출 차단
return new Response(upstream.body, { status: upstream.status, headers });
},
};
프론트에서는 이렇게 부르면 됩니다.
// 직접 부르면 CORS로 막히던 걸
fetch('https://api.foo.com/data.json')
// 내 프록시를 거쳐서
fetch('https://cors.mysite.com/?url=' + encodeURIComponent('https://api.foo.com/data.json'))
정리 — 서버 없이 되는 것, 진짜 백엔드가 필요한 것
두 용도를 관통하는 감각은 이렇습니다.
| 이럴 때 | 선택 |
|---|---|
| 상시 상태·DB 트랜잭션·복잡한 도메인 로직 | 진짜 백엔드 서버 |
| 요청 앞단의 가벼운 서버사이드 로직 (로깅·집계·인증체크·리다이렉트) | Worker |
| 남의 API가 CORS를 안 열어줌 | Worker 프록시(allowlist) |
| 프론트만으로 되는 일 | 그냥 프론트 |
Worker는 만능이 아닙니다. 무거운 상태나 긴 트랜잭션이 필요하면 여전히 진짜 백엔드가 답이에요. 하지만 "풀 서버는 과하고 프론트만으론 안 되는" 그 애매한 중간 지대 — 통계 하나, CORS 하나 — 를 서버 없이 메우는 데는 이만한 게 없더라고요.
혹시 정적 사이트를 운영하면서 "이것 때문에 서버까지 세워야 하나…" 싶은 순간이 오면, Worker를 한 번 떠올려 보시길 바랍니다. 생각보다 코드가 많지 않아요 ^^
이 글은 "언제 왜 Worker인가"까지입니다. 실제로 이 통계를 처음부터 만드는 법(D1 셋업부터 코드까지)은 다음 글에서 이어집니다.
댓글