Initial implementation of the project structure and basic functionality.

This commit is contained in:
2026-03-26 23:14:00 +09:00
parent 2a7b44b1c7
commit 4b937b8d67
48 changed files with 14732 additions and 0 deletions

549
docs/PRD.md Normal file
View File

@@ -0,0 +1,549 @@
# 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 문서에 기록한다.

209
docs/PRD_PROMPT.md Normal file
View File

@@ -0,0 +1,209 @@
# PRD 생성 메타 프롬프트
# 노션 견적서 → 웹 뷰어 & PDF 다운로드 MVP
> **사용법**: 아래 `---START---`부터 `---END---`까지 전체를 복사하여 Claude에게 붙여넣으세요.
---START---
## 역할
당신은 시니어 프로덕트 매니저 겸 풀스택 아키텍트입니다.
아래 제품 아이디어와 기술 제약조건을 바탕으로 실행 가능한 MVP PRD 문서를 작성하세요.
---
## 제품 아이디어
**서비스명 (가칭)**: InvoiceLink
**한 줄 요약**:
노션 데이터베이스에 입력한 견적서를 고유 링크로 공유하면,
클라이언트가 브라우저에서 확인하고 PDF로 다운로드할 수 있는 웹 서비스.
**핵심 사용 흐름**:
1. 공급자(supplier)가 노션 데이터베이스에 견적 항목을 입력한다
2. InvoiceLink 대시보드에서 "견적서 발행" 클릭 → 고유 URL 생성
3. 공급자가 URL을 클라이언트에게 전달(이메일, 카카오톡 등)
4. 클라이언트가 링크를 열면 브랜딩된 견적서 페이지를 확인한다
5. 클라이언트가 "PDF 다운로드" 버튼을 클릭하여 파일을 저장한다
---
## 기술 제약조건 (반드시 반영)
이 프로젝트의 기존 스택을 기반으로 설계한다:
- **프레임워크**: Nuxt 3 (App Router 방식, `app/` 디렉토리 구조)
- **UI**: `@nuxt/ui` v4
- **인증/DB**: Supabase (`@nuxtjs/supabase`) — magic link + Google OAuth
- **AI 기능 (선택)**: Anthropic Claude Sonnet 4.6 (`@anthropic-ai/sdk`) — streaming
- **패키지 매니저**: pnpm
- **검증**: Zod
- **아이콘**: Lucide + Iconify
- **배포**: Vercel 또는 Netlify (서버리스 우선)
- **개발 인원**: 1인, 빠른 출시 우선, 비용 최소화
---
## PRD 작성 구조 (아래 순서대로 빠짐없이 작성)
### 1. 배경 및 문제 정의
- 현재 견적서 전달 방식의 Pain Point 3가지 (이메일 첨부, 엑셀, 구두 전달 등)
- 타겟 사용자 페르소나: 노션을 이미 사용하는 프리랜서 / 소규모 에이전시
- 해결하려는 핵심 문제를 **1문장**으로 정의
### 2. 목표 (Goals & Non-Goals)
**MVP Goals** (3개 이내, 측정 가능하게):
- 예: "견적서 발행부터 클라이언트 PDF 수령까지 5분 이내"
**Non-Goals** (MVP에서 의도적으로 제외):
- 클라이언트 서명/결재 기능
- 다국어 지원
- 그 외 범위 방어 항목 추가
### 3. 사용자 스토리
형식: `나는 [역할]로서, [목적]을 위해 [행동]을 할 수 있다.`
각 스토리마다 **수용 기준(Acceptance Criteria)** 2~3개 포함.
- 공급자(Supplier) 스토리 4개
- 클라이언트(Client) 스토리 3개
### 4. 기능 명세
#### F1. 노션 연동
- 연동 방식: Notion Integration Token (OAuth는 Non-Goal)
- 필수 데이터베이스 속성 매핑 테이블:
| 노션 속성명 | 타입 | InvoiceLink 필드 | 필수 여부 |
|---|---|---|---|
| 견적번호 | Title | quote_number | 필수 |
| 발행일 | Date | issued_at | 필수 |
| 유효기간 | Date | expires_at | 필수 |
| 수신자명 | Text | client_name | 필수 |
| 수신자 이메일 | Email | client_email | 선택 |
| 항목명 | Text | item_name | 필수 |
| 수량 | Number | quantity | 필수 |
| 단가 (원) | Number | unit_price | 필수 |
| 세율 (%) | Number | tax_rate | 필수 |
| 비고 | Text | notes | 선택 |
- 동기화 트리거 방식 및 근거 (수동 버튼 vs 자동 webhook 중 선택)
- 에러 처리: 필수 필드 누락 시 동작 정의
#### F2. 고유 링크 생성
- URL 구조: `/q/[uuid-v4]` (추측 불가 설계)
- 링크 유효기간 정책 (기본값 및 커스터마이징 범위)
- 비공개/공개 접근 제어: 링크 소유자만 비활성화 가능
- 링크 복사 UX (클립보드 복사 버튼)
#### F3. 견적서 웹 뷰어
레이아웃 구성 요소:
- 헤더: 공급자 로고, 상호명, 연락처
- 견적 메타: 견적번호, 발행일, 유효기간, 수신자 정보
- 품목 테이블: 항목명 / 수량 / 단가 / 세액 / 소계
- 합계 영역: 공급가액, 세액, 총액 (천 단위 콤마 포맷)
- 푸터: 비고, 공급자 사업자 정보
반응형 요구사항:
- 모바일(375px)에서 품목 테이블 가로 스크롤 허용
- PDF 출력 시 A4 1페이지 기준 최적화
브랜딩 커스터마이징 범위 (MVP):
- 로고 이미지 업로드
- Primary 컬러 선택 (6가지 프리셋)
#### F4. PDF 다운로드
- 생성 방식: **서버사이드** — Nuxt server route(`server/api/quote/[id]/pdf.get.ts`)에서 `@sparticuz/chromium` + `puppeteer-core`로 헤드리스 렌더링
- 근거: 클라이언트 `window.print()`는 브라우저별 출력 차이 존재, 서버사이드가 일관성 보장
- 파일명 규칙: `견적서_{견적번호}_{YYYYMMDD}.pdf`
- 다운로드 이벤트: Supabase `quote_events` 테이블에 `pdf_downloaded` 로그 기록
- Vercel 서버리스 함수 메모리 제한(1GB) 내 처리 가능 여부 명시
#### F5. 공급자 대시보드
- 견적서 목록 (발행일 내림차순, 페이지네이션)
- 견적서별 상태 표시: `draft` / `sent` / `viewed` / `expired`
- 클라이언트 조회 여부 및 PDF 다운로드 여부 표시
- 견적서 비활성화(링크 만료) 토글
### 5. 데이터 모델
아래 엔티티에 대해 Supabase(PostgreSQL) 기준 핵심 필드 정의.
필드명 영문 snake_case, PostgreSQL 타입 명시, RLS 정책 방향 포함.
- `suppliers` (공급자)
- `quotes` (견적서 헤더)
- `quote_items` (견적 항목)
- `quote_events` (조회/다운로드 이벤트 로그)
### 6. API 설계 (Nuxt Server Routes)
| Method | Route | 설명 | Auth |
|---|---|---|---|
| GET | /api/quote/[id] | 견적서 데이터 조회 | Public (링크 보유자) |
| GET | /api/quote/[id]/pdf | PDF 생성 및 다운로드 | Public |
| POST | /api/quotes | 견적서 생성 (노션 동기화) | Supplier |
| PATCH | /api/quotes/[id] | 견적서 상태 변경 | Supplier |
| GET | /api/notion/sync | 노션 DB 항목 목록 조회 | Supplier |
### 7. 기술 스택 최종 제안
아래 항목별로 선택한 라이브러리와 선택 이유를 한 줄로 명시:
- PDF 생성
- 노션 API 클라이언트
- 날짜 처리
- 숫자/통화 포맷
- 이메일 발송 (선택)
### 8. MVP 마일스톤
| Phase | 기간 | 완성 기준 |
|---|---|---|
| Phase 1 | Week 1~2 | ? |
| Phase 2 | Week 3~4 | ? |
| Launch | Week 5 | ? |
**런치 기준(Launch Criteria)**: 이 조건이 모두 충족되어야 배포 가능
- [ ] 조건 1
- [ ] 조건 2
- [ ] 조건 3
### 9. 성공 지표 (Metrics)
- **Primary Metric** (북극성 지표) 1개
- **Supporting Metrics** 3개 이내
- 각 지표의 측정 방법 및 도구 명시 (Supabase Analytics, PostHog 등)
### 10. 리스크 & 미해결 질문
**리스크**:
- Vercel 서버리스에서 Headless Chrome 실행 시 Cold Start 지연 가능성
- 노션 API rate limit (평균 3 req/s) 대응 전략 필요
**Open Questions** (의사결정 필요 항목 3~5개):
각 질문에 현재 유력한 가설(assumption)을 함께 명시.
---
## 출력 형식 요구사항
- 마크다운 형식
- 섹션: H2(`##`), 서브섹션: H3(`###`), 세부항목: H4(`####`)
- 테이블, 체크리스트, 코드블록 적극 활용
- SQL DDL은 코드블록(`sql`)으로 표현
- 분량: 2,000~3,000 단어
- 톤: 군더더기 없는 기술 문서체 (한국어)
- 추상적 표현 금지 — 모든 기능은 구현 레벨로 구체화
- 각 섹션 말미에 **구현 시 주의사항** 한 줄 추가
---END---

