2탄 권2 제3장
2탄 권2 — 제3장

Day 8~14: Supabase + DB + pg_cron + 검수 SLA

DB 본격 구축 — 4 테이블 + 트리거 3개 + IP rate limit Redis + 페이스 점검

📑 이 챕터에서 다룰 내용
📘 들어가며 — DB 본격 구축

권2 제2장에서 G1 통과. 누적 35h. 이번 주 (2주차 Day 8~14)는 DB 본격 구축 + G-6 (IP rate limit Redis) 입증 자리. SPEC v4의 "DB 4 테이블 + 트리거 3 + 복합 인덱스 + pg_cron"이 코드로 박힙니다.

  • 사전 지식: Day 1~7 + G1 통과 / SPEC v4 §3 DB 스키마 / PLAN G2 통과 조건
  • 이 장의 목적: Day 8 (Supabase + 4 테이블) → Day 14 (페이스 점검 + 2주차 회고)
  • 완료 후 결과물: Supabase 프로젝트 + 4 테이블 + 트리거 3 + 복합 인덱스 + pg_cron + admin/queue 페이지 + Slack 알림
3-1 Day 8 — Supabase 프로젝트 + 4 테이블 SQL + IP rate limit Redis 🔗

Day 8은 휴식 의식 후 작업 30% 축소 (4h → 2.5h)로 시작합니다.

Claude가 작성한 0001_init.sql (약 80줄)

💻 supabase/migrations/0001_init.sql
-- 줍줍 4 테이블 + 인덱스

-- 1. users
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  kakao_id TEXT UNIQUE NOT NULL,
  nickname TEXT NOT NULL,
  fcm_token TEXT,
  target_business_type TEXT,
  target_stage TEXT,
  target_region TEXT,
  target_age_min INT,
  target_age_max INT,
  target_household_type TEXT,
  income_criteria TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 2. benefits — SSOT for two tabs (E5 [1])
CREATE TABLE benefits (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source TEXT NOT NULL,
  source_id TEXT NOT NULL,
  name TEXT NOT NULL,
  support_type TEXT,
  deadline DATE,
  support_amount BIGINT,
  target_business_type TEXT,
  target_stage TEXT,
  target_age_min INT,
  target_age_max INT,
  target_household_type TEXT,
  income_criteria TEXT,
  confidence FLOAT NOT NULL,
  is_active BOOLEAN DEFAULT TRUE,
  total_jupjups INT DEFAULT 0,
  total_success INT DEFAULT 0,
  UNIQUE (source, source_id)         -- 중복 INSERT 차단
);

-- 3. jupjups — 사용자 행위 데이터
CREATE TABLE jupjups (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  benefit_id UUID NOT NULL REFERENCES benefits(id) ON DELETE CASCADE,
  result TEXT NOT NULL DEFAULT 'PENDING',
  ip_address INET,                   -- IP rate limit 추적 (G-6)
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE (user_id, benefit_id)       -- ⚠️ 통계 조작 차단 1차
);

