BlogFlow | 블로그

프론트엔드 개발과 IT 기술을 중심으로 실무 경험과 학습을 기록합니다.

Firebase

Firestore seed data 실제 프로젝트 코드 분석

2026.02.12·수정 2026.03.12·약 30분

이 글에서 정리하는 내용

이 글은 단순한 Firestore 리뷰 시딩 코드 설명이 아닙니다. Firestore 읽기 전략, 인덱스 설계, 쓰기 제한 분석, 동시성 고려, 분포 기반 데이터 설계까지 포함한 실무형 설계 분석입니다.

왜 리뷰 시딩 스크립트가 필요한가

ChatGPT Image 2026년 2월 12일 오후 03 28 31

포트폴리오의 신뢰도 문제

리뷰가 전혀 없는 쇼핑몰은 기능은 동작하더라도 실제 서비스처럼 보이지 않습니다. 특히 정렬(orderBy), 필터(where), 페이지네이션(limit, startAfter), 평점 평균 계산, 리뷰 개수 집계 같은 기능은 모두 데이터가 충분히 존재할 때만 의미를 가집니다.

리뷰 데이터가 없다면 인덱스 설계도 검증할 수 없고, 읽기 비용 패턴도 분석할 수 없으며, 정렬 기준이 실제로 성능에 어떤 영향을 미치는지도 확인할 수 없습니다. 즉 리뷰는 단순 콘텐츠가 아니라, Firestore 읽기 전략과 인덱스 구조를 검증하기 위한 테스트 데이터이자 설계 완성도를 보여주는 지표입니다.

단순 더미 vs 설계된 더미 데이터 차이

단순 더미 데이터는 같은 평점, 같은 시간대, 같은 길이의 반복 텍스트를 무작위로 넣는 수준에 머뭅니다. 반면 설계된 더미 데이터는 분포를 가집니다. 생성 시점이 분산되어 있고, 최근 리뷰가 더 많으며, 평점이 일정 확률 구조를 따르고, 상품 옵션(size, color)과 연결되어 있습니다.

이는 단순 시각적 채우기가 아니라 쿼리 확장성, 통계 계산, 집계 전략, 비용 모델 검증까지 고려한 데이터 설계입니다. 이 스크립트의 목적은 ‘리뷰 10개 생성’이 아니라, 실제 쇼핑몰과 유사한 데이터 패턴을 인위적으로 설계하여 Firestore 구조와 읽기 전략을 테스트하는 데 있습니다.

전체 Firestore 구조 설계 분석

[전체 코드 보기]

[Firebase storage 구조] categories → products → reviews

categories
 └── categoryName
      └── products
           └── product

reviews
 └── reviewId

왜 reviews를 루트 컬렉션에 둔 것인가

리뷰를 어디에 두느냐는 “어떻게 자주 조회할 것인가”에 대한 선택입니다. 리뷰를 각 상품 안의 서브컬렉션으로 두면 해당 상품의 리뷰만 가져오는 것은 직관적입니다. 하지만 전체 리뷰를 최신순으로 모아서 보거나, 특정 사용자가 작성한 리뷰만 모아보거나, 전체 평균 평점을 계산하는 작업은 오히려 복잡해집니다.

루트 컬렉션에 reviews를 두면 productId를 기준으로 필터링해서 조회할 수 있고, 동시에 사용자 기준 조회나 통계 집계도 같은 컬렉션에서 처리할 수 있습니다. Firestore는 인덱스 기반으로 동작하고 읽기 요청마다 비용이 발생하기 때문에, 데이터를 쓰기 편한 구조로 배치하기보다 “앞으로 어떤 방식으로 자주 읽을 것인가”를 먼저 결정하고 구조를 설계하는 것이 더 중요합니다.

쿼리 전략 관점 분석