View File

@@ -0,0 +1,182 @@
# 1. 렌더링 방식 + 배포 환경 선택 전략
> Nuxt 4 기준으로 작성됨 (2026-03)
## 렌더링 전략 개요
Nuxt는 네 가지 렌더링 전략을 지원한다. `nuxt.config.ts` 설정만으로 전환 가능하다.
| 전략 | 설명 | 주요 사용 사례 |
|------|------|----------------|
| **SSR** (Server-Side Rendering) | 요청마다 서버에서 HTML 생성 | 실시간 데이터, SEO가 중요한 페이지 |
| **SSG** (Static Site Generation) | 빌드 시 HTML 미리 생성 | 블로그, 문서, 변경이 적은 콘텐츠 |
| **SPA** (Single Page Application) | 클라이언트에서만 렌더링 | 인증 이후 대시보드, 관리자 페이지 |
| **Hybrid** | 페이지마다 다른 전략 적용 | 복합 요구사항을 가진 앱 |
---
## nuxt.config.ts 핵심 옵션
### 전역 렌더링 모드 설정
```ts
// nuxt.config.ts
export default defineNuxtConfig({
// SSR 활성화 여부 (기본값: true)
ssr: true,
app: {
head: {
title: '앱 제목',
meta: [{ name: 'description', content: '설명' }]
}
},
// 런타임 환경변수 (서버/클라이언트 구분)
runtimeConfig: {
// 서버에서만 접근 가능
apiSecret: process.env.API_SECRET,
// 클라이언트에도 노출됨 (public)
public: {
apiBase: process.env.API_BASE_URL
}
},
// Nitro 서버 엔진 설정
nitro: {
preset: 'node-server', // 배포 환경에 따라 변경
},
// 라우트별 개별 전략 (Hybrid 렌더링)
routeRules: {
'/admin/**': { ssr: false }, // SPA로 처리
'/blog/**': { prerender: true }, // SSG로 미리 생성
'/api/**': { cors: true }, // API 라우트 CORS 허용
'/products/**': { swr: 3600 }, // SWR: 1시간 캐시, 백그라운드 재생성
'/dashboard': { isr: 60 }, // ISR: 60초 캐시 (CDN 지원 플랫폼)
}
})
```
---
## 전략별 선택 기준
### SSR을 선택해야 할 때
- 검색엔진 최적화(SEO)가 필요한 공개 페이지
- 사용자마다 다른 데이터를 보여줘야 할 때 (개인화된 피드 등)
- 실시간으로 자주 바뀌는 데이터 (재고, 가격 등)
```ts
// 기본값이 ssr: true이므로 별도 설정 불필요
export default defineNuxtConfig({
ssr: true
})
```
### SSG를 선택해야 할 때
- 변경이 드문 콘텐츠 (블로그, 마케팅 페이지)
- CDN 캐시 최대 활용이 필요한 경우
- 서버 운영 비용을 최소화하고 싶을 때
```ts
// 빌드 시 모든 페이지를 미리 생성
export default defineNuxtConfig({
routeRules: {
'/**': { prerender: true }
}
})
```
### SPA를 선택해야 할 때
- 로그인 후에만 접근하는 페이지 (SEO 불필요)
- 복잡한 인터랙션이 많은 대시보드
- API 서버를 별도로 운영하고 있을 때
```ts
export default defineNuxtConfig({
ssr: false // 전체를 SPA로
// 또는 routeRules로 특정 경로만 SPA
})
```
### Hybrid (SWR / ISR 포함)를 선택해야 할 때
- 페이지마다 요구사항이 다를 때
- 대부분 정적이지만 일부 페이지는 SSR이 필요할 때
```ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }, // 홈: SSG
'/products/**': { swr: 3600 }, // 상품: SWR 1시간 캐시 + 백그라운드 재생성
'/blog': { isr: 3600 }, // 블로그: ISR (CDN 캐시, Vercel/Netlify)
'/blog/**': { isr: true }, // 글: 다음 배포까지 CDN에 캐시
'/cart': { ssr: false }, // 장바구니: SPA
'/checkout': { ssr: true }, // 결제: SSR (실시간)
'/old-page': { redirect: '/new-page' }, // 리다이렉트
}
})
```
---
## SEO 최적화와 캐시 전략
### SEO를 위한 메타태그 설정
```ts
// pages/products/[id].vue
definePageMeta({
title: '상품 상세'
})
// 또는 useHead()로 동적 설정
useHead({
title: computed(() => product.value?.name),
meta: [
{ name: 'description', content: computed(() => product.value?.description) }
]
})
// useSeoMeta() — 타입 안전한 방식 (권장)
useSeoMeta({
title: '상품명',
ogTitle: '상품명',
description: '상품 설명',
ogImage: '/og-image.png'
})
```
### routeRules를 활용한 캐시 전략
```ts
routeRules: {
// CDN에 1시간 캐시
'/blog/**': {
prerender: true,
headers: { 'cache-control': 's-maxage=3600' }
},
// SWR: 서버/리버스 프록시에서 캐시, 만료 후 백그라운드 재생성
'/products/**': { swr: 3600 },
// ISR: CDN 플랫폼(Vercel, Netlify)에서 캐시, 만료 후 재생성
'/deals/**': { isr: 60 },
// 캐시 완전 비활성화
'/api/live/**': { headers: { 'cache-control': 'no-store' } }
}
```
---
## 배포 환경별 Nitro preset
```ts
nitro: {
preset: 'vercel' // Vercel
preset: 'netlify' // Netlify
preset: 'cloudflare' // Cloudflare Workers
preset: 'node-server' // Node.js 서버 (기본)
preset: 'static' // 정적 파일로 빌드
}
```
> `NITRO_PRESET` 환경변수로도 설정 가능하다. 배포 플랫폼에 맞게 자동 감지되는 경우가 많다.

