728x90
반응형

들어가며
블로그에 광고 배너를 달면서 이런 요구사항이 생겼습니다.
요구사항:
- 좌측/우측 배너 각각 클릭 수 추적
- 일별, 시간대별 통계 확인
- 관리자 대시보드에서 시각화
- 서버리스 환경 (Next.js on Vercel)처음엔 간단할 줄 알았습니다. "그냥 데이터베이스에 저장하면 되지 않나?" 하고요.
// ❌ 순진한 첫 시도
async function trackClick(bannerId: string) {
await prisma.bannerClick.create({
data: { bannerId, timestamp: new Date() }
});
}
문제:
- 매 클릭마다 DB 쓰기 → 비용 증가
- 통계 조회 시 전체 레코드 스캔 → 느림
- 시간대별 집계를 매번 계산 → 비효율
그래서 선택한 것이 Vercel KV (Upstash Redis)입니다. 오늘은 이 시스템을 어떻게 설계하고 구현했는지 공유하겠습니다.
Redis? Vercel KV? Upstash? 뭐가 다른가요?
먼저 용어부터 정리하겠습니다.
Redis (일반)
┌─────────────────────────────────┐
│ 전통적인 Redis 서버 │
│ │
│ ┌─────────────┐ │
│ │ Memory │ ← 빠른 읽기 │
│ └─────────────┘ │
│ │
│ ⚠️ 서버 재시작 시 데이터 손실 │
│ ⚠️ TCP 연결 필요 │
│ ⚠️ 서버 관리 필요 │
└─────────────────────────────────┘Upstash Redis
┌─────────────────────────────────┐
│ 서버리스 Redis │
│ │
│ ┌─────────┐ ┌──────────┐ │
│ │ Memory │ ─► │ Disk │ │
│ │ (빠름) │ │ (영구저장)│ │
│ └─────────┘ └──────────┘ │
│ │
│ ✅ HTTP REST API │
│ ✅ 자동 백업 │
│ ✅ 관리 불필요 │
└─────────────────────────────────┘Vercel KV
Vercel KV = Upstash Redis + Vercel 통합
┌────────────────────────────────────┐
│ Vercel 프로젝트 │
│ ┌──────────────────────────┐ │
│ │ 자동 환경변수 설정 │ │
│ │ KV_REST_API_URL │ │
│ │ KV_REST_API_TOKEN │ │
│ └──────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────┐ │
│ │ @vercel/kv 패키지 │ │
│ │ (자동으로 연결) │ │
│ └──────────────────────────┘ │
└────────────────────────────────────┘
↓
Upstash Redis (도쿄)간단히 말하면:
- Redis: 인메모리 데이터베이스 기술
- Upstash: 서버리스 Redis 서비스 제공자
- Vercel KV: Vercel에서 Upstash를 쉽게 쓰게 만든 것
왜 일반 데이터베이스가 아닌 Redis인가?
시나리오: 하루 1,000번 클릭
PostgreSQL 방식
// 매 클릭마다 INSERT
await prisma.bannerClick.create({
data: { bannerId: 'left', timestamp: new Date() }
});
// 통계 조회 시
const stats = await prisma.bannerClick.groupBy({
by: ['bannerId', 'hour'],
where: {
timestamp: { gte: last7Days }
},
_count: true
});
// 문제:
// - 1,000개 레코드 INSERT
// - 통계 조회마다 전체 스캔
// - 시간대별 집계 계산 필요
비용:
- Vercel Postgres 무료 티어: 256MB 저장, 256MB 전송
- 클릭 데이터가 쌓이면 곧 유료
Redis 방식
// 클릭 기록 - 단 3개 명령어
await kv.incr('banner:clicks:left'); // 총 클릭 수 +1
await kv.hincrby('banner:daily:left:2026-01-01', '09', 1); // 9시 클릭 +1
await kv.expire('banner:daily:left:2026-01-01', 7776000); // 90일 TTL
// 통계 조회 - 이미 집계된 데이터
const total = await kv.get('banner:clicks:left');
const hourly = await kv.hgetall('banner:daily:left:2026-01-01');
// 장점:
// - 클릭마다 3개 명령어로 끝
// - 통계는 이미 계산되어 있음
// - 빠른 응답 (< 10ms)
비용:
- Upstash 무료 티어: 월 500,000 커맨드
- 1,000 클릭 × 3 커맨드 = 3,000 (충분!)
시스템 아키텍처
전체 흐름을 먼저 보겠습니다.
┌──────────────────────────────────────────────────┐
│ 사용자가 배너 클릭 │
└──────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────┐
│ SideAd.tsx │
│ ┌────────────────────────────────────────────┐ │
│ │ onClick = () => { │ │
│ │ fetch('/api/banner/click', { │ │
│ │ method: 'POST', │ │
│ │ body: { bannerId: 'left' } │ │
│ │ }).catch(() => {}); │ │
│ │ │ │
│ │ // 응답 안 기다리고 바로 이동 (중요!) │ │
│ │ window.open(bannerUrl); │ │
│ │ } │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────┐
│ POST /api/banner/click │
│ ┌────────────────────────────────────────────┐ │
│ │ 1. bannerId 검증 ('left' | 'right') │ │
│ │ 2. recordBannerClick(bannerId) 호출 │ │
│ │ 3. 총 클릭 수 반환 │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────┐
│ Vercel KV (Upstash Redis - 도쿄) │
│ │
│ banner:clicks:left = 12345 │
│ banner:daily:left:2026-01-01 = { │
│ "00": 5, "01": 3, "09": 45, "10": 67, ... │
│ } │
└──────────────────────────────────────────────────┘핵심 1: 데이터 구조 설계
Redis는 다양한 자료구조를 지원합니다. 어떤 것을 선택할지가 중요합니다.
Key 네이밍 규칙
banner:clicks:{bannerId} # 총 클릭 수 (String, 영구)
banner:daily:{bannerId}:{YYYY-MM-DD} # 일별 시간대 데이터 (Hash, 90일 TTL)왜 이렇게?
// ✅ 채택한 방식
banner:clicks:left → 12345 (Integer)
banner:daily:left:2026-01-01 → { "09": 45, "10": 67, ... }
// ❌ 대안 1: 모든 데이터를 하나의 Hash에
banner:left → {
"total": 12345,
"2026-01-01:09": 45,
"2026-01-01:10": 67,
...
}
// 문제: TTL을 개별 필드에 설정 불가 (전체 Hash에만 가능)
// → 총 클릭 수도 같이 사라짐
// ❌ 대안 2: 각 시간대별 별도 키
banner:hourly:left:2026-01-01:09 → 45
banner:hourly:left:2026-01-01:10 → 67
// 문제: 키가 너무 많아짐 (24시간 × 90일 × 2배너 = 4,320개)
Hash 타입 사용 이유
// Hash: 하나의 키 아래 여러 필드 저장
HSET banner:daily:left:2026-01-01 "09" 45
HSET banner:daily:left:2026-01-01 "10" 67
// 조회
HGETALL banner:daily:left:2026-01-01
→ { "09": 45, "10": 67, "11": 34, ... }
// 장점:
// 1. 하루치 데이터가 하나의 키로 관리됨
// 2. 특정 시간대만 조회 가능 (HGET)
// 3. 시간대별 증가 원자적 (HINCRBY)
TTL 전략
// 총 클릭 수: TTL 없음 (영구 보관)
await kv.incr('banner:clicks:left');
// 일별 데이터: 90일 후 자동 삭제
await kv.expire('banner:daily:left:2026-01-01', 60 * 60 * 24 * 90);
왜 90일?
- 3개월 이상 된 상세 데이터는 분석 가치 감소
- 저장 공간 절약 (자동 정리)
- 총 클릭 수는 영구 보관으로 히스토리 유지
핵심 2: 클릭 기록 구현
비즈니스 로직
// lib/banner-tracking.ts
import { kv } from '@vercel/kv';
export type BannerId = 'left' | 'right';
export async function recordBannerClick(bannerId: BannerId): Promise<number> {
const now = new Date();
const dateStr = now.toISOString().split('T')[0]; // "2026-01-01"
const hour = now.getHours().toString().padStart(2, '0'); // "09"
const totalKey = `banner:clicks:${bannerId}`;
const dailyKey = `banner:daily:${bannerId}:${dateStr}`;
try {
// 병렬 실행으로 성능 최적화
const [totalClicks] = await Promise.all([
kv.incr(totalKey), // 총 클릭 수 +1
kv.hincrby(dailyKey, hour, 1), // 해당 시간대 +1
kv.expire(dailyKey, 60 * 60 * 24 * 90), // 90일 TTL
]);
return totalClicks;
} catch (error) {
console.error('Failed to record banner click:', error);
throw error;
}
}
왜 Promise.all?
// ❌ 순차 실행: 느림
const totalClicks = await kv.incr(totalKey); // 30ms
await kv.hincrby(dailyKey, hour, 1); // 30ms
await kv.expire(dailyKey, TTL); // 30ms
// 총 90ms
// ✅ 병렬 실행: 빠름
const [totalClicks] = await Promise.all([
kv.incr(totalKey),
kv.hincrby(dailyKey, hour, 1),
kv.expire(dailyKey, TTL),
]);
// 총 30ms (가장 느린 것 기준)
API Route
// app/api/banner/click/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { recordBannerClick, type BannerId } from '@/lib/banner-tracking';
const VALID_BANNER_IDS: BannerId[] = ['left', 'right'];
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { bannerId } = body;
// 검증
if (!VALID_BANNER_IDS.includes(bannerId)) {
return NextResponse.json(
{ error: 'Invalid bannerId' },
{ status: 400 }
);
}
// 클릭 기록
const totalClicks = await recordBannerClick(bannerId);
return NextResponse.json({
success: true,
totalClicks,
});
} catch (error) {
console.error('Banner click API error:', error);
return NextResponse.json(
{ error: 'Failed to record click' },
{ status: 500 }
);
}
}
클라이언트 구현 (중요!)
// components/ads/SideAd.tsx
'use client';
import { useState } from 'react';
interface SideAdProps {
position: 'left' | 'right';
imageUrl: string;
linkUrl: string;
alt: string;
}
export function SideAd({ position, imageUrl, linkUrl, alt }: SideAdProps) {
const [isTracking, setIsTracking] = useState(false);
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
// 중복 클릭 방지
if (isTracking) return;
setIsTracking(true);
// Fire-and-forget: 응답 안 기다림
fetch('/api/banner/click', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bannerId: position }),
}).catch((error) => {
// 실패해도 사용자 경험에 영향 없음
console.error('Failed to track click:', error);
});
// 바로 링크로 이동 (새 탭)
window.open(linkUrl, '_blank', 'noopener,noreferrer');
setIsTracking(false);
};
return (
<a
href={linkUrl}
onClick={handleClick}
className="block hover:opacity-80 transition-opacity"
rel="noopener noreferrer sponsored"
>
<img src={imageUrl} alt={alt} className="w-full h-auto" />
</a>
);
}
Fire-and-forget 패턴의 장점:
// ❌ Bad: 응답을 기다림
const handleClick = async () => {
await fetch('/api/banner/click', ...); // 30ms 대기
window.open(linkUrl); // 30ms 후에야 이동
};
// 사용자: "왜 클릭이 느리지?" 😰
// ✅ Good: 응답 안 기다림
const handleClick = () => {
fetch('/api/banner/click', ...).catch(() => {});
window.open(linkUrl); // 즉시 이동!
};
// 사용자: "반응이 빠르네!" ✨
핵심 3: 통계 조회 구현
비즈니스 로직
// lib/banner-tracking.ts (계속)
interface HourlyStats {
[hour: string]: number;
}
interface DailyStats {
date: string;
clicks: number;
hourly: HourlyStats;
}
interface BannerStats {
bannerId: BannerId;
totalClicks: number;
daily: DailyStats[];
}
export async function getBannerStats(
bannerId: BannerId,
days: number = 7
): Promise<BannerStats> {
const totalKey = `banner:clicks:${bannerId}`;
// 조회할 날짜 범위 생성
const dates: string[] = [];
for (let i = 0; i < days; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
dates.push(date.toISOString().split('T')[0]);
}
try {
// 총 클릭 수 조회
const totalClicks = (await kv.get<number>(totalKey)) || 0;
// 일별 데이터 병렬 조회
const dailyStatsPromises = dates.map(async (date) => {
const dailyKey = `banner:daily:${bannerId}:${date}`;
const hourly = (await kv.hgetall<HourlyStats>(dailyKey)) || {};
// 하루 총 클릭 수 계산
const clicks = Object.values(hourly).reduce(
(sum, count) => sum + (count || 0),
0
);
return { date, clicks, hourly };
});
const daily = await Promise.all(dailyStatsPromises);
// 날짜 역순 정렬 (최신순)
daily.sort((a, b) => b.date.localeCompare(a.date));
return {
bannerId,
totalClicks,
daily,
};
} catch (error) {
console.error('Failed to get banner stats:', error);
throw error;
}
}
병렬 조회의 힘:
// ❌ 순차 조회: 7일 = 7 × 30ms = 210ms
for (const date of dates) {
const data = await kv.hgetall(`banner:daily:${bannerId}:${date}`);
}
// ✅ 병렬 조회: 7일 = 30ms (동시 실행)
const promises = dates.map(date =>
kv.hgetall(`banner:daily:${bannerId}:${date}`)
);
const results = await Promise.all(promises);
API Route
// app/api/banner/stats/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getBannerStats, type BannerId } from '@/lib/banner-tracking';
const VALID_BANNER_IDS: BannerId[] = ['left', 'right'];
const MAX_DAYS = 90;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const bannerIdParam = searchParams.get('bannerId');
const daysParam = searchParams.get('days');
// days 파라미터 파싱
const days = Math.min(
parseInt(daysParam || '7', 10),
MAX_DAYS
);
// 특정 배너만 조회
if (bannerIdParam) {
if (!VALID_BANNER_IDS.includes(bannerIdParam as BannerId)) {
return NextResponse.json(
{ error: 'Invalid bannerId' },
{ status: 400 }
);
}
const stats = await getBannerStats(bannerIdParam as BannerId, days);
return NextResponse.json({ [bannerIdParam]: stats });
}
// 모든 배너 조회
const [leftStats, rightStats] = await Promise.all([
getBannerStats('left', days),
getBannerStats('right', days),
]);
return NextResponse.json({
left: leftStats,
right: rightStats,
});
} catch (error) {
console.error('Banner stats API error:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
}
}
핵심 4: 관리자 대시보드
통계 페이지
// app/admin/banner-stats/page.tsx
import { BannerStatsChart } from '@/components/admin/BannerStatsChart';
export default async function BannerStatsPage() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">배너 클릭 통계</h1>
<BannerStatsChart />
</div>
);
}
차트 컴포넌트
// components/admin/BannerStatsChart.tsx
'use client';
import { useEffect, useState } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface BannerStats {
bannerId: string;
totalClicks: number;
daily: Array<{
date: string;
clicks: number;
hourly: { [hour: string]: number };
}>;
}
interface StatsResponse {
left: BannerStats;
right: BannerStats;
}
export function BannerStatsChart() {
const [stats, setStats] = useState<StatsResponse | null>(null);
const [days, setDays] = useState(7);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
setLoading(true);
try {
const response = await fetch(`/api/banner/stats?days=${days}`);
const data = await response.json();
setStats(data);
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
}
fetchStats();
}, [days]);
if (loading) {
return <div className="text-center py-8">Loading...</div>;
}
if (!stats) {
return <div className="text-center py-8">No data available</div>;
}
// 일별 클릭 추이 데이터
const dailyData = stats.left.daily.map((leftDay, index) => ({
date: leftDay.date,
left: leftDay.clicks,
right: stats.right.daily[index]?.clicks || 0,
}));
// 시간대별 평균 데이터 (최근 7일)
const hourlyData = Array.from({ length: 24 }, (_, hour) => {
const hourStr = hour.toString().padStart(2, '0');
const leftTotal = stats.left.daily
.slice(0, 7)
.reduce((sum, day) => sum + (day.hourly[hourStr] || 0), 0);
const rightTotal = stats.right.daily
.slice(0, 7)
.reduce((sum, day) => sum + (day.hourly[hourStr] || 0), 0);
return {
hour: `${hour}시`,
left: Math.round(leftTotal / 7),
right: Math.round(rightTotal / 7),
};
});
return (
<div className="space-y-8">
{/* 기간 선택 */}
<div className="flex gap-2">
{[7, 30, 90].map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-4 py-2 rounded ${
days === d
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
{d}일
</button>
))}
</div>
{/* 총 클릭 수 */}
<div className="grid grid-cols-2 gap-4">
<div className="p-6 bg-blue-50 rounded-lg">
<h3 className="text-lg font-semibold text-blue-900">좌측 배너</h3>
<p className="text-3xl font-bold text-blue-600 mt-2">
{stats.left.totalClicks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 mt-1">총 클릭 수</p>
</div>
<div className="p-6 bg-green-50 rounded-lg">
<h3 className="text-lg font-semibold text-green-900">우측 배너</h3>
<p className="text-3xl font-bold text-green-600 mt-2">
{stats.right.totalClicks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 mt-1">총 클릭 수</p>
</div>
</div>
{/* 일별 클릭 추이 */}
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-xl font-semibold mb-4">일별 클릭 추이</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={dailyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(date) => {
const [, month, day] = date.split('-');
return `${month}/${day}`;
}}
/>
<YAxis />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="left"
stroke="#3b82f6"
name="좌측 배너"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="right"
stroke="#10b981"
name="우측 배너"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* 시간대별 평균 클릭 */}
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-xl font-semibold mb-4">
시간대별 평균 클릭 (최근 7일)
</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={hourlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="left" fill="#3b82f6" name="좌측 배너" />
<Bar dataKey="right" fill="#10b981" name="우측 배너" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
차트 라이브러리 선택:
// Recharts 사용 이유:
// ✅ React 친화적 (컴포넌트 기반)
// ✅ 간단한 데이터 (일별 90개, 시간대 24개)
// ✅ 반응형 차트 쉬움
// ✅ 커스터마이징 용이
// Chart.js는 안 쓴 이유:
// - 데이터가 적어서 성능 이슈 없음
// - Recharts가 더 React스러움
실전 문제 해결
문제 1: TTL이 갱신 안 됨
// ❌ 문제: 첫 클릭 시에만 TTL 설정
const exists = await kv.exists(dailyKey);
if (!exists) {
await kv.expire(dailyKey, TTL);
}
// 문제: 첫 클릭 이후 TTL이 갱신되지 않음
// → 하루 중 첫 클릭 시점부터 90일 후 삭제
// → 마지막 클릭 시점이 아님!
// ✅ 해결: 매번 TTL 갱신
await kv.hincrby(dailyKey, hour, 1);
await kv.expire(dailyKey, TTL); // 매번 90일로 리셋
문제 2: 시간대 문자열 일관성
// ❌ 문제: 시간대 형식 불일치
const hour1 = new Date().getHours(); // 9
const hour2 = new Date().getHours().toString(); // "9"
const hour3 = new Date().getHours().toString().padStart(2, '0'); // "09"
// Hash에 "9"와 "09"가 섞여서 저장됨!
// ✅ 해결: 항상 padStart 사용
const hour = now.getHours().toString().padStart(2, '0');
문제 3: 날짜 기준 (시간대)
// ❌ 문제: 서버 시간대와 사용자 시간대 불일치
const dateStr = new Date().toISOString().split('T')[0];
// 한국 시간 01:00 = UTC 16:00 (전날)
// → 날짜가 하루 밀림!
// ✅ 해결 1: 서버를 한국 시간대로 설정
// Vercel 환경변수: TZ=Asia/Seoul
// ✅ 해결 2: 로컬 시간 사용
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
문제 4: Redis 연결 에러 핸들링
// ❌ 문제: Redis 장애 시 클릭 자체가 실패
try {
await recordBannerClick(bannerId);
} catch (error) {
return NextResponse.json(
{ error: 'Failed' },
{ status: 500 }
);
// 사용자는 배너 클릭이 안 됨!
}
// ✅ 해결: 클라이언트에서 fire-and-forget
fetch('/api/banner/click', ...)
.catch(() => {}); // 실패해도 무시
window.open(linkUrl); // 링크는 무조건 열림
성능 최적화
1. 병렬 실행
// 클릭 기록 시
const [totalClicks] = await Promise.all([
kv.incr(totalKey),
kv.hincrby(dailyKey, hour, 1),
kv.expire(dailyKey, TTL),
]);
// 통계 조회 시
const [totalClicks, ...dailyStats] = await Promise.all([
kv.get(totalKey),
...dates.map(date => kv.hgetall(`banner:daily:${bannerId}:${date}`))
]);
2. 불필요한 데이터 제외
// ❌ Bad: 모든 시간대 데이터 전송
const hourly = { "00": 0, "01": 0, ..., "23": 0 };
// ✅ Good: 클릭이 있는 시간대만
const hourly = { "09": 45, "10": 67, "14": 23 };
3. 응답 캐싱 (선택사항)
// app/api/banner/stats/route.ts
export const revalidate = 60; // 60초 캐싱
export async function GET(request: NextRequest) {
// ...
}
비용 분석
Upstash 무료 티어
월간 한도:
- 500,000 커맨드
- 256MB 저장
- 무제한 대역폭예상 사용량
// 하루 1,000번 클릭
const clicksPerDay = 1000;
const commandsPerClick = 3; // incr, hincrby, expire
const dailyCommands = clicksPerDay * commandsPerClick; // 3,000
// 한 달
const monthlyCommands = dailyCommands * 30; // 90,000
// 통계 조회 (하루 10번)
const statsPerDay = 10;
const commandsPerStats = 2 + 7; // get(total) + hgetall × 7일
const statsCommands = statsPerDay * commandsPerStats * 30; // 2,700
// 총합
const totalCommands = monthlyCommands + statsCommands; // 92,700
결론: 무료 티어로 충분! (500,000 중 92,700 사용)
저장 공간
// 데이터 크기 추정
const totalKeys = 2; // left, right
const dailyKeys = 90 * 2; // 90일 × 2배너 = 180개
// 각 키 크기
const totalKeySize = 20; // "banner:clicks:left" + Integer
const dailyKeySize = 100; // Hash with 24 fields
const totalSize =
(totalKeys * totalKeySize) +
(dailyKeys * dailyKeySize);
// = 40 + 18,000 = 18,040 bytes ≈ 18KB
결론: 256MB 중 18KB만 사용 (0.007%)
모니터링과 디버깅
Upstash 콘솔
Dashboard에서 확인 가능:
- 실시간 커맨드 수
- 메모리 사용량
- 평균 응답 시간
- 에러 로그Vercel 로그
// API Route에서 로깅
console.log('Banner click:', {
bannerId,
timestamp: new Date().toISOString(),
totalClicks,
});
// Vercel Dashboard > Logs에서 확인 가능
간단한 헬스 체크
// app/api/banner/health/route.ts
import { kv } from '@vercel/kv';
export async function GET() {
try {
// PING 테스트
const result = await kv.ping();
return Response.json({
status: 'healthy',
redis: result === 'PONG' ? 'connected' : 'error',
});
} catch (error) {
return Response.json(
{ status: 'unhealthy', error: String(error) },
{ status: 500 }
);
}
}
Vercel KV 설정하기
1. Vercel Dashboard에서 설정
1. Vercel 프로젝트 선택
2. Storage 탭 클릭
3. "Create Database" 클릭
4. "KV" 선택
5. 리전 선택 (Tokyo 추천)
6. "Create" 클릭2. 환경변수 자동 설정
# Vercel이 자동으로 설정해줌
KV_REST_API_URL=https://xxx.upstash.io
KV_REST_API_TOKEN=AxxxYYY...
KV_REST_API_READ_ONLY_TOKEN=...
KV_URL=redis://...
3. 로컬 개발 설정
# 환경변수 다운로드
vercel env pull .env.local
# 또는 수동으로 .env.local 생성
# Vercel Dashboard > Settings > Environment Variables에서 복사
4. 패키지 설치
npm install @vercel/kv
5. 사용
// @vercel/kv가 환경변수를 자동으로 읽음
import { kv } from '@vercel/kv';
// 바로 사용 가능!
await kv.set('key', 'value');
마치며
배운 점
서버리스 환경에서 Redis
- Upstash는 HTTP 기반이라 서버리스 친화적
- 연결 풀 관리 불필요
- 자동 백업과 영구 저장
데이터 구조 설계의 중요성
- Key 네이밍 규칙 정하기
- 적절한 자료형 선택 (Hash, String 등)
- TTL 전략 미리 고민
성능 최적화
- Promise.all로 병렬 실행
- Fire-and-forget 패턴
- 불필요한 데이터 제거
비용 효율
- 무료 티어로 충분한 서비스 가능
- TTL로 자동 정리
전체 파일 구조
project/
├── src/
│ ├── lib/
│ │ └── banner-tracking.ts # 핵심 로직
│ │
│ ├── app/
│ │ ├── api/
│ │ │ └── banner/
│ │ │ ├── click/
│ │ │ │ └── route.ts # POST - 클릭 기록
│ │ │ └── stats/
│ │ │ └── route.ts # GET - 통계 조회
│ │ │
│ │ └── admin/
│ │ └── banner-stats/
│ │ └── page.tsx # 관리자 페이지
│ │
│ └── components/
│ ├── ads/
│ │ └── SideAd.tsx # 배너 컴포넌트
│ │
│ └── admin/
│ └── BannerStatsChart.tsx # 차트
│
├── .env.local # 로컬 환경변수
└── package.json다음 단계
이 시스템을 확장한다면:
// 1. A/B 테스트
interface ABTest {
bannerId: string;
variant: 'A' | 'B';
imageUrl: string;
}
// 2. 전환율 추적
await kv.hincrby('banner:conversions:left', dateStr, 1);
// 3. 리퍼러 분석
await kv.zincrby('banner:referrers:left', 1, referrer);
// 4. 디바이스별 통계
await kv.hincrby(`banner:device:${device}:${bannerId}`, dateStr, 1);
// 5. 실시간 알림
if (clicksPerHour > threshold) {
await sendSlackNotification(`배너 ${bannerId} 급증!`);
}
Vercel KV로 간단하면서도 강력한 클릭 추적 시스템을 만들 수 있었습니다. 서버리스 환경에서 Redis를 쓰고 싶다면 Upstash를 적극 추천합니다!
728x90
반응형
'Backend Development' 카테고리의 다른 글
| CI가 5분씩 걸려서 뜯어봤더니... 린트(Lint)에 무거운 의존성을 태우고 있었다 (0) | 2026.01.23 |
|---|---|
| 파이썬 GIL과 멀티스레딩: 언제 멀티스레드가 효과적일까? (7) | 2025.07.06 |
| CI/CD 파이프라인: 개발부터 배포까지 자동화의 모든 것 (5) | 2025.07.04 |
| 의존성 주입(Dependency Injection): 유연하고 테스트 가능한 코드 만들기 (0) | 2025.07.02 |
| 네트워크 IP 할당의 모든 것: 정적 IP vs 동적 IP, 무엇을 선택해야 할까? (1) | 2025.06.27 |