예를 들어 특정 상품의 리뷰를 “최신순”으로 보여주려면 두 가지 조건이 동시에 필요합니다. 첫 번째는 productId가 같은 리뷰만 가져오는 것, 두 번째는 createdAt 기준으로 정렬하는 것입니다. 즉, “이 상품의 리뷰만” + “작성일 최신순”이라는 두 조건을 한 번에 처리해야 합니다.

Firestore는 이런 복합 조건을 처리하기 위해 미리 정렬 기준을 저장해둔 인덱스를 사용합니다. productId와 createdAt을 함께 사용하는 경우, 두 필드를 묶은 복합 인덱스가 필요합니다. 이 인덱스가 없으면 Firestore는 어떤 순서로 정렬해야 할지 알 수 없기 때문에 에러를 발생시키고, 콘솔에서 인덱스를 생성하라는 안내를 보여줍니다.

즉, 우리가 어떤 조건으로 데이터를 조회할지 먼저 정해야 하고, 그에 맞춰 인덱스를 준비해야 합니다. Firestore는 조회 조건이 곧 설계 기준이 되는 구조이기 때문에, 컬렉션 설계는 항상 “어떻게 읽을 것인가”에서 출발합니다.

리뷰 생성 자동화 코드 분석

3-1. 클라이언트 SDK vs firebase-admin

const { initializeApp } = require('firebase/app');

현재 스크립트는 firebase/app, firebase/firestore를 사용하는 클라이언트 SDK 기반입니다. 이 방식은 원래 브라우저 환경에서 사용자 인증을 전제로 동작하도록 설계되어 있습니다. 따라서 Firestore 보안 규칙의 영향을 그대로 받습니다. 예를 들어 특정 컬렉션에 쓰기 권한이 없으면 시딩 스크립트도 실패합니다.

반면 firebase-admin은 서버 전용 SDK입니다. 서비스 계정 키를 사용해 서버 권한으로 접근하며, 보안 규칙을 우회하고 직접 데이터베이스에 접근합니다. 대량 데이터 시딩, 마이그레이션, 통계 재계산 같은 작업은 일반 사용자 권한이 아니라 서버 권한으로 처리하는 것이 일반적입니다.

소규모 테스트 환경에서는 클라이언트 SDK도 동작하지만, 수천 건 이상의 데이터를 한 번에 처리하거나 운영 데이터에 직접 영향을 주는 작업이라면 admin SDK가 더 안정적이고 빠른 선택입니다. 즉, 어떤 SDK를 쓰느냐는 단순 취향 문제가 아니라 작업 성격에 따른 설계 선택입니다.

저의 프로젝트는 firebase-admin이 아닙니다. 브라우저용 SDK를 Node 환경에서 실행한 구조입니다.

firebase-admin을 사용하려면 서비스 계정 키를 발급받아 서버 권한으로 초기화합니다.

const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json');

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

const db = admin.firestore();

이 방식은 보안 규칙을 통과하는 것이 아니라, 서버 권한으로 직접 접근하는 방식입니다.

3-2. ID 생성 전략 비교

// 리뷰 ID 생성 함수
function generateReviewId() {
  return Math.random().toString(36).substr(2, 20);
}

해당 글을 준비하면서 추가로 공부하니 해당 함수는 필요 없다는 것을 알았습니다.

이 방식은 자바스크립트의 Math.random()을 이용해 임의 문자열을 만드는 방법입니다. 간단하게 구현할 수 있고 별도 설정이 필요 없다는 장점이 있습니다. 하지만 난수 생성은 완전한 고유값을 보장하지 않기 때문에, 데이터가 많아질수록 아주 낮은 확률로라도 ID 충돌이 발생할 가능성이 존재합니다.

Firestore는 자체적으로 autoId 생성 기능을 제공합니다. 예를 들어 doc(collection(db,'reviews')).id를 사용하면, 내부적으로 충돌 가능성을 최소화하도록 설계된 20자 랜덤 ID가 생성됩니다. 이 ID는 분산 저장 구조에 맞게 설계되어 있어 쓰기 시 특정 구간에 트래픽이 몰리지 않도록 돕는 역할도 합니다.