View File

@@ -0,0 +1,164 @@
# 2. Rendering 흐름 & Lifecycle
## SSR → Hydration → CSR 전환 흐름
Nuxt SSR은 세 단계로 구성된다.
```
[브라우저 요청]
[서버] setup() 실행 → HTML 생성 → 클라이언트로 전송
[브라우저] HTML 즉시 표시 (사용자에게 보임)
[브라우저] Vue JS 로드 → Hydration (서버 HTML에 이벤트 연결)
[브라우저] onMounted() 실행 → 완전한 SPA처럼 동작
```
---
## Lifecycle 훅 실행 위치
| 훅 | 서버 | 클라이언트 | 설명 |
|----|------|------------|------|
| `setup()` | ✅ | ✅ | 컴포넌트 초기화, 양쪽 모두 실행 |
| `onServerPrefetch()` | ✅ | ❌ | 서버에서만 실행, SSR용 데이터 페칭 |
| `onBeforeMount()` | ❌ | ✅ | DOM 마운트 직전 |
| `onMounted()` | ❌ | ✅ | DOM 마운트 완료, 브라우저 API 사용 가능 |
| `onBeforeUnmount()` | ❌ | ✅ | 컴포넌트 제거 직전 |
| `onUnmounted()` | ❌ | ✅ | 컴포넌트 제거 완료 |
---
## 실전 코드 예시
### setup()에서의 서버/클라이언트 분기
```vue
<script setup>
// setup()은 서버와 클라이언트 양쪽에서 실행됨
const config = useRuntimeConfig()
// 서버/클라이언트 환경 확인
if (import.meta.server) {
console.log('서버에서만 출력')
}
if (import.meta.client) {
console.log('클라이언트에서만 출력')
}
</script>
```
### onServerPrefetch — 서버에서 데이터 미리 준비
```vue
<script setup>
const data = ref(null)
// 서버에서 먼저 데이터를 가져오고, 클라이언트로 상태 전달
onServerPrefetch(async () => {
data.value = await $fetch('/api/products')
})
</script>
```
> `useAsyncData` / `useFetch`가 내부적으로 `onServerPrefetch`를 사용한다.
> 직접 쓸 일은 드물지만, 동작 원리 이해에 중요하다.
### onMounted — 브라우저 전용 작업
```vue
<script setup>
// ❌ 잘못된 예: setup()에서 window 접근 → 서버에서 오류 발생
// const width = window.innerWidth
// ✅ 올바른 예: onMounted에서 브라우저 API 사용
const windowWidth = ref(0)
onMounted(() => {
windowWidth.value = window.innerWidth
// 이벤트 리스너, setTimeout, localStorage 등 모두 여기서
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
// 반드시 정리해야 메모리 누수를 막는다
window.removeEventListener('resize', handleResize)
})
</script>
```
---
## Hydration이란?
서버가 생성한 정적 HTML에 Vue의 반응형 시스템과 이벤트 핸들러를 연결하는 과정이다.
```
서버 HTML: <button>클릭</button> ← 이벤트 없음
↓ Hydration
클라이언트: <button @click="handleClick">클릭</button> ← 이벤트 연결됨
```
### Hydration 불일치(Mismatch) 주의
서버와 클라이언트에서 다른 결과를 렌더링하면 경고가 발생한다.
```vue
<script setup>
// ❌ 문제: 서버(undefined)와 클라이언트(실제값) 결과가 다름
const isClient = ref(typeof window !== 'undefined')
// ✅ 해결: ClientOnly 컴포넌트 사용
</script>
<template>
<!-- 브라우저에서만 렌더링해야 하는 컴포넌트 -->
<ClientOnly>
<BrowserOnlyComponent />
<template #fallback>
<p>로딩 ...</p>
</template>
</ClientOnly>
</template>
```
---
## Nuxt 전용 훅 (useNuxtApp)
```vue
<script setup>
const nuxtApp = useNuxtApp()
// 페이지 전환 이벤트
nuxtApp.hook('page:start', () => {
console.log('페이지 전환 시작')
})
nuxtApp.hook('page:finish', () => {
console.log('페이지 전환 완료')
})
</script>
```
---
## 요약
```
서버 실행: setup() → onServerPrefetch()
HTML 생성 및 전송
클라이언트 수신: HTML 즉시 표시
Hydration (JS 연결)
setup() → onBeforeMount() → onMounted()
이후 SPA처럼 동작
```

