stack-analysis
CHAPTER 85 / 90
읽기 약 2분
FUNCTION
좋아요/북마크/공유 시스템
핵심 개념
optimistic UI·카운터 캐싱·중복 방지·공유 통계 — 핵심 인터랙션.
본문
데이터 모델
CREATE TABLE likes (
user_id UUID NOT NULL,
post_id UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, post_id)
);
CREATE INDEX ON likes(post_id);
CREATE INDEX ON likes(user_id, created_at DESC);
CREATE TABLE bookmarks (
user_id UUID NOT NULL,
post_id UUID NOT NULL,
collection_id UUID, -- 컬렉션 분류
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, post_id)
);
-- 컬렉션 (북마크 분류)
CREATE TABLE bookmark_collections (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
name VARCHAR NOT NULL,
is_private BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- 공유 통계
CREATE TABLE shares (
id UUID PRIMARY KEY,
post_id UUID NOT NULL,
user_id UUID, -- NULL = 비로그인
channel VARCHAR NOT NULL, -- 'twitter', 'kakao', 'copy_link', 'email'
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX ON shares(post_id, created_at DESC);좋아요 토글 (트랜잭션)
async function toggleLike(userId: string, postId: string) {
return db.$transaction(async (tx) => {
const existing = await tx.like.findUnique({
where: { userId_postId: { userId, postId } },
});
if (existing) {
// 취소
await tx.like.delete({
where: { userId_postId: { userId, postId } },
});
await tx.post.update({
where: { id: postId },
data: { likesCount: { decrement: 1 } },
});
return { liked: false };
} else {
await tx.like.create({
data: { userId, postId },
});
await tx.post.update({
where: { id: postId },
data: { likesCount: { increment: 1 } },
});
// 알림 (자기 자신 제외)
const post = await tx.post.findUnique({ where: { id: postId } });
if (post!.userId !== userId) {
await notify({
userId: post!.userId,
type: 'like',
title: '좋아요',
body: `누군가가 회원님의 게시물에 좋아요를 눌렀습니다`,
link: `/posts/${postId}`,
});
}
return { liked: true };
}
});
}낙관적 UI (즉시 반영)
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
function LikeButton({ postId }: { postId: string }) {
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
return res.json();
},
// 낙관적 업데이트
onMutate: async () => {
await qc.cancelQueries({ queryKey: ['post', postId] });
const prev = qc.getQueryData(['post', postId]);
qc.setQueryData(['post', postId], (old: any) => ({
...old,
liked: !old.liked,
likesCount: old.likesCount + (old.liked ? -1 : 1),
}));
return { prev };
},
// 실패 시 롤백
onError: (err, _vars, ctx) => {
qc.setQueryData(['post', postId], ctx?.prev);
toast.error('실패했습니다');
},
// 서버 응답 후 재동기화
onSettled: () => {
qc.invalidateQueries({ queryKey: ['post', postId] });
},
});
const post = qc.getQueryData<Post>(['post', postId])!;
return (
<button
onClick={() => mutation.mutate()}
className={post.liked ? 'liked' : ''}
>
{post.liked ? '❤️' : '🤍'} {post.likesCount.toLocaleString()}
</button>
);
}카운터 정확성 (분산 환경)
// 동시성 폭주 시 — Redis 카운터 + 주기적 동기화
async function fastLike(userId: string, postId: string) {
// 1. Redis에 즉시 반영
const liked = await redis.sismember(`post:${postId}:likes`, userId);
if (liked) {
await redis.srem(`post:${postId}:likes`, userId);
await redis.decr(`post:${postId}:likes_count`);
} else {
await redis.sadd(`post:${postId}:likes`, userId);
await redis.incr(`post:${postId}:likes_count`);
}
// 2. 큐에 영속화 작업 추가
await likeQueue.add('persist', { userId, postId, action: liked ? 'unlike' : 'like' });
return { liked: !liked };
}
// Worker — DB 동기화
const persistWorker = new Worker('like', async (job) => {
const { userId, postId, action } = job.data;
if (action === 'like') {
await db.like.upsert({
where: { userId_postId: { userId, postId } },
create: { userId, postId },
update: {},
});
} else {
await db.like.delete({
where: { userId_postId: { userId, postId } },
}).catch(() => {});
}
});
// 주기적 카운터 동기화 (1시간)
setInterval(async () => {
const posts = await db.post.findMany({ select: { id: true } });
for (const post of posts) {
const count = await redis.scard(`post:${post.id}:likes`);
await db.post.update({ where: { id: post.id }, data: { likesCount: count } });
}
}, 3600000);북마크 + 컬렉션
async function bookmark(userId: string, postId: string, collectionId?: string) {
return db.bookmark.upsert({
where: { userId_postId: { userId, postId } },
create: { userId, postId, collectionId },
update: { collectionId },
});
}
// 사용자의 모든 북마크
async function getBookmarks(userId: string, collectionId?: string) {
return db.bookmark.findMany({
where: { userId, ...(collectionId && { collectionId }) },
include: { post: { include: { user: true } } },
orderBy: { createdAt: 'desc' },
});
}공유 추적
function ShareButton({ post }: { post: Post }) {
const trackShare = (channel: string) => {
fetch(`/api/posts/${post.id}/share`, {
method: 'POST',
body: JSON.stringify({ channel }),
});
};
const url = `${window.location.origin}/posts/${post.id}`;
return (
<Dropdown>
<Item onClick={() => {
navigator.clipboard.writeText(url);
trackShare('copy_link');
toast.success('링크 복사됨');
}}>
링크 복사
</Item>
<Item onClick={() => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}`);
trackShare('twitter');
}}>
Twitter
</Item>
<Item onClick={() => {
// KakaoTalk SDK
Kakao.Share.sendDefault({
objectType: 'feed',
content: { title: post.title, link: { mobileWebUrl: url, webUrl: url } },
});
trackShare('kakao');
}}>
카카오톡
</Item>
</Dropdown>
);
}다음 챕터
CH.86 "댓글 시스템: 중첩 댓글 + 멘션".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 프로젝트의 좋아요 북마크 공유 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
좋아요 북마크 공유 관련 실제 서비스 5개를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 코드베이스에서 좋아요 북마크 공유 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 풀스택 시장의 좋아요 북마크 공유 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
좋아요/북마크/공유 시스템은 이 3가지만 확실히 잡으세요
1.낙관적 UI = 즉시 반영 + 실패 시 롤백 — UX 빠름
2.대규모는 Redis 카운터 + 큐로 영속화 — DB 부담 분산
3.공유는 채널별 추적 — 어디서 가장 많이 공유되나 분석
공유하기
진행도 85 / 90