따라서 소규모 테스트에서는 Math.random 방식도 동작하지만, 대량 데이터 시딩이나 운영 환경을 고려한다면 Firestore의 autoId를 사용하는 것이 더 안전하고 구조적으로도 적합한 선택입니다.

// Firestore autoId 사용 예시
const reviewRef = doc(collection(db, 'reviews'));

await setDoc(reviewRef, {
  productId: product.id,
  userName: userName,
  rating: template.rating,
  createdAt: Timestamp.now()
});

3-3. Timestamp 사용 이유

// 핵심 부분만 축약
const createdAt = new Date();

const review = {
  productId: product.id,
  rating: template.rating,
  createdAt: Timestamp.fromDate(createdAt),
  updatedAt: Timestamp.fromDate(createdAt)
};

여기서 중요한 부분은 Timestamp.fromDate(createdAt)입니다. 자바스크립트의 Date 객체를 그대로 저장하는 것이 아니라, Firestore 전용 Timestamp 타입으로 변환해서 저장하고 있습니다.

Firestore는 내부적으로 Timestamp 타입을 기준으로 정렬과 비교 연산을 수행합니다. 예를 들어 orderBy('createdAt') 쿼리를 실행하면, Firestore는 해당 필드를 인덱스에 저장된 Timestamp 기준으로 정렬합니다. 이 타입이 명확해야 정렬이 안정적으로 동작하고, 범위 조회(startAfter, endBefore)도 정확하게 처리됩니다.

Date를 그대로 저장하면 SDK에 따라 직렬화 과정이 달라질 수 있고, 서버 기준 시간과 클라이언트 기준 시간이 혼합될 가능성도 존재합니다. Timestamp는 초와 나노초 단위까지 명확하게 저장되며, Firestore 서버 시간 기준 정렬에 최적화된 구조입니다. 따라서 단순 시간 저장이 아니라, 쿼리 안정성과 인덱스 정렬을 위한 타입 선택이라고 이해하는 것이 정확합니다.

3-4. 분포 기반 시간 설계

현재 로직은 0~60일 사이를 균등하게 나누는 방식입니다. 즉, 1일 전 리뷰와 58일 전 리뷰가 동일한 확률로 생성됩니다. 구현은 단순하지만 실제 서비스 패턴과는 다소 차이가 있습니다.

실제 쇼핑몰 데이터를 보면 리뷰는 초기에 많이 몰리고, 시간이 지날수록 점점 줄어드는 경향을 보입니다. 예를 들어 최근 7일 내 리뷰가 전체의 약 60%를 차지하고, 이후 30일까지 완만히 감소하며, 그 이후는 소수만 존재하는 구조입니다. 이는 구매 직후 리뷰 작성이 집중되기 때문입니다.

이를 구현하려면 단순 Math.random()이 아니라 가중치 기반 랜덤을 적용할 수 있습니다.

// 최근 7일 60%, 30일 20%, 그 외 20%
function getWeightedDaysAgo() {
  const r = Math.random();
  if (r < 0.6) return Math.floor(Math.random() * 7);
  if (r < 0.8) return Math.floor(Math.random() * 23) + 7;
  return Math.floor(Math.random() * 30) + 30;
}

최근 날짜에 더 높은 확률을 부여하는 방식입니다. 이렇게 하면 최신 리뷰가 자연스럽게 많아 보이는 패턴을 만들 수 있습니다.

3-5. 평점 분포 설계

현재 구조는 템플릿에 정의된 고정 평점을 순서대로 사용하거나 랜덤 선택하는 방식입니다. 이 방식은 구현이 단순하지만, 여러 상품에 동일한 패턴이 반복될 수 있습니다. 예를 들어 항상 5점 6개, 4점 3개, 3점 1개 구조가 반복된다면 데이터가 인위적으로 보일 수 있습니다.