View File

@@ -0,0 +1,212 @@
# 3. 서버 라우트·엔드포인트 (server/api/* & server/routes/*)
## Nitro 엔진
Nuxt의 서버는 **Nitro**로 구동된다. `server/` 디렉토리에 파일을 만들면 자동으로 API 엔드포인트가 생성된다.
```
server/
├── api/ # /api/ 접두사가 붙음
│ ├── hello.ts → GET /api/hello
│ ├── users/
│ │ ├── index.ts → GET /api/users
│ │ └── [id].ts → GET /api/users/:id
│ └── products.post.ts → POST /api/products (메서드 지정)
└── routes/ # 접두사 없음
└── sitemap.xml.ts → GET /sitemap.xml
```
---
## 기본 사용법
### eventHandler
```ts
// server/api/hello.ts
export default defineEventHandler((event) => {
return { message: 'Hello, Nuxt!' }
})
```
### getQuery — 쿼리 파라미터 읽기
```ts
// server/api/products.ts
// GET /api/products?category=tent&limit=10
export default defineEventHandler((event) => {
const query = getQuery(event)
// query.category → 'tent'
// query.limit → '10'
return {
category: query.category,
limit: Number(query.limit) || 20
}
})
```
### readBody — 요청 본문(Body) 읽기
```ts
// server/api/products.post.ts
// POST /api/products
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// body = { name: '텐트', price: 150000 }
// DB에 저장하는 로직
const product = await db.create(body)
return product
})
```
---
## 라우트 파라미터
```ts
// server/api/users/[id].ts
// GET /api/users/123
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id')
// id → '123'
return { userId: id }
})
```
---
## HTTP 메서드 지정
파일명에 메서드를 붙이면 해당 메서드만 처리한다.
```
server/api/products.get.ts → GET만 처리
server/api/products.post.ts → POST만 처리
server/api/products.put.ts → PUT만 처리
server/api/products.delete.ts → DELETE만 처리
```
메서드 제한 없이 처리하려면 내부에서 분기한다:
```ts
// server/api/products.ts
export default defineEventHandler(async (event) => {
const method = getMethod(event)
if (method === 'GET') {
return await getProducts()
}
if (method === 'POST') {
const body = await readBody(event)
return await createProduct(body)
}
})
```
---
## 유틸리티 함수 모음
```ts
import {
defineEventHandler, // 핸들러 정의
getQuery, // ?key=value 읽기
readBody, // POST body 읽기
getRouterParam, // URL 파라미터 읽기 (/users/:id)
getMethod, // HTTP 메서드 확인
getHeaders, // 요청 헤더 읽기
getCookie, // 쿠키 읽기
setCookie, // 쿠키 설정
setResponseStatus, // 응답 상태코드 설정
sendRedirect, // 리다이렉트
createError, // 에러 생성
} from 'h3' // Nuxt가 내부적으로 h3를 사용
```
---
## 에러 처리
```ts
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const user = await db.findUser(id)
if (!user) {
// 404 에러를 던지면 Nuxt가 적절한 응답을 보냄
throw createError({
statusCode: 404,
statusMessage: '사용자를 찾을 수 없습니다'
})
}
return user
})
```
---
## 미들웨어 (서버 전용)
```ts
// server/middleware/auth.ts
// 모든 API 요청에 자동으로 실행됨
export default defineEventHandler((event) => {
const token = getHeader(event, 'authorization')
if (!token) {
throw createError({ statusCode: 401, statusMessage: '인증 필요' })
}
})
```
---
## server/routes/ vs server/api/
```ts
// server/api/data.ts → /api/data
// server/routes/data.ts → /data (접두사 없음)
// sitemap, robots.txt 같은 특수 엔드포인트에 유용
// server/routes/robots.txt.ts
export default defineEventHandler(() => {
return `User-agent: *\nDisallow: /admin`
})
```
---
## 실전 예시: CRUD API
```ts
// server/api/purchases/index.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const purchases = await db
.from('purchases')
.select('*')
.limit(Number(query.limit) || 20)
return purchases
})
// server/api/purchases/index.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const purchase = await db.from('purchases').insert(body)
setResponseStatus(event, 201)
return purchase
})
// server/api/purchases/[id].delete.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
await db.from('purchases').delete().eq('id', id)
return { success: true }
})
```

View File

