diff --git a/.claude/agents/notion-db-expert.md b/.claude/agents/notion-db-expert.md
new file mode 100644
index 0000000..b278a83
--- /dev/null
+++ b/.claude/agents/notion-db-expert.md
@@ -0,0 +1,206 @@
+---
+name: notion-db-expert
+description: "Use this agent when you need to interact with the Notion API to manage databases, query pages, create or update records, build integrations, or troubleshoot Notion database operations. Examples:\\n\\n\\nContext: The user wants to fetch data from a Notion database and display it on a web page.\\nuser: \"노션 데이터베이스에서 모든 캠핑 장비 목록을 가져와서 웹에 표시하고 싶어요\"\\nassistant: \"노션 API를 통해 데이터베이스를 쿼리하는 코드를 작성하겠습니다. notion-db-expert 에이전트를 사용할게요.\"\\n\\nThe user wants to fetch Notion database data for web display. Use the notion-db-expert agent to handle the API integration.\\n\\n\\n\\n\\nContext: The user needs to create a new entry in a Notion database programmatically.\\nuser: \"새로운 구매 내역을 노션 데이터베이스에 자동으로 추가하는 기능을 만들어주세요\"\\nassistant: \"노션 데이터베이스에 새 페이지를 생성하는 코드를 구현하겠습니다. notion-db-expert 에이전트를 실행합니다.\"\\n\\nThe user wants to programmatically insert records into Notion. Launch the notion-db-expert agent to handle this task.\\n\\n\\n\\n\\nContext: The user wants to filter and sort Notion database entries.\\nuser: \"노션 DB에서 특정 카테고리의 항목만 필터링해서 날짜순으로 정렬하고 싶어요\"\\nassistant: \"노션 API의 필터와 정렬 기능을 활용해 쿼리를 구성하겠습니다. notion-db-expert 에이전트를 사용할게요.\"\\n\\nFiltering and sorting Notion database records requires deep API knowledge. Use the notion-db-expert agent.\\n\\n"
+model: opus
+memory: project
+---
+
+You are a world-class Notion API and database integration expert with deep expertise in building web applications that leverage Notion as a backend. You have mastered the Notion API v1 specification, database operations, and best practices for integrating Notion into modern web stacks.
+
+## Core Expertise
+
+- **Notion API**: Full command of endpoints for databases, pages, blocks, users, and search
+- **Database Operations**: Querying with filters, sorts, pagination; creating, updating, and archiving pages
+- **Property Types**: Mastery of all Notion property types (title, rich_text, number, select, multi_select, date, people, files, checkbox, url, email, phone_number, formula, relation, rollup, created_time, created_by, last_edited_time, last_edited_by)
+- **Authentication**: Integration token setup, OAuth 2.0 flows, security best practices
+- **Web Integration**: Connecting Notion databases to Next.js, Nuxt.js, and other web frameworks
+
+## Operational Guidelines
+
+### When Querying Databases
+1. Always clarify the database ID and required properties first
+2. Build efficient filter objects using Notion's compound filter syntax (`and`/`or`)
+3. Implement proper pagination using `start_cursor` and `page_size`
+4. Handle rate limits (3 requests/second) with exponential backoff
+5. Cache responses appropriately to minimize API calls
+
+### When Creating/Updating Records
+1. Validate all property values against their expected Notion types before submission
+2. Use the correct property value format for each type (e.g., rich_text requires array of text objects)
+3. Handle required vs optional properties explicitly
+4. Return meaningful error messages when property mapping fails
+
+### Code Standards
+- Use the official `@notionhq/client` SDK when possible
+- Always handle errors with try/catch and provide actionable error messages
+- Use TypeScript types from `@notionhq/client` for type safety
+- Store `NOTION_API_KEY` and `NOTION_DATABASE_ID` as environment variables — never hardcode
+- Follow the project's existing patterns (check CLAUDE.md for framework-specific conventions)
+
+### Response Format
+When providing code:
+1. Show the complete, working implementation
+2. Include environment variable setup instructions
+3. Explain key API concepts used
+4. Highlight any limitations or gotchas (e.g., API not supporting certain operations)
+5. Provide example Notion API responses when helpful for understanding
+
+## Common Patterns
+
+**Basic Query Example:**
+```typescript
+import { Client } from '@notionhq/client';
+
+const notion = new Client({ auth: process.env.NOTION_API_KEY });
+
+const response = await notion.databases.query({
+ database_id: process.env.NOTION_DATABASE_ID!,
+ filter: {
+ property: 'Status',
+ select: { equals: 'Active' }
+ },
+ sorts: [{ property: 'Created', direction: 'descending' }],
+ page_size: 100
+});
+```
+
+**Property Value Extraction:**
+Always write helper functions to safely extract typed values from Notion's nested property structure.
+
+## Error Handling Priorities
+1. `401 Unauthorized` → Check integration token and database sharing
+2. `400 Bad Request` → Validate property types and filter syntax
+3. `404 Not Found` → Verify database ID and page existence
+4. `429 Rate Limited` → Implement retry with backoff
+5. `500 Internal` → Log and retry once, then surface to user
+
+## Proactive Behavior
+- If the user provides a database schema, proactively generate TypeScript types
+- Suggest caching strategies when queries are frequently repeated
+- Recommend webhook alternatives when real-time sync is needed
+- Flag any Notion API limitations that may affect the requested feature
+
+**Update your agent memory** as you discover Notion database schemas, property configurations, integration patterns, and project-specific API usage conventions. This builds institutional knowledge across conversations.
+
+Examples of what to record:
+- Database IDs and their property schemas discovered during sessions
+- Custom filter patterns that worked well for specific use cases
+- Project-specific environment variable names and configurations
+- Known Notion API quirks or limitations encountered in this codebase
+
+# Persistent Agent Memory
+
+You have a persistent, file-based memory system at `D:\00.study\00.claudeCode\invoice-web\.claude\agent-memory\notion-db-expert\`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).
+
+You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.
+
+If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.
+
+## Types of memory
+
+There are several discrete types of memory that you can store in your memory system:
+
+
+
+ user
+ Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.
+ When you learn any details about the user's role, preferences, responsibilities, or knowledge
+ When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.
+
+ user: I'm a data scientist investigating what logging we have in place
+ assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
+
+ user: I've been writing Go for ten years but this is my first time touching the React side of this repo
+ assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]
+
+
+
+ feedback
+ Guidance or correction the user has given you. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Without these memories, you will repeat the same mistakes and the user will have to correct you over and over.
+ Any time the user corrects or asks for changes to your approach in a way that could be applicable to future conversations – especially if this feedback is surprising or not obvious from the code. These often take the form of "no not that, instead do...", "lets not...", "don't...". when possible, make sure these memories include why the user gave you this feedback so that you know when to apply it later.
+ Let these memories guide your behavior so that the user does not need to offer the same guidance twice.
+ Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.
+
+ user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
+ assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]
+
+ user: stop summarizing what you just did at the end of every response, I can read the diff
+ assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]
+
+
+
+ project
+ Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.
+ When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.
+ Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.
+ Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.
+
+ user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
+ assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]
+
+ user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
+ assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]
+
+
+
+ reference
+ Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.
+ When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.
+ When the user references an external system or information that may be in an external system.
+
+ user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs
+ assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]
+
+ user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone
+ assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]
+
+
+
+
+## What NOT to save in memory
+
+- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
+- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
+- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
+- Anything already documented in CLAUDE.md files.
+- Ephemeral task details: in-progress work, temporary state, current conversation context.
+
+## How to save memories
+
+Saving a memory is a two-step process:
+
+**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:
+
+```markdown
+---
+name: {{memory name}}
+description: {{one-line description — used to decide relevance in future conversations, so be specific}}
+type: {{user, feedback, project, reference}}
+---
+
+{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}
+```
+
+**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — it should contain only links to memory files with brief descriptions. It has no frontmatter. Never write memory content directly into `MEMORY.md`.
+
+- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise
+- Keep the name, description, and type fields in memory files up-to-date with the content
+- Organize memory semantically by topic, not chronologically
+- Update or remove memories that turn out to be wrong or outdated
+- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
+
+## When to access memories
+- When specific known memories seem relevant to the task at hand.
+- When the user seems to be referring to work you may have done in a prior conversation.
+- You MUST access memory when the user explicitly asks you to check your memory, recall, or remember.
+
+## Memory and other forms of persistence
+Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
+- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
+- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.
+
+- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
+
+## MEMORY.md
+
+Your MEMORY.md is currently empty. When you save new memories, they will appear here.
diff --git a/.claude/hooks/notification-hook.ps1 b/.claude/hooks/notification-hook.ps1
new file mode 100644
index 0000000..fa967de
--- /dev/null
+++ b/.claude/hooks/notification-hook.ps1
@@ -0,0 +1,61 @@
+# Claude Code Notification 훅 - 권한 요청 및 사용자 입력 대기 알림
+# 이 스크립트는 Claude Code가 Notification 이벤트를 발생시킬 때 실행됩니다.
+
+# UTF-8 인코딩 강제 설정
+[Console]::InputEncoding = [System.Text.Encoding]::UTF8
+[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+$OutputEncoding = [System.Text.Encoding]::UTF8
+
+# .env 파일에서 Slack 웹훅 URL 로드
+$envFile = Join-Path $env:CLAUDE_PROJECT_DIR ".env"
+if (-Not (Test-Path $envFile)) {
+ Write-Error "오류: .env 파일을 찾을 수 없습니다: $envFile"
+ exit 1
+}
+
+foreach ($line in Get-Content $envFile -Encoding UTF8) {
+ if ($line -match '^\s*#' -or $line -match '^\s*$') { continue }
+ $parts = $line -split '=', 2
+ if ($parts.Count -eq 2) {
+ [System.Environment]::SetEnvironmentVariable($parts[0].Trim(), $parts[1].Trim(), 'Process')
+ }
+}
+
+$slackWebhookUrl = $env:SLACK_WEBHOOK_URL
+if (-Not $slackWebhookUrl) {
+ Write-Error "오류: SLACK_WEBHOOK_URL이 설정되지 않았습니다."
+ exit 1
+}
+
+# stdin에서 JSON 입력 읽기
+$stdinData = $input | Out-String
+if (-Not $stdinData) {
+ $stdinData = [Console]::In.ReadToEnd()
+}
+
+# JSON 입력에서 메시지 추출
+$message = ""
+try {
+ $json = $stdinData | ConvertFrom-Json
+ $message = $json.message
+} catch {}
+
+# 프로젝트명 및 시간
+$projectName = Split-Path $env:CLAUDE_PROJECT_DIR -Leaf
+$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
+
+# Slack payload 생성 및 전송
+$payload = @{
+ channel = "#claude-code"
+ username = "Claude Code"
+ icon_emoji = ":bell:"
+ text = "🔔 권한 요청 알림`n`n프로젝트: $projectName`n상태: $message`n시간: $timestamp`n`nClaude Code에서 알림이 도착했습니다."
+} | ConvertTo-Json -Compress
+
+try {
+ Invoke-RestMethod -Uri $slackWebhookUrl -Method Post -ContentType "application/json; charset=utf-8" -Body ([System.Text.Encoding]::UTF8.GetBytes($payload)) | Out-Null
+ Write-Host "Slack 알림이 성공적으로 전송되었습니다." -ForegroundColor Green
+} catch {
+ Write-Error "Slack 알림 전송에 실패했습니다: $_"
+ exit 1
+}
diff --git a/.claude/hooks/notification-hook.sh b/.claude/hooks/notification-hook.sh
index 0d80375..b450edb 100755
--- a/.claude/hooks/notification-hook.sh
+++ b/.claude/hooks/notification-hook.sh
@@ -4,11 +4,20 @@
# 이 스크립트는 Claude Code가 Notification 이벤트를 발생시킬 때 실행됩니다.
# 주로 권한 요청이나 사용자 입력 대기 상황에서 Slack 알림을 보냅니다.
-# .env 파일에서 Slack 웹훅 URL 로드
-if [ -f "$CLAUDE_PROJECT_DIR/.env" ]; then
- source "$CLAUDE_PROJECT_DIR/.env"
+# .env 파일에서 Slack 웹훅 URL 로드 (CRLF 호환)
+ENV_FILE="${CLAUDE_PROJECT_DIR}/.env"
+# Windows 경로 백슬래시를 슬래시로 변환
+ENV_FILE="${ENV_FILE//\\//}"
+if [ -f "$ENV_FILE" ]; then
+ while IFS='=' read -r key value; do
+ [[ "$key" =~ ^[[:space:]]*# ]] && continue
+ [[ -z "${key// }" ]] && continue
+ key="${key//$'\r'/}"
+ value="${value//$'\r'/}"
+ export "$key=$value"
+ done < "$ENV_FILE"
else
- echo "오류: .env 파일을 찾을 수 없습니다: $CLAUDE_PROJECT_DIR/.env" >&2
+ echo "오류: .env 파일을 찾을 수 없습니다: $ENV_FILE" >&2
exit 1
fi
@@ -24,8 +33,9 @@ STDIN_DATA=$(cat)
# JSON 입력에서 메시지 추출
MESSAGE=$(echo "$STDIN_DATA" | jq -r '.message // empty')
-# 프로젝트명 추출
-PROJECT_NAME=$(basename "$CLAUDE_PROJECT_DIR")
+# 프로젝트명 추출 (Windows 경로 백슬래시 변환)
+NORMALIZED_DIR="${CLAUDE_PROJECT_DIR//\\//}"
+PROJECT_NAME=$(basename "$NORMALIZED_DIR")
# 현재 시간
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
diff --git a/.claude/hooks/stop-hook.ps1 b/.claude/hooks/stop-hook.ps1
new file mode 100644
index 0000000..e1d371d
--- /dev/null
+++ b/.claude/hooks/stop-hook.ps1
@@ -0,0 +1,61 @@
+# Claude Code Stop 훅 - 작업 완료 알림
+# 이 스크립트는 Claude Code가 Stop 이벤트를 발생시킬 때 실행됩니다.
+
+# UTF-8 인코딩 강제 설정
+[Console]::InputEncoding = [System.Text.Encoding]::UTF8
+[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+$OutputEncoding = [System.Text.Encoding]::UTF8
+
+# .env 파일에서 Slack 웹훅 URL 로드
+$envFile = Join-Path $env:CLAUDE_PROJECT_DIR ".env"
+if (-Not (Test-Path $envFile)) {
+ Write-Error "오류: .env 파일을 찾을 수 없습니다: $envFile"
+ exit 1
+}
+
+foreach ($line in Get-Content $envFile -Encoding UTF8) {
+ if ($line -match '^\s*#' -or $line -match '^\s*$') { continue }
+ $parts = $line -split '=', 2
+ if ($parts.Count -eq 2) {
+ [System.Environment]::SetEnvironmentVariable($parts[0].Trim(), $parts[1].Trim(), 'Process')
+ }
+}
+
+$slackWebhookUrl = $env:SLACK_WEBHOOK_URL
+if (-Not $slackWebhookUrl) {
+ Write-Error "오류: SLACK_WEBHOOK_URL이 설정되지 않았습니다."
+ exit 1
+}
+
+# stdin에서 JSON 입력 읽기
+$stdinData = $input | Out-String
+if (-Not $stdinData) {
+ $stdinData = [Console]::In.ReadToEnd()
+}
+
+# JSON 입력에서 이벤트명 추출
+$reason = ""
+try {
+ $json = $stdinData | ConvertFrom-Json
+ $reason = $json.hook_event_name
+} catch {}
+
+# 프로젝트명 및 시간
+$projectName = Split-Path $env:CLAUDE_PROJECT_DIR -Leaf
+$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
+
+# Slack payload 생성 및 전송
+$payload = @{
+ channel = "#claude-code"
+ username = "Claude Code"
+ icon_emoji = ":white_check_mark:"
+ text = "✅ 작업 완료 알림`n`n프로젝트: $projectName`n상태: $reason`n시간: $timestamp`n`nClaude Code 작업이 완료되었습니다."
+} | ConvertTo-Json -Compress
+
+try {
+ Invoke-RestMethod -Uri $slackWebhookUrl -Method Post -ContentType "application/json; charset=utf-8" -Body ([System.Text.Encoding]::UTF8.GetBytes($payload)) | Out-Null
+ Write-Host "Slack 알림이 성공적으로 전송되었습니다." -ForegroundColor Green
+} catch {
+ Write-Error "Slack 알림 전송에 실패했습니다: $_"
+ exit 1
+}
diff --git a/.claude/hooks/stop-hook.sh b/.claude/hooks/stop-hook.sh
index 38f68ad..bd1e388 100755
--- a/.claude/hooks/stop-hook.sh
+++ b/.claude/hooks/stop-hook.sh
@@ -4,11 +4,20 @@
# 이 스크립트는 Claude Code가 Stop 이벤트를 발생시킬 때 실행됩니다.
# Claude가 응답을 완료했을 때 Slack 알림을 보냅니다.
-# .env 파일에서 Slack 웹훅 URL 로드
-if [ -f "$CLAUDE_PROJECT_DIR/.env" ]; then
- source "$CLAUDE_PROJECT_DIR/.env"
+# .env 파일에서 Slack 웹훅 URL 로드 (CRLF 호환)
+ENV_FILE="${CLAUDE_PROJECT_DIR}/.env"
+# Windows 경로 백슬래시를 슬래시로 변환
+ENV_FILE="${ENV_FILE//\\//}"
+if [ -f "$ENV_FILE" ]; then
+ while IFS='=' read -r key value; do
+ [[ "$key" =~ ^[[:space:]]*# ]] && continue
+ [[ -z "${key// }" ]] && continue
+ key="${key//$'\r'/}"
+ value="${value//$'\r'/}"
+ export "$key=$value"
+ done < "$ENV_FILE"
else
- echo "오류: .env 파일을 찾을 수 없습니다: $CLAUDE_PROJECT_DIR/.env" >&2
+ echo "오류: .env 파일을 찾을 수 없습니다: $ENV_FILE" >&2
exit 1
fi
@@ -18,8 +27,9 @@ if [ -z "$SLACK_WEBHOOK_URL" ]; then
exit 1
fi
-# 프로젝트명 추출
-PROJECT_NAME=$(basename "$CLAUDE_PROJECT_DIR")
+# 프로젝트명 추출 (Windows 경로 백슬래시 변환)
+NORMALIZED_DIR="${CLAUDE_PROJECT_DIR//\\//}"
+PROJECT_NAME=$(basename "$NORMALIZED_DIR")
# 현재 시간
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index c551125..fad2dd3 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -27,7 +27,7 @@
"hooks": [
{
"type": "command",
- "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notification-hook.sh"
+ "command": "powershell -ExecutionPolicy Bypass -File \"$CLAUDE_PROJECT_DIR/.claude/hooks/notification-hook.ps1\""
}
]
}
@@ -38,7 +38,7 @@
"hooks": [
{
"type": "command",
- "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-hook.sh"
+ "command": "powershell -ExecutionPolicy Bypass -File \"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-hook.ps1\""
}
]
}
diff --git a/docs/PRD.md b/docs/PRD.md
new file mode 100644
index 0000000..70f2ef4
--- /dev/null
+++ b/docs/PRD.md
@@ -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(fn: () => Promise, max = 3): Promise {
+ 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 문서에 기록한다.
diff --git a/docs/PRD_PROMPT.md b/docs/PRD_PROMPT.md
new file mode 100644
index 0000000..74a42ef
--- /dev/null
+++ b/docs/PRD_PROMPT.md
@@ -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---