개선 방법은 먼저 상품별 기준 평균(baseline rating)을 설정하는 것입니다. 예를 들어 어떤 상품은 평균 4.6, 어떤 상품은 4.2로 시작합니다. 이후 ±0.5 범위 내에서 확률 기반 분산을 적용합니다. 이렇게 하면 각 상품마다 평점 구조가 조금씩 달라지고, 전체 평균도 자연스럽게 형성됩니다.

또한 5점이 가장 많되, 1~2점도 소수 포함시키는 방식이 현실적입니다.

// baseline 평균 기준 ±0.5 분산
function generateRating(baseline = 4.5) {
  const variance = (Math.random() - 0.5);
  const rating = baseline + variance;
  return Math.max(1, Math.min(5, Math.round(rating)));
}

상품마다 기준 평균을 다르게 두고 소폭 분산을 주면, 반복 패턴 없이 자연스러운 평점 분포를 만들 수 있습니다.

현재 구조의 한계점

N번 네트워크 요청 문제

await setDoc(...)

현재 구조는 리뷰 하나를 저장할 때마다 await setDoc()이 한 번씩 실행됩니다. 즉, 상품이 100개이고 상품당 리뷰를 10개 생성하면 총 1000번의 네트워크 요청이 순차적으로 발생합니다. 한 번 저장할 때마다 "서버에 요청 → 저장 → 응답 대기" 과정을 반복하는 구조입니다.

Firestore는 무제한으로 빠르게 쓰기를 허용하는 구조가 아닙니다. 문서 하나에 대해 초당 1회 정도의 쓰기를 권장하고 있으며, 특정 컬렉션에 짧은 시간 동안 요청이 몰리면 내부적으로 속도를 제한합니다. 이때 서버가 "RESOURCE_EXHAUSTED(429)" 오류를 반환할 수 있습니다. 쉽게 말하면 "요청이 너무 많으니 잠시 후 다시 시도하라"는 의미입니다.

지금처럼 요청을 하나씩 보내는 구조는 상품 수가 적을 때는 큰 문제가 없습니다. 하지만 상품이 수천 개로 늘어나면 수만 번의 요청이 발생하고, 실행 시간이 길어지거나 중간에 실패할 가능성도 커집니다. 특히 클라이언트 SDK 기반 구조는 브라우저 환경을 전제로 설계되었기 때문에 대량 데이터 마이그레이션이나 시딩 작업에는 구조적으로 비효율적입니다.

즉, 이 문제의 핵심은 단순히 "느리다"가 아니라, 네트워크 요청 수가 데이터 개수에 비례해 선형적으로 증가한다는 점입니다. 이 한계를 이해하고 batch 처리나 서버 SDK 전환을 고려하는 것이 실무 설계 관점에서 중요합니다.

중복 방지 없음

현재 구조는 스크립트를 다시 실행하면 기존 리뷰를 확인하지 않고 그대로 새 리뷰를 또 생성합니다. 즉, 같은 상품에 대해 리뷰 10개를 만들고 다시 실행하면 20개, 또 실행하면 30개로 계속 누적됩니다.

테스트 단계에서는 단순히 "많이 생겼네" 정도로 보일 수 있지만, 자동화 환경에서는 큰 문제가 됩니다. 예를 들어 CI 환경에서 시딩 스크립트를 여러 번 실행하면 데이터가 의도하지 않게 계속 증가하고, 평균 평점이나 리뷰 개수도 왜곡됩니다.

아래는 현재 구조와 같은 문제를 만드는 단순 생성 코드입니다.

// 기존 구조 (중복 체크 없음)
for (let i = 0; i < 10; i++) {
  const reviewRef = doc(collection(db, 'reviews'));
  await setDoc(reviewRef, {
    productId: product.id,
    rating: 5,
    createdAt: Timestamp.now()
  });
}