@@ -0,0 +1,221 @@
# 4. 데이터 패칭 (useAsyncData, useFetch 등)
## 핵심 개념
Nuxt의 데이터 패칭 컴포저블은 SSR과 CSR 양쪽을 처리한다.
- **서버에서 데이터를 가져와 HTML에 포함** → SEO 최적화, 빠른 초기 로딩
- **클라이언트로 상태 전달(payload)** → Hydration 시 중복 요청 방지
- **캐시·로딩·에러 상태**를 자동으로 관리
---
## useFetch vs useAsyncData
| | `useFetch` | `useAsyncData` |
|--|-----------|----------------|
| 용도 | URL 기반 데이터 패칭 | 모든 비동기 로직 |
| 내부 구현 | `useAsyncData` + `$fetch` | 직접 사용 |
| URL 변경 시 자동 재요청 | ✅ (watch 옵션) | 수동 설정 필요 |
| 권장 상황 | API URL이 명확할 때 | DB 쿼리, 복합 로직 |
---
## useFetch 기본 사용법
```vue
<script setup>
// GET /api/products 요청
const { data, pending, error, refresh } = await useFetch('/api/products')
// data: Ref<응답값>
// pending: Ref<boolean> — 로딩 상태
// error: Ref<Error | null>
// refresh: () => Promise — 수동 재요청
</script>
<template>
<div v-if="pending">로딩 중...</div>
<div v-else-if="error">에러: {{ error.message }}</div>
<ul v-else>
<li v-for="item in data" :key="item.id">{{ item.name }}</li>
</ul>
</template>
```
### 옵션 활용
```vue
<script setup>
const { data } = await useFetch('/api/products', {
// 요청 파라미터 (반응형 지원)
query: { category: 'tent', limit: 10 },
// 요청 헤더
headers: { Authorization: `Bearer ${token}` },
// 응답 데이터 변환
transform: (response) => response.items,
// 기본값 (data가 null일 때)
default: () => [],
// 서버에서만 실행 (클라이언트에서는 캐시 사용)
server: true,
// 클라이언트에서만 실행
// server: false,
// 컴포넌트 마운트 후 자동 실행 안 함
lazy: true,
})
</script>
```
---
## useAsyncData 기본 사용법
```vue
<script setup>
// 첫 번째 인자는 캐시 키 (고유해야 함)
const { data, pending, error } = await useAsyncData(
'products-list',
() => $fetch('/api/products')
)
</script>
```
### Supabase와 함께 사용
```vue
<script setup>
const client = useSupabaseClient()
const { data: purchases } = await useAsyncData(
'purchases',
() => client
.from('purchases')
.select('*')
.order('created_at', { ascending: false })
.then(({ data }) => data)
)
</script>
```
---
## 반응형 쿼리 (URL 파라미터 변경 시 자동 재요청)
```vue
<script setup>
const route = useRoute()
// route.params.id가 바뀌면 자동으로 재요청
const { data: product } = await useFetch(
() => `/api/products/${route.params.id}`
)
// 또는 watch 옵션 사용
const category = ref('tent')
const { data } = await useFetch('/api/products', {
query: { category }, // category가 바뀌면 자동 재요청
watch: [category],
})
</script>
<template>
<select v-model="category">
<option value="tent">텐트</option>
<option value="chair">의자</option>
</select>
</template>
```
---
## SSR 동작 원리
```
1. [서버] useFetch('/api/products') 실행
2. [서버] 데이터 가져옴: [{ id: 1, name: '텐트' }, ...]
3. [서버] 데이터를 HTML에 포함 + payload에 직렬화
4. [클라이언트] HTML 즉시 표시 (데이터가 이미 있음)
5. [클라이언트] Hydration 시 payload에서 데이터 복원
6. [클라이언트] /api/products 중복 요청 안 함 ✅
```
---
## 서버/클라이언트 실행 분기
```vue
<script setup>
// server: false → 클라이언트에서만 실행 (SEO 불필요한 데이터)
const { data: userProfile } = await useFetch('/api/me', {
server: false, // 서버에서 실행 안 함
})
// lazy: true → 비동기적으로 로딩 (페이지 전환을 막지 않음)
const { data: recommendations } = await useFetch('/api/recommendations', {
lazy: true,
})
</script>
```
---
## $fetch — 이벤트 핸들러에서 직접 요청
`$fetch`는 컴포저블 없이 즉시 요청할 때 사용한다.
(setup() 최상위에서는 `useFetch`를 쓸 것)
```vue
<script setup>
// 버튼 클릭 같은 이벤트에서 직접 호출
async function handleSubmit() {
const result = await $fetch('/api/purchases', {
method: 'POST',
body: { name: '텐트', price: 150000 }
})
console.log(result)
}
// 삭제
async function handleDelete(id) {
await $fetch(`/api/purchases/${id}`, { method: 'DELETE' })
}
</script>
```
---
## 로딩/에러 상태 처리 패턴
```vue
<script setup>
const { data, pending, error, refresh } = await useFetch('/api/products', {
default: () => []
})
</script>
<template>
<!-- 로딩 -->
<USkeletonList v-if="pending" />
<!-- 에러 -->
<UAlert
v-else-if="error"
color="red"
title="데이터를 불러오지 못했습니다"
:description="error.message"
>
<template #actions>
<UButton @click="refresh">다시 시도</UButton>
</template>
</UAlert>
<!-- 데이터 -->
<ProductList v-else :items="data" />
</template>
```

View File

