Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

Upstash Redis로 Next.js 블로그에 조회수 카운터 구현하기

Lee의 블로그에서 영감을 얻어 이 기능을 만들게 되었습니다. 그의 블로그에서는 모든 글마다 조회수가 표시되는데, 저도 제 페이지에 같은 기능을 넣어보고 싶었습니다. 저 역시 새로운 App Router가 적용된 Next.js 13을 사용하고 있으며, 페이지뷰를 MySQL 데이터베이스에 저장하는 대신 Upstash Redis를 활용하는 방식으로 구현했습니다.

아래는 최종 결과물의 예시입니다. 홈 화면의 각 카드에 해당 글의 조회수가 함께 표시됩니다.

Upstash Redis로 Next.js 블로그에 조회수 카운터 구현하기

왜 Redis인가?

Redis는 중복 제거와 카운터 증가를 아주 손쉽게 처리할 수 있는 강력한 명령어 두 가지를 기본으로 제공합니다.

더 정확한 카운터를 위해 저는 조회수 증가 로직에 디바운스(debounce)를 적용했습니다. 사용자가 페이지를 새로고침하더라도 카운터는 한 번만 증가해야 하기 때문입니다. 이 작업은 Redis의 SET 명령어로 매우 간단하게 처리할 수 있습니다. SET은 키가 아직 존재하지 않을 때만 값을 설정하는 NX 옵션과, 일정 시간(초)이 지나면 키를 자동으로 만료시키는 EX 옵션을 지원합니다. 두 옵션을 조합하면 특정 사용자가 주어진 시간 안에 카운터를 여러 번 올리지 못하도록 막을 수 있습니다.

두 번째 명령어는 INCR입니다. 이 명령어는 지정된 키의 값을 원자적으로(atomic) 1씩 증가시켜 줍니다.

Redis 설정하기

Upstash에서 데이터베이스를 만드는 일은 매우 간단하며, 하루 1만 회 요청까지 무료로 사용할 수 있습니다. 여기에서 새 데이터베이스를 생성할 수 있고, 실제로 몇 초면 끝납니다. 생성 후 페이지를 아래로 스크롤하여 REST 연결 시크릿을 복사한 뒤 .env.local 파일에 붙여넣습니다:

.env.local
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

Next.js 구현하기

Redis 데이터베이스가 준비되었다면 이제 카운터를 구현할 차례입니다. 먼저 @upstash/redis 패키지를 설치합니다:

pnpm add @upstash/redis

페이지뷰를 저장하려면 두 가지 요소가 필요합니다. 바로 API 라우트와 클라이언트 컴포넌트입니다. 먼저 API 라우트부터 시작해 보겠습니다.

/api/incr.ts

Upstash와 @upstash/redis는 Vercel의 엣지 함수(edge function)와 호환됩니다. 따라서 필요한 모듈을 임포트하고 Redis 인스턴스를 설정한 뒤, 런타임을 edge로 지정하는 것부터 시작합니다.

새 파일 /api/incr.ts를 만들고 다음 코드를 추가하세요:

/api/incr.ts
import { NextRequest, NextResponse } from "next/server";
 
import { Redis } from "@upstash/redis";
 
const redis = Redis.fromEnv();
 
export const config = {
 runtime: "edge",
};

다음으로, 요청 본문에 slug(또는 유사한 식별자)가 반드시 포함되도록 합니다. 값이 없다면 400 상태 코드를 반환합니다.

/api/incr.ts
export default async function incr(req: NextRequest): Promise<NextResponse> {
 const body = await req.json();
 const slug = body.slug as string | undefined;
 if (!slug) {
 return new NextResponse("Slug not found", { status: 400 });
 }
 
 // more to come here
}