개선 방법은 "이미 존재하는지 먼저 확인"하거나, 시딩 전에 기존 데이터를 정리하는 것입니다.

// 개선 구조 1: 존재 여부 체크
const existing = await getDocs(
  query(collection(db,'reviews'), where('productId','==',product.id))
);

if (existing.empty) {
  // 리뷰가 없을 때만 생성
}

// 개선 구조 2: 시딩 전 정리
const oldReviews = await getDocs(
  query(collection(db,'reviews'), where('productId','==',product.id))
);

oldReviews.forEach(docSnap => deleteDoc(docSnap.ref));

핵심은 "여러 번 실행해도 항상 동일한 상태가 되도록(idempotent) 만드는 것"입니다. 테스트 자동화 환경에서는 이 원칙이 매우 중요합니다.

동시성 고려 부족

동시성 문제는 여러 요청이 같은 데이터를 동시에 수정할 때 발생합니다. 예를 들어 리뷰가 동시에 2개 등록되면서 product.rating을 다시 계산한다고 가정해보겠습니다.

아래는 안전하지 않은 평균 계산 방식입니다.

// 위험한 방식 (race condition 가능)
const productSnap = await getDoc(productRef);
const data = productSnap.data();

const newAverage = (data.rating * data.reviewCount + newRating) 
  / (data.reviewCount + 1);

await updateDoc(productRef, {
  rating: newAverage,
  reviewCount: data.reviewCount + 1
});

이 구조에서는 읽기 → 계산 → 쓰기 사이에 다른 요청이 끼어들 수 있습니다. 두 요청이 동시에 실행되면 마지막 요청이 앞선 계산을 덮어쓰게 됩니다.

이를 방지하려면 트랜잭션을 사용해 읽기와 쓰기를 하나의 원자적 작업으로 묶어야 합니다.

// 안전한 트랜잭션 방식
await runTransaction(db, async (transaction) => {
  const snap = await transaction.get(productRef);
  const data = snap.data();

  const newCount = data.reviewCount + 1;
  const newTotal = data.totalRating + newRating;

  transaction.update(productRef, {
    reviewCount: newCount,
    totalRating: newTotal,
    rating: newTotal / newCount
  });
});

트랜잭션은 중간에 데이터가 변경되면 자동으로 재시도합니다. 즉 "동시에 여러 사용자가 접근하는 상황"을 전제로 안전하게 동작하도록 설계된 구조입니다.

product.rating을 동시에 갱신할 경우 race condition이 발생할 수 있습니다. 단순 평균 계산 후 set은 안전하지 않습니다.

동시성 문제는 여러 요청이 같은 데이터를 동시에 수정할 때 발생합니다. 예를 들어 리뷰가 동시에 2개 등록되면서 product.rating을 다시 계산한다고 가정해보겠습니다. 두 요청이 모두 "기존 평균은 4.5"라고 읽고 각각 평균을 계산한 뒤 set으로 덮어쓰면, 나중에 저장된 값이 앞선 계산을 덮어버릴 수 있습니다. 이것이 race condition입니다.

단순히 평균을 다시 계산해서 set하는 방식은 안전하지 않습니다. 왜냐하면 읽기 → 계산 → 쓰기 사이에 다른 요청이 끼어들 수 있기 때문입니다. 데이터가 적을 때는 잘 동작하는 것처럼 보이지만, 트래픽이 늘어나면 평균 값이 틀어지는 문제가 발생할 수 있습니다.

이를 해결하려면 Firestore 트랜잭션을 사용해 읽기와 쓰기를 하나의 원자적 작업으로 묶거나, 총합과 개수를 따로 관리하는 분산 카운터 전략을 사용해야 합니다. 즉, 단순 계산 문제가 아니라 "동시에 여러 사용자가 접근하는 상황"을 전제로 설계해야 하는 영역입니다.

product.rating을 동시에 갱신할 경우 race condition이 발생할 수 있습니다. 단순 평균 계산 후 set은 안전하지 않습니다.