@@ -0,0 +1,234 @@
# 5. 상태관리 전략
## 세 가지 선택지
| 방법 | 범위 | 언제 사용 |
|------|------|----------|
| **Local State** (`ref`, `reactive`) | 컴포넌트 내부 | 해당 컴포넌트만 사용하는 UI 상태 |
| **Composable** (`useState`) | 여러 컴포넌트 공유 | 경량 전역 상태, 인증 정보 등 |
| **Pinia** | 앱 전체 | 복잡한 비즈니스 로직, 대규모 앱 |
---
## Local State — 컴포넌트 내부 상태
```vue
<script setup>
// 컴포넌트에서만 쓰는 상태
const isOpen = ref(false)
const formData = reactive({
name: '',
price: 0
})
function toggle() {
isOpen.value = !isOpen.value
}
</script>
```
**적합한 경우:**
- 모달 열림/닫힘
- 폼 입력값
- 로컬 필터/정렬 상태
---
## Composable (useState) — 경량 전역 상태
`useState`는 Nuxt 전용으로, SSR에서 서버/클라이언트 간 상태를 안전하게 공유한다.
`ref`와 달리 SSR payload에 포함되어 Hydration 시 상태가 유지된다.
```ts
// composables/useAuth.ts
export function useAuth() {
// 첫 번째 인자는 고유 키 (SSR payload 직렬화에 사용됨)
const user = useState('auth-user', () => null)
async function login(email: string, password: string) {
const data = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
user.value = data.user
}
function logout() {
user.value = null
}
const isLoggedIn = computed(() => !!user.value)
return { user, isLoggedIn, login, logout }
}
```
```vue
<!-- 어떤 컴포넌트에서든 같은 상태를 공유 -->
<script setup>
const { user, isLoggedIn, logout } = useAuth()
</script>
```
---
## Pinia — 대규모 앱의 전역 상태
Nuxt에서 Pinia는 `@pinia/nuxt` 모듈로 자동 연동된다.
### Store 정의
```ts
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
// State
const items = ref<CartItem[]>([])
// Getters (computed)
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
// Actions
function addItem(product: Product) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(productId: string) {
items.value = items.value.filter(i => i.id !== productId)
}
async function checkout() {
await $fetch('/api/orders', {
method: 'POST',
body: { items: items.value }
})
items.value = []
}
return { items, totalCount, totalPrice, addItem, removeItem, checkout }
})
```
### Store 사용
```vue
<script setup>
const cart = useCartStore()
// storeToRefs로 반응성을 유지하며 구조분해
const { items, totalCount, totalPrice } = storeToRefs(cart)
// 액션은 그냥 구조분해 가능
const { addItem, removeItem } = cart
</script>
<template>
<button @click="addItem(product)">
장바구니 담기 ({{ totalCount }})
</button>
</template>
```
---
## 전역 UI 상태 — 모달/토스트 패턴
```ts
// composables/useModal.ts
export function useModal() {
const isOpen = useState('modal-open', () => false)
const modalContent = useState<string | null>('modal-content', () => null)
function open(content: string) {
modalContent.value = content
isOpen.value = true
}
function close() {
isOpen.value = false
modalContent.value = null
}
return { isOpen, modalContent, open, close }
}
```
```ts
// composables/useToast.ts — Nuxt UI의 useToast 활용
export function useAppToast() {
const toast = useToast()
function success(message: string) {
toast.add({
title: '성공',
description: message,
color: 'green'
})
}
function error(message: string) {
toast.add({
title: '오류',
description: message,
color: 'red'
})
}
return { success, error }
}
```
---
## 인증 상태 관리
프로젝트에서 Supabase를 사용할 때의 패턴:
```ts
// composables/useAuth.ts
export function useAuth() {
const client = useSupabaseClient()
const user = useSupabaseUser() // @nuxtjs/supabase 제공
const isLoggedIn = computed(() => !!user.value)
async function signInWithEmail(email: string) {
await client.auth.signInWithOtp({ email })
}
async function signOut() {
await client.auth.signOut()
navigateTo('/login')
}
return { user, isLoggedIn, signInWithEmail, signOut }
}
```
---
## 전략 선택 가이드
```
이 상태를 다른 컴포넌트에서도 쓰나?
├── 아니오 → Local State (ref, reactive)
└── 예 →
복잡한 로직이나 비동기 작업이 있나?
├── 예 → Pinia Store
└── 아니오 → Composable (useState)
```
**흔한 패턴:**
- 모달 열림/닫힘 → `Local State`
- 사용자 정보, 인증 → `Composable` (useAuth)
- 장바구니, 알림 목록 → `Pinia`
- 테마, 언어 설정 → `Composable` (useState + localStorage)

View File

