Files
nuxt-deep/docs/PRD.md

550 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# InvoiceLink MVP PRD
> **버전**: 0.1.0 | **작성일**: 2026-03-14 | **작성자**: 1인 개발
---
## 1. 배경 및 문제 정의
### Pain Point
| # | 현재 방식 | 문제 |
|---|-----------|------|
| 1 | 이메일 첨부 (Word/HWP) | 파일 버전 불일치, 수정 후 재발송 필요, 모바일에서 열기 어려움 |
| 2 | 엑셀 공유 (Google Sheets 링크) | 브랜딩 부재, 인쇄 레이아웃 깨짐, 실수로 수정 가능 |
| 3 | 구두/카카오톡 문자 전달 | 공식 증빙 불가, 분쟁 발생 시 근거 없음, PDF 재요청 빈번 |
### 타겟 페르소나
- **노션을 이미 사용하는 프리랜서 / 소규모 에이전시** (1~5인)
- 월 10~50건 견적서 발행
- 별도 ERP 도입 비용을 감당하기 어렵고, 노션 DB를 이미 운영 중
### 핵심 문제 정의
> 노션에 입력한 견적 데이터를 클라이언트에게 **전문적인 웹 뷰어**로 즉시 공유하고, **단일 PDF**로 제공하는 통합 수단이 없다.
**구현 시 주의사항**: 문제 범위를 "노션 사용자"로 명시적으로 한정함으로써 범용 견적 SaaS와의 차별점을 유지한다.
---
## 2. 목표 (Goals & Non-Goals)
### MVP Goals
| # | 목표 | 측정 기준 |
|---|------|-----------|
| G1 | 견적서 발행에서 클라이언트 PDF 수령까지 **5분 이내** | 사용자 테스트 3명 중 3명 달성 |
| G2 | 노션 DB 속성 매핑 오류율 **0%** (필수 속성 10개 기준) | QA 체크리스트 통과 |
| G3 | PDF 생성 응답 시간 **10초 이내** (A4 1페이지 기준) | Vercel 함수 p95 지표 |
### Non-Goals (MVP 제외)
- 클라이언트 전자 서명 / 결재 기능
- 다국어 지원 (한국어 단일)
- Notion OAuth 앱 등록 (Public Integration)
- 반복 견적 템플릿 / 자동 발송 스케줄러
- 결제 연동 (세금계산서 발행, PG 연동)
- 멀티 워크스페이스 지원
**구현 시 주의사항**: Non-Goal은 스코프 크리프 방어선이다. 기능 요청이 들어오면 이 목록을 먼저 확인한다.
---
## 3. 사용자 스토리
### 공급자 (Supplier)
**S1. 노션 연동**
> 나는 공급자로서, 기존 노션 DB를 그대로 사용하기 위해 Integration Token을 InvoiceLink에 등록할 수 있다.
- AC1: Token 저장 후 "연결 테스트" 버튼 클릭 시 DB 접근 가능 여부를 3초 이내에 표시한다.
- AC2: 토큰이 유효하지 않으면 구체적인 오류 메시지(인증 실패 / 권한 없음)를 표시한다.
- AC3: Token은 암호화되어 Supabase에 저장되며 UI에서 마스킹(`ntn_****`)으로 표시된다.
**S2. 견적서 발행**
> 나는 공급자로서, 노션 DB 항목을 선택하여 고유 링크를 즉시 생성할 수 있다.
- AC1: 필수 속성 10개가 모두 있을 때만 "발행" 버튼이 활성화된다.
- AC2: 발행 클릭 시 `/q/[uuid-v4]` 형식의 URL이 클립보드에 복사되고 토스트 알림이 표시된다.
- AC3: 발행된 견적서는 대시보드 목록에 `sent` 상태로 즉시 반영된다.
**S3. 링크 관리**
> 나는 공급자로서, 발행한 링크를 비활성화하여 클라이언트가 더 이상 열람하지 못하게 할 수 있다.
- AC1: 대시보드에서 토글 클릭 → 1초 이내 상태가 `expired`로 변경된다.
- AC2: 비활성화된 링크 접근 시 클라이언트에게 "만료된 견적서" 안내 페이지를 표시한다.
- AC3: 비활성화는 토글로 재활성화할 수 있다.
**S4. 열람/다운로드 추적**
> 나는 공급자로서, 클라이언트가 견적서를 열람했는지 PDF를 다운로드했는지 확인할 수 있다.
- AC1: 클라이언트가 견적서 링크를 열면 대시보드의 상태가 `viewed`로 변경된다.
- AC2: PDF 다운로드 시 대시보드에 "다운로드 완료" 배지가 표시된다.
- AC3: 최초 열람 일시가 KST 기준으로 표시된다.
### 클라이언트 (Client)
**C1. 견적서 열람**
> 나는 클라이언트로서, 링크 하나만으로 로그인 없이 견적서를 확인할 수 있다.
- AC1: 링크 접근 시 로그인 요구 없이 견적서 페이지가 로드된다.
- AC2: 모바일(375px)에서 품목 테이블이 가로 스크롤로 열람 가능하다.
- AC3: 유효기간이 지난 견적서는 만료 안내만 표시하고 내용은 노출하지 않는다.
**C2. PDF 다운로드**
> 나는 클라이언트로서, 견적서를 PDF로 저장하여 내부 결재에 사용할 수 있다.
- AC1: "PDF 다운로드" 버튼 클릭 시 10초 이내에 파일이 저장된다.
- AC2: 파일명은 `견적서_{견적번호}_{YYYYMMDD}.pdf` 규칙을 따른다.
- AC3: A4 1페이지에 모든 내용이 인쇄 최적화된 레이아웃으로 출력된다.
**C3. 견적서 진위 확인**
> 나는 클라이언트로서, 견적서가 공식적으로 발행된 것임을 신뢰할 수 있다.
- AC1: 견적서 페이지에 발행 일시와 견적번호가 명시된다.
- AC2: 링크가 만료되었거나 비활성화된 경우 변조 가능성 없이 안내 메시지만 표시한다.
**구현 시 주의사항**: 클라이언트는 계정이 없으므로 모든 클라이언트 기능은 Public 라우트로 설계하되, uuid-v4로 추측 공격을 방어한다.
---
## 4. 기능 명세
### F1. 노션 연동
#### 연동 방식
- **Notion Internal Integration Token** 사용 (OAuth Public App은 Non-Goal)
- 설정 위치: 대시보드 → 설정 → 노션 연동
- Token 저장: Supabase `suppliers.notion_token` 컬럼 (AES-256 암호화, 서버 사이드 복호화)
#### 환경변수 (서버 전용)
```env
NOTION_TOKEN=ntn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
```typescript
// nuxt.config.ts
runtimeConfig: {
notionToken: process.env.NOTION_TOKEN, // 서버 전용, public 미노출
}
```
#### SDK 초기화
```typescript
// server/utils/notion.ts
import { Client } from '@notionhq/client'
export function createNotionClient(token: string) {
return new Client({ auth: token, timeoutMs: 30_000 })
}
```
#### 필수 데이터베이스 속성 매핑
| 노션 속성명 | 노션 타입 | InvoiceLink 필드 | 추출 방법 | 필수 여부 |
|-------------|-----------|------------------|-----------|-----------|
| 견적번호 | `title` | `quote_number` | `prop.title.map(t => t.plain_text).join('')` | 필수 |
| 발행일 | `date` | `issued_at` | `prop.date.start` | 필수 |
| 유효기간 | `date` | `expires_at` | `prop.date.start` | 필수 |
| 수신자명 | `rich_text` | `client_name` | `prop.rich_text.map(t => t.plain_text).join('')` | 필수 |
| 수신자 이메일 | `email` | `client_email` | `prop.email \|\| ''` | 선택 |
| 항목명 | `rich_text` | `item_name` | `prop.rich_text.map(t => t.plain_text).join('')` | 필수 |
| 수량 | `number` | `quantity` | `prop.number` | 필수 |
| 단가 (원) | `number` | `unit_price` | `prop.number` | 필수 |
| 세율 (%) | `number` | `tax_rate` | `prop.number` | 필수 |
| 비고 | `rich_text` | `notes` | `prop.rich_text.map(t => t.plain_text).join('')` | 선택 |
#### 속성 추출 헬퍼 (TypeScript)
```typescript
// server/utils/notion-extract.ts
import type { PageObjectResponse } from '@notionhq/client/build/src/api-endpoints'
type Prop = PageObjectResponse['properties'][string]
export const extract = {
title: (p: Prop): string =>
p.type === 'title' ? p.title.map(t => t.plain_text).join('') : '',
text: (p: Prop): string =>
p.type === 'rich_text' ? p.rich_text.map(t => t.plain_text).join('') : '',
number: (p: Prop): number | null =>
p.type === 'number' ? p.number : null,
date: (p: Prop): string | null =>
p.type === 'date' && p.date ? p.date.start : null,
email: (p: Prop): string =>
p.type === 'email' ? (p.email ?? '') : '',
}
```
#### 동기화 트리거: 수동 버튼
- **선택**: 수동 버튼 (대시보드 "동기화" 클릭)
- **근거**: Notion Webhook은 Public Integration만 지원하여 Internal Token 방식으로는 불가. 자동 폴링은 rate limit(3 req/s) 소진 위험. 1인 MVP에서 수동 동기화가 구현 복잡도 대비 충분한 UX를 제공함.
#### Rate Limit 대응
- Notion API: 평균 3 req/s per integration
- 서버 라우트에서 Nitro `defineCachedFunction` 적용 (TTL: 60초)
- 429 응답 시 지수 백오프(1s → 2s → 4s, 최대 3회 재시도)
```typescript
// server/utils/notion-retry.ts
import { APIResponseError } from '@notionhq/client'
export async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
for (let i = 0; i <= max; i++) {
try { return await fn() }
catch (e) {
if (e instanceof APIResponseError && e.status === 429 && i < max) {
const wait = Math.pow(2, i) * 1000
await new Promise(r => setTimeout(r, wait))
continue
}
throw e
}
}
throw new Error('unreachable')
}
```
#### 에러 처리
| HTTP 상태 | 원인 | 사용자 메시지 |
|-----------|------|---------------|
| 400 | 잘못된 속성명/필터 | "DB 속성명을 확인하세요. 대소문자와 공백이 정확해야 합니다." |
| 401 | 토큰 만료/무효 | "Notion Integration Token이 유효하지 않습니다." |
| 404 | DB ID 오류 또는 권한 미부여 | "데이터베이스를 찾을 수 없습니다. DB에 Integration을 연결했는지 확인하세요." |
| 429 | Rate limit 초과 | "잠시 후 다시 시도하세요. (요청 제한)" |
**구현 시 주의사항**: 노션 속성명은 한국어 포함 대소문자·공백이 완전히 일치해야 하므로, 설정 화면에서 속성명 매핑을 사용자가 직접 입력하는 방식을 고려한다.
---
### F2. 고유 링크 생성
- **URL 구조**: `/q/[uuid-v4]` — 128비트 랜덤, 추측 불가
- **유효기간**: 기본값 30일. 발행 시 커스터마이징 가능 (7일 / 14일 / 30일 / 무제한)
- **접근 제어**: 링크 소유 공급자만 비활성화 가능. 비활성화 즉시 클라이언트 접근 차단.
- **클립보드 복사 UX**: `navigator.clipboard.writeText()` + `@nuxt/ui` Toast 알림
```typescript
// server/api/quotes/index.post.ts
import { v4 as uuidv4 } from 'uuid'
const slug = uuidv4() // e.g. "550e8400-e29b-41d4-a716-446655440000"
// quote 레코드 생성 후 /q/${slug} 반환
```
**구현 시 주의사항**: `crypto.randomUUID()`를 Node.js 환경에서 직접 사용할 수 있으나, 브라우저 호환성을 위해 `uuid` 패키지를 명시적으로 사용한다.
---
### F3. 견적서 웹 뷰어
#### 레이아웃 구성
```
┌─────────────────────────────────────────┐
│ [로고] 상호명 / 연락처 / 이메일 │ ← 헤더
├─────────────────────────────────────────┤
│ 견적번호: Q-2026-001 발행일: 2026-03-14 │
│ 유효기간: 2026-04-14 수신자: 홍길동 님 │ ← 견적 메타
├─────────────────────────────────────────┤
│ 항목명 수량 단가 세액 소계 │
│ ────────────────────────────────────── │
│ 웹 개발 1 3,000,000 300,000 3.3M │ ← 품목 테이블
│ 디자인 1 1,000,000 100,000 1.1M │
├─────────────────────────────────────────┤
│ 공급가액 4,000,000 │
│ 세액 400,000 │ ← 합계 영역
│ 총액 4,400,000 │
├─────────────────────────────────────────┤
│ 비고: 계약금 50% 선입금 후 착수 │
│ 사업자번호: 000-00-00000 │ ← 푸터
└─────────────────────────────────────────┘
```
#### 반응형 요구사항
- 모바일(375px): 품목 테이블 `overflow-x: auto`, 헤더는 세로 스택
- PDF 출력: `@media print` CSS로 A4(210×297mm) 최적화, 푸터 고정
#### 브랜딩 커스터마이징 (MVP)
- 로고 이미지 업로드 → Supabase Storage 저장
- Primary 컬러 6가지 프리셋: `#2563EB` `#16A34A` `#DC2626` `#D97706` `#7C3AED` `#0891B2`
**구현 시 주의사항**: `@nuxt/ui` 컴포넌트의 color prop과 CSS 변수(`--color-primary`)를 연동하여 동적 테마 전환을 구현한다.
---
### F4. PDF 다운로드
- **생성 방식**: 서버사이드 헤드리스 렌더링
- **라우트**: `server/api/quote/[id]/pdf.get.ts`
- **라이브러리**: `@sparticuz/chromium` + `puppeteer-core`
- **근거**: 클라이언트 `window.print()`는 브라우저별 출력 차이 발생. 서버사이드가 레이아웃 일관성 보장.
```typescript
// server/api/quote/[id]/pdf.get.ts
import chromium from '@sparticuz/chromium'
import puppeteer from 'puppeteer-core'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
// 1. DB에서 quote 조회
// 2. HTML 템플릿 렌더링
// 3. Puppeteer로 PDF 생성
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
})
const page = await browser.newPage()
await page.setContent(htmlContent, { waitUntil: 'networkidle0' })
const pdf = await page.pdf({ format: 'A4', printBackground: true })
await browser.close()
setResponseHeaders(event, {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
})
return pdf
})
```
- **파일명 규칙**: `견적서_{quote_number}_{YYYYMMDD}.pdf`
- **이벤트 로깅**: 다운로드 완료 시 `quote_events` 테이블에 `{ type: 'pdf_downloaded', quote_id, created_at }` 삽입
- **Vercel 제약**: 서버리스 함수 메모리 1GB, 실행 시간 최대 60초(Pro 플랜). `@sparticuz/chromium`은 50MB 이하로 제한 내 처리 가능.
**구현 시 주의사항**: Vercel Edge Runtime은 Node.js API 미지원. PDF 라우트는 반드시 `export const runtime = 'nodejs'`를 명시한다.
---
### F5. 공급자 대시보드
- **견적서 목록**: 발행일 내림차순, 20개씩 페이지네이션
- **상태 뱃지**:
| 상태 | 조건 | 색상 |
|------|------|------|
| `draft` | 발행 전 | 회색 |
| `sent` | 링크 생성 완료 | 파란색 |
| `viewed` | 클라이언트 최초 열람 | 초록색 |
| `expired` | 유효기간 경과 또는 수동 만료 | 빨간색 |
- **이벤트 표시**: "열람 ✓" / "PDF 다운로드 ✓" 아이콘 배지
- **링크 만료 토글**: `UToggle` 컴포넌트, PATCH `/api/quotes/[id]` 호출
**구현 시 주의사항**: 상태 전환 로직은 서버에서만 처리하며 클라이언트에서 직접 `status` 필드를 조작하지 않는다.
---
## 5. 데이터 모델
```sql
-- suppliers: 공급자 (Supabase Auth users와 1:1)
CREATE TABLE suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_name TEXT NOT NULL,
contact TEXT,
logo_url TEXT,
primary_color CHAR(7) DEFAULT '#2563EB',
notion_token TEXT, -- AES-256 암호화 저장
notion_db_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);
-- RLS: 본인 레코드만 읽기/쓰기
ALTER TABLE suppliers ENABLE ROW LEVEL SECURITY;
CREATE POLICY "suppliers_self" ON suppliers
USING (auth.uid() = user_id);
-- quotes: 견적서 헤더
CREATE TABLE quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
slug UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), -- /q/[slug]
notion_page_id TEXT,
quote_number TEXT NOT NULL,
client_name TEXT NOT NULL,
client_email TEXT,
issued_at DATE NOT NULL,
expires_at DATE,
notes TEXT,
status TEXT NOT NULL DEFAULT 'sent'
CHECK (status IN ('draft','sent','viewed','expired')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
total_amount NUMERIC(12,2),
tax_amount NUMERIC(12,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- RLS: 공급자는 본인 견적만, 공개 slug 읽기는 허용
ALTER TABLE quotes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "quotes_supplier" ON quotes
USING (supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid()));
CREATE POLICY "quotes_public_read" ON quotes
FOR SELECT USING (is_active = TRUE);
-- quote_items: 견적 항목
CREATE TABLE quote_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
item_name TEXT NOT NULL,
quantity NUMERIC(10,2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12,2) NOT NULL,
tax_rate NUMERIC(5,2) NOT NULL DEFAULT 10,
sort_order SMALLINT NOT NULL DEFAULT 0
);
-- RLS: quotes와 동일 공급자 정책 상속
ALTER TABLE quote_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY "quote_items_via_quotes" ON quote_items
USING (quote_id IN (SELECT id FROM quotes
WHERE supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid())));
CREATE POLICY "quote_items_public_read" ON quote_items
FOR SELECT USING (quote_id IN (SELECT id FROM quotes WHERE is_active = TRUE));
-- quote_events: 열람/다운로드 이벤트 로그
CREATE TABLE quote_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
event_type TEXT NOT NULL CHECK (event_type IN ('viewed','pdf_downloaded')),
ip_hash TEXT, -- 개인정보 최소화: IP를 SHA-256 해시로 저장
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- RLS: 공급자만 이벤트 읽기, 삽입은 서버(service_role)만
ALTER TABLE quote_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY "events_supplier_read" ON quote_events
FOR SELECT USING (quote_id IN (
SELECT id FROM quotes
WHERE supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid())
));
```
**구현 시 주의사항**: `quote_events` 삽입은 서버 라우트에서 `service_role` 키로 처리하고 클라이언트에 해당 키를 노출하지 않는다.
---
## 6. API 설계 (Nuxt Server Routes)
| Method | Route | 설명 | Auth |
|--------|-------|------|------|
| `GET` | `/api/quote/[slug]` | 슬러그로 견적서 + 항목 조회 | Public (slug 보유자) |
| `GET` | `/api/quote/[slug]/pdf` | PDF 생성 및 스트림 응답 | Public |
| `POST` | `/api/quotes` | 견적서 생성 (노션 데이터 포함) | Supplier (세션 필요) |
| `PATCH` | `/api/quotes/[id]` | 상태/활성화 변경 | Supplier |
| `DELETE` | `/api/quotes/[id]` | 견적서 삭제 | Supplier |
| `GET` | `/api/notion/sync` | 노션 DB 페이지 목록 조회 | Supplier |
| `POST` | `/api/notion/validate` | Token + DB ID 유효성 검사 | Supplier |
### 요청/응답 예시
```typescript
// POST /api/quotes
// Request body (Zod 검증)
const createQuoteSchema = z.object({
notionPageId: z.string(),
expiresAt: z.string().date().optional(),
primaryColor: z.string().regex(/^#[0-9A-F]{6}$/i).optional(),
})
// GET /api/quote/[slug] Response
interface QuoteResponse {
quote: { quoteNumber: string; clientName: string; issuedAt: string; expiresAt: string | null }
items: Array<{ itemName: string; quantity: number; unitPrice: number; taxRate: number }>
supplier: { companyName: string; contact: string; logoUrl: string | null; primaryColor: string }
}
```
**구현 시 주의사항**: 모든 입력값은 Zod로 서버에서 재검증. 클라이언트 검증만으로는 부족하다.
---
## 7. 기술 스택 최종 제안
| 용도 | 라이브러리 | 선택 이유 |
|------|-----------|-----------|
| **PDF 생성** | `@sparticuz/chromium` + `puppeteer-core` | Vercel 서버리스 환경에서 Chromium 번들링 가능한 유일한 실용적 옵션 |
| **노션 API 클라이언트** | `@notionhq/client` | Notion 공식 TypeScript SDK, 타입 완전 지원 |
| **날짜 처리** | `date-fns` | 경량(tree-shaking), 순수 함수형, `format(date, 'yyyyMMdd')` 직관적 API |
| **숫자/통화 포맷** | `Intl.NumberFormat` (내장) | 외부 의존성 없이 `new Intl.NumberFormat('ko-KR').format(n)` 으로 천 단위 콤마 처리 |
| **이메일 발송 (선택)** | `Resend` + `react-email` | Vercel 환경 최적화, 무료 플랜 월 3,000건 충분, SDK 단순함 |
| **입력 검증** | `zod` | 기존 스택 일관성, Nuxt server route와 네이티브 통합 |
| **UUID 생성** | `crypto.randomUUID()` (Node 내장) | 외부 의존성 불필요, RFC 4122 v4 준수 |
**구현 시 주의사항**: `puppeteer-core` 버전은 `@sparticuz/chromium`과 호환 버전을 반드시 맞춘다. (패키지 README에서 버전 매트릭스 확인)
---
## 8. MVP 마일스톤
| Phase | 기간 | 완성 기준 |
|-------|------|-----------|
| **Phase 1** 인프라 + 노션 연동 | Week 1~2 | Supabase 스키마 적용, 노션 토큰 등록·검증, DB 항목 목록 조회 API 동작 |
| **Phase 2** 핵심 기능 | Week 3~4 | 견적서 발행(링크 생성), 웹 뷰어 렌더링, PDF 다운로드 (A4 출력 확인) |
| **Launch** 대시보드 + 안정화 | Week 5 | 공급자 대시보드(상태·이벤트 추적), 에러 처리 완비, Vercel 배포 |
### 런치 기준 (Launch Criteria)
- [ ] 노션 연동 → 견적서 발행 → PDF 수령 E2E 시나리오 3회 반복 성공
- [ ] PDF 생성 p95 응답 시간 10초 이내 (Vercel 로그 확인)
- [ ] 만료된 링크 접근 시 클라이언트에게 적절한 안내 페이지 표시
- [ ] Supabase RLS 정책 검증: 타 공급자 데이터 접근 불가 확인
- [ ] 모바일(375px) Chrome에서 웹 뷰어 및 PDF 다운로드 정상 동작
**구현 시 주의사항**: 런치 기준은 QA 체크리스트로 관리하며, 모든 항목 체크 완료 전까지 프로덕션 도메인 공유를 금한다.
---
## 9. 성공 지표 (Metrics)
### Primary Metric (북극성)
- **주간 활성 견적서 발행 수** (WAQ: Weekly Active Quotes)
- 측정: Supabase `quotes` 테이블 `created_at` 집계
- 목표: 런치 4주 후 WAQ ≥ 10
### Supporting Metrics
| 지표 | 설명 | 측정 도구 |
|------|------|-----------|
| PDF 변환 성공률 | 전체 다운로드 시도 중 성공 비율 ≥ 95% | Supabase `quote_events` + Vercel 함수 에러 로그 |
| 견적서 열람률 | 발행된 링크 중 클라이언트가 실제 열람한 비율 | `quote_events` `viewed` / `quotes` `sent` |
| 평균 발행 소요 시간 | 노션 동기화 클릭 → 링크 복사 완료까지 | 브라우저 Performance API (PostHog 커스텀 이벤트) |
**구현 시 주의사항**: PostHog는 무료 플랜(월 100만 이벤트)으로 충분. Nuxt 플러그인으로 초기화하고 `usePostHog()`로 이벤트 전송.
---
## 10. 리스크 & Open Questions
### 리스크
| 리스크 | 발생 확률 | 영향도 | 대응 전략 |
|--------|-----------|--------|-----------|
| Vercel 서버리스 Cold Start (Chromium) | 높음 | 중간 | Vercel Pro Fluid Compute 또는 최소 warm-up 요청 설정. 대안: `html-pdf-node` 검토 |
| Notion API Rate Limit (3 req/s) | 중간 | 중간 | Nitro 60초 캐시 + 지수 백오프. 동기화는 수동 트리거로 제한 |
| Notion API 속성명 변경 | 낮음 | 높음 | 속성명 매핑을 대시보드에서 사용자가 설정 가능하게 구현 |
| Supabase Free Tier 제한 (500MB DB) | 낮음 | 낮음 | `quote_events` 90일 이상 데이터 주기적 아카이브 또는 자동 삭제 정책 |
### Open Questions
| # | 질문 | 현재 유력 가설 |
|---|------|----------------|
| OQ1 | 노션 속성명 매핑을 코드에 하드코딩할 것인가, 대시보드에서 설정 가능하게 할 것인가? | 설정 가능하게 구현. 초기 기본값 제공, 변경 허용. |
| OQ2 | PDF 생성을 Vercel 서버리스로 할 것인가, 별도 마이크로서비스(Render)로 분리할 것인가? | MVP는 Vercel 단일 배포. 10초 초과 시 Render 분리 검토. |
| OQ3 | 클라이언트 열람 이벤트를 IP 기반으로 중복 제거할 것인가? | SHA-256 해시로 저장, 24시간 내 동일 IP 재열람은 이벤트 미중복 기록. |
| OQ4 | 견적서 항목이 여러 노션 페이지(하위 DB)에 분산된 경우를 지원할 것인가? | MVP는 단일 DB, 단일 페이지 = 단일 견적서 구조만 지원. |
| OQ5 | 무료 플랜과 유료 플랜을 MVP에서 구분할 것인가? | MVP는 전체 무료. 100건 발행 초과 시 유료 전환 검토. |
**구현 시 주의사항**: OQ1~OQ5는 Phase 1 시작 전에 의사결정을 완료하고 CLAUDE.md 또는 별도 ADR 문서에 기록한다.