Files
web-page-backend/docs/superpowers/plans/2026-04-27-portfolio.md
gahusb b671d275eb docs: portfolio 서비스 구현 계획 (15 tasks)
백엔드 DB/API + 프론트 3탭 + 인프라 + 홈 연동 전체 구현 계획.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:31:11 +09:00

78 KiB

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: 빈 패키지 파일 생성

# portfolio/app/__init__.py
# (빈 파일)
  • Step 2: db.py 작성 — 연결 헬퍼 + 5개 테이블 초기화
# 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 끝에 추가:

# ── 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)
# ── 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)
# ── 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
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 작성

# 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 작성 — 토큰 관리
# 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
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 작성

# 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
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 작성

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
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: 블록 앞에 삽입:

  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 블록 앞에 삽입:

  # 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를 수정:

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:

BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office portfolio frontend"

Line 12:

CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office travel-proxy portfolio lotto-frontend"

Line 14:

HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio"

Line 16:

DATA_DIRS="music stock blog realestate agent-office portfolio"
  • Step 5: Commit
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 뒤에 추가:

export const IconPortfolio = () =>
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
    <circle cx="9" cy="7" r="4" />
    <path d="M22 21v-2a4 4 0 0 0-3-3.87" />
    <path d="M16 3.13a4 4 0 0 1 0 7.75" />
  </svg>;
  • Step 2: routes.jsx에 Portfolio navLink + route 추가

import 섹션에 추가:

import { IconPortfolio } from './components/Icons';

lazy import 추가 (기존 lazy import 블록 끝에):

const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));

navLinks 배열에서 agent-office 항목 앞에 추가:

    {
        id: 'portfolio',
        label: 'Portfolio',
        path: '/portfolio',
        subtitle: 'RESUME',
        description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
        icon: <IconPortfolio />,
        accent: '#06b6d4',
    },

appRoutes 배열에서 agent-office 항목 앞에 추가:

    {
        path: 'portfolio',
        element: <Portfolio />,
    },
  • Step 3: usePortfolioApi.js 작성
// web-ui/src/pages/portfolio/usePortfolioApi.js
import { useState, useCallback } from 'react';

const BASE = '/api/profile';

async function apiFetch(path, options = {}) {
    const res = await fetch(`${BASE}${path}`, {
        headers: { 'Content-Type': 'application/json', ...options.headers },
        ...options,
    });
    if (!res.ok) {
        const err = await res.json().catch(() => ({ detail: res.statusText }));
        throw new Error(err.detail || res.statusText);
    }
    return res.json();
}