실무형 개선 전략

ChatGPT Image 2026년 2월 12일 오후 03 28 37

1. writeBatch 적용

현재 구조는 리뷰 하나마다 await setDoc()을 호출합니다. 즉 리뷰 1000개면 1000번 서버와 통신합니다. 이 방식은 구현은 단순하지만 네트워크 요청 수가 데이터 개수에 비례해 그대로 증가합니다.

기존 방식 (요청 1회 = 문서 1개)

for (const review of reviews) {
  await setDoc(doc(db, 'reviews', review.id), review);
}

이 구조에서는 리뷰 하나 저장 → 응답 대기 → 다음 저장 순서로 반복됩니다. 데이터가 많아질수록 실행 시간이 길어지고, 쓰기 제한에 걸릴 가능성도 커집니다.

개선 방식 (Batch 단위 커밋)

import { writeBatch } from 'firebase/firestore';

let batch = writeBatch(db);
let count = 0;

for (const review of reviews) {
  const ref = doc(db, 'reviews', review.id);
  batch.set(ref, review);
  count++;

  if (count === 500) {
    await batch.commit();
    batch = writeBatch(db);
    count = 0;
  }
}

if (count > 0) {
  await batch.commit();
}

writeBatch는 여러 문서를 하나의 묶음으로 모아 한 번에 commit합니다. 이렇게 하면 네트워크 요청 수를 크게 줄일 수 있습니다. 예를 들어 리뷰 1000개를 저장한다면, 기존 방식은 1000회 요청이지만 batch 방식은 2회 commit으로 끝납니다.

다만 writeBatch는 한 번에 최대 500개 문서까지만 포함할 수 있습니다. 이를 초과하면 위 예시처럼 500개 단위로 나누어 분할 커밋해야 합니다. 즉, "무한 묶기"가 아니라 "500개 단위로 끊어 처리"가 원칙입니다.

2. firebase-admin 전환

현재 스크립트는 클라이언트 SDK 기반으로 동작합니다. 이 방식은 보안 규칙을 통과해야 하며, 사용자 권한 범위 내에서만 쓰기가 가능합니다. 즉 "테스트 사용자"가 데이터를 쓰는 구조입니다.

반면 firebase-admin은 서버 전용 SDK입니다. 서비스 계정 키를 사용해 서버 권한으로 직접 데이터베이스에 접근합니다. 이 경우 보안 규칙 검사를 거치지 않고, 관리자 권한으로 고속 처리할 수 있습니다. 대량 시딩, 데이터 마이그레이션, 통계 재계산 작업은 일반적으로 admin SDK를 사용하는 것이 안전합니다.

기존 구조 (클라이언트 SDK)

const { initializeApp } = require('firebase/app');
const { getFirestore } = require('firebase/firestore');

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

이 구조는 인증된 사용자가 Firestore에 접근하는 방식과 동일합니다.

개선 구조 (firebase-admin)

const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json');

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

const db = admin.firestore();

이 방식은 서버 권한으로 직접 Firestore에 접근합니다. 대량 쓰기 시 보안 규칙 검사 비용이 없고, 네트워크 재시도 로직도 안정적으로 동작합니다. 운영 환경에서 수천~수만 건을 한 번에 처리해야 한다면 admin SDK 전환이 구조적으로 더 적합합니다.

3. product.rating 계산 전략

상품 상세 페이지에서 평균 평점은 자주 조회되는 값입니다. 문제는 리뷰가 추가될 때마다 이 평균을 어떻게 안전하게 갱신할 것인가입니다. 단순히 "기존 평균을 읽고 → 새 리뷰를 더해 다시 계산 → set으로 저장"하는 방식은 동시 요청이 발생할 경우 값이 틀어질 수 있습니다.

