diff --git a/docs/superpowers/plans/2026-04-27-portfolio.md b/docs/superpowers/plans/2026-04-27-portfolio.md
new file mode 100644
index 0000000..effd678
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-27-portfolio.md
@@ -0,0 +1,2129 @@
+# Portfolio Service Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 개인 포트폴리오 서비스 — 프로필/경력/프로젝트/기술스택/자기소개 CRUD + 비밀번호 인증 + PDF 내보내기 + 홈 연동
+
+**Architecture:** 새 백엔드 서비스 `portfolio/` (FastAPI + SQLite, 포트 18850) + 프론트 `/portfolio` 페이지 (3탭: 프로필&이력, 프로젝트, 자기소개). 기존 `/api/portfolio`가 stock-lab 주식 포트폴리오로 사용 중이므로 새 서비스 API 경로는 `/api/profile/`로 설정.
+
+**Tech Stack:** Python 3.12, FastAPI, SQLite, React, CSS
+
+---
+
+## File Structure
+
+### Backend (`web-backend/portfolio/`)
+
+| 파일 | 역할 |
+|------|------|
+| `portfolio/Dockerfile` | Python 3.12-alpine 기반 컨테이너 |
+| `portfolio/requirements.txt` | fastapi, uvicorn, pydantic |
+| `portfolio/app/__init__.py` | 빈 패키지 파일 |
+| `portfolio/app/main.py` | FastAPI 앱, 라우트, CORS, 인증 미들웨어 |
+| `portfolio/app/db.py` | SQLite 연결, 테이블 초기화, CRUD 함수 |
+| `portfolio/app/models.py` | Pydantic 요청/응답 모델 |
+| `portfolio/app/auth.py` | 비밀번호 검증, 토큰 관리 |
+
+### Infra (기존 파일 수정)
+
+| 파일 | 변경 |
+|------|------|
+| `docker-compose.yml` | portfolio 서비스 블록 추가 |
+| `nginx/default.conf` | `/api/profile/` 프록시 추가 |
+| `scripts/deploy-nas.sh` | SERVICES에 portfolio 추가 |
+| `scripts/deploy.sh` | BUILD_TARGETS, CONTAINER_NAMES, HEALTH_ENDPOINTS, DATA_DIRS에 추가 |
+
+### Frontend (`web-ui/src/`)
+
+| 파일 | 역할 |
+|------|------|
+| `pages/portfolio/Portfolio.jsx` | 메인 페이지 (3탭 컨테이너 + 편집 모드) |
+| `pages/portfolio/Portfolio.css` | 전체 스타일 |
+| `pages/portfolio/ProfileTab.jsx` | 탭 1: 프로필 + 경력 타임라인 + 기술스택 |
+| `pages/portfolio/ProjectTab.jsx` | 탭 2: 프로젝트 카드 그리드 + 카테고리 필터 |
+| `pages/portfolio/IntroTab.jsx` | 탭 3: 자기소개 다중 버전 관리 |
+| `pages/portfolio/PasswordModal.jsx` | 비밀번호 입력 모달 |
+| `pages/portfolio/ResumeView.jsx` | PDF 출력 전용 이력서 레이아웃 |
+| `pages/portfolio/usePortfolioApi.js` | API 호출 + 인증 상태 관리 훅 |
+| `routes.jsx` | navLink + appRoute 추가 |
+| `components/Icons.jsx` | IconPortfolio 추가 |
+| `pages/home/Home.jsx` | Profile 섹션을 API 연동 요약 카드로 교체 |
+
+---
+
+### Task 1: Backend — DB 스키마 + 초기화
+
+**Files:**
+- Create: `portfolio/app/__init__.py`
+- Create: `portfolio/app/db.py`
+
+- [ ] **Step 1: 빈 패키지 파일 생성**
+
+```python
+# portfolio/app/__init__.py
+# (빈 파일)
+```
+
+- [ ] **Step 2: db.py 작성 — 연결 헬퍼 + 5개 테이블 초기화**
+
+```python
+# portfolio/app/db.py
+import sqlite3
+import json
+import logging
+from typing import Dict, Any, List, Optional
+
+logger = logging.getLogger("portfolio")
+
+DB_PATH = "/app/data/portfolio.db"
+
+
+def _conn():
+ c = sqlite3.connect(DB_PATH, timeout=10)
+ c.row_factory = sqlite3.Row
+ c.execute("PRAGMA journal_mode=WAL;")
+ c.execute("PRAGMA foreign_keys=ON;")
+ return c
+
+
+def _row_to_dict(r) -> Dict[str, Any]:
+ if r is None:
+ return None
+ d = {c: r[c] for c in r.keys()}
+ if "tech_stack" in d and isinstance(d["tech_stack"], str):
+ try:
+ d["tech_stack"] = json.loads(d["tech_stack"])
+ except (json.JSONDecodeError, TypeError):
+ d["tech_stack"] = []
+ return d
+
+
+def init_db():
+ with _conn() as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS profile (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ name TEXT NOT NULL DEFAULT '',
+ name_en TEXT NOT NULL DEFAULT '',
+ role TEXT NOT NULL DEFAULT '',
+ role_en TEXT NOT NULL DEFAULT '',
+ email TEXT NOT NULL DEFAULT '',
+ phone TEXT NOT NULL DEFAULT '',
+ github_url TEXT NOT NULL DEFAULT '',
+ blog_url TEXT NOT NULL DEFAULT '',
+ photo_url TEXT NOT NULL DEFAULT '',
+ bio TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+ )
+ """)
+ conn.execute("""
+ INSERT OR IGNORE INTO profile (id) VALUES (1)
+ """)
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS careers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category TEXT NOT NULL DEFAULT 'company',
+ organization TEXT NOT NULL DEFAULT '',
+ role TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ start_date TEXT NOT NULL DEFAULT '',
+ end_date TEXT NOT NULL DEFAULT '',
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+ )
+ """)
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS projects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category TEXT NOT NULL DEFAULT 'personal',
+ title TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ tech_stack TEXT NOT NULL DEFAULT '[]',
+ role TEXT NOT NULL DEFAULT '',
+ start_date TEXT NOT NULL DEFAULT '',
+ end_date TEXT NOT NULL DEFAULT '',
+ url TEXT NOT NULL DEFAULT '',
+ image_url TEXT NOT NULL DEFAULT '',
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+ )
+ """)
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS skills (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category TEXT NOT NULL DEFAULT 'language',
+ name TEXT NOT NULL DEFAULT '',
+ level INTEGER NOT NULL DEFAULT 3,
+ sort_order INTEGER NOT NULL DEFAULT 0
+ )
+ """)
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS introductions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL DEFAULT '',
+ content TEXT NOT NULL DEFAULT '',
+ is_main INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+ )
+ """)
+ logger.info("portfolio DB initialized")
+```
+
+- [ ] **Step 3: db.py — CRUD 함수 추가 (profile)**
+
+아래 함수들을 `db.py` 끝에 추가:
+
+```python
+# ── Profile ──
+
+def get_profile() -> Dict[str, Any]:
+ with _conn() as conn:
+ row = conn.execute("SELECT * FROM profile WHERE id = 1").fetchone()
+ return _row_to_dict(row)
+
+
+def update_profile(data: Dict[str, Any]) -> Dict[str, Any]:
+ fields = {k: v for k, v in data.items() if k != "id" and v is not None}
+ if not fields:
+ return get_profile()
+ set_clauses = ", ".join(f"{k} = ?" for k in fields)
+ set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
+ with _conn() as conn:
+ conn.execute(
+ f"UPDATE profile SET {set_clauses} WHERE id = 1",
+ list(fields.values()),
+ )
+ return get_profile()
+```
+
+- [ ] **Step 4: db.py — CRUD 함수 추가 (careers, projects, skills)**
+
+```python
+# ── Careers ──
+
+def get_careers() -> List[Dict[str, Any]]:
+ with _conn() as conn:
+ rows = conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()
+ return [_row_to_dict(r) for r in rows]
+
+
+def create_career(data: Dict[str, Any]) -> Dict[str, Any]:
+ with _conn() as conn:
+ conn.execute(
+ """INSERT INTO careers (category, organization, role, description, start_date, end_date, sort_order)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (data.get("category", "company"), data.get("organization", ""),
+ data.get("role", ""), data.get("description", ""),
+ data.get("start_date", ""), data.get("end_date", ""),
+ data.get("sort_order", 0)),
+ )
+ row = conn.execute("SELECT * FROM careers ORDER BY id DESC LIMIT 1").fetchone()
+ return _row_to_dict(row)
+
+
+def update_career(career_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
+ if not fields:
+ return get_career(career_id)
+ set_clauses = ", ".join(f"{k} = ?" for k in fields)
+ set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
+ with _conn() as conn:
+ existing = conn.execute("SELECT id FROM careers WHERE id = ?", (career_id,)).fetchone()
+ if not existing:
+ return None
+ conn.execute(f"UPDATE careers SET {set_clauses} WHERE id = ?", list(fields.values()) + [career_id])
+ row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone()
+ return _row_to_dict(row)
+
+
+def delete_career(career_id: int) -> bool:
+ with _conn() as conn:
+ cur = conn.execute("DELETE FROM careers WHERE id = ?", (career_id,))
+ return cur.rowcount > 0
+
+
+def get_career(career_id: int) -> Optional[Dict[str, Any]]:
+ with _conn() as conn:
+ row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone()
+ return _row_to_dict(row)
+
+
+# ── Projects ──
+
+def get_projects() -> List[Dict[str, Any]]:
+ with _conn() as conn:
+ rows = conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()
+ return [_row_to_dict(r) for r in rows]
+
+
+def create_project(data: Dict[str, Any]) -> Dict[str, Any]:
+ tech = json.dumps(data.get("tech_stack", []), ensure_ascii=False)
+ with _conn() as conn:
+ conn.execute(
+ """INSERT INTO projects (category, title, description, tech_stack, role, start_date, end_date, url, image_url, sort_order)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (data.get("category", "personal"), data.get("title", ""),
+ data.get("description", ""), tech,
+ data.get("role", ""), data.get("start_date", ""),
+ data.get("end_date", ""), data.get("url", ""),
+ data.get("image_url", ""), data.get("sort_order", 0)),
+ )
+ row = conn.execute("SELECT * FROM projects ORDER BY id DESC LIMIT 1").fetchone()
+ return _row_to_dict(row)
+
+
+def update_project(project_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
+ if "tech_stack" in fields and isinstance(fields["tech_stack"], list):
+ fields["tech_stack"] = json.dumps(fields["tech_stack"], ensure_ascii=False)
+ if not fields:
+ return get_project(project_id)
+ set_clauses = ", ".join(f"{k} = ?" for k in fields)
+ set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
+ with _conn() as conn:
+ existing = conn.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone()
+ if not existing:
+ return None
+ conn.execute(f"UPDATE projects SET {set_clauses} WHERE id = ?", list(fields.values()) + [project_id])
+ row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
+ return _row_to_dict(row)
+
+
+def delete_project(project_id: int) -> bool:
+ with _conn() as conn:
+ cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
+ return cur.rowcount > 0
+
+
+def get_project(project_id: int) -> Optional[Dict[str, Any]]:
+ with _conn() as conn:
+ row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
+ return _row_to_dict(row)
+
+
+# ── Skills ──
+
+def get_skills() -> List[Dict[str, Any]]:
+ with _conn() as conn:
+ rows = conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()
+ return [_row_to_dict(r) for r in rows]
+
+
+def create_skill(data: Dict[str, Any]) -> Dict[str, Any]:
+ with _conn() as conn:
+ conn.execute(
+ "INSERT INTO skills (category, name, level, sort_order) VALUES (?, ?, ?, ?)",
+ (data.get("category", "language"), data.get("name", ""),
+ data.get("level", 3), data.get("sort_order", 0)),
+ )
+ row = conn.execute("SELECT * FROM skills ORDER BY id DESC LIMIT 1").fetchone()
+ return _row_to_dict(row)
+
+
+def update_skill(skill_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ fields = {k: v for k, v in data.items() if k != "id" and v is not None}
+ if not fields:
+ return get_skill(skill_id)
+ set_clauses = ", ".join(f"{k} = ?" for k in fields)
+ with _conn() as conn:
+ existing = conn.execute("SELECT id FROM skills WHERE id = ?", (skill_id,)).fetchone()
+ if not existing:
+ return None
+ conn.execute(f"UPDATE skills SET {set_clauses} WHERE id = ?", list(fields.values()) + [skill_id])
+ row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
+ return _row_to_dict(row)
+
+
+def delete_skill(skill_id: int) -> bool:
+ with _conn() as conn:
+ cur = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
+ return cur.rowcount > 0
+
+
+def get_skill(skill_id: int) -> Optional[Dict[str, Any]]:
+ with _conn() as conn:
+ row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
+ return _row_to_dict(row)
+```
+
+- [ ] **Step 5: db.py — CRUD 함수 추가 (introductions + public)**
+
+```python
+# ── Introductions ──
+
+def get_introductions() -> List[Dict[str, Any]]:
+ with _conn() as conn:
+ rows = conn.execute("SELECT * FROM introductions ORDER BY is_main DESC, updated_at DESC").fetchall()
+ return [_row_to_dict(r) for r in rows]
+
+
+def create_introduction(data: Dict[str, Any]) -> Dict[str, Any]:
+ with _conn() as conn:
+ conn.execute(
+ "INSERT INTO introductions (title, content, is_main) VALUES (?, ?, ?)",
+ (data.get("title", ""), data.get("content", ""), data.get("is_main", 0)),
+ )
+ row = conn.execute("SELECT * FROM introductions ORDER BY id DESC LIMIT 1").fetchone()
+ return _row_to_dict(row)
+
+
+def update_introduction(intro_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
+ if not fields:
+ return get_introduction(intro_id)
+ set_clauses = ", ".join(f"{k} = ?" for k in fields)
+ set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
+ with _conn() as conn:
+ existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone()
+ if not existing:
+ return None
+ conn.execute(f"UPDATE introductions SET {set_clauses} WHERE id = ?", list(fields.values()) + [intro_id])
+ row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
+ return _row_to_dict(row)
+
+
+def delete_introduction(intro_id: int) -> bool:
+ with _conn() as conn:
+ cur = conn.execute("DELETE FROM introductions WHERE id = ?", (intro_id,))
+ return cur.rowcount > 0
+
+
+def get_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
+ with _conn() as conn:
+ row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
+ return _row_to_dict(row)
+
+
+def set_main_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
+ with _conn() as conn:
+ existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone()
+ if not existing:
+ return None
+ conn.execute("UPDATE introductions SET is_main = 0 WHERE is_main = 1")
+ conn.execute("UPDATE introductions SET is_main = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (intro_id,))
+ row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
+ return _row_to_dict(row)
+
+
+# ── Public (일괄 조회) ──
+
+def get_public_data() -> Dict[str, Any]:
+ with _conn() as conn:
+ profile = _row_to_dict(conn.execute("SELECT * FROM profile WHERE id = 1").fetchone())
+ careers = [_row_to_dict(r) for r in conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()]
+ projects = [_row_to_dict(r) for r in conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()]
+ skills = [_row_to_dict(r) for r in conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()]
+ main_intro_row = conn.execute("SELECT * FROM introductions WHERE is_main = 1 LIMIT 1").fetchone()
+ main_introduction = _row_to_dict(main_intro_row) if main_intro_row else None
+ return {
+ "profile": profile,
+ "careers": careers,
+ "projects": projects,
+ "skills": skills,
+ "main_introduction": main_introduction,
+ }
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add portfolio/app/__init__.py portfolio/app/db.py
+git commit -m "feat(portfolio): DB 스키마 + CRUD 함수 (5 테이블)"
+```
+
+---
+
+### Task 2: Backend — Pydantic 모델 + 인증
+
+**Files:**
+- Create: `portfolio/app/models.py`
+- Create: `portfolio/app/auth.py`
+
+- [ ] **Step 1: models.py 작성**
+
+```python
+# portfolio/app/models.py
+from typing import Optional, List
+from pydantic import BaseModel
+
+
+class ProfileUpdate(BaseModel):
+ name: Optional[str] = None
+ name_en: Optional[str] = None
+ role: Optional[str] = None
+ role_en: Optional[str] = None
+ email: Optional[str] = None
+ phone: Optional[str] = None
+ github_url: Optional[str] = None
+ blog_url: Optional[str] = None
+ photo_url: Optional[str] = None
+ bio: Optional[str] = None
+
+
+class CareerCreate(BaseModel):
+ category: str = "company"
+ organization: str = ""
+ role: str = ""
+ description: str = ""
+ start_date: str = ""
+ end_date: str = ""
+ sort_order: int = 0
+
+
+class CareerUpdate(BaseModel):
+ category: Optional[str] = None
+ organization: Optional[str] = None
+ role: Optional[str] = None
+ description: Optional[str] = None
+ start_date: Optional[str] = None
+ end_date: Optional[str] = None
+ sort_order: Optional[int] = None
+
+
+class ProjectCreate(BaseModel):
+ category: str = "personal"
+ title: str = ""
+ description: str = ""
+ tech_stack: List[str] = []
+ role: str = ""
+ start_date: str = ""
+ end_date: str = ""
+ url: str = ""
+ image_url: str = ""
+ sort_order: int = 0
+
+
+class ProjectUpdate(BaseModel):
+ category: Optional[str] = None
+ title: Optional[str] = None
+ description: Optional[str] = None
+ tech_stack: Optional[List[str]] = None
+ role: Optional[str] = None
+ start_date: Optional[str] = None
+ end_date: Optional[str] = None
+ url: Optional[str] = None
+ image_url: Optional[str] = None
+ sort_order: Optional[int] = None
+
+
+class SkillCreate(BaseModel):
+ category: str = "language"
+ name: str = ""
+ level: int = 3
+ sort_order: int = 0
+
+
+class SkillUpdate(BaseModel):
+ category: Optional[str] = None
+ name: Optional[str] = None
+ level: Optional[int] = None
+ sort_order: Optional[int] = None
+
+
+class IntroCreate(BaseModel):
+ title: str = ""
+ content: str = ""
+ is_main: int = 0
+
+
+class IntroUpdate(BaseModel):
+ title: Optional[str] = None
+ content: Optional[str] = None
+
+
+class AuthRequest(BaseModel):
+ password: str
+```
+
+- [ ] **Step 2: auth.py 작성 — 토큰 관리**
+
+```python
+# portfolio/app/auth.py
+import os
+import uuid
+import time
+import logging
+from fastapi import Header, HTTPException
+
+logger = logging.getLogger("portfolio")
+
+EDIT_PASSWORD = os.getenv("PORTFOLIO_EDIT_PASSWORD", "")
+TOKEN_TTL = 86400 # 24시간
+
+_tokens: dict[str, float] = {} # token -> expiry timestamp
+
+
+def authenticate(password: str) -> dict:
+ if not EDIT_PASSWORD:
+ raise HTTPException(status_code=503, detail="Edit password not configured")
+ if password != EDIT_PASSWORD:
+ raise HTTPException(status_code=401, detail="Invalid password")
+ token = uuid.uuid4().hex
+ _tokens[token] = time.time() + TOKEN_TTL
+ _cleanup()
+ return {"token": token, "expires_in": TOKEN_TTL}
+
+
+def require_auth(authorization: str = Header("")):
+ token = authorization.replace("Bearer ", "").strip()
+ if not token or token not in _tokens:
+ raise HTTPException(status_code=401, detail="Authentication required")
+ if time.time() > _tokens[token]:
+ del _tokens[token]
+ raise HTTPException(status_code=401, detail="Token expired")
+
+
+def _cleanup():
+ now = time.time()
+ expired = [t for t, exp in _tokens.items() if now > exp]
+ for t in expired:
+ del _tokens[t]
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add portfolio/app/models.py portfolio/app/auth.py
+git commit -m "feat(portfolio): Pydantic 모델 + 토큰 인증"
+```
+
+---
+
+### Task 3: Backend — FastAPI 앱 + 전체 라우트
+
+**Files:**
+- Create: `portfolio/app/main.py`
+
+- [ ] **Step 1: main.py 작성**
+
+```python
+# portfolio/app/main.py
+import os
+import logging
+from contextlib import asynccontextmanager
+from fastapi import FastAPI, Depends, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+
+from .db import (
+ init_db, get_public_data,
+ get_profile, update_profile,
+ get_careers, create_career, update_career, delete_career,
+ get_projects, create_project, update_project, delete_project,
+ get_skills, create_skill, update_skill, delete_skill,
+ get_introductions, create_introduction, update_introduction,
+ delete_introduction, set_main_introduction,
+)
+from .models import (
+ ProfileUpdate, CareerCreate, CareerUpdate,
+ ProjectCreate, ProjectUpdate, SkillCreate, SkillUpdate,
+ IntroCreate, IntroUpdate, AuthRequest,
+)
+from .auth import authenticate, require_auth
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
+logger = logging.getLogger("portfolio")
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ init_db()
+ logger.info("portfolio service 시작")
+ yield
+
+
+app = FastAPI(lifespan=lifespan)
+
+_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[o.strip() for o in _cors_origins],
+ allow_credentials=False,
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
+ allow_headers=["Content-Type", "Authorization"],
+)
+
+
+@app.get("/health")
+def health():
+ return {"status": "ok"}
+
+
+# ── Public ──
+
+@app.get("/api/profile/public")
+def api_public():
+ return get_public_data()
+
+
+# ── Auth ──
+
+@app.post("/api/profile/auth")
+def api_auth(body: AuthRequest):
+ return authenticate(body.password)
+
+
+# ── Profile (편집) ──
+
+@app.get("/api/profile/profile", dependencies=[Depends(require_auth)])
+def api_profile_get():
+ return get_profile()
+
+
+@app.put("/api/profile/profile", dependencies=[Depends(require_auth)])
+def api_profile_update(body: ProfileUpdate):
+ return update_profile(body.model_dump(exclude_none=True))
+
+
+# ── Careers (편집) ──
+
+@app.get("/api/profile/careers", dependencies=[Depends(require_auth)])
+def api_careers_list():
+ return get_careers()
+
+
+@app.post("/api/profile/careers", status_code=201, dependencies=[Depends(require_auth)])
+def api_career_create(body: CareerCreate):
+ return create_career(body.model_dump())
+
+
+@app.put("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)])
+def api_career_update(career_id: int, body: CareerUpdate):
+ result = update_career(career_id, body.model_dump(exclude_none=True))
+ if not result:
+ raise HTTPException(status_code=404, detail="Career not found")
+ return result
+
+
+@app.delete("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)])
+def api_career_delete(career_id: int):
+ if not delete_career(career_id):
+ raise HTTPException(status_code=404, detail="Career not found")
+ return {"ok": True}
+
+
+# ── Projects (편집) ──
+
+@app.get("/api/profile/projects", dependencies=[Depends(require_auth)])
+def api_projects_list():
+ return get_projects()
+
+
+@app.post("/api/profile/projects", status_code=201, dependencies=[Depends(require_auth)])
+def api_project_create(body: ProjectCreate):
+ return create_project(body.model_dump())
+
+
+@app.put("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)])
+def api_project_update(project_id: int, body: ProjectUpdate):
+ result = update_project(project_id, body.model_dump(exclude_none=True))
+ if not result:
+ raise HTTPException(status_code=404, detail="Project not found")
+ return result
+
+
+@app.delete("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)])
+def api_project_delete(project_id: int):
+ if not delete_project(project_id):
+ raise HTTPException(status_code=404, detail="Project not found")
+ return {"ok": True}
+
+
+# ── Skills (편집) ──
+
+@app.get("/api/profile/skills", dependencies=[Depends(require_auth)])
+def api_skills_list():
+ return get_skills()
+
+
+@app.post("/api/profile/skills", status_code=201, dependencies=[Depends(require_auth)])
+def api_skill_create(body: SkillCreate):
+ return create_skill(body.model_dump())
+
+
+@app.put("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)])
+def api_skill_update(skill_id: int, body: SkillUpdate):
+ result = update_skill(skill_id, body.model_dump(exclude_none=True))
+ if not result:
+ raise HTTPException(status_code=404, detail="Skill not found")
+ return result
+
+
+@app.delete("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)])
+def api_skill_delete(skill_id: int):
+ if not delete_skill(skill_id):
+ raise HTTPException(status_code=404, detail="Skill not found")
+ return {"ok": True}
+
+
+# ── Introductions (편집) ──
+
+@app.get("/api/profile/introductions", dependencies=[Depends(require_auth)])
+def api_intros_list():
+ return get_introductions()
+
+
+@app.post("/api/profile/introductions", status_code=201, dependencies=[Depends(require_auth)])
+def api_intro_create(body: IntroCreate):
+ return create_introduction(body.model_dump())
+
+
+@app.put("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)])
+def api_intro_update(intro_id: int, body: IntroUpdate):
+ result = update_introduction(intro_id, body.model_dump(exclude_none=True))
+ if not result:
+ raise HTTPException(status_code=404, detail="Introduction not found")
+ return result
+
+
+@app.delete("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)])
+def api_intro_delete(intro_id: int):
+ if not delete_introduction(intro_id):
+ raise HTTPException(status_code=404, detail="Introduction not found")
+ return {"ok": True}
+
+
+@app.patch("/api/profile/introductions/{intro_id}/main", dependencies=[Depends(require_auth)])
+def api_intro_set_main(intro_id: int):
+ result = set_main_introduction(intro_id)
+ if not result:
+ raise HTTPException(status_code=404, detail="Introduction not found")
+ return result
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add portfolio/app/main.py
+git commit -m "feat(portfolio): FastAPI 앱 + 전체 API 라우트"
+```
+
+---
+
+### Task 4: Backend — Dockerfile + requirements.txt
+
+**Files:**
+- Create: `portfolio/Dockerfile`
+- Create: `portfolio/requirements.txt`
+
+- [ ] **Step 1: Dockerfile 작성**
+
+```dockerfile
+FROM python:3.12-alpine
+ENV PYTHONUNBUFFERED=1
+
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+```
+
+- [ ] **Step 2: requirements.txt 작성**
+
+```
+fastapi==0.115.6
+uvicorn[standard]==0.30.6
+pydantic>=2.0
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add portfolio/Dockerfile portfolio/requirements.txt
+git commit -m "feat(portfolio): Dockerfile + requirements"
+```
+
+---
+
+### Task 5: Infra — Docker Compose + Nginx + 배포 스크립트
+
+**Files:**
+- Modify: `docker-compose.yml` (agent-office 블록 뒤에 삽입)
+- Modify: `nginx/default.conf` (portfolio 프록시를 `/api/profile/`로 추가)
+- Modify: `scripts/deploy-nas.sh:5`
+- Modify: `scripts/deploy.sh:10,12,14,16`
+
+- [ ] **Step 1: docker-compose.yml에 portfolio 서비스 추가**
+
+agent-office 서비스 블록(`healthcheck` 3줄 포함) 뒤, `travel-proxy:` 블록 앞에 삽입:
+
+```yaml
+ portfolio:
+ build:
+ context: ./portfolio
+ container_name: portfolio
+ restart: unless-stopped
+ ports:
+ - "18850:8000"
+ environment:
+ - TZ=${TZ:-Asia/Seoul}
+ - PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
+ - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
+ volumes:
+ - ${RUNTIME_PATH:-.}/data/portfolio:/app/data
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+```
+
+- [ ] **Step 2: nginx/default.conf에 `/api/profile/` 프록시 추가**
+
+기존 `/api/portfolio` (stock-lab) 블록 뒤, `# agent-office` 블록 앞에 삽입:
+
+```nginx
+ # profile API (Portfolio Service)
+ location /api/profile/ {
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass http://portfolio:8000/api/profile/;
+ }
+```
+
+- [ ] **Step 3: deploy-nas.sh SERVICES에 portfolio 추가**
+
+Line 5를 수정:
+
+```bash
+SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office portfolio nginx scripts"
+```
+
+- [ ] **Step 4: deploy.sh에 portfolio 추가 (4곳)**
+
+Line 10:
+```bash
+BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office portfolio frontend"
+```
+
+Line 12:
+```bash
+CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office travel-proxy portfolio lotto-frontend"
+```
+
+Line 14:
+```bash
+HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio"
+```
+
+Line 16:
+```bash
+DATA_DIRS="music stock blog realestate agent-office portfolio"
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docker-compose.yml nginx/default.conf scripts/deploy-nas.sh scripts/deploy.sh
+git commit -m "infra(portfolio): Docker Compose + Nginx + 배포 스크립트"
+```
+
+---
+
+### Task 6: Frontend — 라우팅 + 아이콘 + API 훅
+
+**Files:**
+- Modify: `web-ui/src/routes.jsx`
+- Modify: `web-ui/src/components/Icons.jsx`
+- Create: `web-ui/src/pages/portfolio/usePortfolioApi.js`
+
+- [ ] **Step 1: Icons.jsx에 IconPortfolio 추가**
+
+파일 끝 `export const IconBuilding` 뒤에 추가:
+
+```jsx
+export const IconPortfolio = () =>
+ ;
+```
+
+- [ ] **Step 2: routes.jsx에 Portfolio navLink + route 추가**
+
+import 섹션에 추가:
+```jsx
+import { IconPortfolio } from './components/Icons';
+```
+
+lazy import 추가 (기존 lazy import 블록 끝에):
+```jsx
+const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
+```
+
+navLinks 배열에서 `agent-office` 항목 앞에 추가:
+```jsx
+ {
+ id: 'portfolio',
+ label: 'Portfolio',
+ path: '/portfolio',
+ subtitle: 'RESUME',
+ description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
+ icon:
편집하려면 비밀번호를 입력하세요.
+ +{profile.name_en}
} +{profile.role || profile.role_en}
+{profile.bio}
} +{c.description}
} + {editing && ( +프로젝트가 없습니다.
} + {filtered.map(p => ( +{p.role}
} + {p.description &&{p.description}
} + {p.tech_stack?.length > 0 && ( +자기소개 글이 없습니다.
} + {introductions.map(intro => ( +{intro.content}
+{profile.role}
+{main_introduction?.content || profile.bio}
+{c.description}
} +{p.description}
} + {p.tech_stack?.length > 0 && ( +{p.tech_stack.join(' · ')}
+ )} +{skills.map(s => s.name).join(' · ')}
+불러오는 중...
{error}
페이지 주인 소개 영역입니다.
+{portfolio?.profile?.role || 'Server Developer'}
+{portfolio?.profile?.name || '박 재 오'}
++ {portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'} +
+