export default function usePortfolioApi() {
    const [token, setToken] = useState(null);
    const [authError, setAuthError] = useState('');

    const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};

    const login = useCallback(async (password) => {
        setAuthError('');
        try {
            const data = await apiFetch('/auth', {
                method: 'POST',
                body: JSON.stringify({ password }),
            });
            setToken(data.token);
            return true;
        } catch (err) {
            setAuthError(err.message);
            return false;
        }
    }, []);

    const logout = useCallback(() => setToken(null), []);

    // ── Public ──
    const fetchPublic = useCallback(() => apiFetch('/public'), []);

    // ── Profile ──
    const fetchProfile = useCallback(() =>
        apiFetch('/profile', { headers: authHeaders }), [token]);
    const saveProfile = useCallback((data) =>
        apiFetch('/profile', { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);

    // ── Careers ──
    const fetchCareers = useCallback(() =>
        apiFetch('/careers', { headers: authHeaders }), [token]);
    const addCareer = useCallback((data) =>
        apiFetch('/careers', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
    const editCareer = useCallback((id, data) =>
        apiFetch(`/careers/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
    const removeCareer = useCallback((id) =>
        apiFetch(`/careers/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);

    // ── Projects ──
    const fetchProjects = useCallback(() =>
        apiFetch('/projects', { headers: authHeaders }), [token]);
    const addProject = useCallback((data) =>
        apiFetch('/projects', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
    const editProject = useCallback((id, data) =>
        apiFetch(`/projects/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
    const removeProject = useCallback((id) =>
        apiFetch(`/projects/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);

    // ── Skills ──
    const fetchSkills = useCallback(() =>
        apiFetch('/skills', { headers: authHeaders }), [token]);
    const addSkill = useCallback((data) =>
        apiFetch('/skills', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
    const editSkill = useCallback((id, data) =>
        apiFetch(`/skills/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
    const removeSkill = useCallback((id) =>
        apiFetch(`/skills/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);

    // ── Introductions ──
    const fetchIntros = useCallback(() =>
        apiFetch('/introductions', { headers: authHeaders }), [token]);
    const addIntro = useCallback((data) =>
        apiFetch('/introductions', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
    const editIntro = useCallback((id, data) =>
        apiFetch(`/introductions/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
    const removeIntro = useCallback((id) =>
        apiFetch(`/introductions/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
    const setMainIntro = useCallback((id) =>
        apiFetch(`/introductions/${id}/main`, { method: 'PATCH', headers: authHeaders }), [token]);

    return {
        token, authError, login, logout,
        fetchPublic,
        fetchProfile, saveProfile,
        fetchCareers, addCareer, editCareer, removeCareer,
        fetchProjects, addProject, editProject, removeProject,
        fetchSkills, addSkill, editSkill, removeSkill,
        fetchIntros, addIntro, editIntro, removeIntro, setMainIntro,
    };
}
  • Step 4: Commit
cd web-ui
git add src/routes.jsx src/components/Icons.jsx src/pages/portfolio/usePortfolioApi.js
git commit -m "feat(portfolio): 라우팅 + 아이콘 + API 훅"

Task 7: Frontend — PasswordModal 컴포넌트

Files:

  • Create: web-ui/src/pages/portfolio/PasswordModal.jsx

  • Step 1: PasswordModal.jsx 작성

// web-ui/src/pages/portfolio/PasswordModal.jsx
import { useState } from 'react';

export default function PasswordModal({ open, onAuth, onClose, error }) {
    const [pw, setPw] = useState('');
    const [loading, setLoading] = useState(false);

    if (!open) return null;

    const handleSubmit = async (e) => {
        e.preventDefault();
        if (!pw.trim()) return;
        setLoading(true);
        await onAuth(pw);
        setLoading(false);
        setPw('');
    };

    return (
        <div className="pf-modal-backdrop" onClick={onClose}>
            <div className="pf-modal" onClick={(e) => e.stopPropagation()}>
                <h3 className="pf-modal__title">편집 모드</h3>
                <p className="pf-modal__desc">편집하려면 비밀번호를 입력하세요.</p>
                <form onSubmit={handleSubmit}>
                    <input
                        type="password"
                        className="pf-modal__input"
                        placeholder="비밀번호"
                        value={pw}
                        onChange={(e) => setPw(e.target.value)}
                        autoFocus
                    />
                    {error && <p className="pf-modal__error">{error}</p>}
                    <div className="pf-modal__actions">
                        <button type="button" className="button ghost" onClick={onClose}>취소</button>
                        <button type="submit" className="button primary" disabled={loading || !pw.trim()}>
                            {loading ? '확인 중...' : '확인'}
                        </button>
                    </div>
                </form>
            </div>
        </div>
    );
}
  • Step 2: Commit
cd web-ui
git add src/pages/portfolio/PasswordModal.jsx
git commit -m "feat(portfolio): 비밀번호 모달 컴포넌트"

Task 8: Frontend — ProfileTab (탭 1: 프로필 + 경력 + 기술)

Files:

  • Create: web-ui/src/pages/portfolio/ProfileTab.jsx

  • Step 1: ProfileTab.jsx 작성

// web-ui/src/pages/portfolio/ProfileTab.jsx
import { useState } from 'react';

const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' };
const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' };

const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 };

export default function ProfileTab({ data, editing, api, onRefresh }) {
    const { profile, careers, skills } = data;
    const [editingProfile, setEditingProfile] = useState(null);
    const [careerForm, setCareerForm] = useState(null);
    const [skillForm, setSkillForm] = useState(null);

    // ── Profile 편집 ──
    const startEditProfile = () => setEditingProfile({ ...profile });
    const saveProfileEdit = async () => {
        await api.saveProfile(editingProfile);
        setEditingProfile(null);
        onRefresh();
    };

    // ── Career CRUD ──
    const saveCareer = async () => {
        if (careerForm.id) {
            await api.editCareer(careerForm.id, careerForm);
        } else {
            await api.addCareer(careerForm);
        }
        setCareerForm(null);
        onRefresh();
    };
    const deleteCareer = async (id) => {
        await api.removeCareer(id);
        onRefresh();
    };

    // ── Skill CRUD ──
    const saveSkill = async () => {
        if (skillForm.id) {
            await api.editSkill(skillForm.id, skillForm);
        } else {
            await api.addSkill(skillForm);
        }
        setSkillForm(null);
        onRefresh();
    };
    const deleteSkill = async (id) => {
        await api.removeSkill(id);
        onRefresh();
    };

    const grouped = (items, catMap) => {
        const groups = {};
        for (const key of Object.keys(catMap)) groups[key] = [];
        for (const item of items) {
            const cat = item.category || Object.keys(catMap)[0];
            if (!groups[cat]) groups[cat] = [];
            groups[cat].push(item);
        }
        return groups;
    };

    return (
        <div className="pf-profile-tab">
            {/* ── 프로필 카드 ── */}
            <div className="pf-profile-card">
                {editingProfile ? (
                    <div className="pf-edit-form">
                        <label>이름 <input value={editingProfile.name} onChange={(e) => setEditingProfile(p => ({...p, name: e.target.value}))} /></label>
                        <label>이름(영문) <input value={editingProfile.name_en} onChange={(e) => setEditingProfile(p => ({...p, name_en: e.target.value}))} /></label>
                        <label>직함 <input value={editingProfile.role} onChange={(e) => setEditingProfile(p => ({...p, role: e.target.value}))} /></label>
                        <label>직함(영문) <input value={editingProfile.role_en} onChange={(e) => setEditingProfile(p => ({...p, role_en: e.target.value}))} /></label>
                        <label>이메일 <input value={editingProfile.email} onChange={(e) => setEditingProfile(p => ({...p, email: e.target.value}))} /></label>
                        <label>전화번호 <input value={editingProfile.phone} onChange={(e) => setEditingProfile(p => ({...p, phone: e.target.value}))} /></label>
                        <label>GitHub <input value={editingProfile.github_url} onChange={(e) => setEditingProfile(p => ({...p, github_url: e.target.value}))} /></label>
                        <label>블로그 <input value={editingProfile.blog_url} onChange={(e) => setEditingProfile(p => ({...p, blog_url: e.target.value}))} /></label>
                        <label>사진 URL <input value={editingProfile.photo_url} onChange={(e) => setEditingProfile(p => ({...p, photo_url: e.target.value}))} /></label>
                        <label>소개 <textarea value={editingProfile.bio} rows={3} onChange={(e) => setEditingProfile(p => ({...p, bio: e.target.value}))} /></label>
                        <div className="pf-edit-form__actions">
                            <button className="button ghost" onClick={() => setEditingProfile(null)}>취소</button>
                            <button className="button primary" onClick={saveProfileEdit}>저장</button>
                        </div>
                    </div>
                ) : (
                    <>
                        <div className="pf-profile-card__header">
                            {profile.photo_url && <img className="pf-profile-card__photo" src={profile.photo_url} alt="" />}
                            <div>
                                <h2 className="pf-profile-card__name">{profile.name || '이름 미설정'}</h2>
                                {profile.name_en && <p className="pf-profile-card__name-en">{profile.name_en}</p>}
                                <p className="pf-profile-card__role">{profile.role || profile.role_en}</p>
                            </div>
                        </div>
                        {profile.bio && <p className="pf-profile-card__bio">{profile.bio}</p>}
                        <div className="pf-profile-card__links">
                            {profile.email && <a href={`mailto:${profile.email}`}>{profile.email}</a>}
                            {profile.github_url && <a href={profile.github_url} target="_blank" rel="noreferrer">GitHub</a>}
                            {profile.blog_url && <a href={profile.blog_url} target="_blank" rel="noreferrer">Blog</a>}
                        </div>
                        {editing && <button className="button ghost pf-edit-btn" onClick={startEditProfile}>프로필 수정</button>}
                    </>
                )}
            </div>

            {/* ── 경력 타임라인 ── */}
            <div className="pf-section">
                <div className="pf-section__header">
                    <h3>경력</h3>
                    {editing && <button className="button ghost" onClick={() => setCareerForm({...emptyCareer})}>+ 추가</button>}
                </div>
                {careerForm && (
                    <div className="pf-edit-form">
                        <label>구분
                            <select value={careerForm.category} onChange={(e) => setCareerForm(f => ({...f, category: e.target.value}))}>
                                {Object.entries(CAREER_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
                            </select>
                        </label>
                        <label>기관명 <input value={careerForm.organization} onChange={(e) => setCareerForm(f => ({...f, organization: e.target.value}))} /></label>
                        <label>직함 <input value={careerForm.role} onChange={(e) => setCareerForm(f => ({...f, role: e.target.value}))} /></label>
                        <label>설명 <textarea value={careerForm.description} rows={2} onChange={(e) => setCareerForm(f => ({...f, description: e.target.value}))} /></label>
                        <div className="pf-edit-form__row">
                            <label>시작 <input type="month" value={careerForm.start_date} onChange={(e) => setCareerForm(f => ({...f, start_date: e.target.value}))} /></label>
                            <label>종료 <input type="month" value={careerForm.end_date} onChange={(e) => setCareerForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
                        </div>
                        <div className="pf-edit-form__actions">
                            <button className="button ghost" onClick={() => setCareerForm(null)}>취소</button>
                            <button className="button primary" onClick={saveCareer}>저장</button>
                        </div>
                    </div>
                )}
                {Object.entries(grouped(careers, CAREER_CATEGORIES)).map(([cat, items]) =>
                    items.length > 0 && (
                        <div key={cat} className="pf-career-group">
                            <h4 className="pf-career-group__title">{CAREER_CATEGORIES[cat]}</h4>
                            {items.map((c) => (
                                <div key={c.id} className="pf-career-item">
                                    <span className="pf-career-item__period">{c.start_date}  {c.end_date || '현재'}</span>
                                    <strong className="pf-career-item__role">{c.role}</strong>
                                    <span className="pf-career-item__org">{c.organization}</span>
                                    {c.description && <p className="pf-career-item__desc">{c.description}</p>}
                                    {editing && (
                                        <div className="pf-career-item__actions">
                                            <button className="button ghost" onClick={() => setCareerForm({...c})}>수정</button>
                                            <button className="button ghost" onClick={() => deleteCareer(c.id)}>삭제</button>
                                        </div>
                                    )}
                                </div>
                            ))}
                        </div>
                    )
                )}
            </div>

            {/* ── 기술 스택 ── */}
            <div className="pf-section">
                <div className="pf-section__header">
                    <h3>기술 스택</h3>
                    {editing && <button className="button ghost" onClick={() => setSkillForm({...emptySkill})}>+ 추가</button>}
                </div>
                {skillForm && (
                    <div className="pf-edit-form">
                        <label>구분
                            <select value={skillForm.category} onChange={(e) => setSkillForm(f => ({...f, category: e.target.value}))}>
                                {Object.entries(SKILL_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
                            </select>
                        </label>
                        <label>기술명 <input value={skillForm.name} onChange={(e) => setSkillForm(f => ({...f, name: e.target.value}))} /></label>
                        <label>숙련도 (1~5)
                            <input type="range" min={1} max={5} value={skillForm.level} onChange={(e) => setSkillForm(f => ({...f, level: +e.target.value}))} />
                            <span>{skillForm.level}</span>
                        </label>
                        <div className="pf-edit-form__actions">
                            <button className="button ghost" onClick={() => setSkillForm(null)}>취소</button>
                            <button className="button primary" onClick={saveSkill}>저장</button>
                        </div>
                    </div>
                )}
                {Object.entries(grouped(skills, SKILL_CATEGORIES)).map(([cat, items]) =>
                    items.length > 0 && (
                        <div key={cat} className="pf-skill-group">
                            <h4 className="pf-skill-group__title">{SKILL_CATEGORIES[cat]}</h4>
                            <div className="pf-skill-group__tags">
                                {items.map((s) => (
                                    <span key={s.id} className="pf-skill-tag" data-level={s.level}>
                                        {s.name}
                                        {editing && (
                                            <span className="pf-skill-tag__actions">
                                                <button onClick={() => setSkillForm({...s})}>&#9998;</button>
                                                <button onClick={() => deleteSkill(s.id)}>&times;</button>
                                            </span>
                                        )}
                                    </span>
                                ))}
                            </div>
                        </div>
                    )
                )}
            </div>
        </div>
    );
}
  • Step 2: Commit
cd web-ui
git add src/pages/portfolio/ProfileTab.jsx
git commit -m "feat(portfolio): ProfileTab — 프로필 + 경력 + 기술 탭"

Task 9: Frontend — ProjectTab (탭 2: 프로젝트)

Files:

  • Create: web-ui/src/pages/portfolio/ProjectTab.jsx

  • Step 1: ProjectTab.jsx 작성

// web-ui/src/pages/portfolio/ProjectTab.jsx
import { useState } from 'react';

const CATEGORIES = [
    { key: 'all', label: '전체' },
    { key: 'company', label: '회사' },
    { key: 'personal', label: '개인' },
    { key: 'academy', label: '아카데미' },
];

const emptyProject = {
    category: 'personal', title: '', description: '', tech_stack: [],
    role: '', start_date: '', end_date: '', url: '', image_url: '', sort_order: 0,
};

export default function ProjectTab({ projects, editing, api, onRefresh }) {
    const [filter, setFilter] = useState('all');
    const [form, setForm] = useState(null);
    const [techInput, setTechInput] = useState('');

    const filtered = filter === 'all' ? projects : projects.filter(p => p.category === filter);

    const addTech = () => {
        const tag = techInput.trim();
        if (tag && !form.tech_stack.includes(tag)) {
            setForm(f => ({ ...f, tech_stack: [...f.tech_stack, tag] }));
        }
        setTechInput('');
    };

    const removeTech = (tag) => {
        setForm(f => ({ ...f, tech_stack: f.tech_stack.filter(t => t !== tag) }));
    };

    const save = async () => {
        if (form.id) {
            await api.editProject(form.id, form);
        } else {
            await api.addProject(form);
        }
        setForm(null);
        setTechInput('');
        onRefresh();
    };

    const remove = async (id) => {
        await api.removeProject(id);
        onRefresh();
    };

    return (
        <div className="pf-project-tab">
            {/* 카테고리 필터 */}
            <div className="pf-filter-bar">
                {CATEGORIES.map(c => (
                    <button
                        key={c.key}
                        className={`pf-filter-btn${filter === c.key ? ' is-active' : ''}`}
                        onClick={() => setFilter(c.key)}
                    >
                        {c.label}
                    </button>
                ))}
                {editing && <button className="button ghost" onClick={() => { setForm({...emptyProject}); setTechInput(''); }}>+ 추가</button>}
            </div>

            {/* 추가/수정 폼 */}
            {form && (
                <div className="pf-edit-form">
                    <label>구분
                        <select value={form.category} onChange={(e) => setForm(f => ({...f, category: e.target.value}))}>
                            {CATEGORIES.filter(c => c.key !== 'all').map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
                        </select>
                    </label>
                    <label>프로젝트명 <input value={form.title} onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
                    <label>설명 <textarea value={form.description} rows={3} onChange={(e) => setForm(f => ({...f, description: e.target.value}))} /></label>
                    <label>담당 역할 <input value={form.role} onChange={(e) => setForm(f => ({...f, role: e.target.value}))} /></label>
                    <div className="pf-edit-form__row">
                        <label>시작 <input type="month" value={form.start_date} onChange={(e) => setForm(f => ({...f, start_date: e.target.value}))} /></label>
                        <label>종료 <input type="month" value={form.end_date} onChange={(e) => setForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
                    </div>
                    <label>URL <input value={form.url} onChange={(e) => setForm(f => ({...f, url: e.target.value}))} /></label>
                    <label>기술 스택
                        <div className="pf-tech-input">
                            <input
                                value={techInput}
                                onChange={(e) => setTechInput(e.target.value)}
                                onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTech(); } }}
                                placeholder="기술명 입력 후 Enter"
                            />
                            <div className="pf-tech-tags">
                                {form.tech_stack.map(t => (
                                    <span key={t} className="pf-tech-tag">{t} <button onClick={() => removeTech(t)}>&times;</button></span>
                                ))}
                            </div>
                        </div>
                    </label>
                    <div className="pf-edit-form__actions">
                        <button className="button ghost" onClick={() => { setForm(null); setTechInput(''); }}>취소</button>
                        <button className="button primary" onClick={save}>저장</button>
                    </div>
                </div>
            )}

            {/* 프로젝트 카드 그리드 */}
            <div className="pf-project-grid">
                {filtered.length === 0 && <p className="pf-empty">프로젝트가 없습니다.</p>}
                {filtered.map(p => (
                    <div key={p.id} className="pf-project-card">
                        <div className="pf-project-card__header">
                            <span className={`pf-project-card__cat pf-project-card__cat--${p.category}`}>{CATEGORIES.find(c => c.key === p.category)?.label}</span>
                            <span className="pf-project-card__period">{p.start_date}  {p.end_date || '현재'}</span>
                        </div>
                        <h4 className="pf-project-card__title">{p.title}</h4>
                        {p.role && <p className="pf-project-card__role">{p.role}</p>}
                        {p.description && <p className="pf-project-card__desc">{p.description}</p>}
                        {p.tech_stack?.length > 0 && (
                            <div className="pf-project-card__tags">
                                {p.tech_stack.map(t => <span key={t} className="pf-tech-tag">{t}</span>)}
                            </div>
                        )}
                        {p.url && <a className="pf-project-card__link" href={p.url} target="_blank" rel="noreferrer">링크 &rarr;</a>}
                        {editing && (
                            <div className="pf-project-card__actions">
                                <button className="button ghost" onClick={() => { setForm({...p}); setTechInput(''); }}>수정</button>
                                <button className="button ghost" onClick={() => remove(p.id)}>삭제</button>
                            </div>
                        )}
                    </div>
                ))}
            </div>
        </div>
    );
}
  • Step 2: Commit
cd web-ui
git add src/pages/portfolio/ProjectTab.jsx
git commit -m "feat(portfolio): ProjectTab — 프로젝트 카드 그리드 + 필터"

Task 10: Frontend — IntroTab (탭 3: 자기소개 관리)

Files:

  • Create: web-ui/src/pages/portfolio/IntroTab.jsx

  • Step 1: IntroTab.jsx 작성

// web-ui/src/pages/portfolio/IntroTab.jsx
import { useState } from 'react';

const emptyIntro = { title: '', content: '', is_main: 0 };

export default function IntroTab({ introductions, editing, api, onRefresh }) {
    const [form, setForm] = useState(null);
    const [copiedId, setCopiedId] = useState(null);

    const save = async () => {
        if (form.id) {
            await api.editIntro(form.id, { title: form.title, content: form.content });
        } else {
            await api.addIntro(form);
        }
        setForm(null);
        onRefresh();
    };

    const remove = async (id) => {
        await api.removeIntro(id);
        onRefresh();
    };

    const setMain = async (id) => {
        await api.setMainIntro(id);
        onRefresh();
    };

    const copyToClipboard = async (intro) => {
        try {
            await navigator.clipboard.writeText(intro.content);
            setCopiedId(intro.id);
            setTimeout(() => setCopiedId(null), 1500);
        } catch {
            /* 무시 */
        }
    };

    return (
        <div className="pf-intro-tab">
            {editing && (
                <div className="pf-intro-tab__toolbar">
                    <button className="button primary" onClick={() => setForm({...emptyIntro})}>+   작성</button>
                </div>
            )}

            {/* 작성/수정 폼 */}
            {form && (
                <div className="pf-edit-form">
                    <label>버전명 <input value={form.title} placeholder="예: 이직용 짧은 버전" onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
                    <label>본문 <textarea value={form.content} rows={8} onChange={(e) => setForm(f => ({...f, content: e.target.value}))} /></label>
                    <div className="pf-edit-form__actions">
                        <button className="button ghost" onClick={() => setForm(null)}>취소</button>
                        <button className="button primary" onClick={save}>저장</button>
                    </div>
                </div>
            )}

            {/* 자기소개 목록 */}
            <div className="pf-intro-list">
                {introductions.length === 0 && <p className="pf-empty">자기소개 글이 없습니다.</p>}
                {introductions.map(intro => (
                    <div key={intro.id} className={`pf-intro-card${intro.is_main ? ' is-main' : ''}`}>
                        <div className="pf-intro-card__header">
                            <span className="pf-intro-card__title">
                                {intro.is_main && <span className="pf-intro-card__badge">MAIN</span>}
                                {intro.title || '제목 없음'}
                            </span>
                            <span className="pf-intro-card__date">
                                {intro.updated_at ? new Date(intro.updated_at).toLocaleDateString('ko-KR') : ''}
                            </span>
                        </div>
                        <p className="pf-intro-card__preview">{intro.content}</p>
                        <div className="pf-intro-card__actions">
                            <button
                                className="button ghost"
                                onClick={() => copyToClipboard(intro)}
                            >
                                {copiedId === intro.id ? '복사됨!' : '복사'}
                            </button>
                            {editing && (
                                <>
                                    <button className="button ghost" onClick={() => setForm({...intro})}>수정</button>
                                    {!intro.is_main && <button className="button ghost" onClick={() => setMain(intro.id)}>메인 지정</button>}
                                    <button className="button ghost" onClick={() => remove(intro.id)}>삭제</button>
                                </>
                            )}
                        </div>
                    </div>
                ))}
            </div>
        </div>
    );
}
  • Step 2: Commit
cd web-ui
git add src/pages/portfolio/IntroTab.jsx
git commit -m "feat(portfolio): IntroTab — 자기소개 다중 버전 + 클립보드 복사"

Task 11: Frontend — ResumeView (PDF 인쇄 전용)

Files:

  • Create: web-ui/src/pages/portfolio/ResumeView.jsx

  • Step 1: ResumeView.jsx 작성

// web-ui/src/pages/portfolio/ResumeView.jsx

export default function ResumeView({ data, onClose }) {
    const { profile, careers, projects, skills, main_introduction } = data;

    const handlePrint = () => {
        window.print();
    };

    return (
        <div className="pf-resume-overlay">
            <div className="pf-resume-actions no-print">
                <button className="button primary" onClick={handlePrint}>PDF 저장 / 인쇄</button>
                <button className="button ghost" onClick={onClose}>닫기</button>
            </div>
            <div className="pf-resume">
                {/* 헤더 */}
                <header className="pf-resume__header">
                    <div>
                        <h1 className="pf-resume__name">{profile.name}</h1>
                        <p className="pf-resume__role">{profile.role}</p>
                    </div>
                    <div className="pf-resume__contact">
                        {profile.email && <span>{profile.email}</span>}
                        {profile.phone && <span>{profile.phone}</span>}
                        {profile.github_url && <span>{profile.github_url}</span>}
                    </div>
                </header>

                {/* About */}
                {(main_introduction?.content || profile.bio) && (
                    <section className="pf-resume__section">
                        <h2>About</h2>
                        <p>{main_introduction?.content || profile.bio}</p>
                    </section>
                )}

                {/* Experience */}
                {careers.length > 0 && (
                    <section className="pf-resume__section">
                        <h2>Experience</h2>
                        {careers.map(c => (
                            <div key={c.id} className="pf-resume__item">
                                <div className="pf-resume__item-header">
                                    <strong>{c.role}</strong>
                                    <span>{c.organization}</span>
                                    <span className="pf-resume__period">{c.start_date}  {c.end_date || '현재'}</span>
                                </div>
                                {c.description && <p>{c.description}</p>}
                            </div>
                        ))}
                    </section>
                )}

                {/* Projects */}
                {projects.length > 0 && (
                    <section className="pf-resume__section">
                        <h2>Projects</h2>
                        {projects.map(p => (
                            <div key={p.id} className="pf-resume__item">
                                <div className="pf-resume__item-header">
                                    <strong>{p.title}</strong>
                                    <span className="pf-resume__period">{p.start_date}  {p.end_date || '현재'}</span>
                                </div>
                                {p.description && <p>{p.description}</p>}
                                {p.tech_stack?.length > 0 && (
                                    <p className="pf-resume__tech">{p.tech_stack.join(' · ')}</p>
                                )}
                            </div>
                        ))}
                    </section>
                )}

                {/* Skills */}
                {skills.length > 0 && (
                    <section className="pf-resume__section">
                        <h2>Skills</h2>
                        <p className="pf-resume__skills">{skills.map(s => s.name).join(' · ')}</p>
                    </section>
                )}
            </div>
        </div>
    );
}
  • Step 2: Commit
cd web-ui
git add src/pages/portfolio/ResumeView.jsx
git commit -m "feat(portfolio): ResumeView — PDF 인쇄 전용 이력서 레이아웃"

Task 12: Frontend — Portfolio 메인 페이지 + CSS

Files:

  • Create: web-ui/src/pages/portfolio/Portfolio.jsx

  • Create: web-ui/src/pages/portfolio/Portfolio.css

  • Step 1: Portfolio.jsx 작성

// web-ui/src/pages/portfolio/Portfolio.jsx
import { useCallback, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import usePortfolioApi from './usePortfolioApi';
import PasswordModal from './PasswordModal';
import ProfileTab from './ProfileTab';
import ProjectTab from './ProjectTab';
import IntroTab from './IntroTab';
import ResumeView from './ResumeView';
import './Portfolio.css';

export default function Portfolio() {
    const isMobile = useIsMobile();
    const api = usePortfolioApi();

    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState('');
    const [editing, setEditing] = useState(false);
    const [showPwModal, setShowPwModal] = useState(false);
    const [showResume, setShowResume] = useState(false);

    const load = useCallback(async () => {
        setLoading(true);
        setError('');
        try {
            const d = await api.fetchPublic();
            setData(d);
        } catch (err) {
            setError(err.message);
        } finally {
            setLoading(false);
        }
    }, []);

    useEffect(() => { load(); }, [load]);

    const handleEditToggle = () => {
        if (editing) {
            setEditing(false);
            return;
        }
        if (api.token) {
            setEditing(true);
        } else {
            setShowPwModal(true);
        }
    };

    const handleAuth = async (pw) => {
        const ok = await api.login(pw);
        if (ok) {
            setShowPwModal(false);
            setEditing(true);
        }
    };

    // 편집 후 데이터 리프레시
    const refresh = useCallback(async () => {
        try {
            const d = await api.fetchPublic();
            setData(d);
        } catch { /* 무시 */ }
    }, []);

    if (loading && !data) return <div className="pf-page"><p className="pf-loading">불러오는 ...</p></div>;
    if (error && !data) return <div className="pf-page"><p className="pf-error">{error}</p></div>;
    if (!data) return null;

    if (showResume) {
        return <ResumeView data={data} onClose={() => setShowResume(false)} />;
    }

    const tabs = [
        {
            key: 'profile',
            label: '프로필',
            content: <ProfileTab data={data} editing={editing} api={api} onRefresh={refresh} />,
        },
        {
            key: 'projects',
            label: '프로젝트',
            content: <ProjectTab projects={data.projects} editing={editing} api={api} onRefresh={refresh} />,
        },
        {
            key: 'intro',
            label: '자기소개',
            content: <IntroTab introductions={data.introductions || []} editing={editing} api={api} onRefresh={refresh} />,
        },
    ];

    return (
        <div className="pf-page">
            <div className="pf-toolbar">
                <button className={`button ${editing ? 'primary' : 'ghost'}`} onClick={handleEditToggle}>
                    {editing ? '편집 완료' : '편집'}
                </button>
                <button className="button ghost" onClick={() => setShowResume(true)}>
                    PDF 내보내기
                </button>
            </div>

            {isMobile ? (
                <SwipeableView tabs={tabs} />
            ) : (
                <SwipeableView tabs={tabs} />
            )}

            <PasswordModal
                open={showPwModal}
                onAuth={handleAuth}
                onClose={() => setShowPwModal(false)}
                error={api.authError}
            />
        </div>
    );
}
  • Step 2: Portfolio.css 작성

이 파일은 전체 포트폴리오 페이지 + 모든 컴포넌트의 스타일을 포함합니다. CSS 파일이 길지만, 프로젝트 패턴(단일 CSS per page)을 따릅니다. 실제 구현 시 기존 사이버펑크 테마 변수(var(--surface-card), var(--line), var(--text-bright) 등)를 활용하여 작성합니다.

주요 클래스 목록:

  • .pf-page — 페이지 루트 (grid, gap)
  • .pf-toolbar — 편집/PDF 버튼 바
  • .pf-modal-backdrop, .pf-modal — 비밀번호 모달
  • .pf-profile-card — 프로필 카드
  • .pf-section — 경력/기술 섹션 래퍼
  • .pf-career-group, .pf-career-item — 경력 타임라인
  • .pf-skill-group, .pf-skill-tag — 기술 태그
  • .pf-edit-form — 인라인 편집 폼 (공통)
  • .pf-filter-bar, .pf-filter-btn — 프로젝트 필터
  • .pf-project-grid, .pf-project-card — 프로젝트 카드
  • .pf-tech-input, .pf-tech-tag — 기술 태그 입력
  • .pf-intro-tab, .pf-intro-card — 자기소개 카드
  • .pf-resume-overlay, .pf-resume — PDF 이력서 뷰
  • @media print — 인쇄 전용 스타일
  • @media (max-width: 768px) — 모바일 반응형

(CSS 전체 코드는 구현 subagent가 위 클래스 구조에 맞춰 사이버펑크 테마로 작성)

  • Step 3: Commit
cd web-ui
git add src/pages/portfolio/Portfolio.jsx src/pages/portfolio/Portfolio.css
git commit -m "feat(portfolio): 메인 페이지 + 3탭 구조 + CSS"

Task 13: Frontend — IntroTab에 introductions 데이터 전달 수정

Files:

  • Modify: web-ui/src/pages/portfolio/Portfolio.jsx

  • Step 1: public API에서 introductions도 가져오도록 수정

현재 get_public_data()main_introduction만 반환하므로, IntroTab에서 전체 목록이 필요합니다. 두 가지 선택:

편집 모드 진입 시 fetchIntros()를 별도 호출하여 전체 목록을 로드합니다.

Portfolio.jsx의 state에 intros 추가:

const [intros, setIntros] = useState([]);

handleAuth 성공 후 intros 로드:

const handleAuth = async (pw) => {
    const ok = await api.login(pw);
    if (ok) {
        setShowPwModal(false);
        setEditing(true);
        try {
            const list = await api.fetchIntros();
            setIntros(list);
        } catch { /* 무시 */ }
    }
};

refresh에서도:

const refresh = useCallback(async () => {
    try {
        const d = await api.fetchPublic();
        setData(d);
        if (api.token) {
            const list = await api.fetchIntros();
            setIntros(list);
        }
    } catch { /* 무시 */ }
}, [api.token]);

IntroTab에 전달:

content: <IntroTab introductions={editing ? intros : (data.main_introduction ? [data.main_introduction] : [])} editing={editing} api={api} onRefresh={refresh} />,
  • Step 2: Commit
cd web-ui
git add src/pages/portfolio/Portfolio.jsx
git commit -m "fix(portfolio): IntroTab에 편집 모드 시 전체 자기소개 목록 로드"

Task 14: Frontend — 홈 페이지 Profile 섹션 연동

Files:

  • Modify: web-ui/src/pages/home/Home.jsx

  • Modify: web-ui/src/pages/home/Home.css

  • Step 1: Home.jsx Profile 섹션을 API 연동 요약 카드로 교체

기존 하드코딩 Profile 섹션(line 215~271)을 교체합니다.

Home 컴포넌트 상단에 state 추가:

const [portfolio, setPortfolio] = useState(null);

useEffect(() => {
    fetch('/api/profile/public')
        .then(r => r.ok ? r.json() : null)
        .catch(() => null)
        .then(d => setPortfolio(d));
}, []);

기존 Profile 섹션을 교체:

<section className="home-section">
    <div className="home-section__header">
        <h2>Profile</h2>
        <p>페이지 주인 소개 영역입니다.</p>
    </div>
    <div className="home-profile">
        <div className="home-profile__card">
            <div className="home-profile__identity">
                <img
                    className="home-profile__avatar"
                    src={portfolio?.profile?.photo_url || myPhoto}
                    alt="Profile"
                />
                <div>
                    <p className="home-profile__role">{portfolio?.profile?.role || 'Server Developer'}</p>
                    <p className="home-profile__name">{portfolio?.profile?.name || '박 재 오'}</p>
                </div>
            </div>
            <p className="home-profile__bio">
                {portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
            </p>
            <div className="home-profile__tags">
                {(portfolio?.skills || []).slice(0, 8).map((s) => (
                    <span key={s.id || s.name}>{s.name}</span>
                ))}
                {!portfolio && ['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
                    <span key={tag}>{tag}</span>
                ))}
            </div>
            <div className="home-profile__actions">
                <Link className="button ghost" to="/portfolio">
                    포트폴리오 보기
                </Link>
                <a className="button primary" href={`mailto:${portfolio?.profile?.email || 'bgg8988@gmail.com'}`}>
                    연락하기
                </a>
            </div>
        </div>
    </div>
</section>

기존 연혁(timeline) 섹션과 "프로필 수정" 버튼은 제거합니다 — 포트폴리오 페이지에서 관리.

  • Step 2: Commit
cd web-ui
git add src/pages/home/Home.jsx src/pages/home/Home.css
git commit -m "refactor(home): Profile 섹션 portfolio API 연동 + 요약 카드"

Task 15: CLAUDE.md 문서 업데이트

Files:

  • Modify: web-backend/CLAUDE.md

  • Step 1: portfolio 서비스 섹션 추가

Docker 서비스 & 포트 테이블에 추가:

| `portfolio` | 18850 | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개 관리) |

Nginx 라우팅 규칙 테이블에 추가:

| `/api/profile/` | portfolio | 포트폴리오 API |

서비스별 핵심 정보에 portfolio 섹션 추가:

### portfolio (portfolio/)
- 개인 포트폴리오 서비스 (프로필, 경력, 프로젝트, 기술스택, 자기소개 관리)
- DB: `/app/data/portfolio.db` (profile, careers, projects, skills, introductions 테이블)
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`

**환경변수**
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)

**portfolio API 목록**

| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
| GET | `/api/profile/profile` | 프로필 조회 (인증) |
| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
| GET | `/api/profile/careers` | 경력 목록 (인증) |
| POST | `/api/profile/careers` | 경력 추가 (인증) |
| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
| GET | `/api/profile/skills` | 기술 목록 (인증) |
| POST | `/api/profile/skills` | 기술 추가 (인증) |
| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
  • Step 2: Commit
git add CLAUDE.md
git commit -m "docs: CLAUDE.md에 portfolio 서비스 추가"