예를 들어 동시에 두 개의 리뷰가 등록되면, 두 요청이 모두 "현재 평균은 4.5"라고 읽은 뒤 각각 계산해서 저장합니다. 나중에 저장된 값이 앞선 계산을 덮어쓰게 되는데, 이것이 race condition입니다. 이 문제를 방지하려면 읽기와 쓰기를 하나의 원자적 작업으로 묶는 트랜잭션을 사용해야 합니다.

트랜잭션 방식

await runTransaction(db, async (transaction) => {
  const productRef = doc(db, 'products', productId);
  const snap = await transaction.get(productRef);

  const data = snap.data();
  const newCount = data.reviewCount + 1;
  const newTotal = data.totalRating + newRating;

  transaction.update(productRef, {
    reviewCount: newCount,
    totalRating: newTotal,
    rating: newTotal / newCount
  });
});

이 방식은 읽기와 업데이트를 하나의 작업으로 묶어 중간에 다른 요청이 끼어들지 못하게 합니다.

또 다른 방식은 평균을 매번 계산하지 않고, 총합(totalRating)과 개수(reviewCount)를 미리 저장해두는 precomputed field 전략입니다. 리뷰 작성 시점에 쓰기 비용이 조금 더 증가하지만, 조회 시에는 별도의 계산 없이 바로 평균을 사용할 수 있습니다.

반대로 aggregation 쿼리 방식을 사용하면 평균을 조회할 때마다 리뷰 컬렉션을 읽어 계산합니다. 이 경우 쓰기 비용은 줄어들지만, 조회할 때마다 읽기 비용이 누적됩니다.

Firestore는 읽기와 쓰기 모두 비용 기반 구조입니다. 즉 aggregation 방식은 읽기 비용이 증가하는 모델이고, precompute 방식은 쓰기 비용이 증가하는 모델입니다. 어떤 전략이 더 적합한지는 "리뷰가 자주 쓰이는가"와 "평균이 자주 조회되는가"에 따라 달라집니다. 이 차이를 이해하는 것이 단순 평균 계산과 설계 전략의 차이입니다.

4. 가중 분포 적용

현재 방식은 모든 날짜에 동일한 확률을 부여하는 단순 랜덤 구조입니다. 즉 1일 전 리뷰와 50일 전 리뷰가 같은 확률로 생성됩니다. 구현은 쉽지만 실제 쇼핑몰 데이터 패턴과는 차이가 있습니다.

기존 방식 (균등 분포)

// 0~60일 사이 균등 랜덤
const daysAgo = Math.floor(Math.random() * 60);

이 방식은 "모든 구간이 동일 확률"이라는 특징을 가집니다. 데이터가 많아질수록 최근 리뷰 밀도가 낮아 보이고, 오래된 리뷰가 과도하게 섞이는 현상이 발생합니다.

개선 방식 (가중 분포 적용)

// 최근 7일 60%, 30일 이내 20%, 그 외 20%
function getWeightedDaysAgo() {
  const r = Math.random();
  if (r < 0.6) return Math.floor(Math.random() * 7);
  if (r < 0.8) return Math.floor(Math.random() * 23) + 7;
  return Math.floor(Math.random() * 30) + 30;
}

이 방식은 확률을 설계합니다. 최근 구간에 높은 가중치를 주고, 시간이 지날수록 확률을 낮추는 구조입니다. 이렇게 하면 최신순 정렬 시 상단에 자연스럽게 리뷰가 밀집됩니다.

핵심은 "난수를 사용하는 것"이 아니라 "확률을 설계하는 것"입니다. 통계 계산, 최근 리뷰 강조 UI, 신뢰도 판단 로직을 테스트할 때 훨씬 현실적인 환경을 제공합니다.

5. 인덱스 전략 명시

리뷰를 특정 상품 기준으로 최신순 정렬하려면 두 조건이 동시에 필요합니다. productId로 필터링하고, createdAt으로 정렬하는 구조입니다.

기존 방식 (인덱스 고려 없음)