@@ -0,0 +1,265 @@
# 6. 커스텀 Composables & useXxx 패턴
## Composable이란?
반복되는 상태·로직을 `useXxx` 함수로 추상화하는 Vue/Nuxt의 패턴이다.
`composables/` 디렉토리에 파일을 만들면 **자동으로 전역 import**된다.
```
composables/
├── useAuth.ts → useAuth() 자동 임포트
├── useApi.ts → useApi() 자동 임포트
└── usePurchases.ts → usePurchases() 자동 임포트
```
---
## 기본 구조
```ts
// composables/useCounter.ts
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return { count, increment, decrement, reset }
}
```
```vue
<script setup>
// 자동 임포트 — import 문 불필요
const { count, increment, reset } = useCounter(10)
</script>
```
---
## 핵심 규칙
### 1. setup() 최상위에서만 호출
```vue
<script setup>
// ✅ 올바른 위치
const { data } = usePurchases()
function handleClick() {
// ❌ 함수 내부에서 호출 불가 (onMounted, watch 내부도 마찬가지)
// const { data } = usePurchases()
}
</script>
```
### 2. 서버/클라이언트 환경 분기 안전하게 처리
```ts
// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
const value = useState<T>(key, () => defaultValue)
// localStorage는 브라우저에만 존재
if (import.meta.client) {
const stored = localStorage.getItem(key)
if (stored) {
value.value = JSON.parse(stored)
}
}
watch(value, (newValue) => {
if (import.meta.client) {
localStorage.setItem(key, JSON.stringify(newValue))
}
})
return value
}
```
### 3. useState로 SSR 안전 전역 상태
```ts
// ❌ ref는 컴포넌트 인스턴스마다 별개
export function useBad() {
const count = ref(0) // 각 컴포넌트가 자신만의 count를 가짐
return { count }
}
// ✅ useState는 앱 전체에서 하나의 상태 공유
export function useGood() {
const count = useState('shared-count', () => 0)
return { count }
}
```
---
## 실전 예시: useApi
```ts
// composables/useApi.ts
export function useApi() {
const config = useRuntimeConfig()
const { user } = useAuth()
async function get<T>(path: string, options?: object): Promise<T> {
return $fetch<T>(path, {
baseURL: config.public.apiBase,
headers: {
Authorization: user.value ? `Bearer ${user.value.token}` : undefined,
},
...options
})
}
async function post<T>(path: string, body: object): Promise<T> {
return $fetch<T>(path, {
method: 'POST',
body,
baseURL: config.public.apiBase,
})
}
return { get, post }
}
```
---
## 실전 예시: usePurchases (이 프로젝트 패턴)
```ts
// composables/usePurchases.ts
export function usePurchases() {
const client = useSupabaseClient()
const toast = useToast()
const { data: purchases, pending, refresh } = useAsyncData(
'purchases',
() => client
.from('purchases')
.select('*')
.order('purchased_at', { ascending: false })
.then(({ data, error }) => {
if (error) throw error
return data
})
)
async function create(purchase: PurchaseInsert) {
const { error } = await client.from('purchases').insert(purchase)
if (error) {
toast.add({ title: '등록 실패', color: 'red', description: error.message })
return false
}
toast.add({ title: '등록 완료', color: 'green' })
await refresh()
return true
}
async function remove(id: string) {
const { error } = await client.from('purchases').delete().eq('id', id)
if (error) {
toast.add({ title: '삭제 실패', color: 'red' })
return false
}
await refresh()
return true
}
return { purchases, pending, create, remove, refresh }
}
```
```vue
<!-- pages/purchases/index.vue -->
<script setup>
const { purchases, pending, create } = usePurchases()
</script>
```
---
## 서버/클라이언트 환경 분기
```ts
// composables/usePlatform.ts
export function usePlatform() {
const isServer = import.meta.server
const isClient = import.meta.client
// 서버에서만 사용 가능한 기능
function getServerData() {
if (!isServer) return null
return process.env.SECRET_DATA
}
// 클라이언트에서만 사용 가능한 기능
function getBrowserData() {
if (!isClient) return null
return {
userAgent: navigator.userAgent,
language: navigator.language,
}
}
return { isServer, isClient, getServerData, getBrowserData }
}
```
---
## Hydration 안전 패턴
서버에서 생성된 값과 클라이언트에서 재생성된 값이 달라 생기는 불일치를 방지한다.
```ts
// ❌ 위험: Math.random()은 서버와 클라이언트에서 다른 값을 생성
export function useUnsafeId() {
const id = ref(Math.random()) // Hydration mismatch!
return { id }
}
// ✅ 안전: useState로 서버에서 생성한 값을 클라이언트에 전달
export function useSafeId() {
const id = useState('unique-id', () => Math.random())
return { id }
}
// ✅ 안전: 브라우저 전용 값은 onMounted에서 설정
export function useSafeWindowSize() {
const width = ref(0) // 서버에서는 0
onMounted(() => {
width.value = window.innerWidth // 클라이언트에서 업데이트
})
return { width }
}
```
---
## 정리: 언제 무엇을 쓸까
```
반복되는 로직인가?
└── 예 → Composable로 추출
상태를 컴포넌트 간 공유하나?
├── 아니오 → 컴포넌트 내 ref/reactive
└── 예 →
SSR이 필요하거나 Hydration 안전이 필요한가?
├── 예 → useState
└── 아니오 → ref (composable 내부)
복잡한 비즈니스 로직이 많은가?
└── 예 → Pinia Store 고려
```

View File