그다음은 사용자의 IP 주소를 가져오는 단계입니다. req.ip 속성을 사용하면 되는데, IP 주소를 그대로 저장하면 보안상 문제가 될 수 있으므로 SHA-256 알고리즘으로 해싱한 후 데이터베이스에 저장합니다. 참고로 최신 버전의 Next.js에서는 req.ip가 제공되지 않을 수 있으므로, 이 경우 x-forwarded-for 헤더 등을 통해 IP를 직접 읽어와야 합니다.

/api/incr.ts
const ip = req.ip;
// Hash the IP and turn it into a hex string
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip));
const hash = Array.from(new Uint8Array(buf))
 .map((b) => b.toString(16).padStart(2, "0"))
 .join("");

이제 앞서 소개한 첫 번째 Redis 명령어를 활용해 보겠습니다. SETNX, EX 옵션을 함께 사용하면 특정 IP가 지난 24시간 내에 해당 페이지를 조회했는지 손쉽게 확인할 수 있습니다:

/api/incr.ts
const isNew = await redis.set(["deduplicate", hash, slug].join(":"), true, {
 nx: true,
 ex: 24 * 60 * 60,
});
if (!isNew) {
 new NextResponse(null, { status: 202 });
}

마지막 단계는 해당 slug의 조회수 카운터를 증가시키는 것입니다. INCR 명령어를 사용합니다:

/api/incr.ts
await redis.incr(["pageviews", "projects", slug].join(":"));
return new NextResponse(null, { status: 202 });

참고로 전체 코드는 여기에서 확인할 수 있습니다.

/app/[slug]/view.tsx

이번에는 마운트될 때마다 방금 만든 API 라우트로 요청을 전송하는 작은 클라이언트 컴포넌트를 만들겠습니다. 이 컴포넌트는 조회수를 추적하고 싶은 어떤 페이지에든 삽입해 사용할 수 있습니다.

/app/[slug]/view.tsx
"use client";
 
import { useEffect } from "react";
 
export const ReportView: React.FC<{ slug: string }> = ({ slug }) => {
 useEffect(() => {
 fetch("/api/incr", {
 method: "POST",
 headers: {
 "Content-Type": "application/json",
 },
 body: JSON.stringify({ slug }),
 });
 }, [slug]);
 
 return null;
};

/app/[slug]/page.tsx

마지막으로 조회수를 추적하고 싶은 페이지에 ReportView 컴포넌트를 추가합니다:

/app/[slug]/page.tsx
import { ReportView } from "./view";
 
type Props = {
 params: {
 slug: string;
 };
};
 
export default function Page({ params }: Props) {
 return (
 <div>
 <ReportView slug={params.slug} />
 {/* Add your page content here */}
 </div>
 );
}

이제부터 /app/[slug] 경로로 들어오는 모든 방문이 기록되며, 지난 24시간 동안 각 고유 방문자당 카운터가 한 번씩 증가합니다.

조회수 표시하기

조회수를 추적하는 것만으로도 좋지만, 이를 공개적으로 표시하는 것도 중요합니다. 어떻게 구현하는지 살펴보겠습니다.

조회수를 표시하려면 데이터베이스에서 값을 조회해야 하며, 이때 GET 명령어를 사용하면 됩니다. 또한 페이지 컴포넌트에 revalidate 설정을 추가해, 매 요청마다가 아니라 60초마다 한 번씩 페이지가 재검증되도록 하는 것이 좋습니다.

/app/[slug]/page.tsx
type Props = {
 params: {
 slug: string;
 };
};
 
export const revalidate = 60
 
export default function Page({ params }: Props) {
 const views = await redis.get<number>(["pageviews", "projects", params.slug].join(":")) ?? 0
 
 return ...
}

마무리

chronark.com에서 완성된 예시를 직접 확인해 보세요. 코드 역시 GitHub에 공개되어 있으며, 특히 다음 부분들이 핵심입니다:

  • API 라우트
  • 방문 추적 컴포넌트
  • 조회수 불러오기

더 많은 페이지뷰 분석 기능에 관심이 있다면 Twitter나 Discord로 의견을 남겨주세요.