Cloudflare Workers & KV 이용해서 서버리스 방문자 카운팅 API 만들기 (2/2)

쿠키 기반 중복 방문자 처리, 커스텀 도메인, CORS, 응답 캐싱 기능이 적용된 방문자 카운팅 API를 블로그에 적용하는 방법을 설명합니다.
들어가며
이전 포스팅에서 간단한 KV Store GET, PUT만으로 방문자 카운팅 api를 만들어 보았는데요, 이 방식은
- 중복 방문자 처리가 안됨
- CORS 설정이 되지 않아서 api 요청 시 access 오류
- GET 요청 - 페이지 뷰 정보 업데이트 - 응답 구조여서 응답 속도 느려짐
이런 문제점들을 가지고 있습니다.
이번 포스팅에서는 실제 배포 환경에서도 적당히 써먹을 수 있도록 쿠키를 이용한 중복 방문자 처리, CORS 설정, api 경로 분리, 응답 캐싱을 통한 성능 개선 방법을 알아보고 블로그에 적용까지 해보겠습니다.
사전 지식
쿠키(Cookie)
쿠키는 HTTP 통신과 같이 상태를 기억하지 않는(stateless) 프로토콜에서 상태 정보를 기억하기 위해 사용하는 key-value 구조의 작은 데이터 조각입니다.
직관적인 이해를 위해서는 출입증을 생각해보시면 됩니다. 건물 보안 시스템을 예로 들어서 3층까지만 출입 가능한 사람, 10층까지만 출입 가능한 사람 2명이 있다고 가정해 보겠습니다.
3층까지 출입 가능한 카드(cardId=3), 10층까지만 출입 가능한 카드(cardId=10)을 한번 발급 후 가지고 다니라고 하면, 출입마다 일일히 신원조회 하는게 아닌 카드 정보만 확인하고 바로 통과시킬 수 있겠죠?
서버도 마찬가지로 사용자가 로그인하면 서버는 출입증처럼 생긴 쿠키를 발급하고, 이 쿠키는 사용자의 브라우저에 저장됩니다.
그 다음부터 사용자가 다시 서버에 요청할 때마다 브라우저가 출입증(쿠키)을 같이 제출해주는데요, 이제 서버는 쿠키에 담긴 정보만 보고 바로 통과시켜줄 수 있게 됩니다.
물론 출입증도 잃어버리면 처음부터 다시 발급받아야 하고, 너무 많은 정보를 적어두면 위험하듯이, 쿠키도 민감한 정보는 절대 담지 말아야 합니다.
HTTP/1.1 200
Set-Cookie: id=12345; Domain=day1swhan.com; Path=/; HttpOnly; Secure; Max-Age=86400; SameSite=Strict;
사용자에게 쿠키를 발급할 때는 HTTP 응답 헤더에 Set-Cookie
옵션을 주면 되는데요, 쿠키 만들 때 사용하는 옵션들 알아보겠습니다.
-
Domain: 쿠키를 전송할 도메인을 지정하는 옵션. 도메인이 명시되면 서브도메인들도 포함됩니다.(ex.
Domain=day1swhan.com
→subdomain.day1swhan.com
요청 가능) -
Path: 쿠키를 전송할 경로를 지정하는 옵션. (ex.
Path=/
→ 모든 URL 범위에서 전송 가능,Path=/test
→/test
또는/test/...
같은 하위 범위로만 전송 가능) -
HttpOnly: JavaScript를 통해 쿠키에 접근을 방지하고, 말 그대로 HTTP(S) 통신에만 전송되게 지정하는 옵션
-
Secure: 암호화된 HTTPS 통신 시에만 전송되게 지정하는 옵션 (그래도 민감한 정보는 절대 쿠키에 저장되면 안됩니다)
-
Max-Age: 쿠키가 유지되는 시간(초). 쿠키가 저장되는 클라이언트의 시간을 기준으로 더해져서 계산됩니다. (ex.
Max-Age=3600
→ 1시간 후 자동 삭제됨) -
SameSite: 쿠키를 사용할 도메인을 지정하는 옵션 (ex.
day1swhan.com
에서api.day1swhan.com
으로 요청을 보낼 때 api 서버거Set-Cookie: SameSite=Strict; Domain=day1swhan.com
옵션으로 쿠키 설정해서 보내줌 → 도메인(day1swhan.com
) 일치 → 쿠키 저장)
CORS(Cross-Origin Resource Sharing)
CORS는 말 그대로 브라우저가 다른 출처(Cross-Origin)의 자원을 요청할 때, 그 서버에게 “저기요… 혹시 당신 리소스를 써도 될까요…?” 하고 허락을 구하는 정책이라고 생각하시면 됩니다.
CORS가 생겨난 이유와, 어떻게 작동하는지 은행 사이트를 예시로 들어보겠습니다. 은행 사이트에서 송금할 때 사용하는 api가
POST /show-me-the-money
Host: bank.com
Content-Type: application/json
Cookie: userId=12345
'{"target": "swhan", "money": "1,000,000,000"}';
이렇게 있다고 할 때(실제로 이러면 인류 문명은 석기 시대로 돌아가는 겁니다) bank.com
에 로그인한 사용자가 해커 사이트 hacker-site.com
에 접속했을 때 해커 사이트 측에서 스크립트가 작동해 사용자 식별 정보(userId=12345)를 가져다가 bank.com/show-me-the-money
로 POST 요청을 보내면 난리가 나겠죠?
하지만 핀테크 서비스를 통해 송금 하듯이, 서로 다른 출처끼리 통신이 필요한 시대에, 무작정 다른 출처(Cross-Origin) 요청을 막아버릴 순 없는 법이죠. 콘텐츠 가진 자가 왕이듯이, 리소스를 가진 자가 진짜 왕인 세상이라, 브라우저는 서버 눈치를 보며 허락을 구해야 합니다.
이 허락을 구하는 방식을 표준화 시키기 위해 탄생한 것이 바로 CORS입니다. 여기서 핵심은 **사전 요청(Preflighted Request)**이라고 볼 수 있는에요, 이게 통과되면 드디어 브라우저는 서버의 API를 호출할 자격을 얻게 됩니다.
사전 요청이란 실제 요청을 보내는 것이 안전한지 판단하기 위해 브라우저가 먼저 OPTIONS 메서드를 사용해 다른 출처의 리소스한테 HTTP 요청을 보내서 간을 보는 겁니다.
fintech-service.com
이라는 핀테크 사이트에서 api를 통해서 bank.com
에 송금 요청을 하는 상황을 생각해 봅시다.
fetch("https://bank.com/show-me-the-money", {
method: "POST",
mode: "cors", // cors 모드라고 선언해주고
credentials: "include", // cookie 값 같이 보낼거라는 선언
headers: {
"Content-Type": "application/json",
},
body: '{"target": "swhan", "money": "1,000,000,000"}',
});
핀테크 사이트에서 이 스크립트가 실행되면 브라우저는
OPTIONS /show-me-the-money HTTP/1.1
Host: bank.com
Origin: https://fintech-service.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
이렇게 OPTIONS 요청을 먼저 은행 서버에 보내서 간을 보는데요, 이 요청의 의미는 은행 서버야~
- Origin:
fintech-service.com
이라는 친구가 호출한 요청이고 - Access-Control-Request-Method: 요청 method는 POST일거야
- Access-Control-Request-Headers: 그리고 헤더에는 content-type이 설정되어있는데 내 마음(요청) 받아줄거야?
이런 뜻입니다. 이제 은행 서버가 핀테크 서비스에서 보낸 요청 받아주기로 했다면 아래처럼 응답을 돌려줍니다.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://fintech-service.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 60
이 요청의 의미는
- Access-Control-Allow-Origin: 응~
https://fintech-service.com
이 친구 api 접근 가능해~ - Access-Control-Allow-Methods: 물론 POST, GET, OPTIONS 요청만 받아줄거고
- Access-Control-Allow-Headers: 헤더 값에 추가된 Content-Type 까지는 받아줄게.
- Access-Control-Allow-Credentials: 그리고 쿠키 같이 자격 인증 정보(credentials) 보내도 됨ㅇㅇ
- Access-Control-Max-Age: 그리고 60초 동안 인증 유효하니까 그동안은 귀찮게 다시 요청하지마~
이렇게 해석하시면 됩니다. 이제 사전 요청으로 서로 간 보는게 끝났고, 서로 합의된 게 확인되었으니 실제 요청(POST)이 전송됩니다.
API 설계
프로젝트 준비
우선 가장 중요한 API 먼저 설계해 보도록 하겠습니다. 방문자 카운팅 정보 같이 실시간 정합성이 중요하지 않은 시스템에서는 응답 속도를 위해서 페이지 뷰 가져오기(GET), 페이지 뷰 업데이트(POST) 요청을 분리해서 처리해야 합니다.
- GET /view: api 호출시 querystring 값으로 넘어온 postId를 기반으로 페이지 뷰 제공
- POST /count: body에 postId 값 담아서 보내고, api 서버는 요청 헤더에 포함된 Cookie(사용자 세션 id)로 중복 방문자 확인 후 KV 저장소에 페이지 뷰 정보 PUT 요청
미리 만들어놓은 방문자 카운터 샘플 프로젝트 clone 후 어떻게 구현되어 있는지 하나씩 알아보겠습니다.
git clone https://github.com/day1swhan/visitor-counter-example.git && \
cd visitor-counter-example && \
git checkout -b dev 3960ce57 && \
npm install && \
npm run types
GET - 페이지 뷰 가져오기
페이지 뷰 정보를 빠르게 전달해주기 위해서는 view:postId
를 키 값으로 빠르게 조회해서 그대로 넘겨주면 되는데요
const routeKey = method + " " + path;
switch (routeKey) {
case "GET /view": {
const postId = query["id"];
if (!postId) {
return badRequest("Invalid Request Payload");
}
const pageViewEvent = await getPageView({ postId: postId }, env);
const headers: Record<string, string> = generateCorsHeaders(origin);
const content: Partial<PageViewEvent> = {
postId: pageViewEvent.postId,
count: pageViewEvent.count,
lastUpdate: pageViewEvent.lastUpdate,
};
return Response.json(content, { status: 200, headers: headers });
}
}
엥? 방금 전에는 CORS 요청 시 브라우저가 먼저 OPTIONS
메서드로 사전 요청(Preflighted Request) 을 보낸다고 해놓고, 여기서는 GET /view
요청에 CORS 응답 헤더를 바로 담아서 응답해버리네요?
이건 CORS에서 말하는 단순 요청(Simple Request)이라고 하는데요, GET
처럼 안전하다고 여겨지는 메서드(CORS safelisted method)에, 특정 조건(아래 링크 첨부)을 만족할 경우, 브라우저는 굳이 두 번씩 요청(Preflight)을 보내지 않습니다.
그냥 본 요청(GET)만 보내고, 서버가 응답에 교차 출처 인증에서 가장 중요한 Access-Control-Allow-Origin
헤더만 잘 달아주면, 브라우저는 응답과 동시에 “오케이~ 인증 완료!” 하고 바로 통과시킵니다.
직관적으로 이해하려면, 당근마켓에서 모르는 사람과 100만원짜리 노트북 거래할 때와 대학교 캠퍼스 안에서 만 원짜리 중고 책 거래할 때를 생각하시면 됩니다. 송금 전에 신원 확인도 하고, 톡으로 여러 번 간도 보냐(사전 요청 필요) 아니면 돈 먼저 보내고 사물함에 넣어놓은 책 알아서 찾아가냐(단순 요청) 딱 그 차이입니다
요청의 위험도나 민감도에 따라, 브라우저가 더 까다롭게 굴지, 아니면 바로 넘어가줄지를 정하는 거죠.(“요청은 하나만, 대신 응답에 CORS 인증 헤더만 잘 챙겨줘”)
단순 요청을 만족하는 특정 조건에 뭐가 더 있는지 궁금하신 분들은 MDN 홈페이지에 있는 접근 제어 시나리오 예제-단순 요청(Simple requests)을 참조해주시기 바랍니다.
OPTIONS - 믿을 수 있는 요청인지 검증
이제 단순히 정보를 전달하는 방법은 해결되었으니, 서버의 상태를 변경하는 요청(페이지 뷰 업데이트)을 위한 최소한의 안전장치인 브라우저가 먼저 보내는 OPTIONS 요청 처리 방법을 알아보겠습니다.
CORS에서 가장 중요한 건 요청이 어디서 왔는지를 나타내는 출처(origin)라고 말씀드렸죠? 이건 마치 술집에 입장할 때 신분증에 적힌 생년월일처럼, 입장 가능한 대상인지 판단하는 기준이죠. 브라우저가 "저 이 술집 들어가도 되나요?" 하고 묻는 게 바로 OPTIONS 요청입니다.
const ALLOWED_ORIGINS = ["https://blog.day1swhan.com", "http://localhost:3000"];
const routeKey = method + " " + path;
const origin = headers["origin"] || "";
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin);
// origin 검증
if (!isAllowedOrigin) {
return Response.json({ message: "Not Allowd Origin" }, { status: 403 });
}
switch (routeKey) {
case "OPTIONS /count": {
return new Response(null, {
status: 204,
headers: generateCorsHeaders(origin),
});
}
}
ALLOWED_ORIGINS
배열에 우리가 믿을 수 있는 출처를을 담아두고, 요청의 Origin
값을 검사해서 일치하면 204 No Content
, 틀리면 403 Forbidden
응답을 보내주는 방식입니다.
POST - 페이지 뷰 업데이트
위에서 Origin 검증이 끝났으니 body에 담겨서 들어온 postId
를 키 값으로
- GET: 페이지 뷰 정보 가져옴
- ADD: 가져온 정보에 + 1
- PUT: 페이지 뷰 정보 저장
이렇게 단순하게 구현할 수 있습니다.
const routeKey = method + " " + path;
switch (routeKey) {
case "POST /count": {
const { postId } = JSON.parse(body);
const pageViewEvent = await getPageView({ postId }, env);
const content = { ok: false };
try {
await putPageView({ postId, count: Number(pageViewEvent.count || 0) + 1 }, env);
content.ok = true;
} catch (e) {
console.log("putPageView error!");
}
return Response.json(content, {
status: 200,
headers: generateCorsHeaders(origin),
});
}
}
쿠키(Cookie) 기반 중복 방문자 처리
이제 POST 요청으로 페이지 뷰 업데이트가 가능하지만 조금 더 똑똑하게 요청을 처리할 필요가 있습니다. 기존 코드는 방문자가 페이지를 새로고침 하거나, 다른 포스팅 둘러보다가 돌아왔을 때도 계속 페이지 뷰가 업데이트 되는 문제를 가지고 있는데요, 저희는 쿠키(Cookie)를 이용해서 24시간동안 중복 방문자는 카운팅하지 않도록 만들어 보겠습니다.
중복 방문자 처리는 정확한 페이지 뷰라는 장점도 있지만, 쓰기 요청이라는 비싼 작업(쓰기 요청은 읽기 요청보다 100배 비싼 작업입니다)을 최대한 아낄 수 있는 생존을 위한 전략입니다.
KV Store에서 가장 저렴한 요청인 KV GET(하루 100,000 요청 무료)을 최대한 활용하기 위해서 위에서 구현한 POST 방식을 조금 수정해 보겠습니다.
const cookie = parseCookie(headers["cookie"] || "");
const routeKey = method + " " + path;
switch (routeKey) {
case "POST /count": {
const { postId } = JSON.parse(body);
const content = { ok: false };
const headers = generateCorsHeaders(origin);
const ttl = 86400;
const sid = cookie["sid"] || crypto.randomUUID().replace(/\-/g, "");
const { sessionId } = await getSessionId({ sessionId: sid, postId }, env);
if (!sessionId) {
const pageViewEvent = await getPageView({ postId }, env);
try {
await Promise.all([
putSessionId({ sessionId: sid, postId, expirationTtl: ttl }, env),
putPageView({ postId, count: Number(count || 0) + 1 }, env),
]);
content.ok = true;
} catch (e) {
console.log("Update PageView error!");
}
headers["Set-Cookie"] = `sid=${sid}; Domain=${hostname}; Path=/; HttpOnly; Max-Age=${ttl}$; SameSite=Strict;`;
}
return Response.json(content, {
status: 200,
headers: headers,
});
}
}
- 요청 헤더에 Cookie(sid)가 있는지 확인하고
- key 값으로
visit:${sid}:${postId}
설정해서 포스팅마다 독립적인 세션 정보를 확인하고 - 존재하면 이미 방문한 페이지니 쓰기 작업 없이 응답 종료
- 존재하지 않으면 첫 방문자니 세션 정보 업데이트(유효기간 24시간), 페이지 뷰 업데이트 후 응답 종료
- 이때 헤더 값에 Cookie(sid)를 심어준다.(세션 정보에 맞춰 TTL 24시간 설정)
이렇게 쿠키에 sid라는 세션 정보를 담아서 중복 방문를 처리하는것은 물론, KV Store 비용도 절약할 수 있습니다.
커스텀 도메인 배포
이전 포스팅에서 Workers 배포시 xxx.workers.dev
라는 도메인을 기본적으로 제공해주는 것을 보았는데요, 셋방 살이 싫어서 자체 블로그 구축하는 중인데, 방문자 카운팅 api도 저희 도메인으로 소유해야 마음이 편하겠죠?
개발자 친화적인 Cloudflare답게 wrangler.jsonc
파일 수정만으로 Worker를 저희 도메인에 연결할 수 있습니다.
{
"name": "visitor-counter-example",
...
"routes": [
{
"pattern": "visitor.day1swhan.com",
"custom_domain": true
}
]
}
이렇게 routes
부분에 사용할 도메인 넣어주면 끝입니다. 바로 배포해 주시고
npm run deploy
> visitor-counter-example@2.1.1 deploy
> wrangler deploy
Total Upload: 8.31 KiB / gzip: 2.32 KiB
Your Worker has access to the following bindings:
Binding Resource
env.VISITOR_COUNT_DB (xxxxxx) KV Namespace
...
Deployed visitor-counter-example triggers (1.49 sec)
visitor.day1swhan.com (custom domain)
배포 환경에서도 정상 작동하는지 검증해 보도록 하겠습니다.
사전 요청 확인(Preflighted Request)
curl -X OPTIONS \
-H 'Origin: http://localhost:3000' \
'https://visitor.day1swhan.com/count
HTTP/2 204
...
access-control-allow-origin: http://localhost:3000
access-control-allow-credentials: true
access-control-allow-headers: Content-Type
access-control-allow-methods: GET, POST, OPTIONS
access-control-max-age: 60
vary: Origin
카운팅 정보 업데이트
curl -X POST \
-H 'Origin: http://localhost:3000' \
-H 'Content-type: application/json' \
-d '{"postId":"my-second-post"}' \
'https://visitor.day1swhan.com/count'
HTTP/2 200
...
content-type: application/json
access-control-allow-origin: http://localhost:3000
access-control-allow-credentials: true
access-control-allow-headers: Content-Type
access-control-allow-methods: GET, POST, OPTIONS
access-control-max-age: 60
vary: Origin
set-cookie: sid=e91fdb02d3b4468fa974c5424482d759; HttpOnly; SameSite=Strict; Path=/; Domain=visitor.day1swhan.com; Max-Age=86400;
{"ok":true}
카운팅 정보 가져오기
curl -X GET \
-H 'Origin: http://localhost:3000' \
'https://visitor.day1swhan.com/view?id=my-second-post'
HTTP/2 200
...
access-control-allow-origin: http://localhost:3000
access-control-allow-credentials: true
access-control-allow-headers: Content-Type
access-control-allow-methods: GET, POST, OPTIONS
access-control-max-age: 60
vary: Origin
{
"postId": "my-second-post",
"count": 1,
"lastUpdate": "2025-07-26T09:30:00Z"
}
블로그에 적용하기
이제 컴포넌트 만들어서 블로그에 적용해 볼건데요, API Fetch 요청이 서버가 아닌 클라이언트 단에서 일아니니 page-view-counter.tsx
라는 Client Component 먼저 작성해 줍니다.
// src/app/_components/page-view-counter.tsx
"use client";
import { useEffect, useState } from "react";
const BASE_URL = "https://visitor.day1swhan.com";
export const GetPageView = ({ slug }: { slug: string }) => {
const [count, setCount] = useState(0);
useEffect(() => {
(async () => {
const endpoint = `${BASE_URL}/view?id=${slug}`;
const res = await fetch(endpoint, {
method: "GET",
mode: "cors",
cache: "no-cache",
});
try {
const body = await res.json();
const count = body.count || 1;
setCount(count);
} catch (e) {
setCount(0);
}
})();
}, [slug]);
if (!count) {
return null;
}
return <div className="my-12">{`views: ${count}`}</div>;
};
export const UpdatePageView = ({ slug }: { slug: string }) => {
useEffect(() => {
(async () => {
const endpoint = `${BASE_URL}/count`;
const content = { postId: slug };
await fetch(endpoint, {
method: "POST",
mode: "cors", // CORS 모드 꼭 선언해줘야 합니다.
credentials: "include", // 쿠키 보내야 하니까 include 설정
cache: "no-cache",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(content),
});
})();
}, [slug]);
return null;
};
저는 포스팅 방문시 헤더 부분(포스팅 제목, 커버 이미지) 렌더링 하는 컴포넌트(post-header.tsx
)에 간단하게 view 정보 넣어보도록 하겠습니다.
// src/app/_components/post-header.tsx
export function PostHeader({ post }: { post: Post }) {
const { slug } = post;
...
return (
<section>
<PostTitle />
<CoverImage />
<GetPageView slug={slug} />
<UpdatePageView slug={slug} />
</section>
);
}
이제 API 설계 & 배포했고, 블로그에 적용할 컴포넌트도 모두 완료되었으니 렌더링도 정상 작동하는지 확인해 보겠습니다. (로컬에서 테스트 해보실 분들은 BASE_URL 부분을 http://localhost:8787
로 바꾸고 진행하시면 됩니다)
npm run build && npm run start
이전에 작성한 Cloudflare Workers & KV 이용해서 서버리스 방문자 카운팅 API 만들기 (1/2) 들어가보면
Fetch를 통한 GET 요청 성공한 것을 볼 수 있고
렌더링도 정상적으로 잘 되고 있습니다.
API 서버에 POST 요청 시 origin, cors 정보 잘 들어간 것도 보이고
JSON으로 보낸 Body에도 postId 정보 잘 들어갔습니다.
응답 부분에도 Access-Control-Allow-*
부분들도 모두 정상이고, Set-Cookie
헤더도 정상적으로 작동하는 거 보이실 겁니다.
클라우드플레어 KV Dashboard에서도 정상적으로 페이지 뷰, 방문자 세션 정보 저장된 것을 보실 수 있습니다.
마무리
이렇게 페이지 뷰 API 하나 만들어보는 것을 통해 Workers, Workers KV, CORS, 쿠키, 효율적인 API 설계 모두 알아보았는데요, 역시 개발자들은 작은 프로젝트를 통해 익숙하지 않은 도구들이 손에 익고, 흐릿하게 알던 것들을 확실하게 기억할 수 있게 되면서 만족을 느끼는 존재인 것 같습니다. 포스팅을 두개에 나눠서 끝내려다보니 생각보다 길어졌는데요, 긴 글 읽으시느라 모두 고생하셨습니다. 감사합니다.