@@ -0,0 +1,266 @@
# 7. 최적화 캐시 / 재호출 전략
## 캐시 레이어 구조
Nuxt의 캐시는 여러 레이어에서 작동한다.
```
브라우저 캐시 (HTTP Cache-Control)
CDN / Edge 캐시
Nitro 서버 캐시 (cachedEventHandler, cachedFunction)
useFetch / useAsyncData 캐시 (payload 기반)
```
---
## 1. useFetch / useAsyncData 캐시
### 기본 캐시 동작 (payload)
```vue
<script setup>
// 서버에서 가져온 데이터는 payload에 저장됨
// 클라이언트에서 같은 키로 요청하면 재요청 없이 payload 사용
const { data } = await useFetch('/api/products', {
key: 'products-list' // 명시적 캐시 키 (기본값: URL + params 조합)
})
</script>
```
### stale-while-revalidate 패턴
"캐시된 데이터를 즉시 보여주면서 백그라운드에서 최신 데이터를 가져오는" 전략이다.
```vue
<script setup>
const { data, refresh } = await useFetch('/api/products', {
// 1. 캐시된 데이터로 즉시 렌더링
// 2. 백그라운드에서 새 데이터 요청
// 3. 새 데이터 도착 시 자동으로 UI 업데이트
getCachedData(key, nuxtApp) {
// nuxtApp.payload.data에 이미 캐시된 데이터가 있으면 사용
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
}
})
</script>
```
### 수동 refresh와 자동 watch
```vue
<script setup>
const page = ref(1)
const category = ref('tent')
const { data, pending, refresh } = await useFetch('/api/products', {
query: { page, category },
watch: [page, category], // 값이 바뀌면 자동으로 재요청
})
// 수동으로 재요청
async function handleRefresh() {
await refresh()
}
</script>
```
### invalidation — 캐시 무효화 타이밍
```vue
<script setup>
const { data: products, refresh } = await useFetch('/api/products')
// 상품 등록 후 목록 갱신
async function createProduct(newProduct) {
await $fetch('/api/products', {
method: 'POST',
body: newProduct
})
// 캐시 무효화: 수동 refresh
await refresh()
}
// 또는 clearNuxtData로 특정 키 캐시 삭제
function invalidateCache() {
clearNuxtData('products-list')
}
</script>
```
---
## 2. Nitro 서버 캐시
서버 API 응답 자체를 캐시한다. DB 부하를 크게 줄일 수 있다.
### cachedEventHandler
```ts
// server/api/products.ts
export default cachedEventHandler(async (event) => {
// DB에서 데이터 조회
const products = await db.from('products').select('*')
return products
}, {
maxAge: 60 * 60, // 1시간 캐시
name: 'products', // 캐시 키 이름
getKey: (event) => {
// 쿼리 파라미터에 따라 다른 캐시 키 사용
const query = getQuery(event)
return `products-${query.category}-${query.page}`
},
// 캐시 무효화 조건
shouldBypassCache: (event) => {
return getHeader(event, 'Cache-Control') === 'no-cache'
}
})
```
### cachedFunction
```ts
// server/utils/db.ts
export const getCachedProducts = cachedFunction(
async (category: string) => {
return await db.from('products').select('*').eq('category', category)
},
{
maxAge: 60 * 10, // 10분 캐시
name: 'get-products',
getKey: (category) => category,
}
)
```
---
## 3. routeRules 캐시 (ISR)
```ts
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// ISR: 첫 요청 후 60초간 캐시, 만료 시 백그라운드에서 재생성
'/products/**': { isr: 60 },
// SSG: 빌드 시 생성, 변경 없음
'/blog/**': { prerender: true },
// CDN 캐시 헤더 설정
'/api/public/**': {
headers: {
'Cache-Control': 's-maxage=3600, stale-while-revalidate=86400'
}
},
// 캐시 완전 비활성화
'/api/realtime/**': {
headers: { 'Cache-Control': 'no-store' }
}
}
})
```
---
## 4. 컴포넌트 레벨 캐시
### lazy 로딩 + 조건부 요청
```vue
<script setup>
const isTabActive = ref(false)
// isTabActive가 true가 될 때만 데이터 요청
const { data } = await useFetch('/api/analytics', {
immediate: false, // 즉시 요청하지 않음
})
watch(isTabActive, (active) => {
if (active) refresh()
})
</script>
```
### keep-alive로 컴포넌트 캐시
```vue
<!-- 페이지 전환해도 컴포넌트 상태 유지 -->
<NuxtPage :keepalive="{ include: ['ProductList', 'Dashboard'] }" />
```
```ts
// pages/products/index.vue
defineOptions({
name: 'ProductList' // keepalive에서 참조하는 이름
})
```
---
## 5. 실전 캐시 전략 예시
### 상품 목록 (자주 안 바뀜)
```ts
// nuxt.config.ts
routeRules: {
'/products': { isr: 3600 } // 1시간마다 재생성
}
// server/api/products.ts
export default cachedEventHandler(handler, {
maxAge: 60 * 60, // Nitro도 1시간 캐시
})
```
### 사용자 전용 데이터 (캐시 불가)
```vue
<script setup>
// server: false로 서버 캐시 우회
// 사용자별 데이터는 서버에서 캐시하면 안 됨
const { data: myOrders } = await useFetch('/api/my/orders', {
server: false, // 클라이언트에서만 요청
headers: useRequestHeaders(['cookie']) // 인증 쿠키 전달
})
</script>
```
### 실시간 데이터 (폴링)
```vue
<script setup>
const { data, refresh } = await useFetch('/api/stock', {
server: false
})
// 30초마다 자동 갱신
let interval: ReturnType<typeof setInterval>
onMounted(() => {
interval = setInterval(refresh, 30_000)
})
onUnmounted(() => {
clearInterval(interval)
})
</script>
```
---
## 요약
| 전략 | 사용 시점 | 구현 방법 |
|------|----------|----------|
| **Payload 캐시** | SSR 초기 로딩 중복 방지 | useFetch 기본 동작 |
| **수동 refresh** | 변경 후 최신 데이터 필요 | `refresh()` 호출 |
| **watch 자동 재요청** | 필터/페이지 변경 시 | `watch` 옵션 |
| **Nitro 서버 캐시** | DB 부하 절감 | `cachedEventHandler` |
| **ISR** | 준정적 콘텐츠 | `routeRules: { isr: N }` |
| **keep-alive** | 페이지 전환 시 상태 유지 | `<NuxtPage keepalive>` |
| **폴링** | 실시간 데이터 | `setInterval + refresh` |

View File

@@ -0,0 +1,24 @@
1. 설정&모듈 + 서버 렌더링 모드 (SSR / SSG / SPA / Edge)
nuxt.config.ts에서의 핵심 옵션(app, runtimeConfig, nitro, routeRules 등).
Nuxt 4에서 지원하는 렌더링 전략(SSR, SSG, SPA, Hybrid)과 nuxt.config에서의 설정 방식.
언제 SSR/SSG를 써야 하는지, SEO·성능·캐싱 관점의 차이.
2. rendering 흐름 & lifecycle
SSR → Hydration → CSR 전환 흐름서버/클라이언트별 lifecycle (setup, onMounted, onServerPrefetch 등)
3. 서버 엔드포인트 (server/api/_ & server/routes/_)
Nitro 기반 서버: server/api/\*.ts로 API 라우트 만드는 법서버 유틸(eventHandler, getQuery, readBody) 사용.
4. 데이터 페칭 (useAsyncData, useFetch 등)
컴포넌트에서 비동기 데이터 불러오는 패턴과 캐싱,
에러/로딩 상태 관리.서버에서 먼저 데이터 로딩 후 HTML 렌더링되는 흐름(SSR 시) 이해.
서버/클라 어디에서 어떻게 부를지, 에러·로딩 관리 패턴
5. 상태관리 기준
언제 local state / composable / Pinia를 쓸지 기준 세우기auth, 공통 UI 상태(모달/토스트) 같은 예시 기반
6. 컴포저블(Composables) & useXxx 패턴
composables/ 폴더에 공통 로직을 useAuth, useApi처럼 추상화하는 방식.
상태 공유, 서버/클라이언트 환경 차이(hydration) 고려한 composable 설계.
7. 간단한 캐시 / 재호출 전략 설계
stale-while-revalidate 느낌의 패턴재호출 조건, invalidation 타이밍, 초간단 리스트/상세 캐싱 패턴