OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 85 / 90
stack-analysis
CHAPTER 85 / 90
읽기 약 2
FUNCTION

좋아요/북마크/공유 시스템


핵심 개념

optimistic UI·카운터 캐싱·중복 방지·공유 통계 — 핵심 인터랙션.

본문

데이터 모델

SQL📋 코드 (40줄)
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);

좋아요 토글 (트랜잭션)

TYPESCRIPT📋 코드 (41줄)
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 (즉시 반영)

TSX📋 코드 (49줄)
'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>
  );
}

카운터 정확성 (분산 환경)

TYPESCRIPT📋 코드 (45줄)
// 동시성 폭주 시 — 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);

북마크 + 컬렉션

TYPESCRIPT📋 코드 (17줄)
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' },
  });
}

공유 추적

TSX📋 코드 (38줄)
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