Vercel KV로 배너 클릭 추적 시스템 만들기: Redis를 서버리스에서 사용하는 법

2026. 1. 1. 18:10·Backend Development
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');

마치며

배운 점

  1. 서버리스 환경에서 Redis

    • Upstash는 HTTP 기반이라 서버리스 친화적
    • 연결 풀 관리 불필요
    • 자동 백업과 영구 저장
  2. 데이터 구조 설계의 중요성

    • Key 네이밍 규칙 정하기
    • 적절한 자료형 선택 (Hash, String 등)
    • TTL 전략 미리 고민
  3. 성능 최적화

    • Promise.all로 병렬 실행
    • Fire-and-forget 패턴
    • 불필요한 데이터 제거
  4. 비용 효율

    • 무료 티어로 충분한 서비스 가능
    • 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
'Backend Development' 카테고리의 다른 글
  • CI가 5분씩 걸려서 뜯어봤더니... 린트(Lint)에 무거운 의존성을 태우고 있었다
  • 파이썬 GIL과 멀티스레딩: 언제 멀티스레드가 효과적일까?
  • CI/CD 파이프라인: 개발부터 배포까지 자동화의 모든 것
  • 의존성 주입(Dependency Injection): 유연하고 테스트 가능한 코드 만들기
Kun Woo Kim
Kun Woo Kim
안녕하세요, 김건우입니다! 웹과 앱 개발에 열정적인 전문가로, React, TypeScript, Next.js, Node.js, Express, Flutter 등을 활용한 프로젝트를 다룹니다. 제 블로그에서는 개발 여정, 기술 분석, 실용적 코딩 팁을 공유합니다. 창의적인 솔루션을 실제로 적용하는 과정의 통찰도 나눌 예정이니, 궁금한 점이나 상담은 언제든 환영합니다.
  • Kun Woo Kim
    WhiteMouseDev
    김건우
  • 깃허브
    포트폴리오
    velog
  • 전체
    오늘
    어제
  • 공지사항

    • [인사말] 이제 티스토리에서도 만나요! WhiteMouse⋯
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 분류 전체보기 (143)
      • Frontend Development (64)
      • Backend Development (27)
      • AI · ML (3)
        • Computer Vision (3)
      • Algorithm (35)
        • 백준 (11)
        • 프로그래머스 (18)
        • 알고리즘 (5)
      • Infra (1)
      • 자료구조 (4)
      • Language (6)
        • JavaScript (6)
  • 링크

    • Github
    • Portfolio
    • Velog
  • 인기 글

  • 태그

    객체탐지
    모델비교
    YOLO
    frontend development
    컴퓨터비전
    CNN
    Object Detection
    tailwindcss
    rt-detr
    AI
    transformer
    딥러닝
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Kun Woo Kim
Vercel KV로 배너 클릭 추적 시스템 만들기: Redis를 서버리스에서 사용하는 법
상단으로

티스토리툴바