발행일 : 2025-07-28
Next.js와 Supabase를 활용해서 로그인 없이 좋아요 기능을 구현한 방법을 공유합니다.
↗️ 저기 있는 버튼
블로그나 개인 프로젝트에 적용하기 좋은 방식입니다.
💡 핵심 아이디어
- 방문자 ID(visitorId) 를
localStorage에 저장하여 사용자 식별 - Supabase에
post_id+visitor_id조합으로 좋아요 저장 - 옵티미스틱 UI로 네트워크 요청 완료 전에도 즉시 좋아요 상태/개수 반영
- 로그인 없이도 좋아요 토글 및 개수 표시 가능
🏭 Visitor ID 생성 함수
// lib/utils.ts
export function getOrCreateVisitorId() {
let visitorId = localStorage.getItem("visitorId");
if (!visitorId) {
visitorId = crypto.randomUUID();
localStorage.setItem("visitorId", visitorId);
}
return visitorId;
}
🗃 Supabase 테이블 구조
| Column | Type | Description |
|---|---|---|
post_id | UUID | 블로그 포스트 ID |
visitor_id | UUID | 방문자 ID |
created_at | TIMESTAMP | 생성 시각 |
🔌 Supabase 서비스 함수 (posts.ts)
"use server";
export const fetchPost = cache(async (slug: string) => {
const supabase = await createClient();
const { data: post, error } = await supabase
.from("posts")
.select("*")
.match({ slug })
.single();
if (error) {
console.error("Error fetching post:", error);
throw new Error(error.message);
}
return post;
});
export async function fetchLikedId(postId: string, visitorId: string) {
const supabase = await createClient();
const { data, error } = await supabase
.from("post_likes")
.select("id")
.match({ post_id: postId, visitor_id: visitorId })
.maybeSingle();
if (error) {
console.error("Error fetching likes:", error);
throw new Error(error.message);
}
return data;
}
export const fetchLikedCount = async (postId: string): Promise<number> => {
const supabase = await createClient();
const { count } = await supabase
.from("post_likes")
.select("id", { count: "exact" })
.eq("post_id", postId);
return count || 0;
};
export async function likePost(postId: string, visitorId: string) {
const supabase = await createClient();
const { error } = await supabase.from("post_likes").insert({
post_id: postId,
visitor_id: visitorId,
});
if (error) {
console.error("Error while liking:", error);
throw new Error(error.message);
}
}
export async function unlikePost(postId: string, visitorId: string) {
const supabase = await createClient();
const { error } = await supabase
.from("post_likes")
.delete()
.match({ post_id: postId })
.match({ visitor_id: visitorId });
if (error) {
console.error("Error while unliking:", error);
throw new Error(error.message);
}
}
?의문점
- post를 가져올 때 아래와 같이 좋아요수를 조인해서 같이 가져오지 않는 이유가 있나요??
export const fetchPost = cache(async (slug: string) => {
const supabase = await createClient();
const { data: post, error } = await supabase
.from("posts")
.select(
`
*,
post_likes ( id ), 👈 post_likes 테이블 조인
comments (*)
`,
)
.match({ slug })
.single();
if (error) {
console.error("Error fetching post:", error);
throw new Error(error.message);
}
return post;
});
- post 정보는 현재 아래와 같이
generateStaticParams함수에 의해 정적으로 생성되고 있습니다.
export async function generateStaticParams(): Promise<{ slug: string }[]> {
const posts = await fetchPosts("dev");
return posts.map((post) => ({ slug: post.slug }));
}
- 따라서
handleLikeToggle함수에서router.refresh를 하더라도 dynamic page가 아니기때문에 카운트가 변경이되어도 해당 사항을 반영하지 못합니다. - 결국 client side fetching을 하여 최신 DB의 상태를 반영하게 합니다.
❤️ 좋아요 버튼 컴포넌트 (옵티미스틱 UI 적용)
"use client";
import { Button } from "@/components/ui/button";
import {
fetchLikedId,
fetchLikedCount,
likePost,
unlikePost,
} from "@/lib/supabase/service/posts";
import { getOrCreateVisitorId } from "@/lib/utils/utils";
import { HeartIcon, LoaderCircleIcon } from "lucide-react";
import { useEffect, useState } from "react";
type Props = {
postId: string;
};
export default function PostLikes({ postId }: Props) {
const [isFetching, setIsFetching] = useState(true);
const [isLiked, setIsLiked] = useState(false);
const [likedCount, setLikedCount] = useState(0);
useEffect(() => {
const initializeLikes = async () => {
setIsFetching(true);
const visitorId = getOrCreateVisitorId();
const [likedIdData, likedCountData] = await Promise.all([
fetchLikedId(postId, visitorId),
fetchLikedCount(postId),
]);
setIsLiked(!!likedIdData);
setLikedCount(likedCountData);
setIsFetching(false);
};
initializeLikes();
}, [postId]);
const handleLikeToggle = async () => {
if (isFetching) return;
const visitorId = getOrCreateVisitorId();
setIsLiked((prev) => !prev);
setLikedCount((prev) => (isLiked ? prev - 1 : prev + 1));
try {
isLiked
? await unlikePost(postId, visitorId)
: await likePost(postId, visitorId);
} catch (error) {
console.error("Failed to like/unlike post:", error);
setIsLiked((prev) => !prev);
setLikedCount((prev) => (isLiked ? prev + 1 : prev - 1));
}
};
return (
<Button
onClick={handleLikeToggle}
className="w-16 cursor-pointer shadow-2xl"
variant="outline"
disabled={isFetching}
>
{isFetching ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<>
{isLiked ? <HeartIcon className="fill-primary" /> : <HeartIcon />}
{likedCount}
</>
)}
</Button>
);
}
⚠️ localStorage 기반 visitorId 한계
- 브라우저 초기화, 쿠키 삭제, 시크릿 모드 → visitorId 변경됨
- 동일 사용자가 다른 기기·브라우저에서 중복 좋아요 가능
- abuse 방지를 위해 rate limit 고려 필요
✅ 정리
- 간단한 블로그나 개인 프로젝트에 적합
- 로그인 없이도 사용자 참여를 유도할 수 있음
- 옵티미스틱 UI로 UX를 크게 향상시킬 수 있음