이번 포스트에서는 여러분의 웹사이트에 대기실(waiting room) 페이지를 구현하는 방법을 단계별로 살펴봅니다.
왜 대기실이 필요할까요?
웹사이트에 방문자가 많다는 것은 일반적으로 좋은 일이지만, 항상 그런 것은 아닙니다. 갑작스러운 트래픽 폭증은 애플리케이션을 쉽게 마비시켜 서비스 전체가 중단될 수 있습니다. 대기실은 이러한 트래픽 급증 상황에서 유입량을 조절하고 리소스를 보호할 수 있는 검증된 솔루션입니다. Cloudflare Waiting Room은 훌륭한 선택지이지만, 아쉽게도 비즈니스(Business) 및 엔터프라이즈(Enterprise) 요금제에서만 제공됩니다. 하지만 걱정하지 마세요. 이 글에서는 Cloudflare Workers와 Upstash Redis를 활용해 어떤 종류의 웹사이트든 대기실을 직접 구축하는 방법을 소개합니다.
작동 원리
대기실은 두 가지 핵심 파라미터로 제어합니다.
- 최대 세션 지속 시간(Max session duration): 방문자가 웹사이트에서 유휴 상태로 얼마나 오래 머물 수 있는가?
- 최대 사이트 용량(Max website capacity): 웹사이트가 동시에 몇 명의 방문자까지 허용할 수 있는가?
방문자가 웹사이트에 접속하면 고유한 키(세션 키와 유사한 형태)를 생성해 Redis에 저장합니다. 이때 이 키에는 최대 세션 지속 시간에 해당하는 만료 시간을 함께 설정합니다. 방문자를 사이트에 들여보내기 전에 Redis의 dbsize를 확인하고, 현재 크기가 최대 사이트 용량보다 크면 해당 방문자를 대기실로 안내합니다. 대기실은 정적 HTML 페이지지만 30초마다 자동으로 새로고침되며, 즉 30초마다 한 번씩 자리가 날 때 방문자가 웹사이트에 입장할 수 있습니다.
생성된 고유 키는 방문자의 요청에 쿠키로도 기록됩니다. 따라서 같은 방문자의 모든 요청에는 동일한 키가 담기게 됩니다. 이는 용량이 가득 찬 상황에서 해당 방문자가 이미 사이트 내부에 유효한 세션을 보유하고 있는지 확인하기 위해 반드시 필요합니다. 방문자 입장에서는 Redis 키스페이스에 자신의 키가 존재하는 한, 용량이 가득 차 있더라도 웹사이트에 계속 머물 수 있습니다. 반면 최대 세션 지속 시간보다 오래 유휴 상태로 있으면 키가 Redis에서 삭제되며, 이후 요청 시점에 용량이 가득 차 있다면 대기실로 되돌려집니다.
이 로직은 Cloudflare Workers에서 구현하고, Redis 저장소로는 Upstash를 사용합니다. 이러한 기술 선택의 배경을 자세히 살펴보겠습니다.
왜 Cloudflare Workers인가?
대기실 구현은 웹사이트로 향하는 모든 요청을 가로채야 하므로, 성능 오버헤드는 최소화되어야 합니다. Cloudflare Workers는 Cloudflare의 글로벌 엣지 인프라를 활용하기 때문에 전 세계 어디서나 최소 지연 시간을 보장합니다. 또한 AWS Lambda와 달리 콜드 스타트(cold start) 문제가 없으며, 서버리스 기술이기 때문에 확장성 역시 걱정할 필요가 없습니다.
왜 Upstash Redis인가?
새로운 방문자를 받아들이기 전에 현재 사이트의 접속 규모를 매번 확인해야 합니다. Cloudflare Workers는 상태를 저장하지 않는(stateless) 환경이므로 이 정보를 외부에 보관해야 하는데, 낮은 지연 시간이라는 강점 때문에 Redis가 가장 적합한 선택입니다. 하지만 기존 Redis 서비스들은 TCP 기반 연결을 요구하며, Cloudflare Workers는 TCP 연결을 지원하지 않습니다. Upstash는 내장 REST API를 제공하는 유일한 Redis 서비스이며, 글로벌 복제(Global replication) 덕분에 전 세계 어디에서나 낮은 지연 시간을 경험할 수 있습니다.
단계별 구현
아래에서 프로젝트를 단계별로 구현해 보겠습니다. 프로젝트를 클론해 바로 여러분의 웹사이트에 대기실을 설정하고 싶다면 소스 코드의 README에 나온 절차를 따르면 됩니다.
1. 프로젝트 설정
wrangler로 프로젝트를 생성합니다.
wrangler generate waiting-room
그다음 의존성을 설치합니다.
npm install cookie upstash@redis
2. wrangler.toml 업데이트
타입(type)을 업데이트합니다.
type = "webpack"
Cloudflare 계정 ID를 설정합니다. 계정 ID를 찾는 방법은 여기를 참고하세요.
account_id = "REPLACE_HERE"
다음 변수들을 추가합니다.
[vars]
UPSTASH_REDIS_REST_TOKEN = "REPLACE_HERE"
UPSTASH_REDIS_REST_URL = "REPLACE_HERE"
TOTAL_ACTIVE_USERS = 10
SESSION_DURATION_SECONDS = 30
Upstash 콘솔에서 글로벌(Global) 데이터베이스를 생성해야 합니다. 콘솔에서 REST 토큰과 URL을 복사해 붙여넣기만 하면 됩니다. 이때 Redis 데이터베이스는 초기에 비어 있어야 하며, 이 애플리케이션 전용으로 사용되어야 합니다.
TOTAL_ACTIVE_USERS와 SESSION_DURATION_SECONDS 값은 여러분의 서비스 요구 사항에 맞게 조정하세요.
3. index.js
index.js는 Cloudflare Workers의 구현 파일로, 모든 로직이 이 파일에 들어갑니다. 아래 코드를 복사해 붙여넣으세요.
import { parse } from "cookie";
import { Redis } from "@upstash/redis/cloudflare";
const redis = Redis.fromEnv();
addEventListener("fetch", (event) => {
event.respondWith(
handleRequest(event.request).catch(
(err) => new Response(err.stack, { status: 500 })
)
);
});
const COOKIE_NAME_ID = "__waiting_room_id";
const COOKIE_NAME_TIME = "__waiting_room_last_update_time";
const init = {
headers: {
Authorization: "Bearer " + UPSTASH_REDIS_REST_TOKEN,
},
};
async function handleRequest(request) {
const { pathname } = new URL(request.url);
if (!pathname.startsWith("/favicon")) {
const cookie = parse(request.headers.get("Cookie") || "");
let userId;
if (cookie[COOKIE_NAME_ID] != null) {
userId = cookie[COOKIE_NAME_ID];
} else {
userId = makeid(8);
}
const size = await redis.dbsize();
console.log("current capacity:" + size);
// there is enough capacity
if (size < TOTAL_ACTIVE_USERS) {
return getDefaultResponse(request, cookie, userId);
} else {
// site capacity is full
const user = await redis.get(userId);
if (user === "1") {
// the user has already active session
return getDefaultResponse(request, cookie, userId);
} else {
// capacity is full so the user is forwarded to waiting room
return getWaitingRoomResponse(userId);
}
}
} else {
return fetch(request);
}
}
async function getDefaultResponse(request, cookie, userId) {
// uncomment below to test the function with a static html content
// const newResponse = new Response(default_html)
// newResponse.headers.append('content-type', 'text/html;charset=UTF-8')
const response = await fetch(request);
const newResponse = new Response(response.body, response);
const now = Date.now();
let lastUpdate = cookie[COOKIE_NAME_TIME];
if (!lastUpdate) lastUpdate = 0;
const diff = now - lastUpdate;
const updateInterval = (SESSION_DURATION_SECONDS * 1000) / 2;
if (diff > updateInterval) {
await redis.setex(userId, SESSION_DURATION_SECONDS, 1);
newResponse.headers.append(
"Set-Cookie",
`${COOKIE_NAME_TIME}=${now}; path=/`
);
}
newResponse.headers.append(
"Set-Cookie",
`${COOKIE_NAME_ID}=${userId}; path=/`
);
return newResponse;
}
async function getWaitingRoomResponse(userId) {
const newResponse = new Response(waiting_room_html);
newResponse.headers.set("content-type", "text/html;charset=UTF-8");
return newResponse;
}
function makeid(length) {
let result = "";
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
const waiting_room_html = `
<title>Waiting Room</title>
<meta http-equiv='refresh' content='30' />
<style>*{box-sizing:border-box;margin:0;padding:0}body{line-height:1.4;font-size:1rem;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif;padding:2rem;display:grid;place-items:center;min-height:100vh}.container{width:100%;max-width:800px}p{margin-top:.5rem}</style>
<div class='container'>
<h1>
<div>You are now in line.</div>
<div>Thanks for your patience.</div>
</h1>
<p>We are experiencing a high volume of traffic. Please sit tight and we will let you in soon. </p>
<p><b>This page will automatically refresh, please do not close your browser.</b></p>
</div>
`;
const default_html = `
<title>Waiting Room Demo</title>
<style>*{box-sizing:border-box;margin:0;padding:0}body{line-height:1.4;font-size:1rem;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif;padding:2rem;display:grid;place-items:center;min-height:100vh}.container{width:100%;max-width:800px}p{margin-top:.5rem}</style>
<div class="container">
<h1>
<div>Waiting Room Demo</div>
</h1>
<p>
Visit this site from a different browser, you will be forwarded to the waiting room when the capacity is full.
</p>
<p> Check <a href={"https://github.com/upstash/waiting-room"} style={{"color": "blue"}}>this project </a> to set up a waiting room for your website.</p>
</div>
`;
위 코드에서 waiting_room_html 변수는 자유롭게 수정할 수 있습니다. 이 변수는 대기실 페이지의 정적 HTML을 담고 있습니다.
handleRequest 메서드는 웹사이트의 가용 여부에 따라 getDefaultResponse 또는 getWaitingRoomResponse를 반환합니다.
4. 로컬에서 실행
대기실을 로컬에서 테스트하려면 용량을 1로, 세션 지속 시간을 30초로 설정하는 것이 편리합니다. 그런 다음 아래 명령어를 실행합니다.
wrangler dev
이제 Chrome에서 https://127.0.0.1:8787/ 을 열면 다음과 같은 화면이 보입니다.

그다음 같은 URL을 Safari(또는 Chrome 시크릿 모드)에서 열면 다음과 같이 대기실 화면이 표시됩니다.

30초 이상 기다리면 대기실 페이지가 자동으로 새로고침되면서 웹사이트에 입장되는 것을 확인할 수 있습니다.
로컬 환경에서는 실제 웹사이트로 포워딩되지 않기 때문에 Cloudflare의 404 페이지가 보이는데, 이는 정상적인 현상입니다.
5. 배포
아래 명령어로 Cloudflare Workers 함수를 배포합니다.
wrangler publish
배포가 완료되면 https://waiting-room.upsdev.workers.dev/ 와 같은 URL이 발급됩니다.
이제 Worker를 여러분의 웹사이트에 라우팅해 보겠습니다. 먼저 도메인의 네임서버가 Cloudflare를 가리키도록 설정해야 합니다(참고 문서). 그다음 CF Workers 대시보드에서 도메인을 라우트(route)로 추가하고 해당 Workers 함수를 선택하면 됩니다.

마무리
Cloudflare Workers와 Upstash Redis 덕분에 애플리케이션 코드를 전혀 수정하지 않고도 대기실을 성공적으로 구축했습니다. 엣지 함수가 Upstash와 결합하면 얼마나 강력해지는지 보여주는 또 하나의 사례라고 생각합니다.
앞으로 개선하면 좋을 부분들도 있습니다.
- 예상 대기 시간 표시: 평균 대기 시간을 계산해 방문자에게 보여줄 수 있습니다.
- 공정하고 순서가 있는 대기열: 현재는 자리가 생길 때마다 대기 중인 방문자가 무작위로 입장합니다. 큐(queue)를 유지하면 순서대로 입장시킬 수 있습니다.
다만 위의 두 개선 사항 모두 더 많은 상태 저장과 원격 호출이 필요하기 때문에, 이번 구현에서는 의도적으로 생략했습니다. 만약 여러분의 사용 사례에서 이런 기능이 필수적이라면, Cloudflare 팀이 엔터프라이즈 솔루션을 구축한 과정을 다룬 블로그 글에서 영감을 얻어 보세요.
전체 소스 코드를 확인해 보세요.