query(
  collection(db, 'reviews'),
  where('productId','==', id),
  orderBy('createdAt', 'desc')
);

이 쿼리는 실행 자체는 가능하지만, 복합 인덱스가 없으면 Firestore는 에러를 발생시키고 콘솔에서 인덱스 생성 링크를 안내합니다. 즉, 실행 후 문제를 인지하는 구조입니다.

개선 방식 (인덱스 사전 정의)

{
  "collectionGroup": "reviews",
  "queryScope": "COLLECTION",
  "fields": [
    { "fieldPath": "productId", "order": "ASCENDING" },
    { "fieldPath": "createdAt", "order": "DESCENDING" }
  ]
}

사전에 인덱스를 정의하면 Firestore는 해당 조합에 맞게 정렬 구조를 미리 저장합니다. 조회 시 별도 정렬 계산 없이 빠르게 탐색할 수 있습니다.

또한 향후 평점 정렬, 추천 여부 필터, 상태(active) 조건이 추가될 경우 새로운 복합 인덱스가 필요합니다. 따라서 인덱스는 단순 설정이 아니라 "앞으로 어떤 쿼리를 확장할 것인가"를 전제로 한 설계 요소입니다.

결론

이 스크립트는 단순히 리뷰를 자동으로 생성하는 보조 도구가 아닙니다. Firestore라는 비용 기반, 인덱스 기반 데이터베이스의 특성을 이해하고 그 위에서 읽기 전략을 먼저 정의한 뒤 컬렉션 구조를 결정한 설계 사례입니다.

리뷰를 루트 컬렉션에 둔 이유는 단순 편의가 아니라 조회 패턴을 기준으로 한 선택이었습니다. where + orderBy 조합을 전제로 복합 인덱스를 설계했고, 향후 정렬·필터 조건 확장 가능성까지 고려했습니다. 이는 "데이터를 어떻게 저장할 것인가"가 아니라 "데이터를 어떻게 읽을 것인가"에서 출발한 구조입니다.

또한 N회 네트워크 요청 구조의 한계를 분석하고 writeBatch 500개 제한을 고려한 분할 커밋 전략을 제시했습니다. 클라이언트 SDK와 firebase-admin의 차이를 구분하고, 대량 시딩 작업에 적합한 실행 환경까지 고민했습니다. 이는 단순 코드 작성이 아니라 실행 비용과 안정성까지 포함한 판단입니다.

product.rating 계산에서는 트랜잭션 필요성과 race condition 위험을 짚었고, precompute 전략과 aggregation 전략의 비용 모델 차이까지 비교했습니다. Firestore는 읽기와 쓰기 모두 비용이 발생하는 구조이기 때문에, 어떤 시점에 비용을 지불할 것인지 선택해야 합니다.

마지막으로 시간 가중 분포와 평점 분산 설계를 통해 데이터 자체를 "현실적인 패턴"으로 만들었습니다. 이는 단순 랜덤 생성이 아니라 통계적 신뢰도와 UI 테스트 환경을 고려한 확률 설계입니다.

결국 이 시딩 스크립트는 더미 데이터 생성 코드가 아니라, Firestore 구조 이해 → 조회 전략 정의 → 비용 분석 → 동시성 고려 → 확장성 대비까지 이어지는 설계 사고의 결과물입니다. 이 관점이 드러날 때, 포트폴리오 코드는 단순 구현을 넘어 설계 사례로 평가받을 수 있습니다.

FAQ

Q. 리뷰는 왜 루트 컬렉션에 두었나요?
집계, 인덱스 단순화, 확장성 확보 때문입니다.

Q. 쓰기 제한은 어느 정도인가요?
문서당 초당 1회 쓰기 권장 제한이 있으며 대량 병렬 처리 시 429 오류가 발생할 수 있습니다.

Q. 평균 평점은 어떻게 안전하게 계산하나요?
트랜잭션 또는 분산 카운터 전략을 사용합니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기