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

리뷰/평점 시스템


핵심 개념

review·rating·verified purchase·moderation·집계 캐싱 — 신뢰 가능한 리뷰.

본문

데이터 모델

SQL📋 코드 (34줄)
CREATE TABLE reviews (
  id UUID PRIMARY KEY,
  product_id UUID NOT NULL,
  user_id UUID NOT NULL,
  order_id UUID,                    -- 검증된 구매 (verified)
  rating SMALLINT CHECK (rating BETWEEN 1 AND 5),
  title VARCHAR,
  content TEXT NOT NULL,
  images TEXT[],
  verified_purchase BOOLEAN DEFAULT false,
  helpful_count INT DEFAULT 0,
  status VARCHAR DEFAULT 'pending',  -- pending, published, rejected
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE (product_id, user_id, order_id)  -- 1주문당 1리뷰
);

CREATE INDEX ON reviews(product_id, created_at DESC) WHERE status = 'published';
CREATE INDEX ON reviews(user_id);


-- 도움됨 표시
CREATE TABLE review_helpful (
  user_id UUID,
  review_id UUID,
  helpful BOOLEAN,  -- true=helpful, false=unhelpful
  PRIMARY KEY (user_id, review_id)
);


-- 상품 평점 집계 (캐싱)
ALTER TABLE products ADD COLUMN avg_rating DECIMAL(3,2) DEFAULT 0;
ALTER TABLE products ADD COLUMN reviews_count INT DEFAULT 0;
ALTER TABLE products ADD COLUMN ratings_breakdown JSONB DEFAULT '{}';
-- 예: { "1": 2, "2": 5, "3": 12, "4": 30, "5": 50 }

리뷰 작성 — Verified Purchase

TYPESCRIPT📋 코드 (62줄)
async function createReview(userId: string, input: CreateReviewInput) {
  // 1. 검증된 구매인가?
  const order = await db.order.findFirst({
    where: {
      id: input.orderId,
      userId,
      status: { in: ['delivered', 'completed'] },
      items: { some: { variant: { productId: input.productId } } },
    },
  });

  // 2. 이미 작성했는가?
  const existing = await db.review.findFirst({
    where: { productId: input.productId, userId, orderId: input.orderId },
  });
  if (existing) throw new ConflictError('Already reviewed');

  // 3. 작성 (검증된 구매면 즉시 published, 아니면 pending)
  return db.$transaction(async (tx) => {
    const review = await tx.review.create({
      data: {
        ...input,
        userId,
        verifiedPurchase: !!order,
        status: order ? 'published' : 'pending',  // 비검증은 모더레이션 후
      },
    });

    if (review.status === 'published') {
      await updateProductStats(tx, input.productId);
    }

    return review;
  });
}


async function updateProductStats(tx: any, productId: string) {
  const stats = await tx.review.groupBy({
    by: ['rating'],
    where: { productId, status: 'published' },
    _count: true,
  });

  const breakdown: any = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
  let total = 0;
  let sum = 0;
  for (const s of stats) {
    breakdown[s.rating] = s._count;
    total += s._count;
    sum += s.rating * s._count;
  }

  await tx.product.update({
    where: { id: productId },
    data: {
      avgRating: total > 0 ? Number((sum / total).toFixed(2)) : 0,
      reviewsCount: total,
      ratingsBreakdown: breakdown,
    },
  });
}

도움됨 토글

TYPESCRIPT📋 코드 (28줄)
async function toggleHelpful(userId: string, reviewId: string) {
  return db.$transaction(async (tx) => {
    const existing = await tx.reviewHelpful.findUnique({
      where: { userId_reviewId: { userId, reviewId } },
    });

    if (existing?.helpful) {
      // 취소
      await tx.reviewHelpful.delete({
        where: { userId_reviewId: { userId, reviewId } },
      });
      await tx.review.update({
        where: { id: reviewId },
        data: { helpfulCount: { decrement: 1 } },
      });
    } else {
      await tx.reviewHelpful.upsert({
        where: { userId_reviewId: { userId, reviewId } },
        create: { userId, reviewId, helpful: true },
        update: { helpful: true },
      });
      await tx.review.update({
        where: { id: reviewId },
        data: { helpfulCount: { increment: 1 } },
      });
    }
  });
}

모더레이션

TYPESCRIPT📋 코드 (45줄)
// 어뷰즈 자동 검출
async function checkAbuse(content: string) {
  // 1. 부적절 단어 필터
  const banned = await db.bannedWord.findMany();
  if (banned.some(w => content.toLowerCase().includes(w.word))) {
    return { flagged: true, reason: 'banned_word' };
  }

  // 2. 도배 (반복 문자)
  if (/(.)\1{10,}/.test(content)) {
    return { flagged: true, reason: 'spam_pattern' };
  }

  // 3. 외부 링크 다수
  const links = (content.match(/https?:\/\//g) ?? []).length;
  if (links > 2) {
    return { flagged: true, reason: 'too_many_links' };
  }

  // 4. AI 모더레이션 (OpenAI Moderation API)
  const res = await fetch('https://api.openai.com/v1/moderations', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${process.env.OPENAI_KEY}` },
    body: JSON.stringify({ input: content }),
  });
  const data = await res.json();
  if (data.results[0].flagged) {
    return { flagged: true, reason: 'ai_flagged', categories: data.results[0].categories };
  }

  return { flagged: false };
}


async function createReview(userId: string, input: any) {
  const { flagged } = await checkAbuse(input.content);
  const review = await db.review.create({
    data: {
      ...input,
      userId,
      status: flagged ? 'pending' : 'published',
      // pending = 관리자 검토 후 published
    },
  });
}

리뷰 정렬·필터

TYPESCRIPT📋 코드 (28줄)
app.get('/products/:id/reviews', async (req, res) => {
  const { sort = 'helpful', filter, page = 1 } = req.query;

  const orderBy = {
    helpful: [{ helpfulCount: 'desc' }, { createdAt: 'desc' }],
    recent: [{ createdAt: 'desc' }],
    high: [{ rating: 'desc' }, { createdAt: 'desc' }],
    low: [{ rating: 'asc' }, { createdAt: 'desc' }],
  }[sort as string] ?? [{ createdAt: 'desc' }];

  const where: any = {
    productId: req.params.id,
    status: 'published',
  };
  if (filter === 'verified') where.verifiedPurchase = true;
  if (filter === 'with_images') where.images = { isEmpty: false };

  const [items, total] = await Promise.all([
    db.review.findMany({
      where, orderBy,
      take: 10, skip: (Number(page) - 1) * 10,
      include: { user: { select: { name: true, avatarUrl: true } } },
    }),
    db.review.count({ where }),
  ]);

  res.json({ items, total });
});

다음 챕터

CH.78 "쿠폰/할인 로직".


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.verified_purchase = 실제 구매 후 작성 — 신뢰도 향상
2.평점 집계는 캐싱 (avg_rating, ratings_breakdown) — 매번 계산 X
3.모더레이션은 룰 기반 + AI (OpenAI Moderation) 조합


공유하기
진행도 77 / 90