이 글에서는 Vercel Edge Functions와 Upstash Redis를 활용해 Next.js 애플리케이션에 대기실(waiting room)을 구현하는 방법을 단계별로 살펴봅니다.
대기실(Waiting Room)이란?
대기실은 웹사이트의 동시 접속자 수를 제한하여 서버 리소스 과부하를 방지하고 싶을 때 매우 유용한 기능입니다. 티켓팅 오픈, 한정판 상품 판매 등 순간적으로 트래픽이 몰리는 상황에서 특히 효과적입니다.
이번 구현에서는 최대 동시 접속자 수를 직접 설정할 수 있으며, 트래픽 제어를 위한 두 가지 핵심 파라미터가 사용됩니다.
- 최대 웹사이트 용량(Max website capacity): 웹사이트에 동시에 머무를 수 있는 최대 방문자 수
- 최대 세션 타임아웃(Max session timeout): 방문자가 유휴(idle) 상태로 있을 수 있는 최대 시간(초)
1단계: 프로젝트 설정
Next.js 앱을 생성합니다:
examples git:(master) ✗ npx create-next-app@latest --typescript
✔ What is your project named? … nextjs-waiting-room
Creating a new Next.js app in /Users/enes/dev/examples/nextjs-waiting-room.
upstash-redis 패키지를 설치합니다:
npm install @upstash/redis
2단계: 구현
Vercel은 Next.js 미들웨어를 통해 Edge Functions를 지원합니다. 따라서 pages/api/ 디렉터리 아래에 _middleware.ts 파일을 추가하면 됩니다. 이 미들웨어 코드는 /api 경로로 들어오는 모든 요청을 가로채 처리합니다. 다른 경로나 다양한 설정 옵션이 필요하다면 공식 문서를 참고하세요.
pages/api/_middleware.ts 파일을 아래와 같이 작성합니다:
import { Redis } from "@upstash/redis";
import { NextFetchEvent, NextRequest, NextResponse } from "next/server";
const COOKIE_NAME_ID = "__waiting_room_id";
const COOKIE_NAME_TIME = "__waiting_room_last_update_time";
const UPSTASH_REDIS_REST_TOKEN = "REPLACE_HERE";
const UPSTASH_REDIS_REST_URL = "REPLACE_HERE";
const TOTAL_ACTIVE_USERS = 10;
const SESSION_DURATION_SECONDS = 30;
const redis = new Redis({
url: UPSTASH_REDIS_REST_URL,
token: UPSTASH_REDIS_REST_TOKEN,
});
export async function middleware(req: NextRequest, ev: NextFetchEvent) {
const cookies = req.cookies;
let userId;
if (cookies[COOKIE_NAME_ID] != null) {
userId = cookies[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(req, userId);
} else {
// site capacity is full
const user = await redis.get(userId);
if (user === "1") {
// the user has already active session
return getDefaultResponse(req, userId);
} else {
// capacity is full so the user is forwarded to waiting room
return getWaitingRoomResponse();
}
}
}
function makeid(length: number) {
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;
}
async function getDefaultResponse(request: NextRequest, userId: string) {
// uncomment below to test the function with a static html content
let newResponse = new NextResponse(default_html);
newResponse.headers.set("content-type", "text/html;charset=UTF-8");
// const response = await fetch(request)
// const newResponse = new Response(response.body, response)
const cookies = request.cookies;
const now = Date.now();
let lastUpdate = cookies[COOKIE_NAME_TIME];
let lastUpdateTime = 0;
if (lastUpdate) lastUpdateTime = parseInt(lastUpdate);
const diff = now - lastUpdateTime;
const updateInterval = (SESSION_DURATION_SECONDS * 1000) / 2;
if (diff > updateInterval) {
await redis.setex(userId, SESSION_DURATION_SECONDS, "1");
newResponse.cookie(COOKIE_NAME_TIME, now.toString());
}
newResponse.cookie(COOKIE_NAME_ID, userId);
return newResponse;
}
async function getWaitingRoomResponse() {
const newResponse = new NextResponse(waiting_room_html);
newResponse.headers.set("content-type", "text/html;charset=UTF-8");
return newResponse;
}
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='//github.com/upstash/redis-examples/tree/master/nextjs-waiting-room' style={{"color": "blue"}}>this project </a> to set up a waiting room for your website.</p>
</div>
`;
동작 원리
활성 사용자 세션을 관리하기 위해 Upstash Redis를 상태 저장소로 사용합니다. Upstash는 REST API를 제공하기 때문에 HTTP 요청만 지원하는 Vercel Edge Functions 환경과도 완벽하게 호환됩니다.
먼저 Upstash 콘솔에서 Global 데이터베이스를 생성해야 합니다. 콘솔에서 발급된 REST 토큰과 REST URL을 복사해 코드의 REPLACE_HERE 부분에 붙여넣으세요. 이때 Redis 데이터베이스는 비어 있어야 하며, 반드시 이 애플리케이션 전용으로만 사용해야 합니다. 그런 다음 자신의 서비스 규모에 맞게 TOTAL_ACTIVE_USERS(최대 동시 접속자 수)와 SESSION_DURATION_SECONDS(세션 유휴 타임아웃) 값을 조정하면 됩니다.
구체적인 동작 흐름은 다음과 같습니다.
- 새 방문자가 접속하면 고유 ID를 생성해 쿠키로 설정하고 Redis에 저장합니다.
- 이후 재방문 시에는 Redis를 조회해 해당 방문자가 이미 활성 세션을 가지고 있는지 확인합니다.
- Redis에 세션을 삽입할 때 만료 시간(TTL)을 세션 유휴 타임아웃으로 설정해, 일정 시간 활동이 없으면 자동으로 세션이 해제됩니다.
- 현재 세션 수가 최대 용량을 초과하면 신규 사용자는 대기실 페이지로 안내됩니다.
waiting_room_html 변수를 수정하면 대기실 페이지의 디자인과 문구를 자유롭게 커스터마이징할 수 있습니다. 마찬가지로 getDefaultResponse() 메서드를 변경하면 정적 HTML 대신 NextResponse를 통해 실제 서비스 페이지로 포워딩할 수도 있습니다.
3단계: 실행 및 배포
npm run dev 명령으로 애플리케이션을 로컬에서 실행합니다. 테스트를 쉽게 하려면 TOTAL_ACTIVE_USERS를 1로 설정한 뒤, 서로 다른 브라우저에서 https://localhost:3000/api/hello 페이지를 열어보세요. 두 번째 브라우저에서는 대기실 페이지가 표시되는 것을 확인할 수 있습니다.
테스트가 끝나면 아래 명령으로 Vercel에 배포합니다.
vercel deploy –prod
Vercel은 전 세계 엣지 로케이션에서 _middleware.ts를 실행하므로, 어디서 접속하더라도 낮은 지연 시간으로 대기실 기능이 동작합니다.
마치며
이 튜토리얼을 통해 Vercel과 Upstash를 활용하면 엣지 환경에서 동적인 애플리케이션을 얼마나 간편하게 구축할 수 있는지 확인했습니다. 더 많은 예제는 예제 저장소에서 살펴볼 수 있습니다.