Day 8~14: Supabase + DB + pg_cron + 검수 SLA
DB 본격 구축 — 4 테이블 + 트리거 3개 + IP rate limit Redis + 페이스 점검
📑 이 챕터에서 다룰 내용
권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 알림
Day 8은 휴식 의식 후 작업 30% 축소 (4h → 2.5h)로 시작합니다.
Claude가 작성한 0001_init.sql (약 80줄)
-- 줍줍 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 본격)
// 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) };
}
[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 여유
Claude가 작성한 0002_triggers.sql (약 60줄)
-- 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;
- 결정: jupjups 통계를 트리거로 실시간 갱신 (cron X)
- 근거: SPEC v4 §3 + Day 17 마이페이지 "실시간 정확성" 가치
- 대안: 매일 새벽 cron — 분 단위 지연 가능 → 사용자 "방금 한 거 안 보여요" 항의
- 부작용: jupjups 추가 시 트리거 1번 더 호출 (성능 영향 미미)
누적: 37.5h + Day 9 (2.5h) = 40h / E2: 60h 트리거 20h 여유
권1 제7장 75분 패치의 P1 (15분) 항목을 적용합니다.
-- 복합 인덱스 (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 보강
- 결정: 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 여유
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(); $$
);
- 결정: 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 여유
"어드민 검수 큐 페이지 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건 처리 결과
- 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 시점 시딩)
- 결정: 재분류 시 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 페이스 점검 필수)
"검수 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일+)"
- 결정: 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 페이스 점검 의무.
Day 14 페이스 점검 (E2)
- 누적 시간: 49.5h
- R4 트리거 60h 거리: 10.5h 여유
- 1주차: 14h (가용 28h, 50% 사용)
- 2주차: 14h (가용 28h, 50% 사용)
- 토요일 작업: 0회 (Day 5·12 모두 작업 X 확인)
- 회고 부재 누적: 0주 (1주차 회고 완료)
- ☐ 누적 60h+/4주 — No (49.5h)
- ☐ 회고 부재 2주+ — No (1주차 작성)
- ☐ 토요일 4주 연속 — No (0회)
- ☐ "피곤하다" BUILD.md 회고 등장 — No
4 트리거 모두 미접근 ✅ — 페이스 안전.
2주차 회고 (BUILD.md 1.5KB)
산출물
- 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 메타 원칙 입증 (이번 주)
- WITH-CONDITIONS 75분 패치 효과 — P1 복합 인덱스가 Day 10 INSERT 13건 시점에 정상 작동
- DB 제약 + Redis 보강 — UNIQUE (DB 1차) + IP rate limit (Redis 2차) = 통계 조작 원천 차단
- 트리거 + 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 예방)
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% 축소 결정. 운영자 자체가 매주 점검 받는 자리입니다.