-- 4. reports — 후기 신고
CREATE TABLE reports (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  reporter_user_id UUID NOT NULL REFERENCES users(id),
  reported_jupjup_id UUID NOT NULL REFERENCES jupjups(id),
  reason TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'PENDING',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

IP rate limit Redis 설정 (G-6 본격)

💻 supabase/functions/_shared/redis-rate-limit.ts (45줄)
// G-6 본격 — IP rate limit (1h 5+ reject)
const REDIS_URL = Deno.env.get('UPSTASH_REDIS_REST_URL')!;
const REDIS_TOKEN = Deno.env.get('UPSTASH_REDIS_REST_TOKEN')!;

export async function checkIpRate(ip: string): Promise<{ allowed: boolean; remaining: number }> {
  const key = `jupjup:ip:${ip}`;

  // INCR + EXPIRE (atomic via Redis pipeline)
  const incrResp = await fetch(`${REDIS_URL}/incr/${key}`, {
    headers: { Authorization: `Bearer ${REDIS_TOKEN}` },
  });
  const { result: count } = await incrResp.json();

  // 첫 호출이면 TTL 1h 설정
  if (count === 1) {
    await fetch(`${REDIS_URL}/expire/${key}/3600`, {
      headers: { Authorization: `Bearer ${REDIS_TOKEN}` },
    });
  }

  return { allowed: count <= 5, remaining: Math.max(0, 5 - count) };
}
📘 Day 8 LogOnTable

[LogOnTable 트레이스 ① — UNIQUE 제약]

  • 결정: jupjups 테이블 UNIQUE (user_id, benefit_id) — DB 레벨 차단
  • 근거: SPEC v4 §3 + 1탄 v2 새 9장 9-5 절 LogOnTable 사례. DB 제약이 외부 검증보다 견고
  • 대안: 트리거 안에서 COUNT 검증 — 우회 가능 (race condition)
  • 부작용: 사용자가 "줍줍 취소 → 재줍줍" 하려면 DELETE 후 INSERT. Day 17 마이페이지 UI에서 명시

[LogOnTable 트레이스 ② — Redis vs DB temp table]

  • 결정: IP rate limit을 Upstash Redis로 (DB temp table X)
  • 근거: 1탄 v2 새 14장 + 권1 제4장 G-6 명세. 빈번한 read/write에 Redis가 적합. 무료 tier 10K req/day
  • 대안: PostgreSQL temp table — 매 INSERT마다 DB 쿼리 부담 (병목)
  • 부작용: 외부 의존성 1개 추가 (Upstash). Redis 장애 시 rate limit 통과 허용 (안전 측면, 사용성 우선)

누적: 35h + Day 8 (2.5h) = 37.5h / E2: 60h 트리거 22.5h 여유

3-2 Day 9 — 트리거 3개 🔗

Claude가 작성한 0002_triggers.sql (약 60줄)

💻 supabase/migrations/0002_triggers.sql
-- 1. jupjups 집계 트리거 (INSERT/UPDATE/DELETE)
CREATE OR REPLACE FUNCTION update_benefits_stats()
RETURNS TRIGGER AS $$
DECLARE
  target_benefit_id UUID;
BEGIN
  IF TG_OP = 'DELETE' THEN
    target_benefit_id := OLD.benefit_id;
  ELSE
    target_benefit_id := NEW.benefit_id;
  END IF;

  UPDATE benefits SET
    total_jupjups = (SELECT COUNT(*) FROM jupjups WHERE benefit_id = target_benefit_id),
    total_success = (
      SELECT COUNT(*) FROM jupjups
      WHERE benefit_id = target_benefit_id AND result = 'SUCCESS'
    ),
    avg_days_taken = (
      SELECT AVG(days_taken) FROM jupjups
      WHERE benefit_id = target_benefit_id AND days_taken IS NOT NULL
        AND result = 'SUCCESS'
    ),
    updated_at = NOW()
  WHERE id = target_benefit_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_jupjups_stats
AFTER INSERT OR UPDATE OR DELETE ON jupjups
FOR EACH ROW EXECUTE FUNCTION update_benefits_stats();

-- 2. is_active 자동 비활성화 (G-3) — pg_cron에서 호출
CREATE OR REPLACE FUNCTION deactivate_expired_benefits()
RETURNS void AS $$
BEGIN
  UPDATE benefits
  SET is_active = false, updated_at = NOW()
  WHERE deadline < NOW() AND is_active = true;
END;
$$ LANGUAGE plpgsql;
📘 Day 9 LogOnTable — 집계 트리거 vs 별도 cron
  • 결정: jupjups 통계를 트리거로 실시간 갱신 (cron X)
  • 근거: SPEC v4 §3 + Day 17 마이페이지 "실시간 정확성" 가치
  • 대안: 매일 새벽 cron — 분 단위 지연 가능 → 사용자 "방금 한 거 안 보여요" 항의
  • 부작용: jupjups 추가 시 트리거 1번 더 호출 (성능 영향 미미)

누적: 37.5h + Day 9 (2.5h) = 40h / E2: 60h 트리거 20h 여유

3-3 Day 10 — 복합 인덱스 (P1 패치) + 시딩 INSERT 🔗

권1 제7장 75분 패치의 P1 (15분) 항목을 적용합니다.

💻 supabase/migrations/0003_indexes.sql
-- 복합 인덱스 (P1 — REVIEW.md 1차 W-C → PASS 전환)
CREATE INDEX idx_benefits_target_business
  ON benefits (target_business_type, target_stage, deadline)
  WHERE is_active = true;

CREATE INDEX idx_benefits_target_individual
  ON benefits (target_age_min, target_age_max, target_household_type, deadline)
  WHERE is_active = true;

CREATE INDEX idx_jupjups_user_benefit
  ON jupjups (user_id, benefit_id);  -- UNIQUE 보강
📘 Day 10 LogOnTable — auto_active만 INSERT
  • 결정: admin 큐 7건은 INSERT 보류. auto_active 13건만 사용자 노출 대상
  • 근거: SPEC v4 §3-2 — confidence < 0.7 admin 큐. 신뢰 보호 우선
  • 대안: 13건 + 7건 모두 INSERT + is_active=false — admin 처리 부담 증가
  • 부작용: Day 12 admin 페이지에서 7건 검수 작업 필요

누적: 40h + Day 10 (1.5h) = 41.5h / E2: 60h 트리거 18.5h 여유

3-4 Day 11 — pg_cron 매일 03:00 (4 API 자동 호출) 🔗
💻 supabase/migrations/0004_pg_cron.sql
CREATE EXTENSION IF NOT EXISTS pg_cron;

-- 1. 매일 03:00 (KST) → 4 API 호출 Edge Function 트리거
SELECT cron.schedule(
  'daily-fetch-public-data',
  '0 18 * * *',  -- UTC 18:00 = KST 03:00
  $$ SELECT net.http_post(
    'https://[PROJECT_REF].supabase.co/functions/v1/fetch-public-data',
    '{}'::jsonb,
    '{}'::jsonb,
    '{"Content-Type":"application/json","Authorization":"Bearer [SERVICE_ROLE]"}'::jsonb
  ); $$
);

-- 2. 매일 04:00 → is_active 자동 비활성화 (G-3)
SELECT cron.schedule(
  'daily-deactivate-expired',
  '0 19 * * *',  -- UTC 19:00 = KST 04:00
  $$ SELECT deactivate_expired_benefits(); $$
);
📘 Day 11 LogOnTable — pg_cron vs 외부 cron
  • 결정: pg_cron 사용 (Vercel Cron · GitHub Actions X)
  • 근거: SPEC v4 §3 + Supabase 내장 = 외부 의존성 1개 줄임 + 비용 0
  • 대안: GitHub Actions cron — 무료지만 Supabase 외부 호출 보안 부담
  • 부작용: pg_cron 장애 = Supabase 장애 (R7) — Day 56 Sentry 통합 시 모니터링 추가

누적: 41.5h + Day 11 (3h) = 44.5h / E2: 60h 트리거 15.5h 여유

3-5 Day 12 — 어드민 검수 큐 페이지 prototype 🔗
💻 Claude Code 자연어 입력
"어드민 검수 큐 페이지 prototype. Day 5의 lib/admin/review-queue.ts
인터페이스 활용.

요구사항:
1. src/app/admin/queue/page.tsx (Next.js App Router 또는 Expo Router)
2. 7건 admin 큐 (5 admin + 2 parse_failed) 노출
3. 각 행: source · raw_response · classify_result · 재분류 버튼
4. 재분류 → Sonnet으로 호출 → 결과 갱신
5. 승인 → benefits INSERT → 큐에서 제거
6. 반려 → 영구 제거"

7건 처리 결과

🎉 Day 12 어드민 처리 결과
  • 5 admin 큐: 재분류 후 4건 confidence 0.7+ → 승인 → INSERT
  • 1건 confidence 여전 0.65 → 반려 (한국어 공고 모호)
  • 2 parse_failed (welfare_local XML): 수동 텍스트 추출 후 재분류 → 1건 승인 + 1건 반려

결과: benefits 13 + 5 = 18건 (Phase 1.0 시점 시딩)

📘 Day 12 LogOnTable — admin 워크플로우
  • 결정: 재분류 시 Sonnet 사용 (Haiku → Sonnet 단계적 escalation)
  • 근거: 1탄 v2 새 28장 비교 우위. Haiku 첫 분류 fail 시 Sonnet 정확도 증가. 재분류 빈도 낮아 비용 부담 적음
  • 대안: 항상 Haiku — admin 부담 증가 / 항상 Sonnet — 비용 4배
  • 부작용: welfare_local XML 본문 일부 깨짐 → Phase 1.1 자동 변환기 우선순위 증가

누적: 44.5h + Day 12 (3.5h) = 48h / ⚠️ E2: R4 트리거 접근 중 (Day 14 페이스 점검 필수)

3-6 Day 13 — 검수 SLA 7일 cron + Slack 알림 🔗
💻 Claude Code 자연어 입력
"검수 SLA 7일 cron + Slack 알림. C-3 발견 구현.

요구사항:
1. supabase/functions/admin-sla-check/ Edge Function
2. 매일 09:00 KST 호출 (pg_cron)
3. lib/admin/review-queue.ts 의 getOverdueItems() 사용
4. 7일+ 미처리 행 발견 시 Slack webhook 호출
5. SLACK_WEBHOOK_URL 환경변수
6. 메시지: 어드민 큐 N건 SLA 위반 (7일+)"
📘 Day 13 LogOnTable — SLA 알림 시점
  • 결정: 09:00 KST 1회/일 (00:00 KST X, 18:00 KST X)
  • 근거: 운영자 (Junho) 작업 시작 시간 일치. 알림 후 즉시 처리 가능
  • 대안: 매시간 — 알림 피로 / 1주 1회 — 누적 7일+ 위험
  • 부작용: 새벽 작업이 있는 날 알림 1회 늦음 — 수용 가능

누적: 48h + Day 13 (1.5h) = 49.5h / ⚠️ E2: 60h 트리거 10.5h 여유. Day 14 페이스 점검 의무.

3-7 Day 14 — ★ 페이스 점검 + 2주차 회고 🔗

Day 14 페이스 점검 (E2)

📘 14일 누적 체크
  • 누적 시간: 49.5h
  • R4 트리거 60h 거리: 10.5h 여유
  • 1주차: 14h (가용 28h, 50% 사용)
  • 2주차: 14h (가용 28h, 50% 사용)
  • 토요일 작업: 0회 (Day 5·12 모두 작업 X 확인)
  • 회고 부재 누적: 0주 (1주차 회고 완료)
🎉 E2 4 트리거 점검 — 모두 미접근
  • ☐ 누적 60h+/4주 — No (49.5h)
  • ☐ 회고 부재 2주+ — No (1주차 작성)
  • ☐ 토요일 4주 연속 — No (0회)
  • ☐ "피곤하다" BUILD.md 회고 등장 — No

4 트리거 모두 미접근 ✅ — 페이스 안전.

2주차 회고 (BUILD.md 1.5KB)

📘 BUILD.md Day 14 — ★ E2 페이스 점검 + 2주차 회고

산출물

  • 0001_init.sql (4 테이블 + 기본 인덱스)
  • 0002_triggers.sql (3 트리거)
  • 0003_indexes.sql (P1 복합 인덱스 3개)
  • 0004_pg_cron.sql (cron 2개)
  • redis-rate-limit.ts (G-6)
  • fetch-public-data/ (Edge Function)
  • admin-sla-check/ (Slack 알림)
  • admin/queue/page.tsx (180줄)
  • benefits 18건 시딩

1탄 v2 메타 원칙 입증 (이번 주)

  1. WITH-CONDITIONS 75분 패치 효과 — P1 복합 인덱스가 Day 10 INSERT 13건 시점에 정상 작동
  2. DB 제약 + Redis 보강 — UNIQUE (DB 1차) + IP rate limit (Redis 2차) = 통계 조작 원천 차단
  3. 트리거 + cron 결합 — jupjups 트리거 (실시간) + pg_cron is_active (매일) 역할 분리

★ 3주차 페이스 축소 결정: 60h 트리거 거리 10.5h → 3주차 작업 4h/일 → 3.5h/일 축소

누적: 49.5h + Day 14 (0.5h 회고) = 50h / 2주차 누적: 14h

📌 권2 제3장 정리

  • 핵심: Day 8~14 = Supabase + DB + G-6 IP rate limit Redis + 페이스 점검. 누적 50h
  • DB 본격 산출물: 4 테이블 + 트리거 3 + P1 복합 인덱스 + pg_cron 2개 + redis-rate-limit.ts + admin-sla-check + admin/queue/page.tsx
  • 5확장 본격 입증: E1 키 분리 / E2 3주차 페이스 축소 결정 / E3 G-3·G-6 입증 / E4 트레이스 9개 / E5 SSOT [1] benefits 단일 출처
  • 1탄 v2 메타 원칙: WITH-CONDITIONS 75분 패치 효과 / DB + Redis 결합 통계 조작 차단 / 트리거+cron 역할 분리
  • 누적: 50h / 60h 트리거 10h 여유
  • ★ 3주차 페이스 축소: 4h/일 → 3.5h/일 (총 24.5h, R4 예방)
🎉 DB 본격 구축이 완료됐습니다

4 테이블 + 트리거 3 + 복합 인덱스 + pg_cron + IP rate limit Redis + 어드민 큐 + SLA 알림. 권1 제4장에서 발견된 G-3 (is_active 트리거) + G-6 (IP rate Redis) 모두 입증됐습니다.

특히 ★ E2 페이스 점검 (Day 14)의 작동 모습이 본문에 박혔습니다. 60h 트리거까지 10h 여유 → 3주차 페이스 25% 축소 결정. 운영자 자체가 매주 점검 받는 자리입니다.