diff --git a/backend/Dockerfile b/backend/Dockerfile
index 35f21ba..0a27a22 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -4,5 +4,6 @@ RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
+RUN mkdir -p /app/uploads
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..84605ce
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,44 @@
+from pydantic_settings import BaseSettings
+from pydantic import Field
+from functools import lru_cache
+
+
+class Settings(BaseSettings):
+ mongodb_url: str = Field(
+ default="mongodb://mongodb:27017",
+ description="MongoDB 연결 URL",
+ )
+ mongodb_database: str = Field(
+ default="todos2",
+ alias="DB_NAME",
+ description="MongoDB 데이터베이스 이름",
+ )
+ redis_url: str = Field(
+ default="redis://redis:6379",
+ description="Redis 연결 URL",
+ )
+ frontend_url: str = Field(
+ default="http://localhost:3000",
+ description="프론트엔드 URL (CORS 허용)",
+ )
+ upload_dir: str = Field(
+ default="/app/uploads",
+ description="파일 업로드 디렉토리",
+ )
+ max_file_size: int = Field(
+ default=10 * 1024 * 1024,
+ description="최대 파일 크기 (bytes, 기본 10MB)",
+ )
+ max_files_per_todo: int = Field(
+ default=5,
+ description="Todo당 최대 첨부 파일 수",
+ )
+
+ class Config:
+ env_file = ".env"
+ populate_by_name = True # alias와 필드명 모두 허용
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..015f1aa
--- /dev/null
+++ b/backend/app/database.py
@@ -0,0 +1,98 @@
+from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
+import redis.asyncio as aioredis
+from app.config import get_settings
+
+
+class Database:
+ client: AsyncIOMotorClient | None = None
+ db: AsyncIOMotorDatabase | None = None
+ redis: aioredis.Redis | None = None
+
+
+db = Database()
+
+
+def get_database() -> AsyncIOMotorDatabase:
+ """MongoDB 데이터베이스 인스턴스를 반환한다.
+ 주의: `if not db.db:` 사용 금지 (pymongo 4.9.x NotImplementedError)
+ """
+ if db.db is None:
+ raise RuntimeError("Database not initialized")
+ return db.db
+
+
+def get_redis() -> aioredis.Redis:
+ """Redis 클라이언트 인스턴스를 반환한다."""
+ if db.redis is None:
+ raise RuntimeError("Redis not initialized")
+ return db.redis
+
+
+async def connect_db():
+ """MongoDB + Redis 연결 초기화"""
+ settings = get_settings()
+
+ # MongoDB
+ db.client = AsyncIOMotorClient(settings.mongodb_url)
+ db.db = db.client[settings.mongodb_database]
+
+ # 인덱스 생성
+ await create_indexes(db.db)
+
+ # Redis
+ db.redis = aioredis.from_url(
+ settings.redis_url,
+ encoding="utf-8",
+ decode_responses=True,
+ )
+
+
+async def disconnect_db():
+ """연결 종료"""
+ if db.client is not None:
+ db.client.close()
+ if db.redis is not None:
+ await db.redis.close()
+
+
+async def create_indexes(database: AsyncIOMotorDatabase):
+ """컬렉션 인덱스 생성"""
+ todos = database["todos"]
+ categories = database["categories"]
+
+ # todos 텍스트 검색 인덱스
+ await todos.create_index(
+ [("title", "text"), ("content", "text"), ("tags", "text")],
+ name="text_search_index",
+ weights={"title": 10, "content": 5, "tags": 3},
+ )
+
+ # todos 복합 인덱스
+ await todos.create_index(
+ [("category_id", 1), ("created_at", -1)],
+ name="category_created",
+ )
+ await todos.create_index(
+ [("completed", 1), ("created_at", -1)],
+ name="completed_created",
+ )
+ await todos.create_index(
+ [("priority", 1), ("created_at", -1)],
+ name="priority_created",
+ )
+ await todos.create_index(
+ [("tags", 1)],
+ name="tags",
+ )
+ await todos.create_index(
+ [("due_date", 1)],
+ name="due_date",
+ )
+ await todos.create_index(
+ [("completed", 1), ("due_date", 1)],
+ name="completed_due_date",
+ )
+
+ # categories 인덱스
+ await categories.create_index("name", unique=True, name="category_name_unique")
+ await categories.create_index("order", name="category_order")
diff --git a/backend/app/main.py b/backend/app/main.py
index f8c47a1..c039c3e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,17 +1,55 @@
-from fastapi import FastAPI
-from fastapi.middleware.cors import CORSMiddleware
+from contextlib import asynccontextmanager
from datetime import datetime
-app = FastAPI(title="API", version="1.0.0")
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
+from app.config import get_settings
+from app.database import connect_db, disconnect_db
-@app.get("/health")
-async def health_check():
- return {"status": "healthy", "timestamp": datetime.now().isoformat()}
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """서버 시작/종료 이벤트: MongoDB + Redis 연결 관리"""
+ await connect_db()
+ yield
+ await disconnect_db()
+
+
+def create_app() -> FastAPI:
+ settings = get_settings()
+
+ app = FastAPI(
+ title="todos2 API",
+ description="확장형 할일 관리 애플리케이션 API",
+ version="1.0.0",
+ lifespan=lifespan,
+ )
+
+ # CORS
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # 라우터 등록
+ from app.routers import todos, categories, tags, search, dashboard, uploads
+ app.include_router(todos.router)
+ app.include_router(categories.router)
+ app.include_router(tags.router)
+ app.include_router(search.router)
+ app.include_router(dashboard.router)
+ app.include_router(uploads.router)
+
+ # 헬스 체크
+ @app.get("/health", tags=["health"])
+ async def health_check():
+ return {"status": "healthy", "timestamp": datetime.now().isoformat()}
+
+ return app
+
+
+app = create_app()
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..4c13632
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,47 @@
+from app.models.common import PyObjectId, ErrorResponse, PaginatedResponse
+from app.models.todo import (
+ Priority,
+ TodoCreate,
+ TodoUpdate,
+ TodoResponse,
+ TodoListResponse,
+ BatchRequest,
+ BatchResponse,
+ ToggleResponse,
+ TagInfo,
+ SearchResponse,
+ DashboardStats,
+ OverviewStats,
+ CategoryStats,
+ PriorityStats,
+ UpcomingDeadline,
+)
+from app.models.category import (
+ CategoryCreate,
+ CategoryUpdate,
+ CategoryResponse,
+)
+
+__all__ = [
+ "PyObjectId",
+ "ErrorResponse",
+ "PaginatedResponse",
+ "Priority",
+ "TodoCreate",
+ "TodoUpdate",
+ "TodoResponse",
+ "TodoListResponse",
+ "BatchRequest",
+ "BatchResponse",
+ "ToggleResponse",
+ "TagInfo",
+ "SearchResponse",
+ "DashboardStats",
+ "OverviewStats",
+ "CategoryStats",
+ "PriorityStats",
+ "UpcomingDeadline",
+ "CategoryCreate",
+ "CategoryUpdate",
+ "CategoryResponse",
+]
diff --git a/backend/app/models/category.py b/backend/app/models/category.py
new file mode 100644
index 0000000..9c524e6
--- /dev/null
+++ b/backend/app/models/category.py
@@ -0,0 +1,49 @@
+from datetime import datetime
+from typing import Optional
+from pydantic import BaseModel, Field, field_validator
+
+
+# === Request 스키마 ===
+
+
+class CategoryCreate(BaseModel):
+ """카테고리 생성 요청 (F-007)"""
+ name: str = Field(..., min_length=1, max_length=50)
+ color: str = Field("#6B7280", pattern=r"^#[0-9A-Fa-f]{6}$")
+
+ @field_validator("name")
+ @classmethod
+ def name_not_blank(cls, v: str) -> str:
+ v = v.strip()
+ if not v:
+ raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다")
+ return v
+
+
+class CategoryUpdate(BaseModel):
+ """카테고리 수정 요청 (F-009) - Partial Update"""
+ name: Optional[str] = Field(None, min_length=1, max_length=50)
+ color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
+ order: Optional[int] = Field(None, ge=0)
+
+ @field_validator("name")
+ @classmethod
+ def name_not_blank(cls, v: Optional[str]) -> Optional[str]:
+ if v is not None:
+ v = v.strip()
+ if not v:
+ raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다")
+ return v
+
+
+# === Response 스키마 ===
+
+
+class CategoryResponse(BaseModel):
+ """카테고리 응답 (F-008)"""
+ id: str
+ name: str
+ color: str
+ order: int
+ todo_count: int = 0
+ created_at: datetime
diff --git a/backend/app/models/common.py b/backend/app/models/common.py
new file mode 100644
index 0000000..e1ddd67
--- /dev/null
+++ b/backend/app/models/common.py
@@ -0,0 +1,29 @@
+from typing import Annotated, Any
+from bson import ObjectId
+from pydantic import BaseModel, BeforeValidator
+
+
+def validate_object_id(v: Any) -> str:
+ """ObjectId <-> str 변환을 위한 유효성 검증기"""
+ if isinstance(v, ObjectId):
+ return str(v)
+ if isinstance(v, str) and ObjectId.is_valid(v):
+ return v
+ raise ValueError(f"Invalid ObjectId: {v}")
+
+
+PyObjectId = Annotated[str, BeforeValidator(validate_object_id)]
+
+
+class ErrorResponse(BaseModel):
+ """표준 에러 응답"""
+ detail: str
+
+
+class PaginatedResponse(BaseModel):
+ """페이지네이션 응답 래퍼"""
+ items: list[Any]
+ total: int
+ page: int
+ limit: int
+ total_pages: int
diff --git a/backend/app/models/todo.py b/backend/app/models/todo.py
new file mode 100644
index 0000000..18f8a3b
--- /dev/null
+++ b/backend/app/models/todo.py
@@ -0,0 +1,221 @@
+from datetime import datetime
+from enum import Enum
+from typing import Optional
+from pydantic import BaseModel, Field, field_validator
+
+
+class Priority(str, Enum):
+ HIGH = "high"
+ MEDIUM = "medium"
+ LOW = "low"
+
+
+# === Request 스키마 ===
+
+
+class TodoCreate(BaseModel):
+ """할일 생성 요청 (F-001)"""
+ title: str = Field(..., min_length=1, max_length=200)
+ content: Optional[str] = Field(None, max_length=2000)
+ category_id: Optional[str] = None
+ tags: list[str] = Field(default_factory=list, max_length=10)
+ priority: Priority = Priority.MEDIUM
+ start_date: Optional[datetime] = None
+ due_date: Optional[datetime] = None
+
+ @field_validator("title")
+ @classmethod
+ def title_not_blank(cls, v: str) -> str:
+ v = v.strip()
+ if not v:
+ raise ValueError("제목은 공백만으로 구성할 수 없습니다")
+ return v
+
+ @field_validator("tags")
+ @classmethod
+ def normalize_tags(cls, v: list[str]) -> list[str]:
+ """소문자 정규화, 중복 제거, 공백 trim"""
+ seen: set[str] = set()
+ result: list[str] = []
+ for tag in v:
+ tag = tag.strip().lower()
+ if tag and len(tag) <= 30 and tag not in seen:
+ seen.add(tag)
+ result.append(tag)
+ return result
+
+ @field_validator("category_id")
+ @classmethod
+ def validate_category_id(cls, v: Optional[str]) -> Optional[str]:
+ from bson import ObjectId
+ if v is not None and not ObjectId.is_valid(v):
+ raise ValueError("유효하지 않은 카테고리 ID 형식입니다")
+ return v
+
+
+class TodoUpdate(BaseModel):
+ """할일 수정 요청 (F-004) - Partial Update"""
+ title: Optional[str] = Field(None, min_length=1, max_length=200)
+ content: Optional[str] = Field(None, max_length=2000)
+ completed: Optional[bool] = None
+ category_id: Optional[str] = None
+ tags: Optional[list[str]] = None
+ priority: Optional[Priority] = None
+ start_date: Optional[datetime] = None
+ due_date: Optional[datetime] = None
+
+ @field_validator("title")
+ @classmethod
+ def title_not_blank(cls, v: Optional[str]) -> Optional[str]:
+ if v is not None:
+ v = v.strip()
+ if not v:
+ raise ValueError("제목은 공백만으로 구성할 수 없습니다")
+ return v
+
+ @field_validator("tags")
+ @classmethod
+ def normalize_tags(cls, v: Optional[list[str]]) -> Optional[list[str]]:
+ if v is None:
+ return None
+ seen: set[str] = set()
+ result: list[str] = []
+ for tag in v:
+ tag = tag.strip().lower()
+ if tag and len(tag) <= 30 and tag not in seen:
+ seen.add(tag)
+ result.append(tag)
+ return result
+
+ @field_validator("category_id")
+ @classmethod
+ def validate_category_id(cls, v: Optional[str]) -> Optional[str]:
+ from bson import ObjectId
+ if v is not None and not ObjectId.is_valid(v):
+ raise ValueError("유효하지 않은 카테고리 ID 형식입니다")
+ return v
+
+
+class BatchRequest(BaseModel):
+ """일괄 작업 요청 (F-019, F-020, F-021)"""
+ action: str = Field(..., pattern="^(complete|delete|move_category)$")
+ ids: list[str] = Field(..., min_length=1)
+ category_id: Optional[str] = None # move_category 시에만 사용
+
+ @field_validator("ids")
+ @classmethod
+ def validate_ids(cls, v: list[str]) -> list[str]:
+ from bson import ObjectId
+ for id_str in v:
+ if not ObjectId.is_valid(id_str):
+ raise ValueError(f"유효하지 않은 ID 형식: {id_str}")
+ return v
+
+
+# === Attachment 스키마 ===
+
+
+class Attachment(BaseModel):
+ """첨부파일 메타데이터"""
+ id: str
+ filename: str
+ stored_filename: str
+ content_type: str
+ size: int
+ uploaded_at: datetime
+
+
+# === Response 스키마 ===
+
+
+class TodoResponse(BaseModel):
+ """할일 응답 (카테고리 정보 포함 가능)"""
+ id: str
+ title: str
+ content: Optional[str] = None
+ completed: bool
+ priority: Priority
+ category_id: Optional[str] = None
+ category_name: Optional[str] = None
+ category_color: Optional[str] = None
+ tags: list[str] = []
+ start_date: Optional[datetime] = None
+ due_date: Optional[datetime] = None
+ attachments: list[Attachment] = []
+ created_at: datetime
+ updated_at: datetime
+
+
+class TodoListResponse(BaseModel):
+ """할일 목록 페이지네이션 응답 (F-002)"""
+ items: list[TodoResponse]
+ total: int
+ page: int
+ limit: int
+ total_pages: int
+
+
+class ToggleResponse(BaseModel):
+ """완료 토글 응답 (F-006)"""
+ id: str
+ completed: bool
+
+
+class BatchResponse(BaseModel):
+ """일괄 작업 응답 (F-019, F-020, F-021)"""
+ action: str
+ processed: int
+ failed: int
+
+
+class TagInfo(BaseModel):
+ """태그 정보 (F-013)"""
+ name: str
+ count: int
+
+
+class SearchResponse(BaseModel):
+ """검색 응답 (F-017)"""
+ items: list[TodoResponse]
+ total: int
+ query: str
+ page: int
+ limit: int
+
+
+# === 대시보드 통계 모델 ===
+
+
+class OverviewStats(BaseModel):
+ total: int
+ completed: int
+ incomplete: int
+ completion_rate: float
+
+
+class CategoryStats(BaseModel):
+ category_id: Optional[str] = None
+ name: str
+ color: str
+ count: int
+
+
+class PriorityStats(BaseModel):
+ high: int
+ medium: int
+ low: int
+
+
+class UpcomingDeadline(BaseModel):
+ id: str
+ title: str
+ due_date: datetime
+ priority: Priority
+
+
+class DashboardStats(BaseModel):
+ """대시보드 통계 응답 (F-018)"""
+ overview: OverviewStats
+ by_category: list[CategoryStats]
+ by_priority: PriorityStats
+ upcoming_deadlines: list[UpcomingDeadline]
diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py
new file mode 100644
index 0000000..bffe6bb
--- /dev/null
+++ b/backend/app/routers/__init__.py
@@ -0,0 +1,9 @@
+from app.routers import todos, categories, tags, search, dashboard
+
+__all__ = [
+ "todos",
+ "categories",
+ "tags",
+ "search",
+ "dashboard",
+]
diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py
new file mode 100644
index 0000000..109669b
--- /dev/null
+++ b/backend/app/routers/categories.py
@@ -0,0 +1,55 @@
+from fastapi import APIRouter, Depends, Response, status
+
+from app.database import get_database, get_redis
+from app.models.category import (
+ CategoryCreate,
+ CategoryUpdate,
+ CategoryResponse,
+)
+from app.services.category_service import CategoryService
+
+router = APIRouter(prefix="/api/categories", tags=["categories"])
+
+
+def _get_service(
+ db=Depends(get_database),
+ redis=Depends(get_redis),
+) -> CategoryService:
+ return CategoryService(db, redis)
+
+
+@router.get("", response_model=list[CategoryResponse])
+async def list_categories(
+ service: CategoryService = Depends(_get_service),
+):
+ """F-008: 카테고리 목록 조회 (order 오름차순, todo_count 포함)"""
+ return await service.list_categories()
+
+
+@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
+async def create_category(
+ data: CategoryCreate,
+ service: CategoryService = Depends(_get_service),
+):
+ """F-007: 카테고리 생성"""
+ return await service.create_category(data)
+
+
+@router.put("/{category_id}", response_model=CategoryResponse)
+async def update_category(
+ category_id: str,
+ data: CategoryUpdate,
+ service: CategoryService = Depends(_get_service),
+):
+ """F-009: 카테고리 수정"""
+ return await service.update_category(category_id, data)
+
+
+@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_category(
+ category_id: str,
+ service: CategoryService = Depends(_get_service),
+):
+ """F-010: 카테고리 삭제 (해당 카테고리의 할일 category_id null 처리)"""
+ await service.delete_category(category_id)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py
new file mode 100644
index 0000000..cf39c67
--- /dev/null
+++ b/backend/app/routers/dashboard.py
@@ -0,0 +1,17 @@
+from fastapi import APIRouter, Depends
+
+from app.database import get_database, get_redis
+from app.models.todo import DashboardStats
+from app.services.dashboard_service import DashboardService
+
+router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
+
+
+@router.get("/stats", response_model=DashboardStats)
+async def get_dashboard_stats(
+ db=Depends(get_database),
+ redis=Depends(get_redis),
+):
+ """F-018: 대시보드 통계 (Redis 캐싱, TTL 60초)"""
+ service = DashboardService(db, redis)
+ return await service.get_stats()
diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py
new file mode 100644
index 0000000..5033790
--- /dev/null
+++ b/backend/app/routers/search.py
@@ -0,0 +1,19 @@
+from fastapi import APIRouter, Depends, Query
+
+from app.database import get_database
+from app.models.todo import SearchResponse
+from app.services.search_service import SearchService
+
+router = APIRouter(prefix="/api/search", tags=["search"])
+
+
+@router.get("", response_model=SearchResponse)
+async def search_todos(
+ q: str = Query(..., min_length=1, max_length=200, description="검색 키워드"),
+ page: int = Query(1, ge=1, description="페이지 번호"),
+ limit: int = Query(20, ge=1, le=100, description="페이지당 항목 수"),
+ db=Depends(get_database),
+):
+ """F-017: 전문 검색 (제목/내용/태그, text score 기준 정렬)"""
+ service = SearchService(db)
+ return await service.search(query=q, page=page, limit=limit)
diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py
new file mode 100644
index 0000000..0bc639a
--- /dev/null
+++ b/backend/app/routers/tags.py
@@ -0,0 +1,16 @@
+from fastapi import APIRouter, Depends
+
+from app.database import get_database
+from app.models.todo import TagInfo
+from app.services.todo_service import TodoService
+
+router = APIRouter(prefix="/api/tags", tags=["tags"])
+
+
+@router.get("", response_model=list[TagInfo])
+async def list_tags(
+ db=Depends(get_database),
+):
+ """F-013: 사용 중인 태그 목록 조회 (사용 횟수 포함, 내림차순)"""
+ service = TodoService(db)
+ return await service.get_tags()
diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py
new file mode 100644
index 0000000..610e169
--- /dev/null
+++ b/backend/app/routers/todos.py
@@ -0,0 +1,106 @@
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query, Response, status
+
+from app.database import get_database, get_redis
+from app.models.todo import (
+ TodoCreate,
+ TodoUpdate,
+ TodoResponse,
+ TodoListResponse,
+ BatchRequest,
+ BatchResponse,
+ ToggleResponse,
+)
+from app.services.todo_service import TodoService
+
+router = APIRouter(prefix="/api/todos", tags=["todos"])
+
+
+def _get_service(
+ db=Depends(get_database),
+ redis=Depends(get_redis),
+) -> TodoService:
+ return TodoService(db, redis)
+
+
+# 중요: /batch를 /{id} 보다 위에 등록
+@router.post("/batch", response_model=BatchResponse)
+async def batch_action(
+ data: BatchRequest,
+ service: TodoService = Depends(_get_service),
+):
+ """F-019~F-021: 일괄 작업 (완료/삭제/카테고리 변경)"""
+ return await service.batch_action(data)
+
+
+@router.get("", response_model=TodoListResponse)
+async def list_todos(
+ page: int = Query(1, ge=1, description="페이지 번호"),
+ limit: int = Query(20, ge=1, le=100, description="페이지당 항목 수"),
+ completed: Optional[bool] = Query(None, description="완료 상태 필터"),
+ category_id: Optional[str] = Query(None, description="카테고리 필터"),
+ priority: Optional[str] = Query(None, description="우선순위 필터 (high/medium/low)"),
+ tag: Optional[str] = Query(None, description="태그 필터"),
+ sort: str = Query("created_at", description="정렬 기준 (created_at/due_date/priority)"),
+ order: str = Query("desc", description="정렬 방향 (asc/desc)"),
+ service: TodoService = Depends(_get_service),
+):
+ """F-002: 할일 목록 조회 (필터/정렬/페이지네이션)"""
+ return await service.list_todos(
+ page=page,
+ limit=limit,
+ completed=completed,
+ category_id=category_id,
+ priority=priority,
+ tag=tag,
+ sort=sort,
+ order=order,
+ )
+
+
+@router.post("", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
+async def create_todo(
+ data: TodoCreate,
+ service: TodoService = Depends(_get_service),
+):
+ """F-001: 할일 생성"""
+ return await service.create_todo(data)
+
+
+@router.get("/{todo_id}", response_model=TodoResponse)
+async def get_todo(
+ todo_id: str,
+ service: TodoService = Depends(_get_service),
+):
+ """F-003: 할일 상세 조회"""
+ return await service.get_todo(todo_id)
+
+
+@router.put("/{todo_id}", response_model=TodoResponse)
+async def update_todo(
+ todo_id: str,
+ data: TodoUpdate,
+ service: TodoService = Depends(_get_service),
+):
+ """F-004: 할일 수정"""
+ return await service.update_todo(todo_id, data)
+
+
+@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_todo(
+ todo_id: str,
+ service: TodoService = Depends(_get_service),
+):
+ """F-005: 할일 삭제"""
+ await service.delete_todo(todo_id)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.patch("/{todo_id}/toggle", response_model=ToggleResponse)
+async def toggle_todo(
+ todo_id: str,
+ service: TodoService = Depends(_get_service),
+):
+ """F-006: 할일 완료 토글"""
+ return await service.toggle_todo(todo_id)
diff --git a/backend/app/routers/uploads.py b/backend/app/routers/uploads.py
new file mode 100644
index 0000000..6896008
--- /dev/null
+++ b/backend/app/routers/uploads.py
@@ -0,0 +1,56 @@
+from typing import List
+
+from fastapi import APIRouter, Depends, File, UploadFile, status
+from fastapi.responses import FileResponse
+
+from app.database import get_database
+from app.models.todo import Attachment
+from app.services.file_service import FileService
+
+router = APIRouter(prefix="/api/todos", tags=["attachments"])
+
+
+def _get_service(db=Depends(get_database)) -> FileService:
+ return FileService(db)
+
+
+@router.post(
+ "/{todo_id}/attachments",
+ response_model=list[Attachment],
+ status_code=status.HTTP_201_CREATED,
+)
+async def upload_attachments(
+ todo_id: str,
+ files: List[UploadFile] = File(...),
+ service: FileService = Depends(_get_service),
+):
+ """파일 업로드 (최대 5개, 파일당 10MB)"""
+ return await service.upload_files(todo_id, files)
+
+
+@router.get("/{todo_id}/attachments/{attachment_id}/download")
+async def download_attachment(
+ todo_id: str,
+ attachment_id: str,
+ service: FileService = Depends(_get_service),
+):
+ """파일 다운로드"""
+ file_path, filename = await service.get_file_info(todo_id, attachment_id)
+ return FileResponse(
+ path=str(file_path),
+ filename=filename,
+ media_type="application/octet-stream",
+ )
+
+
+@router.delete(
+ "/{todo_id}/attachments/{attachment_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def delete_attachment(
+ todo_id: str,
+ attachment_id: str,
+ service: FileService = Depends(_get_service),
+):
+ """첨부파일 삭제"""
+ await service.delete_attachment(todo_id, attachment_id)
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..817bb60
--- /dev/null
+++ b/backend/app/services/__init__.py
@@ -0,0 +1,11 @@
+from app.services.todo_service import TodoService
+from app.services.category_service import CategoryService
+from app.services.search_service import SearchService
+from app.services.dashboard_service import DashboardService
+
+__all__ = [
+ "TodoService",
+ "CategoryService",
+ "SearchService",
+ "DashboardService",
+]
diff --git a/backend/app/services/category_service.py b/backend/app/services/category_service.py
new file mode 100644
index 0000000..a38e703
--- /dev/null
+++ b/backend/app/services/category_service.py
@@ -0,0 +1,161 @@
+from datetime import datetime, timezone
+from typing import Optional
+
+from bson import ObjectId
+from fastapi import HTTPException
+from motor.motor_asyncio import AsyncIOMotorDatabase
+import redis.asyncio as aioredis
+
+from app.models.category import (
+ CategoryCreate,
+ CategoryUpdate,
+ CategoryResponse,
+)
+
+DASHBOARD_CACHE_KEY = "dashboard:stats"
+
+
+class CategoryService:
+ """카테고리 CRUD 비즈니스 로직"""
+
+ def __init__(self, db: AsyncIOMotorDatabase, redis_client: Optional[aioredis.Redis] = None):
+ self.collection = db["categories"]
+ self.todos = db["todos"]
+ self.redis = redis_client
+
+ async def _invalidate_cache(self):
+ """대시보드 캐시 무효화"""
+ if self.redis is not None:
+ try:
+ await self.redis.delete(DASHBOARD_CACHE_KEY)
+ except Exception:
+ pass
+
+ async def list_categories(self) -> list[CategoryResponse]:
+ """F-008: 카테고리 목록 조회 (order 오름차순, todo_count 포함)"""
+ categories = await self.collection.find().sort("order", 1).to_list(None)
+
+ result = []
+ for cat in categories:
+ cat_id_str = str(cat["_id"])
+ # 해당 카테고리의 할일 수 조회
+ todo_count = await self.todos.count_documents({"category_id": cat_id_str})
+
+ result.append(CategoryResponse(
+ id=cat_id_str,
+ name=cat["name"],
+ color=cat.get("color", "#6B7280"),
+ order=cat.get("order", 0),
+ todo_count=todo_count,
+ created_at=cat["created_at"],
+ ))
+
+ return result
+
+ async def create_category(self, data: CategoryCreate) -> CategoryResponse:
+ """F-007: 카테고리 생성"""
+ # 이름 중복 검사
+ existing = await self.collection.find_one({"name": data.name})
+ if existing:
+ raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다")
+
+ # order 자동 설정 (현재 최대값 + 1)
+ max_order_doc = await self.collection.find_one(
+ {},
+ sort=[("order", -1)],
+ )
+ next_order = (max_order_doc["order"] + 1) if max_order_doc and "order" in max_order_doc else 0
+
+ now = datetime.now(timezone.utc)
+ doc = {
+ "name": data.name,
+ "color": data.color,
+ "order": next_order,
+ "created_at": now,
+ }
+
+ result = await self.collection.insert_one(doc)
+ doc["_id"] = result.inserted_id
+
+ await self._invalidate_cache()
+
+ return CategoryResponse(
+ id=str(doc["_id"]),
+ name=doc["name"],
+ color=doc["color"],
+ order=doc["order"],
+ todo_count=0,
+ created_at=doc["created_at"],
+ )
+
+ async def update_category(self, category_id: str, data: CategoryUpdate) -> CategoryResponse:
+ """F-009: 카테고리 수정 (Partial Update)"""
+ if not ObjectId.is_valid(category_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ # 존재 확인
+ existing = await self.collection.find_one({"_id": ObjectId(category_id)})
+ if not existing:
+ raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
+
+ # 변경 필드 추출
+ update_data = data.model_dump(exclude_unset=True)
+ if not update_data:
+ raise HTTPException(status_code=400, detail="수정할 필드가 없습니다")
+
+ # name 변경 시 중복 검사
+ if "name" in update_data:
+ dup = await self.collection.find_one({
+ "name": update_data["name"],
+ "_id": {"$ne": ObjectId(category_id)},
+ })
+ if dup:
+ raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다")
+
+ result = await self.collection.find_one_and_update(
+ {"_id": ObjectId(category_id)},
+ {"$set": update_data},
+ return_document=True,
+ )
+
+ if not result:
+ raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
+
+ # todo_count 조회
+ cat_id_str = str(result["_id"])
+ todo_count = await self.todos.count_documents({"category_id": cat_id_str})
+
+ await self._invalidate_cache()
+
+ return CategoryResponse(
+ id=cat_id_str,
+ name=result["name"],
+ color=result.get("color", "#6B7280"),
+ order=result.get("order", 0),
+ todo_count=todo_count,
+ created_at=result["created_at"],
+ )
+
+ async def delete_category(self, category_id: str) -> None:
+ """F-010: 카테고리 삭제 + 해당 카테고리의 할일 category_id null 처리"""
+ if not ObjectId.is_valid(category_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ # 존재 확인
+ existing = await self.collection.find_one({"_id": ObjectId(category_id)})
+ if not existing:
+ raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
+
+ # 해당 카테고리의 모든 todo.category_id -> null 갱신
+ await self.todos.update_many(
+ {"category_id": category_id},
+ {"$set": {
+ "category_id": None,
+ "updated_at": datetime.now(timezone.utc),
+ }},
+ )
+
+ # 카테고리 삭제
+ await self.collection.delete_one({"_id": ObjectId(category_id)})
+
+ await self._invalidate_cache()
diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py
new file mode 100644
index 0000000..fe31938
--- /dev/null
+++ b/backend/app/services/dashboard_service.py
@@ -0,0 +1,182 @@
+import json
+from datetime import datetime, timezone
+
+from bson import ObjectId
+from motor.motor_asyncio import AsyncIOMotorDatabase
+import redis.asyncio as aioredis
+
+from app.models.todo import (
+ DashboardStats,
+ OverviewStats,
+ CategoryStats,
+ PriorityStats,
+ UpcomingDeadline,
+)
+
+DASHBOARD_CACHE_KEY = "dashboard:stats"
+DASHBOARD_CACHE_TTL = 60 # 60초
+
+
+class DashboardService:
+ """대시보드 통계 + Redis 캐싱"""
+
+ def __init__(self, db: AsyncIOMotorDatabase, redis_client: aioredis.Redis):
+ self.todos = db["todos"]
+ self.categories = db["categories"]
+ self.redis = redis_client
+
+ async def get_stats(self) -> DashboardStats:
+ """F-018: 대시보드 통계
+ 1. Redis 캐시 확인
+ 2. 캐시 히트 -> JSON 파싱 후 반환
+ 3. 캐시 미스 -> MongoDB 집계 -> Redis 저장 (TTL 60s) -> 반환
+ """
+ # Redis 캐시 확인
+ try:
+ cached = await self.redis.get(DASHBOARD_CACHE_KEY)
+ if cached:
+ data = json.loads(cached)
+ return DashboardStats(**data)
+ except Exception:
+ pass # Redis 에러 시 DB에서 직접 조회
+
+ # 캐시 미스: MongoDB 집계
+ stats = await self._compute_stats()
+
+ # Redis에 캐싱
+ try:
+ stats_json = stats.model_dump_json()
+ await self.redis.setex(DASHBOARD_CACHE_KEY, DASHBOARD_CACHE_TTL, stats_json)
+ except Exception:
+ pass # Redis 저장 실패는 무시
+
+ return stats
+
+ async def _compute_stats(self) -> DashboardStats:
+ """MongoDB 집계 파이프라인으로 통계 계산"""
+ # 1. Overview (전체/완료/미완료/완료율)
+ total = await self.todos.count_documents({})
+ completed = await self.todos.count_documents({"completed": True})
+ incomplete = total - completed
+ completion_rate = round((completed / total * 100), 1) if total > 0 else 0.0
+
+ overview = OverviewStats(
+ total=total,
+ completed=completed,
+ incomplete=incomplete,
+ completion_rate=completion_rate,
+ )
+
+ # 2. By Category (카테고리별 할일 분포)
+ by_category = await self._compute_by_category()
+
+ # 3. By Priority (우선순위별 현황)
+ by_priority = await self._compute_by_priority()
+
+ # 4. Upcoming Deadlines (마감 임박 할일 상위 5개)
+ upcoming_deadlines = await self._compute_upcoming_deadlines()
+
+ return DashboardStats(
+ overview=overview,
+ by_category=by_category,
+ by_priority=by_priority,
+ upcoming_deadlines=upcoming_deadlines,
+ )
+
+ async def _compute_by_category(self) -> list[CategoryStats]:
+ """카테고리별 할일 분포 집계"""
+ pipeline = [
+ {"$group": {
+ "_id": "$category_id",
+ "count": {"$sum": 1},
+ }},
+ {"$sort": {"count": -1}},
+ ]
+
+ results = await self.todos.aggregate(pipeline).to_list(None)
+
+ # 카테고리 정보 조회
+ cat_ids = set()
+ for r in results:
+ if r["_id"]:
+ try:
+ cat_ids.add(ObjectId(r["_id"]))
+ except Exception:
+ pass
+
+ cat_map: dict[str, dict] = {}
+ if cat_ids:
+ async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}):
+ cat_map[str(cat["_id"])] = cat
+
+ category_stats = []
+ for r in results:
+ cat_id = r["_id"]
+ if cat_id and cat_id in cat_map:
+ cat = cat_map[cat_id]
+ category_stats.append(CategoryStats(
+ category_id=cat_id,
+ name=cat["name"],
+ color=cat.get("color", "#6B7280"),
+ count=r["count"],
+ ))
+ else:
+ category_stats.append(CategoryStats(
+ category_id=None,
+ name="미분류",
+ color="#9CA3AF",
+ count=r["count"],
+ ))
+
+ return category_stats
+
+ async def _compute_by_priority(self) -> PriorityStats:
+ """우선순위별 현황 집계"""
+ pipeline = [
+ {"$group": {
+ "_id": "$priority",
+ "count": {"$sum": 1},
+ }},
+ ]
+
+ results = await self.todos.aggregate(pipeline).to_list(None)
+
+ priority_map = {"high": 0, "medium": 0, "low": 0}
+ for r in results:
+ if r["_id"] in priority_map:
+ priority_map[r["_id"]] = r["count"]
+
+ return PriorityStats(
+ high=priority_map["high"],
+ medium=priority_map["medium"],
+ low=priority_map["low"],
+ )
+
+ async def _compute_upcoming_deadlines(self) -> list[UpcomingDeadline]:
+ """마감 임박 할일 상위 5개 (미완료, 마감일 있는 것, 마감일 오름차순)"""
+ now = datetime.now(timezone.utc)
+
+ cursor = self.todos.find({
+ "completed": False,
+ "due_date": {"$ne": None, "$gte": now},
+ }).sort("due_date", 1).limit(5)
+
+ docs = await cursor.to_list(5)
+
+ deadlines = []
+ for doc in docs:
+ deadlines.append(UpcomingDeadline(
+ id=str(doc["_id"]),
+ title=doc["title"],
+ due_date=doc["due_date"],
+ priority=doc["priority"],
+ ))
+
+ return deadlines
+
+ async def invalidate_cache(self) -> None:
+ """캐시 무효화: 할일 CUD 작업 후 호출"""
+ try:
+ await self.redis.delete(DASHBOARD_CACHE_KEY)
+ except Exception:
+ pass
diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py
new file mode 100644
index 0000000..f5bdebb
--- /dev/null
+++ b/backend/app/services/file_service.py
@@ -0,0 +1,154 @@
+import shutil
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+from bson import ObjectId
+from fastapi import HTTPException, UploadFile
+from motor.motor_asyncio import AsyncIOMotorDatabase
+
+from app.config import get_settings
+from app.models.todo import Attachment
+
+
+class FileService:
+ """파일 업로드/다운로드/삭제 서비스"""
+
+ def __init__(self, db: AsyncIOMotorDatabase):
+ self.collection = db["todos"]
+ self.settings = get_settings()
+ self.upload_dir = Path(self.settings.upload_dir)
+
+ def _ensure_todo_dir(self, todo_id: str) -> Path:
+ todo_dir = self.upload_dir / todo_id
+ todo_dir.mkdir(parents=True, exist_ok=True)
+ return todo_dir
+
+ async def upload_files(
+ self, todo_id: str, files: list[UploadFile]
+ ) -> list[Attachment]:
+ if not ObjectId.is_valid(todo_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
+ if not todo:
+ raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
+
+ existing_count = len(todo.get("attachments", []))
+ if existing_count + len(files) > self.settings.max_files_per_todo:
+ raise HTTPException(
+ status_code=400,
+ detail=f"최대 {self.settings.max_files_per_todo}개의 파일만 첨부할 수 있습니다",
+ )
+
+ todo_dir = self._ensure_todo_dir(todo_id)
+ attachments: list[Attachment] = []
+ saved_paths: list[Path] = []
+
+ try:
+ for file in files:
+ content = await file.read()
+ if len(content) > self.settings.max_file_size:
+ raise HTTPException(
+ status_code=400,
+ detail=f"파일 '{file.filename}'이 최대 크기(10MB)를 초과합니다",
+ )
+
+ file_id = str(uuid.uuid4())
+ ext = Path(file.filename or "").suffix
+ stored_filename = f"{file_id}{ext}"
+ file_path = todo_dir / stored_filename
+
+ with open(file_path, "wb") as f:
+ f.write(content)
+ saved_paths.append(file_path)
+
+ attachments.append(
+ Attachment(
+ id=file_id,
+ filename=file.filename or "unknown",
+ stored_filename=stored_filename,
+ content_type=file.content_type or "application/octet-stream",
+ size=len(content),
+ uploaded_at=datetime.now(timezone.utc),
+ )
+ )
+
+ await self.collection.update_one(
+ {"_id": ObjectId(todo_id)},
+ {
+ "$push": {
+ "attachments": {
+ "$each": [a.model_dump() for a in attachments]
+ }
+ },
+ "$set": {"updated_at": datetime.now(timezone.utc)},
+ },
+ )
+ except HTTPException:
+ for p in saved_paths:
+ p.unlink(missing_ok=True)
+ raise
+ except Exception:
+ for p in saved_paths:
+ p.unlink(missing_ok=True)
+ raise HTTPException(status_code=500, detail="파일 업로드에 실패했습니다")
+
+ return attachments
+
+ async def delete_attachment(self, todo_id: str, attachment_id: str) -> None:
+ if not ObjectId.is_valid(todo_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
+ if not todo:
+ raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
+
+ attachment = None
+ for att in todo.get("attachments", []):
+ if att["id"] == attachment_id:
+ attachment = att
+ break
+
+ if not attachment:
+ raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
+
+ file_path = self.upload_dir / todo_id / attachment["stored_filename"]
+ if file_path.exists():
+ file_path.unlink()
+
+ await self.collection.update_one(
+ {"_id": ObjectId(todo_id)},
+ {
+ "$pull": {"attachments": {"id": attachment_id}},
+ "$set": {"updated_at": datetime.now(timezone.utc)},
+ },
+ )
+
+ async def get_file_info(
+ self, todo_id: str, attachment_id: str
+ ) -> tuple[Path, str]:
+ """파일 경로와 원본 파일명을 반환"""
+ if not ObjectId.is_valid(todo_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
+ if not todo:
+ raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
+
+ for att in todo.get("attachments", []):
+ if att["id"] == attachment_id:
+ file_path = self.upload_dir / todo_id / att["stored_filename"]
+ if not file_path.exists():
+ raise HTTPException(
+ status_code=404, detail="파일이 디스크에서 찾을 수 없습니다"
+ )
+ return file_path, att["filename"]
+
+ raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
+
+ async def delete_all_todo_files(self, todo_id: str) -> None:
+ """Todo 삭제 시 해당 Todo의 모든 파일 삭제"""
+ todo_dir = self.upload_dir / todo_id
+ if todo_dir.exists():
+ shutil.rmtree(todo_dir)
diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py
new file mode 100644
index 0000000..0bc590a
--- /dev/null
+++ b/backend/app/services/search_service.py
@@ -0,0 +1,92 @@
+import math
+
+from bson import ObjectId
+from fastapi import HTTPException
+from motor.motor_asyncio import AsyncIOMotorDatabase
+
+from app.models.todo import TodoResponse, SearchResponse
+
+
+class SearchService:
+ """MongoDB text index 기반 검색 로직"""
+
+ def __init__(self, db: AsyncIOMotorDatabase):
+ self.collection = db["todos"]
+ self.categories = db["categories"]
+
+ async def _populate_categories_bulk(self, todo_docs: list[dict]) -> list[TodoResponse]:
+ """여러 Todo 문서의 카테고리 정보를 일괄 조회하여 TodoResponse 리스트를 반환한다."""
+ # 카테고리 ID 수집
+ cat_ids = set()
+ for doc in todo_docs:
+ cat_id = doc.get("category_id")
+ if cat_id:
+ try:
+ cat_ids.add(ObjectId(cat_id))
+ except Exception:
+ pass
+
+ # 카테고리 일괄 조회
+ cat_map: dict[str, dict] = {}
+ if cat_ids:
+ async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}):
+ cat_map[str(cat["_id"])] = cat
+
+ # TodoResponse 변환
+ result = []
+ for doc in todo_docs:
+ cat_id = str(doc["category_id"]) if doc.get("category_id") else None
+ cat = cat_map.get(cat_id) if cat_id else None
+
+ result.append(TodoResponse(
+ id=str(doc["_id"]),
+ title=doc["title"],
+ content=doc.get("content"),
+ completed=doc["completed"],
+ priority=doc["priority"],
+ category_id=cat_id,
+ category_name=cat["name"] if cat else None,
+ category_color=cat["color"] if cat else None,
+ tags=doc.get("tags", []),
+ due_date=doc.get("due_date"),
+ created_at=doc["created_at"],
+ updated_at=doc["updated_at"],
+ ))
+
+ return result
+
+ async def search(self, query: str, page: int = 1, limit: int = 20) -> SearchResponse:
+ """F-017: 전문 검색
+ MongoDB text index를 활용한 전문 검색, text score 기준 정렬, 페이지네이션
+ """
+ if not query or not query.strip():
+ raise HTTPException(status_code=422, detail="검색어를 입력해주세요")
+
+ query = query.strip()
+
+ # MongoDB text search
+ search_filter = {"$text": {"$search": query}}
+
+ # 총 개수 조회
+ total = await self.collection.count_documents(search_filter)
+
+ # text score 기준 정렬 + 페이지네이션
+ skip = (page - 1) * limit
+ cursor = self.collection.find(
+ search_filter,
+ {"score": {"$meta": "textScore"}},
+ ).sort(
+ [("score", {"$meta": "textScore"})]
+ ).skip(skip).limit(limit)
+
+ docs = await cursor.to_list(limit)
+
+ items = await self._populate_categories_bulk(docs)
+
+ return SearchResponse(
+ items=items,
+ total=total,
+ query=query,
+ page=page,
+ limit=limit,
+ )
diff --git a/backend/app/services/todo_service.py b/backend/app/services/todo_service.py
new file mode 100644
index 0000000..545ceda
--- /dev/null
+++ b/backend/app/services/todo_service.py
@@ -0,0 +1,396 @@
+import math
+from datetime import datetime, timezone
+from typing import Optional
+
+from bson import ObjectId
+from fastapi import HTTPException
+from motor.motor_asyncio import AsyncIOMotorDatabase
+import redis.asyncio as aioredis
+
+from app.models.todo import (
+ Priority,
+ TodoCreate,
+ TodoUpdate,
+ TodoResponse,
+ TodoListResponse,
+ BatchRequest,
+ BatchResponse,
+ ToggleResponse,
+ TagInfo,
+)
+
+# 우선순위 정렬용 매핑 (높음이 먼저 오도록)
+PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
+
+DASHBOARD_CACHE_KEY = "dashboard:stats"
+
+
+class TodoService:
+ """할일 CRUD + 일괄 작업 + 태그 집계 비즈니스 로직"""
+
+ def __init__(self, db: AsyncIOMotorDatabase, redis_client: Optional[aioredis.Redis] = None):
+ self.collection = db["todos"]
+ self.categories = db["categories"]
+ self.redis = redis_client
+
+ async def _invalidate_cache(self):
+ """대시보드 캐시 무효화"""
+ if self.redis is not None:
+ try:
+ await self.redis.delete(DASHBOARD_CACHE_KEY)
+ except Exception:
+ pass # Redis 오류 시 무시 (캐시 무효화 실패는 치명적이지 않음)
+
+ async def _populate_category(self, todo_doc: dict) -> TodoResponse:
+ """Todo 문서에 카테고리 정보를 추가하여 TodoResponse를 반환한다."""
+ category_name = None
+ category_color = None
+
+ cat_id = todo_doc.get("category_id")
+ if cat_id:
+ category = await self.categories.find_one({"_id": ObjectId(cat_id)})
+ if category:
+ category_name = category["name"]
+ category_color = category["color"]
+
+ return TodoResponse(
+ id=str(todo_doc["_id"]),
+ title=todo_doc["title"],
+ content=todo_doc.get("content"),
+ completed=todo_doc["completed"],
+ priority=todo_doc["priority"],
+ category_id=str(todo_doc["category_id"]) if todo_doc.get("category_id") else None,
+ category_name=category_name,
+ category_color=category_color,
+ tags=todo_doc.get("tags", []),
+ start_date=todo_doc.get("start_date"),
+ due_date=todo_doc.get("due_date"),
+ attachments=todo_doc.get("attachments", []),
+ created_at=todo_doc["created_at"],
+ updated_at=todo_doc["updated_at"],
+ )
+
+ async def _populate_categories_bulk(self, todo_docs: list[dict]) -> list[TodoResponse]:
+ """여러 Todo 문서의 카테고리 정보를 일괄 조회하여 TodoResponse 리스트를 반환한다."""
+ # 카테고리 ID 수집
+ cat_ids = set()
+ for doc in todo_docs:
+ cat_id = doc.get("category_id")
+ if cat_id:
+ try:
+ cat_ids.add(ObjectId(cat_id))
+ except Exception:
+ pass
+
+ # 카테고리 일괄 조회
+ cat_map: dict[str, dict] = {}
+ if cat_ids:
+ async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}):
+ cat_map[str(cat["_id"])] = cat
+
+ # TodoResponse 변환
+ result = []
+ for doc in todo_docs:
+ cat_id = str(doc["category_id"]) if doc.get("category_id") else None
+ cat = cat_map.get(cat_id) if cat_id else None
+
+ result.append(TodoResponse(
+ id=str(doc["_id"]),
+ title=doc["title"],
+ content=doc.get("content"),
+ completed=doc["completed"],
+ priority=doc["priority"],
+ category_id=cat_id,
+ category_name=cat["name"] if cat else None,
+ category_color=cat["color"] if cat else None,
+ tags=doc.get("tags", []),
+ start_date=doc.get("start_date"),
+ due_date=doc.get("due_date"),
+ attachments=doc.get("attachments", []),
+ created_at=doc["created_at"],
+ updated_at=doc["updated_at"],
+ ))
+
+ return result
+
+ # --- CRUD ---
+
+ async def create_todo(self, data: TodoCreate) -> TodoResponse:
+ """F-001: 할일 생성"""
+ # category_id 존재 검증
+ if data.category_id:
+ cat = await self.categories.find_one({"_id": ObjectId(data.category_id)})
+ if not cat:
+ raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
+
+ now = datetime.now(timezone.utc)
+ doc = {
+ "title": data.title,
+ "content": data.content,
+ "completed": False,
+ "priority": data.priority.value,
+ "category_id": data.category_id,
+ "tags": data.tags,
+ "start_date": data.start_date,
+ "due_date": data.due_date,
+ "created_at": now,
+ "updated_at": now,
+ }
+
+ result = await self.collection.insert_one(doc)
+ doc["_id"] = result.inserted_id
+
+ await self._invalidate_cache()
+
+ return await self._populate_category(doc)
+
+ async def list_todos(
+ self,
+ page: int = 1,
+ limit: int = 20,
+ completed: Optional[bool] = None,
+ category_id: Optional[str] = None,
+ priority: Optional[str] = None,
+ tag: Optional[str] = None,
+ sort: str = "created_at",
+ order: str = "desc",
+ ) -> TodoListResponse:
+ """F-002: 할일 목록 조회 (필터, 정렬, 페이지네이션)"""
+ # 필터 조건 구성 (AND)
+ query: dict = {}
+ if completed is not None:
+ query["completed"] = completed
+ if category_id:
+ query["category_id"] = category_id
+ if priority:
+ query["priority"] = priority
+ if tag:
+ query["tags"] = tag
+
+ # 정렬 조건 구성
+ sort_direction = 1 if order == "asc" else -1
+ if sort == "priority":
+ # priority 정렬 시 매핑 사용 (MongoDB에선 문자열 정렬이므로 보조 필드 필요)
+ # 대안: aggregation pipeline 사용
+ pipeline = []
+ if query:
+ pipeline.append({"$match": query})
+ pipeline.append({
+ "$addFields": {
+ "priority_order": {
+ "$switch": {
+ "branches": [
+ {"case": {"$eq": ["$priority", "high"]}, "then": 0},
+ {"case": {"$eq": ["$priority", "medium"]}, "then": 1},
+ {"case": {"$eq": ["$priority", "low"]}, "then": 2},
+ ],
+ "default": 1,
+ }
+ }
+ }
+ })
+ pipeline.append({"$sort": {"priority_order": sort_direction, "created_at": -1}})
+
+ # 총 개수 조회
+ count_pipeline = list(pipeline) + [{"$count": "total"}]
+ count_result = await self.collection.aggregate(count_pipeline).to_list(1)
+ total = count_result[0]["total"] if count_result else 0
+
+ # 페이지네이션
+ skip = (page - 1) * limit
+ pipeline.append({"$skip": skip})
+ pipeline.append({"$limit": limit})
+ pipeline.append({"$project": {"priority_order": 0}})
+
+ docs = await self.collection.aggregate(pipeline).to_list(limit)
+ else:
+ # 일반 정렬
+ sort_field = sort if sort in ("created_at", "due_date") else "created_at"
+ sort_spec = [(sort_field, sort_direction)]
+
+ total = await self.collection.count_documents(query)
+ skip = (page - 1) * limit
+ cursor = self.collection.find(query).sort(sort_spec).skip(skip).limit(limit)
+ docs = await cursor.to_list(limit)
+
+ total_pages = math.ceil(total / limit) if limit > 0 else 0
+ items = await self._populate_categories_bulk(docs)
+
+ return TodoListResponse(
+ items=items,
+ total=total,
+ page=page,
+ limit=limit,
+ total_pages=total_pages,
+ )
+
+ async def get_todo(self, todo_id: str) -> TodoResponse:
+ """F-003: 할일 상세 조회"""
+ if not ObjectId.is_valid(todo_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ doc = await self.collection.find_one({"_id": ObjectId(todo_id)})
+ if not doc:
+ raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
+
+ return await self._populate_category(doc)
+
+ async def update_todo(self, todo_id: str, data: TodoUpdate) -> TodoResponse:
+ """F-004: 할일 수정 (Partial Update)"""
+ if not ObjectId.is_valid(todo_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ # 변경 필드만 추출
+ update_data = data.model_dump(exclude_unset=True)
+
+ if not update_data:
+ raise HTTPException(status_code=400, detail="수정할 필드가 없습니다")
+
+ # category_id 존재 검증
+ if "category_id" in update_data and update_data["category_id"] is not None:
+ cat = await self.categories.find_one({"_id": ObjectId(update_data["category_id"])})
+ if not cat:
+ raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
+
+ # priority enum -> string 변환
+ if "priority" in update_data and update_data["priority"] is not None:
+ update_data["priority"] = update_data["priority"].value if hasattr(update_data["priority"], "value") else update_data["priority"]
+
+ update_data["updated_at"] = datetime.now(timezone.utc)
+
+ result = await self.collection.find_one_and_update(
+ {"_id": ObjectId(todo_id)},
+ {"$set": update_data},
+ return_document=True,
+ )
+
+ if not result:
+ raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
+
+ await self._invalidate_cache()
+
+ return await self._populate_category(result)
+
+ async def delete_todo(self, todo_id: str) -> None:
+ """F-005: 할일 삭제"""
+ if not ObjectId.is_valid(todo_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ result = await self.collection.delete_one({"_id": ObjectId(todo_id)})
+ if result.deleted_count == 0:
+ raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
+
+ # 첨부파일 삭제
+ from app.services.file_service import FileService
+ file_service = FileService(self.collection.database)
+ await file_service.delete_all_todo_files(todo_id)
+
+ await self._invalidate_cache()
+
+ async def toggle_todo(self, todo_id: str) -> ToggleResponse:
+ """F-006: 완료 토글"""
+ if not ObjectId.is_valid(todo_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ doc = await self.collection.find_one({"_id": ObjectId(todo_id)})
+ if not doc:
+ raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
+
+ new_completed = not doc["completed"]
+ await self.collection.update_one(
+ {"_id": ObjectId(todo_id)},
+ {"$set": {
+ "completed": new_completed,
+ "updated_at": datetime.now(timezone.utc),
+ }},
+ )
+
+ await self._invalidate_cache()
+
+ return ToggleResponse(id=todo_id, completed=new_completed)
+
+ # --- 일괄 작업 ---
+
+ async def batch_action(self, data: BatchRequest) -> BatchResponse:
+ """F-019~F-021: 일괄 작업 분기"""
+ if data.action == "complete":
+ return await self._batch_complete(data.ids)
+ elif data.action == "delete":
+ return await self._batch_delete(data.ids)
+ elif data.action == "move_category":
+ return await self._batch_move_category(data.ids, data.category_id)
+ else:
+ raise HTTPException(status_code=400, detail="지원하지 않는 작업입니다")
+
+ async def _batch_complete(self, ids: list[str]) -> BatchResponse:
+ """F-019: 일괄 완료"""
+ object_ids = [ObjectId(id_str) for id_str in ids]
+ result = await self.collection.update_many(
+ {"_id": {"$in": object_ids}},
+ {"$set": {
+ "completed": True,
+ "updated_at": datetime.now(timezone.utc),
+ }},
+ )
+
+ await self._invalidate_cache()
+
+ return BatchResponse(
+ action="complete",
+ processed=result.modified_count,
+ failed=len(ids) - result.modified_count,
+ )
+
+ async def _batch_delete(self, ids: list[str]) -> BatchResponse:
+ """F-020: 일괄 삭제"""
+ object_ids = [ObjectId(id_str) for id_str in ids]
+ result = await self.collection.delete_many({"_id": {"$in": object_ids}})
+
+ await self._invalidate_cache()
+
+ return BatchResponse(
+ action="delete",
+ processed=result.deleted_count,
+ failed=len(ids) - result.deleted_count,
+ )
+
+ async def _batch_move_category(self, ids: list[str], category_id: Optional[str]) -> BatchResponse:
+ """F-021: 일괄 카테고리 변경"""
+ # category_id 검증 (null이 아닌 경우)
+ if category_id is not None:
+ if not ObjectId.is_valid(category_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 카테고리 ID 형식입니다")
+ cat = await self.categories.find_one({"_id": ObjectId(category_id)})
+ if not cat:
+ raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다")
+
+ object_ids = [ObjectId(id_str) for id_str in ids]
+ result = await self.collection.update_many(
+ {"_id": {"$in": object_ids}},
+ {"$set": {
+ "category_id": category_id,
+ "updated_at": datetime.now(timezone.utc),
+ }},
+ )
+
+ await self._invalidate_cache()
+
+ return BatchResponse(
+ action="move_category",
+ processed=result.modified_count,
+ failed=len(ids) - result.modified_count,
+ )
+
+ # --- 태그 ---
+
+ async def get_tags(self) -> list[TagInfo]:
+ """F-013: 태그 목록 조회 (사용 횟수 포함, 사용 횟수 내림차순)"""
+ pipeline = [
+ {"$unwind": "$tags"},
+ {"$group": {"_id": "$tags", "count": {"$sum": 1}}},
+ {"$sort": {"count": -1}},
+ {"$project": {"name": "$_id", "count": 1, "_id": 0}},
+ ]
+
+ results = await self.collection.aggregate(pipeline).to_list(None)
+ return [TagInfo(name=r["name"], count=r["count"]) for r in results]
diff --git a/backend/requirements.txt b/backend/requirements.txt
index cfc3b01..2551eee 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,6 +1,9 @@
-fastapi>=0.104.0
-uvicorn[standard]>=0.24.0
-motor>=3.3.0
-pydantic>=2.5.0
-aioredis>=2.0.0
-python-dotenv>=1.0.0
+fastapi>=0.104
+uvicorn[standard]>=0.24
+motor>=3.3
+pymongo>=4.9,<4.10
+pydantic>=2.5
+pydantic-settings>=2.1
+redis>=5.0
+python-dotenv>=1.0
+python-multipart>=0.0.6
diff --git a/docker-compose.yml b/docker-compose.yml
index 5112ee8..ba12b0a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -52,6 +52,8 @@ services:
- MONGODB_URL=mongodb://${MONGO_USER:-admin}:${MONGO_PASSWORD:-password123}@mongodb:27017/
- DB_NAME=${DB_NAME:-app_db}
- REDIS_URL=redis://redis:6379
+ volumes:
+ - upload_data:/app/uploads
depends_on:
mongodb:
condition: service_healthy
@@ -77,6 +79,7 @@ services:
volumes:
mongodb_data:
redis_data:
+ upload_data:
networks:
app-network:
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..5cb0c0e
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,2648 @@
+# todos2 -- 시스템 아키텍처
+
+> 버전: 1.0.0 | 작성일: 2026-02-10 | 상태: 설계 완료
+> 기반 문서: PLAN.md v1.0.0, FEATURE_SPEC.md v1.0.0, SCREEN_DESIGN.md v1.0.0
+
+---
+
+## 1. 시스템 개요
+
+### 1.1 아키텍처 다이어그램
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ Docker Compose │
+│ │
+│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
+│ │ Frontend (Next.js) │ │ Backend (FastAPI) │ │
+│ │ :3000 │──────>│ :8000 │ │
+│ │ │ REST │ │ │
+│ │ ┌────────────────┐ │ API │ ┌────────┐ ┌───────────┐ │ │
+│ │ │ App Router │ │ │ │Routers │─>│ Services │ │ │
+│ │ │ (pages) │ │ │ └────────┘ └─────┬─────┘ │ │
+│ │ ├────────────────┤ │ │ │ │ │
+│ │ │ Components │ │ │ ┌─────▼─────┐ │ │
+│ │ ├────────────────┤ │ │ │ Database │ │ │
+│ │ │ Tanstack Query │ │ │ │ Layer │ │ │
+│ │ │ + Zustand │ │ │ └─────┬─────┘ │ │
+│ │ └────────────────┘ │ │ │ │ │
+│ └──────────────────────┘ └────────────────────┼────────┘ │
+│ │ │
+│ ┌──────────────────────┐ ┌────────────────────▼────────┐ │
+│ │ Redis 7 │<──────│ MongoDB 7 │ │
+│ │ :6379 │ cache │ :27017 │ │
+│ │ │ aside │ │ │
+│ │ - 대시보드 통계 캐시 │ │ - todos 컬렉션 │ │
+│ │ - TTL 60s │ │ - categories 컬렉션 │ │
+│ └──────────────────────┘ └─────────────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+### 1.2 기술 스택 요약
+
+| 레이어 | 기술 | 버전 | 역할 |
+|--------|------|------|------|
+| **프론트엔드** | Next.js (App Router) | 15 | SSR/CSR 하이브리드 렌더링 |
+| | TypeScript | 5.x | 타입 안정성 |
+| | Tailwind CSS | 4.x | 유틸리티 기반 스타일링 |
+| | shadcn/ui | latest | UI 컴포넌트 라이브러리 |
+| | Recharts | 2.x | 대시보드 차트 |
+| | Tanstack Query | 5.x | 서버 상태 관리 (API 캐싱) |
+| | Zustand | 5.x | 클라이언트 UI 상태 관리 |
+| **백엔드** | Python | 3.11 | 런타임 |
+| | FastAPI | >= 0.104 | REST API 프레임워크 |
+| | Motor | >= 3.6 | MongoDB 비동기 드라이버 |
+| | Pydantic v2 | >= 2.5 | 데이터 검증/직렬화 |
+| | Uvicorn | >= 0.24 | ASGI 서버 |
+| | aioredis | >= 2.0 | Redis 비동기 클라이언트 |
+| **데이터베이스** | MongoDB | 7.0 | 메인 데이터 저장소 |
+| | Redis | 7.0 | 캐싱 레이어 |
+| **인프라** | Docker Compose | 3.8 | 로컬 개발 환경 |
+
+### 1.3 데이터 흐름
+
+```
+[사용자] → [브라우저]
+ │
+ ▼
+[Next.js Frontend :3000]
+ │ fetch (REST API)
+ ▼
+[FastAPI Backend :8000]
+ │
+ ├──▶ [Router] → 요청 검증, 라우팅
+ │ │
+ │ ▼
+ ├──▶ [Service] → 비즈니스 로직
+ │ │
+ │ ├──▶ [MongoDB :27017] → 데이터 읽기/쓰기
+ │ │
+ │ └──▶ [Redis :6379] → 캐시 읽기/쓰기 (대시보드 통계)
+ │
+ └──▶ [Response] → JSON 직렬화 → 클라이언트 반환
+```
+
+---
+
+## 2. 백엔드 아키텍처
+
+### 2.1 디렉토리 구조
+
+```
+backend/
+├── Dockerfile
+├── requirements.txt
+└── app/
+ ├── __init__.py
+ ├── main.py # FastAPI 앱 팩토리, 미들웨어, 이벤트 훅
+ ├── config.py # Settings (pydantic-settings)
+ ├── database.py # MongoDB(Motor) + Redis(aioredis) 연결
+ ├── models/
+ │ ├── __init__.py
+ │ ├── todo.py # Todo 도메인 모델 + Request/Response 스키마
+ │ ├── category.py # Category 도메인 모델 + Request/Response 스키마
+ │ └── common.py # 공통 모델 (PyObjectId, 페이지네이션, 에러 응답)
+ ├── routers/
+ │ ├── __init__.py
+ │ ├── todos.py # /api/todos CRUD + toggle + batch
+ │ ├── categories.py # /api/categories CRUD
+ │ ├── tags.py # /api/tags 목록 조회
+ │ ├── search.py # /api/search 전문 검색
+ │ └── dashboard.py # /api/dashboard/stats 통계
+ └── services/
+ ├── __init__.py
+ ├── todo_service.py # 할일 비즈니스 로직
+ ├── category_service.py # 카테고리 비즈니스 로직
+ ├── search_service.py # 검색 비즈니스 로직
+ └── dashboard_service.py# 대시보드 통계 + Redis 캐싱
+```
+
+> **참고**: PLAN.md에서 `models/tag.py`, `routers/batch.py`, `services/tag_service.py`가 포함되어 있으나, 설계 원칙 7에 따라 태그는 별도 컬렉션 없이 Todo 문서 내 배열로 관리하므로 다음과 같이 조정한다:
+> - `models/tag.py` -> 삭제. 태그 관련 타입은 `models/todo.py` 내에 정의
+> - `routers/batch.py` -> 삭제. 일괄 작업은 `routers/todos.py`의 `/api/todos/batch` 엔드포인트로 통합
+> - `services/tag_service.py` -> 삭제. 태그 집계 로직은 `services/todo_service.py`에 포함
+
+### 2.2 레이어 구조 (Router -> Service -> Database)
+
+```
+┌─────────────────────────────────────────────────┐
+│ Router Layer │
+│ - HTTP 요청/응답 처리 │
+│ - 경로 파라미터/쿼리 파라미터 추출 │
+│ - Request Body -> Pydantic 모델 검증 │
+│ - Service 호출 후 Response 모델로 직렬화 │
+│ - HTTP 상태 코드 결정 │
+└───────────────────────┬─────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────┐
+│ Service Layer │
+│ - 비즈니스 로직 집중 │
+│ - 데이터 검증 (카테고리 존재 여부 등) │
+│ - 태그 정규화 (소문자 변환, 중복 제거) │
+│ - 캐시 무효화 (Redis) │
+│ - 트랜잭션 처리 (카테고리 삭제 시 todo 일괄 갱신) │
+└───────────────────────┬─────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────┐
+│ Database Layer │
+│ - Motor (async) 기반 MongoDB 접근 │
+│ - aioredis 기반 Redis 접근 │
+│ - 컬렉션 참조, 인덱스 생성 │
+│ - 연결 풀 관리 (lifespan event) │
+└─────────────────────────────────────────────────┘
+```
+
+**의존성 주입 패턴**:
+
+```python
+# app/database.py
+from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
+import redis.asyncio as aioredis
+
+class Database:
+ client: AsyncIOMotorClient | None = None
+ db: AsyncIOMotorDatabase | None = None
+ redis: aioredis.Redis | None = None
+
+db = Database()
+
+def get_database() -> AsyncIOMotorDatabase:
+ if db.db is None: # 주의: `if not db.db:` 사용 금지 (pymongo 4.9.x NotImplementedError)
+ raise RuntimeError("Database not initialized")
+ return db.db
+
+def get_redis() -> aioredis.Redis:
+ if db.redis is None:
+ raise RuntimeError("Redis not initialized")
+ return db.redis
+```
+
+```python
+# Router에서 Service 사용 예시
+from fastapi import APIRouter, Depends
+from app.database import get_database, get_redis
+from app.services.todo_service import TodoService
+
+router = APIRouter(prefix="/api/todos", tags=["todos"])
+
+@router.get("")
+async def list_todos(
+ page: int = 1,
+ limit: int = 20,
+ database=Depends(get_database),
+):
+ service = TodoService(database)
+ return await service.list_todos(page=page, limit=limit)
+```
+
+### 2.3 모델 정의 (Pydantic v2)
+
+#### 2.3.1 공통 모델 (`app/models/common.py`)
+
+```python
+from datetime import datetime
+from typing import Annotated, Any
+from bson import ObjectId
+from pydantic import BaseModel, Field, BeforeValidator
+
+# ObjectId <-> str 변환을 위한 커스텀 타입
+def validate_object_id(v: Any) -> str:
+ if isinstance(v, ObjectId):
+ return str(v)
+ if isinstance(v, str) and ObjectId.is_valid(v):
+ return v
+ raise ValueError(f"Invalid ObjectId: {v}")
+
+PyObjectId = Annotated[str, BeforeValidator(validate_object_id)]
+
+
+class ErrorResponse(BaseModel):
+ """표준 에러 응답"""
+ detail: str
+
+
+class PaginatedResponse(BaseModel):
+ """페이지네이션 응답 래퍼"""
+ items: list[Any]
+ total: int
+ page: int
+ limit: int
+ total_pages: int
+```
+
+#### 2.3.2 Todo 모델 (`app/models/todo.py`)
+
+```python
+from datetime import datetime
+from enum import Enum
+from typing import Optional
+from pydantic import BaseModel, Field, field_validator
+from app.models.common import PyObjectId
+
+
+class Priority(str, Enum):
+ HIGH = "high"
+ MEDIUM = "medium"
+ LOW = "low"
+
+
+# === 도메인 모델 (MongoDB 문서 대응) ===
+
+class TodoInDB(BaseModel):
+ """MongoDB에 저장되는 Todo 문서 스키마"""
+ id: PyObjectId = Field(alias="_id")
+ title: str = Field(..., min_length=1, max_length=200)
+ content: Optional[str] = Field(None, max_length=2000)
+ completed: bool = False
+ priority: Priority = Priority.MEDIUM
+ category_id: Optional[PyObjectId] = None
+ tags: list[str] = Field(default_factory=list)
+ due_date: Optional[datetime] = None
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = {
+ "populate_by_name": True, # alias와 필드명 모두 허용
+ "json_encoders": {
+ datetime: lambda v: v.isoformat(),
+ },
+ }
+
+
+# === Request 스키마 ===
+
+class TodoCreate(BaseModel):
+ """할일 생성 요청 (F-001)"""
+ title: str = Field(..., min_length=1, max_length=200)
+ content: Optional[str] = Field(None, max_length=2000)
+ category_id: Optional[str] = None
+ tags: list[str] = Field(default_factory=list, max_length=10)
+ priority: Priority = Priority.MEDIUM
+ due_date: Optional[datetime] = None
+
+ @field_validator("title")
+ @classmethod
+ def title_not_blank(cls, v: str) -> str:
+ v = v.strip()
+ if not v:
+ raise ValueError("제목은 공백만으로 구성할 수 없습니다")
+ return v
+
+ @field_validator("tags")
+ @classmethod
+ def normalize_tags(cls, v: list[str]) -> list[str]:
+ # 소문자 정규화, 중복 제거, 공백 trim
+ seen = set()
+ result = []
+ for tag in v:
+ tag = tag.strip().lower()
+ if tag and len(tag) <= 30 and tag not in seen:
+ seen.add(tag)
+ result.append(tag)
+ return result
+
+ @field_validator("category_id")
+ @classmethod
+ def validate_category_id(cls, v: Optional[str]) -> Optional[str]:
+ from bson import ObjectId
+ if v is not None and not ObjectId.is_valid(v):
+ raise ValueError("유효하지 않은 카테고리 ID 형식입니다")
+ return v
+
+
+class TodoUpdate(BaseModel):
+ """할일 수정 요청 (F-004) - Partial Update"""
+ title: Optional[str] = Field(None, min_length=1, max_length=200)
+ content: Optional[str] = Field(None, max_length=2000)
+ category_id: Optional[str] = None # None = 필드 미포함, "null" 문자열 사용 X
+ tags: Optional[list[str]] = None
+ priority: Optional[Priority] = None
+ due_date: Optional[datetime] = None
+
+ @field_validator("title")
+ @classmethod
+ def title_not_blank(cls, v: Optional[str]) -> Optional[str]:
+ if v is not None:
+ v = v.strip()
+ if not v:
+ raise ValueError("제목은 공백만으로 구성할 수 없습니다")
+ return v
+
+ @field_validator("tags")
+ @classmethod
+ def normalize_tags(cls, v: Optional[list[str]]) -> Optional[list[str]]:
+ if v is None:
+ return None
+ seen = set()
+ result = []
+ for tag in v:
+ tag = tag.strip().lower()
+ if tag and len(tag) <= 30 and tag not in seen:
+ seen.add(tag)
+ result.append(tag)
+ return result
+
+
+class BatchRequest(BaseModel):
+ """일괄 작업 요청 (F-019, F-020, F-021)"""
+ action: str = Field(..., pattern="^(complete|delete|move_category)$")
+ ids: list[str] = Field(..., min_length=1)
+ category_id: Optional[str] = None # move_category 시에만 사용
+
+ @field_validator("ids")
+ @classmethod
+ def validate_ids(cls, v: list[str]) -> list[str]:
+ from bson import ObjectId
+ for id_str in v:
+ if not ObjectId.is_valid(id_str):
+ raise ValueError(f"유효하지 않은 ID 형식: {id_str}")
+ return v
+
+
+# === Response 스키마 ===
+
+class TodoResponse(BaseModel):
+ """할일 응답 (카테고리 정보 포함 가능)"""
+ id: str
+ title: str
+ content: Optional[str] = None
+ completed: bool
+ priority: Priority
+ category_id: Optional[str] = None
+ category_name: Optional[str] = None # populate 시
+ category_color: Optional[str] = None # populate 시
+ tags: list[str] = []
+ due_date: Optional[datetime] = None
+ created_at: datetime
+ updated_at: datetime
+
+
+class TodoListResponse(BaseModel):
+ """할일 목록 페이지네이션 응답 (F-002)"""
+ items: list[TodoResponse]
+ total: int
+ page: int
+ limit: int
+ total_pages: int
+
+
+class ToggleResponse(BaseModel):
+ """완료 토글 응답 (F-006)"""
+ id: str
+ completed: bool
+
+
+class BatchResponse(BaseModel):
+ """일괄 작업 응답 (F-019, F-020, F-021)"""
+ action: str
+ processed: int
+ failed: int
+
+
+class TagInfo(BaseModel):
+ """태그 정보 (F-013)"""
+ name: str
+ count: int
+```
+
+#### 2.3.3 Category 모델 (`app/models/category.py`)
+
+```python
+from datetime import datetime
+from typing import Optional
+from pydantic import BaseModel, Field, field_validator
+from app.models.common import PyObjectId
+import re
+
+
+# === 도메인 모델 ===
+
+class CategoryInDB(BaseModel):
+ """MongoDB에 저장되는 Category 문서 스키마"""
+ id: PyObjectId = Field(alias="_id")
+ name: str = Field(..., min_length=1, max_length=50)
+ color: str = "#6B7280"
+ order: int = 0
+ created_at: datetime
+
+ model_config = {
+ "populate_by_name": True,
+ }
+
+
+# === Request 스키마 ===
+
+class CategoryCreate(BaseModel):
+ """카테고리 생성 요청 (F-007)"""
+ name: str = Field(..., min_length=1, max_length=50)
+ color: str = Field("#6B7280", pattern=r"^#[0-9A-Fa-f]{6}$")
+
+ @field_validator("name")
+ @classmethod
+ def name_not_blank(cls, v: str) -> str:
+ v = v.strip()
+ if not v:
+ raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다")
+ return v
+
+
+class CategoryUpdate(BaseModel):
+ """카테고리 수정 요청 (F-009) - Partial Update"""
+ name: Optional[str] = Field(None, min_length=1, max_length=50)
+ color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
+ order: Optional[int] = Field(None, ge=0)
+
+ @field_validator("name")
+ @classmethod
+ def name_not_blank(cls, v: Optional[str]) -> Optional[str]:
+ if v is not None:
+ v = v.strip()
+ if not v:
+ raise ValueError("카테고리 이름은 공백만으로 구성할 수 없습니다")
+ return v
+
+
+# === Response 스키마 ===
+
+class CategoryResponse(BaseModel):
+ """카테고리 응답 (F-008)"""
+ id: str
+ name: str
+ color: str
+ order: int
+ todo_count: int = 0 # 해당 카테고리에 속한 할일 수
+ created_at: datetime
+```
+
+### 2.4 API 엔드포인트 상세
+
+> **중요**: FastAPI 라우트 순서 -- `/batch` 같은 고정 경로를 `/{id}` 패턴보다 위에 배치해야 한다. 그렇지 않으면 "batch" 문자열이 `{id}` 파라미터로 매칭된다.
+
+#### 2.4.1 할일 API (`/api/todos`)
+
+| # | Method | Path | 기능 ID | 설명 | Request Body | Response | 상태 코드 |
+|---|--------|------|---------|------|-------------|----------|-----------|
+| 1 | POST | `/api/todos` | F-001 | 할일 생성 | `TodoCreate` | `TodoResponse` | 201 / 404 / 422 |
+| 2 | GET | `/api/todos` | F-002 | 할일 목록 조회 | Query Params | `TodoListResponse` | 200 / 422 |
+| 3 | POST | `/api/todos/batch` | F-019~21 | 일괄 작업 | `BatchRequest` | `BatchResponse` | 200 / 404 / 422 |
+| 4 | GET | `/api/todos/{id}` | F-003 | 할일 상세 조회 | - | `TodoResponse` | 200 / 404 / 422 |
+| 5 | PUT | `/api/todos/{id}` | F-004 | 할일 수정 | `TodoUpdate` | `TodoResponse` | 200 / 404 / 422 |
+| 6 | DELETE | `/api/todos/{id}` | F-005 | 할일 삭제 | - | - | 204 / 404 |
+| 7 | PATCH | `/api/todos/{id}/toggle` | F-006 | 완료 토글 | - | `ToggleResponse` | 200 / 404 |
+
+> 라우트 등록 순서: `POST /batch` -> `GET /` -> `POST /` -> `GET /{id}` -> `PUT /{id}` -> `DELETE /{id}` -> `PATCH /{id}/toggle`
+
+**할일 목록 쿼리 파라미터 (F-002)**:
+
+| 파라미터 | 타입 | 기본값 | 설명 |
+|---------|------|--------|------|
+| `page` | int | 1 | 페이지 번호 (>= 1) |
+| `limit` | int | 20 | 페이지당 항목 수 (1~100) |
+| `completed` | bool \| None | None | 완료 상태 필터 |
+| `category_id` | str \| None | None | 카테고리 필터 |
+| `priority` | str \| None | None | 우선순위 필터 (high/medium/low) |
+| `tag` | str \| None | None | 태그 필터 |
+| `sort` | str | "created_at" | 정렬 기준 (created_at/due_date/priority) |
+| `order` | str | "desc" | 정렬 방향 (asc/desc) |
+
+#### 2.4.2 카테고리 API (`/api/categories`)
+
+| # | Method | Path | 기능 ID | 설명 | Request Body | Response | 상태 코드 |
+|---|--------|------|---------|------|-------------|----------|-----------|
+| 1 | GET | `/api/categories` | F-008 | 카테고리 목록 | - | `list[CategoryResponse]` | 200 |
+| 2 | POST | `/api/categories` | F-007 | 카테고리 생성 | `CategoryCreate` | `CategoryResponse` | 201 / 409 / 422 |
+| 3 | PUT | `/api/categories/{id}` | F-009 | 카테고리 수정 | `CategoryUpdate` | `CategoryResponse` | 200 / 404 / 409 |
+| 4 | DELETE | `/api/categories/{id}` | F-010 | 카테고리 삭제 | - | - | 204 / 404 |
+
+#### 2.4.3 태그 API (`/api/tags`)
+
+| # | Method | Path | 기능 ID | 설명 | Response | 상태 코드 |
+|---|--------|------|---------|------|----------|-----------|
+| 1 | GET | `/api/tags` | F-013 | 태그 목록 (사용 횟수 포함) | `list[TagInfo]` | 200 |
+
+#### 2.4.4 검색 API (`/api/search`)
+
+| # | Method | Path | 기능 ID | 설명 | Response | 상태 코드 |
+|---|--------|------|---------|------|----------|-----------|
+| 1 | GET | `/api/search` | F-017 | 전문 검색 | `SearchResponse` | 200 / 422 |
+
+**검색 쿼리 파라미터**:
+
+| 파라미터 | 타입 | 기본값 | 설명 |
+|---------|------|--------|------|
+| `q` | str | (필수) | 검색 키워드 (1~200자) |
+| `page` | int | 1 | 페이지 번호 |
+| `limit` | int | 20 | 페이지당 항목 수 |
+
+#### 2.4.5 대시보드 API (`/api/dashboard`)
+
+| # | Method | Path | 기능 ID | 설명 | Response | 상태 코드 |
+|---|--------|------|---------|------|----------|-----------|
+| 1 | GET | `/api/dashboard/stats` | F-018 | 대시보드 통계 | `DashboardStats` | 200 |
+
+### 2.5 서비스 레이어
+
+#### 2.5.1 TodoService (`app/services/todo_service.py`)
+
+```python
+class TodoService:
+ """할일 CRUD + 일괄 작업 + 태그 집계 비즈니스 로직"""
+
+ def __init__(self, db: AsyncIOMotorDatabase):
+ self.collection = db["todos"]
+ self.categories = db["categories"]
+
+ # --- CRUD ---
+ async def create_todo(self, data: TodoCreate) -> TodoResponse:
+ """F-001: 할일 생성
+ 1. category_id 존재 검증
+ 2. tags 정규화 (소문자, 중복 제거) -> TodoCreate validator에서 처리됨
+ 3. created_at, updated_at = UTC now
+ 4. completed = False
+ 5. MongoDB insert -> 생성된 문서 반환
+ """
+
+ async def list_todos(
+ self, page: int, limit: int,
+ completed: bool | None, category_id: str | None,
+ priority: str | None, tag: str | None,
+ sort: str, order: str,
+ ) -> TodoListResponse:
+ """F-002: 할일 목록 조회
+ 1. 필터 조건 구성 (AND 조합)
+ 2. 정렬 조건 구성 (priority 정렬 시 high=0, medium=1, low=2 매핑)
+ 3. skip/limit 페이지네이션
+ 4. 총 개수(total) 조회
+ 5. 카테고리 정보 populate (lookup 또는 후처리)
+ """
+
+ async def get_todo(self, todo_id: str) -> TodoResponse:
+ """F-003: 할일 상세 조회
+ 1. ObjectId 유효성 검증
+ 2. 문서 조회 (없으면 404)
+ 3. 카테고리 정보 populate
+ """
+
+ async def update_todo(self, todo_id: str, data: TodoUpdate) -> TodoResponse:
+ """F-004: 할일 수정 (Partial Update)
+ 1. data.model_dump(exclude_unset=True) 로 변경 필드만 추출
+ 2. category_id 변경 시 존재 검증
+ 3. updated_at 갱신
+ 4. $set 연산자로 업데이트
+ """
+
+ async def delete_todo(self, todo_id: str) -> None:
+ """F-005: 할일 삭제
+ 1. 문서 존재 확인 (없으면 404)
+ 2. 물리 삭제 (delete_one)
+ """
+
+ async def toggle_todo(self, todo_id: str) -> ToggleResponse:
+ """F-006: 완료 토글
+ 1. 현재 completed 값 조회
+ 2. 반대값으로 $set 업데이트
+ 3. updated_at 갱신
+ """
+
+ # --- 일괄 작업 ---
+ async def batch_action(self, data: BatchRequest) -> BatchResponse:
+ """F-019~F-021: 일괄 작업 분기
+ - action == "complete": batch_complete()
+ - action == "delete": batch_delete()
+ - action == "move_category": batch_move_category()
+ """
+
+ async def batch_complete(self, ids: list[str]) -> BatchResponse:
+ """F-019: 일괄 완료
+ 1. ids -> ObjectId 리스트 변환
+ 2. update_many($set: {completed: true, updated_at: now})
+ 3. modified_count 반환
+ """
+
+ async def batch_delete(self, ids: list[str]) -> BatchResponse:
+ """F-020: 일괄 삭제
+ 1. ids -> ObjectId 리스트 변환
+ 2. delete_many({_id: {$in: ids}})
+ 3. deleted_count 반환
+ """
+
+ async def batch_move_category(self, ids: list[str], category_id: str | None) -> BatchResponse:
+ """F-021: 일괄 카테고리 변경
+ 1. category_id 존재 검증 (null이 아닌 경우)
+ 2. update_many($set: {category_id: ..., updated_at: now})
+ 3. modified_count 반환
+ """
+
+ # --- 태그 ---
+ async def get_tags(self) -> list[TagInfo]:
+ """F-013: 태그 목록 조회
+ 1. aggregate pipeline:
+ $unwind: "$tags"
+ $group: {_id: "$tags", count: {$sum: 1}}
+ $sort: {count: -1}
+ $project: {name: "$_id", count: 1, _id: 0}
+ """
+```
+
+#### 2.5.2 CategoryService (`app/services/category_service.py`)
+
+```python
+class CategoryService:
+ """카테고리 CRUD 비즈니스 로직"""
+
+ def __init__(self, db: AsyncIOMotorDatabase):
+ self.collection = db["categories"]
+ self.todos = db["todos"]
+
+ async def list_categories(self) -> list[CategoryResponse]:
+ """F-008: 카테고리 목록 조회
+ 1. order 기준 오름차순 정렬
+ 2. 각 카테고리별 todo_count 집계 (aggregate 또는 후처리)
+ """
+
+ async def create_category(self, data: CategoryCreate) -> CategoryResponse:
+ """F-007: 카테고리 생성
+ 1. name 중복 검사 (unique index로도 보호)
+ 2. order = 현재 최대값 + 1
+ 3. created_at = UTC now
+ """
+
+ async def update_category(self, category_id: str, data: CategoryUpdate) -> CategoryResponse:
+ """F-009: 카테고리 수정
+ 1. 존재 확인 (404)
+ 2. name 변경 시 중복 검사 (409)
+ 3. Partial Update
+ """
+
+ async def delete_category(self, category_id: str) -> None:
+ """F-010: 카테고리 삭제
+ 1. 존재 확인 (404)
+ 2. 해당 카테고리의 모든 todo.category_id -> null 갱신
+ 3. 카테고리 문서 삭제
+ """
+```
+
+#### 2.5.3 SearchService (`app/services/search_service.py`)
+
+```python
+class SearchService:
+ """MongoDB text index 기반 검색 로직"""
+
+ def __init__(self, db: AsyncIOMotorDatabase):
+ self.collection = db["todos"]
+ self.categories = db["categories"]
+
+ async def search(self, query: str, page: int, limit: int) -> SearchResponse:
+ """F-017: 전문 검색
+ 1. MongoDB text search: {$text: {$search: query}}
+ 2. text score 기준 정렬: {$meta: "textScore"}
+ 3. 페이지네이션 적용
+ 4. 카테고리 정보 populate
+ 5. SearchResponse 구성 (items, total, query, page, limit)
+ """
+```
+
+#### 2.5.4 DashboardService (`app/services/dashboard_service.py`)
+
+```python
+import json
+import redis.asyncio as aioredis
+
+DASHBOARD_CACHE_KEY = "dashboard:stats"
+DASHBOARD_CACHE_TTL = 60 # 60초
+
+class DashboardService:
+ """대시보드 통계 + Redis 캐싱"""
+
+ def __init__(self, db: AsyncIOMotorDatabase, redis_client: aioredis.Redis):
+ self.todos = db["todos"]
+ self.categories = db["categories"]
+ self.redis = redis_client
+
+ async def get_stats(self) -> DashboardStats:
+ """F-018: 대시보드 통계
+ 1. Redis 캐시 확인
+ 2. 캐시 히트 -> JSON 파싱 후 반환
+ 3. 캐시 미스 -> MongoDB 집계 -> Redis 저장 (TTL 60s) -> 반환
+ """
+
+ async def _compute_stats(self) -> DashboardStats:
+ """MongoDB 집계 파이프라인으로 통계 계산
+ - overview: total, completed, incomplete, completion_rate
+ - by_category: aggregate group by category_id + lookup
+ - by_priority: aggregate group by priority
+ - upcoming_deadlines: due_date 오름차순, 미완료, 미래 날짜, limit 5
+ """
+
+ async def invalidate_cache(self) -> None:
+ """캐시 무효화: 할일 CUD 작업 후 호출"""
+ await self.redis.delete(DASHBOARD_CACHE_KEY)
+```
+
+### 2.6 데이터베이스 설정
+
+#### 2.6.1 MongoDB 연결 (Motor async)
+
+```python
+# app/database.py
+from contextlib import asynccontextmanager
+from motor.motor_asyncio import AsyncIOMotorClient
+import redis.asyncio as aioredis
+from app.config import get_settings
+
+class Database:
+ client: AsyncIOMotorClient | None = None
+ db = None # AsyncIOMotorDatabase
+ redis: aioredis.Redis | None = None
+
+db = Database()
+
+async def connect_db():
+ """MongoDB + Redis 연결 초기화"""
+ settings = get_settings()
+
+ # MongoDB
+ db.client = AsyncIOMotorClient(settings.mongodb_url)
+ db.db = db.client[settings.mongodb_database]
+
+ # 인덱스 생성
+ await create_indexes(db.db)
+
+ # Redis
+ db.redis = aioredis.from_url(
+ settings.redis_url,
+ encoding="utf-8",
+ decode_responses=True,
+ )
+
+async def disconnect_db():
+ """연결 종료"""
+ if db.client is not None:
+ db.client.close()
+ if db.redis is not None:
+ await db.redis.close()
+
+async def create_indexes(database):
+ """컬렉션 인덱스 생성"""
+ todos = database["todos"]
+ categories = database["categories"]
+
+ # todos 인덱스
+ await todos.create_index(
+ [("title", "text"), ("content", "text"), ("tags", "text")],
+ name="text_search_index",
+ weights={"title": 10, "tags": 5, "content": 1},
+ )
+ await todos.create_index([("category_id", 1), ("created_at", -1)], name="category_created")
+ await todos.create_index([("completed", 1), ("created_at", -1)], name="completed_created")
+ await todos.create_index([("priority", 1), ("created_at", -1)], name="priority_created")
+ await todos.create_index([("tags", 1)], name="tags")
+ await todos.create_index([("due_date", 1)], name="due_date")
+ await todos.create_index([("completed", 1), ("due_date", 1)], name="completed_due_date")
+
+ # categories 인덱스
+ await categories.create_index("name", unique=True, name="category_name_unique")
+ await categories.create_index("order", name="category_order")
+```
+
+#### 2.6.2 FastAPI 앱 팩토리 (`app/main.py`)
+
+```python
+from contextlib import asynccontextmanager
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from app.config import get_settings
+from app.database import connect_db, disconnect_db
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """서버 시작/종료 이벤트"""
+ await connect_db()
+ yield
+ await disconnect_db()
+
+def create_app() -> FastAPI:
+ settings = get_settings()
+
+ app = FastAPI(
+ title="todos2 API",
+ description="확장형 할일 관리 애플리케이션 API",
+ version="1.0.0",
+ lifespan=lifespan,
+ )
+
+ # CORS
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[settings.frontend_url, "http://localhost:3000"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # 라우터 등록
+ from app.routers import todos, categories, tags, search, dashboard
+ app.include_router(todos.router)
+ app.include_router(categories.router)
+ app.include_router(tags.router)
+ app.include_router(search.router)
+ app.include_router(dashboard.router)
+
+ return app
+
+app = create_app()
+```
+
+#### 2.6.3 설정 (`app/config.py`)
+
+```python
+from pydantic_settings import BaseSettings
+from functools import lru_cache
+
+class Settings(BaseSettings):
+ mongodb_url: str = "mongodb://mongodb:27017"
+ mongodb_database: str = "todos2"
+ redis_url: str = "redis://redis:6379"
+ frontend_url: str = "http://localhost:3000"
+
+ class Config:
+ env_file = ".env"
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
+```
+
+#### 2.6.4 컬렉션 및 인덱스 설계
+
+**todos 컬렉션**:
+
+| 인덱스 이름 | 필드 | 타입 | 용도 |
+|------------|------|------|------|
+| `text_search_index` | title, content, tags | text (가중치: 10, 1, 5) | 전문 검색 (F-017) |
+| `category_created` | category_id ASC, created_at DESC | compound | 카테고리별 목록 조회 |
+| `completed_created` | completed ASC, created_at DESC | compound | 완료 상태별 목록 조회 |
+| `priority_created` | priority ASC, created_at DESC | compound | 우선순위별 목록 조회 |
+| `tags` | tags ASC | multikey | 태그별 필터링 |
+| `due_date` | due_date ASC | single | 마감일 정렬 |
+| `completed_due_date` | completed ASC, due_date ASC | compound | 대시보드 마감 임박 조회 |
+
+**categories 컬렉션**:
+
+| 인덱스 이름 | 필드 | 타입 | 용도 |
+|------------|------|------|------|
+| `category_name_unique` | name | unique | 이름 중복 방지 |
+| `category_order` | order ASC | single | 순서 정렬 |
+
+#### 2.6.5 Redis 캐싱 전략
+
+| 키 | 데이터 | TTL | 무효화 시점 |
+|----|--------|-----|------------|
+| `dashboard:stats` | DashboardStats JSON | 60초 | 할일 생성/수정/삭제/토글/일괄 작업 후 |
+
+**캐시 무효화 패턴**: Cache-Aside (Lazy Invalidation)
+- 쓰기 작업(CUD) 시 `DashboardService.invalidate_cache()` 호출
+- 읽기 시 캐시 미스면 MongoDB 집계 후 Redis에 저장
+
+```python
+# TodoService의 CUD 메서드에서 캐시 무효화
+async def create_todo(self, data: TodoCreate) -> TodoResponse:
+ # ... 할일 생성 로직 ...
+ await self.dashboard_service.invalidate_cache()
+ return result
+```
+
+---
+
+## 3. 프론트엔드 아키텍처
+
+### 3.1 디렉토리 구조
+
+```
+frontend/
+├── Dockerfile
+├── package.json
+├── next.config.ts
+├── tsconfig.json
+├── postcss.config.mjs
+├── components.json # shadcn/ui 설정
+├── public/
+│ └── favicon.ico
+└── src/
+ ├── app/ # App Router 페이지
+ │ ├── layout.tsx # 루트 레이아웃 (MainLayout, Providers)
+ │ ├── page.tsx # P-001 대시보드 (/)
+ │ ├── todos/
+ │ │ ├── page.tsx # P-002 할일 목록 (/todos)
+ │ │ └── [id]/
+ │ │ └── page.tsx # P-003 할일 상세/편집 (/todos/[id])
+ │ ├── categories/
+ │ │ └── page.tsx # P-004 카테고리 관리 (/categories)
+ │ └── search/
+ │ └── page.tsx # P-005 검색 결과 (/search)
+ ├── components/
+ │ ├── ui/ # shadcn/ui 원본 컴포넌트 (자동 생성)
+ │ │ ├── button.tsx
+ │ │ ├── input.tsx
+ │ │ ├── select.tsx
+ │ │ ├── checkbox.tsx
+ │ │ ├── badge.tsx
+ │ │ ├── card.tsx
+ │ │ ├── dialog.tsx
+ │ │ ├── dropdown-menu.tsx
+ │ │ ├── popover.tsx
+ │ │ ├── calendar.tsx
+ │ │ ├── toast.tsx
+ │ │ ├── skeleton.tsx
+ │ │ ├── pagination.tsx
+ │ │ └── command.tsx # 태그 자동완성용
+ │ ├── layout/
+ │ │ ├── Header.tsx # 로고 + 검색바 + 알림 영역
+ │ │ ├── Sidebar.tsx # 네비게이션 + 카테고리 + 태그 목록
+ │ │ └── MainLayout.tsx # Header + Sidebar + main content 구성
+ │ ├── todos/
+ │ │ ├── TodoCard.tsx # 개별 할일 행 (체크박스, 제목, 태그, 마감일 등)
+ │ │ ├── TodoForm.tsx # 할일 생성/수정 모달 폼
+ │ │ ├── TodoList.tsx # 할일 카드 리스트 (loading/empty/error/data)
+ │ │ ├── TodoFilter.tsx # 상태/우선순위/정렬 필터 바
+ │ │ ├── TodoDetailForm.tsx# 할일 상세/편집 폼 (P-003)
+ │ │ └── BatchActions.tsx # 일괄 작업 바 (완료/삭제/카테고리 변경)
+ │ ├── categories/
+ │ │ ├── CategoryList.tsx # 카테고리 목록 (P-004)
+ │ │ ├── CategoryItem.tsx # 개별 카테고리 행
+ │ │ ├── CategoryForm.tsx # 카테고리 생성/수정 인라인 폼
+ │ │ ├── CategorySelect.tsx# 카테고리 드롭다운 (TodoForm용)
+ │ │ └── ColorPicker.tsx # 색상 선택기
+ │ ├── todos/shared/
+ │ │ ├── PrioritySelect.tsx# 우선순위 드롭다운 (색상 구분)
+ │ │ ├── PriorityBadge.tsx # 우선순위 뱃지 표시
+ │ │ ├── DueDateBadge.tsx # 마감일 뱃지 (임박/초과 알림 포함)
+ │ │ └── DatePicker.tsx # 달력 마감일 선택
+ │ ├── tags/
+ │ │ ├── TagBadge.tsx # 태그 뱃지 (클릭 시 필터링)
+ │ │ ├── TagSelect.tsx # 태그 다중 선택 (자동완성)
+ │ │ └── TagInput.tsx # 태그 입력 + 자동완성 + 뱃지 (P-003)
+ │ ├── dashboard/
+ │ │ ├── StatsCards.tsx # 전체/완료/미완료/완료율 카드 4개
+ │ │ ├── CompletionChart.tsx # (StatsCards에 통합 가능, 별도 분리 가능)
+ │ │ ├── CategoryChart.tsx # 카테고리별 도넛 차트 (Recharts)
+ │ │ ├── PriorityChart.tsx # 우선순위별 막대 차트 (Recharts)
+ │ │ └── UpcomingDeadlines.tsx # 마감 임박 할일 Top 5 리스트
+ │ └── search/
+ │ ├── SearchBar.tsx # 헤더 내 검색 입력 (Header에서 사용)
+ │ ├── SearchResults.tsx # 검색 결과 리스트
+ │ └── SearchResultItem.tsx # 개별 검색 결과 (제목 하이라이트)
+ ├── hooks/
+ │ ├── useTodos.ts # Tanstack Query: 할일 CRUD + 일괄 작업
+ │ ├── useCategories.ts # Tanstack Query: 카테고리 CRUD
+ │ ├── useTags.ts # Tanstack Query: 태그 목록 조회
+ │ ├── useDashboard.ts # Tanstack Query: 대시보드 통계
+ │ └── useSearch.ts # Tanstack Query: 검색
+ ├── lib/
+ │ ├── api.ts # fetch wrapper (base URL, 에러 처리)
+ │ └── utils.ts # 유틸리티 (날짜 포맷, 마감일 상태 계산 등)
+ ├── store/
+ │ └── uiStore.ts # Zustand: 사이드바/필터/선택 등 UI 상태
+ └── types/
+ └── index.ts # 전역 TypeScript 타입 정의
+```
+
+### 3.2 라우팅 (App Router)
+
+| 경로 | 페이지 ID | 파일 | 설명 | 렌더링 |
+|------|----------|------|------|--------|
+| `/` | P-001 | `app/page.tsx` | 대시보드 | CSR (Tanstack Query) |
+| `/todos` | P-002 | `app/todos/page.tsx` | 할일 목록 | CSR (필터/페이지네이션) |
+| `/todos/[id]` | P-003 | `app/todos/[id]/page.tsx` | 할일 상세/편집 | CSR |
+| `/categories` | P-004 | `app/categories/page.tsx` | 카테고리 관리 | CSR |
+| `/search` | P-005 | `app/search/page.tsx` | 검색 결과 | CSR (쿼리 파라미터 `?q=`) |
+
+> 모든 페이지는 CSR(Client Side Rendering)로 처리한다. 데이터 패칭은 Tanstack Query가 담당하며, Next.js App Router는 레이아웃 구조와 라우팅만 제공한다.
+
+**루트 레이아웃 (`app/layout.tsx`)**:
+
+```tsx
+// app/layout.tsx
+import { Providers } from "@/components/providers";
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+```
+
+`Providers` 컴포넌트에서 `QueryClientProvider`를 감싼다 (`"use client"` 필수).
+
+### 3.3 컴포넌트 계층
+
+#### 3.3.1 레이아웃 컴포넌트
+
+```
+RootLayout (app/layout.tsx)
+└── Providers (QueryClientProvider + children)
+ └── MainLayout
+ ├── Header
+ │ ├── Logo ("todos2")
+ │ ├── SearchBar
+ │ └── Notification Area
+ ├── Sidebar
+ │ ├── Navigation Links
+ │ │ ├── 대시보드 (/)
+ │ │ ├── 할일 목록 (/todos)
+ │ │ └── 카테고리 관리 (/categories)
+ │ ├── Category List (사이드바용)
+ │ │ └── CategoryItem (이름, 색상 dot, todo_count)
+ │ └── Popular Tags
+ │ └── TagBadge (클릭 -> /todos?tag=xxx)
+ └── Main Content Area
+ └── {children} (각 페이지 콘텐츠)
+```
+
+#### 3.3.2 페이지별 컴포넌트 트리
+
+**P-001 대시보드 (`/`)**:
+```
+DashboardPage
+├── StatsCards
+│ ├── Card: 전체 할일 수
+│ ├── Card: 완료 수
+│ ├── Card: 미완료 수
+│ └── Card: 완료율 (%)
+├── CategoryChart (Recharts PieChart / 도넛)
+├── PriorityChart (Recharts BarChart / 가로 막대)
+└── UpcomingDeadlines
+ └── DeadlineItem (제목, D-day, 우선순위)
+```
+
+**P-002 할일 목록 (`/todos`)**:
+```
+TodoListPage
+├── TodoFilter
+│ ├── Select: 상태 (전체/완료/미완료)
+│ ├── Select: 우선순위 (전체/높음/중간/낮음)
+│ └── Select: 정렬 (생성일/마감일/우선순위 x ASC/DESC)
+├── Button: "+ 새 할일"
+├── BatchActions (선택된 항목 >= 1일 때 표시)
+│ ├── Label: "N개 선택됨"
+│ ├── Button: 일괄 완료
+│ ├── Button: 카테고리 변경 (DropdownMenu)
+│ └── Button: 일괄 삭제
+├── TodoList
+│ └── TodoCard (반복)
+│ ├── Checkbox: 선택 (일괄 작업용)
+│ ├── Checkbox: 완료 토글
+│ ├── Title (완료 시 취소선)
+│ ├── CategoryBadge (색상 + 이름)
+│ ├── TagBadge[] (클릭 -> 필터링)
+│ ├── PriorityBadge (색상 구분)
+│ ├── DueDateBadge (D-day, 임박/초과 알림)
+│ ├── Button: 수정
+│ └── Button: 삭제
+├── Pagination
+└── TodoForm (Modal, 생성/수정 모드)
+ ├── Input: 제목
+ ├── Textarea: 내용
+ ├── CategorySelect
+ ├── PrioritySelect
+ ├── DatePicker: 마감일
+ └── TagSelect: 태그
+```
+
+**P-003 할일 상세/편집 (`/todos/[id]`)**:
+```
+TodoDetailPage
+├── Breadcrumb: 할일 목록 > {제목}
+└── TodoDetailForm
+ ├── Input: 제목
+ ├── Textarea: 내용
+ ├── CategorySelect
+ ├── PrioritySelect
+ ├── DatePicker
+ ├── TagInput (자동완성 + 뱃지)
+ ├── Button: 저장
+ ├── Button: 취소
+ └── Button: 삭제
+```
+
+**P-004 카테고리 관리 (`/categories`)**:
+```
+CategoryPage
+├── Header: "카테고리 관리"
+├── Button: "+ 새 카테고리"
+├── CategoryForm (인라인, 생성 모드)
+│ ├── Input: 이름
+│ ├── ColorPicker: 색상
+│ └── Button: 추가
+└── CategoryList
+ └── CategoryItem (반복)
+ ├── ColorDot (카테고리 색상)
+ ├── Name
+ ├── Label: "N개 할일"
+ ├── Button: 수정 -> CategoryForm (인라인, 수정 모드)
+ └── Button: 삭제 -> 확인 다이얼로그
+```
+
+**P-005 검색 결과 (`/search?q=xxx`)**:
+```
+SearchPage
+├── Label: '"{query}" 검색 결과 (N건)'
+├── SearchResults
+│ └── SearchResultItem (반복)
+│ ├── Title (검색어 하이라이트)
+│ ├── Content snippet (검색어 하이라이트)
+│ ├── CategoryBadge
+│ └── DueDateBadge
+├── Pagination
+└── EmptyState ("검색 결과가 없습니다...")
+```
+
+### 3.4 상태 관리
+
+#### 3.4.1 Tanstack Query: 서버 상태
+
+모든 API 데이터는 Tanstack Query로 관리한다. 쿼리 키 설계:
+
+```typescript
+// hooks/useTodos.ts
+export const todoKeys = {
+ all: ["todos"] as const,
+ lists: () => [...todoKeys.all, "list"] as const,
+ list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
+ details: () => [...todoKeys.all, "detail"] as const,
+ detail: (id: string) => [...todoKeys.details(), id] as const,
+};
+
+export const categoryKeys = {
+ all: ["categories"] as const,
+ list: () => [...categoryKeys.all, "list"] as const,
+};
+
+export const tagKeys = {
+ all: ["tags"] as const,
+ list: () => [...tagKeys.all, "list"] as const,
+};
+
+export const dashboardKeys = {
+ all: ["dashboard"] as const,
+ stats: () => [...dashboardKeys.all, "stats"] as const,
+};
+
+export const searchKeys = {
+ all: ["search"] as const,
+ results: (query: string, page: number) => [...searchKeys.all, query, page] as const,
+};
+```
+
+**캐싱 설정**:
+
+```typescript
+// QueryClient 기본 설정
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60, // 1분 (기본)
+ gcTime: 1000 * 60 * 5, // 5분 (가비지 컬렉션)
+ retry: 1,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
+```
+
+| 쿼리 | staleTime | gcTime | refetchOnMount |
+|------|-----------|--------|----------------|
+| 할일 목록 | 30s | 5m | always |
+| 할일 상세 | 30s | 5m | always |
+| 카테고리 목록 | 60s | 10m | true |
+| 태그 목록 | 60s | 10m | true |
+| 대시보드 통계 | 60s | 5m | always |
+| 검색 결과 | 0s (항상 fresh) | 5m | always |
+
+**Mutation 후 캐시 무효화**:
+
+```typescript
+// hooks/useTodos.ts
+export function useCreateTodo() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (data: TodoCreate) => apiClient.post("/api/todos", data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
+ queryClient.invalidateQueries({ queryKey: tagKeys.all });
+ queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
+ queryClient.invalidateQueries({ queryKey: categoryKeys.all }); // todo_count 갱신
+ },
+ });
+}
+
+export function useToggleTodo() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (id: string) => apiClient.patch(`/api/todos/${id}/toggle`),
+ onSuccess: (_, id) => {
+ queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
+ queryClient.invalidateQueries({ queryKey: todoKeys.detail(id) });
+ queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
+ },
+ });
+}
+```
+
+#### 3.4.2 Zustand: 클라이언트 UI 상태
+
+```typescript
+// store/uiStore.ts
+import { create } from "zustand";
+
+interface TodoFilters {
+ completed?: boolean;
+ category_id?: string;
+ priority?: "high" | "medium" | "low";
+ tag?: string;
+ sort: string;
+ order: "asc" | "desc";
+ page: number;
+ limit: number;
+}
+
+interface UIState {
+ // 사이드바
+ sidebarOpen: boolean;
+ toggleSidebar: () => void;
+
+ // 할일 목록 필터
+ filters: TodoFilters;
+ setFilter: (key: keyof TodoFilters, value: any) => void;
+ resetFilters: () => void;
+
+ // 일괄 선택
+ selectedIds: string[];
+ toggleSelect: (id: string) => void;
+ selectAll: (ids: string[]) => void;
+ clearSelection: () => void;
+
+ // 모달
+ todoFormOpen: boolean;
+ todoFormMode: "create" | "edit";
+ editingTodoId: string | null;
+ openTodoForm: (mode: "create" | "edit", todoId?: string) => void;
+ closeTodoForm: () => void;
+}
+
+const DEFAULT_FILTERS: TodoFilters = {
+ sort: "created_at",
+ order: "desc",
+ page: 1,
+ limit: 20,
+};
+
+export const useUIStore = create((set) => ({
+ // 사이드바
+ sidebarOpen: true,
+ toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
+
+ // 필터
+ filters: { ...DEFAULT_FILTERS },
+ setFilter: (key, value) =>
+ set((s) => ({
+ filters: { ...s.filters, [key]: value, page: key === "page" ? value : 1 },
+ })),
+ resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
+
+ // 일괄 선택
+ selectedIds: [],
+ toggleSelect: (id) =>
+ set((s) => ({
+ selectedIds: s.selectedIds.includes(id)
+ ? s.selectedIds.filter((i) => i !== id)
+ : [...s.selectedIds, id],
+ })),
+ selectAll: (ids) => set({ selectedIds: ids }),
+ clearSelection: () => set({ selectedIds: [] }),
+
+ // 모달
+ todoFormOpen: false,
+ todoFormMode: "create",
+ editingTodoId: null,
+ openTodoForm: (mode, todoId) =>
+ set({ todoFormOpen: true, todoFormMode: mode, editingTodoId: todoId ?? null }),
+ closeTodoForm: () =>
+ set({ todoFormOpen: false, editingTodoId: null }),
+}));
+```
+
+### 3.5 API 클라이언트
+
+```typescript
+// lib/api.ts
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
+
+interface ApiError {
+ detail: string;
+ status: number;
+}
+
+class ApiClient {
+ private baseUrl: string;
+
+ constructor(baseUrl: string) {
+ this.baseUrl = baseUrl;
+ }
+
+ private async request(
+ method: string,
+ path: string,
+ options?: {
+ body?: unknown;
+ params?: Record;
+ }
+ ): Promise {
+ const url = new URL(`${this.baseUrl}${path}`);
+
+ // 쿼리 파라미터 추가 (undefined 제외)
+ if (options?.params) {
+ Object.entries(options.params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ url.searchParams.set(key, String(value));
+ }
+ });
+ }
+
+ const res = await fetch(url.toString(), {
+ method,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: options?.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ // 204 No Content
+ if (res.status === 204) {
+ return undefined as T;
+ }
+
+ // 에러 응답 처리
+ if (!res.ok) {
+ const error = await res.json().catch(() => ({ detail: "알 수 없는 오류가 발생했습니다" }));
+ throw {
+ detail: error.detail || `HTTP ${res.status} Error`,
+ status: res.status,
+ } as ApiError;
+ }
+
+ return res.json();
+ }
+
+ // 편의 메서드
+ get(path: string, params?: Record) {
+ return this.request("GET", path, { params });
+ }
+
+ post(path: string, body?: unknown) {
+ return this.request("POST", path, { body });
+ }
+
+ put(path: string, body?: unknown) {
+ return this.request("PUT", path, { body });
+ }
+
+ patch(path: string, body?: unknown) {
+ return this.request("PATCH", path, { body });
+ }
+
+ delete(path: string) {
+ return this.request("DELETE", path);
+ }
+}
+
+export const apiClient = new ApiClient(API_BASE_URL);
+```
+
+### 3.6 타입 정의
+
+```typescript
+// types/index.ts
+
+// === Enums ===
+export type Priority = "high" | "medium" | "low";
+export type SortField = "created_at" | "due_date" | "priority";
+export type SortOrder = "asc" | "desc";
+export type BatchAction = "complete" | "delete" | "move_category";
+
+// === Todo ===
+export interface Todo {
+ id: string;
+ title: string;
+ content?: string | null;
+ completed: boolean;
+ priority: Priority;
+ category_id?: string | null;
+ category_name?: string | null;
+ category_color?: string | null;
+ tags: string[];
+ due_date?: string | null; // ISO 8601
+ created_at: string; // ISO 8601
+ updated_at: string; // ISO 8601
+}
+
+export interface TodoCreate {
+ title: string;
+ content?: string;
+ category_id?: string | null;
+ tags?: string[];
+ priority?: Priority;
+ due_date?: string | null;
+}
+
+export interface TodoUpdate {
+ title?: string;
+ content?: string | null;
+ category_id?: string | null;
+ tags?: string[];
+ priority?: Priority;
+ due_date?: string | null;
+}
+
+export interface TodoListResponse {
+ items: Todo[];
+ total: number;
+ page: number;
+ limit: number;
+ total_pages: number;
+}
+
+export interface ToggleResponse {
+ id: string;
+ completed: boolean;
+}
+
+// === Batch ===
+export interface BatchRequest {
+ action: BatchAction;
+ ids: string[];
+ category_id?: string | null;
+}
+
+export interface BatchResponse {
+ action: string;
+ processed: number;
+ failed: number;
+}
+
+// === Category ===
+export interface Category {
+ id: string;
+ name: string;
+ color: string;
+ order: number;
+ todo_count: number;
+ created_at: string;
+}
+
+export interface CategoryCreate {
+ name: string;
+ color?: string;
+}
+
+export interface CategoryUpdate {
+ name?: string;
+ color?: string;
+ order?: number;
+}
+
+// === Tag ===
+export interface TagInfo {
+ name: string;
+ count: number;
+}
+
+// === Search ===
+export interface SearchResponse {
+ items: Todo[];
+ total: number;
+ query: string;
+ page: number;
+ limit: number;
+}
+
+// === Dashboard ===
+export interface DashboardOverview {
+ total: number;
+ completed: number;
+ incomplete: number;
+ completion_rate: number;
+}
+
+export interface CategoryStat {
+ category_id: string | null;
+ name: string;
+ color: string;
+ count: number;
+}
+
+export interface PriorityStat {
+ high: number;
+ medium: number;
+ low: number;
+}
+
+export interface UpcomingDeadline {
+ id: string;
+ title: string;
+ due_date: string;
+ priority: Priority;
+}
+
+export interface DashboardStats {
+ overview: DashboardOverview;
+ by_category: CategoryStat[];
+ by_priority: PriorityStat;
+ upcoming_deadlines: UpcomingDeadline[];
+}
+
+// === Filters ===
+export interface TodoFilters {
+ completed?: boolean;
+ category_id?: string;
+ priority?: Priority;
+ tag?: string;
+ sort: SortField;
+ order: SortOrder;
+ page: number;
+ limit: number;
+}
+
+// === Error ===
+export interface ApiError {
+ detail: string;
+ status: number;
+}
+```
+
+---
+
+## 4. 데이터베이스 설계
+
+### 4.1 컬렉션
+
+#### todos 컬렉션 스키마
+
+```javascript
+// MongoDB 문서 구조
+{
+ "_id": ObjectId("..."),
+ "title": "API 문서 작성", // string, 필수, 1~200자
+ "content": "Swagger UI 기반 API 문서를 작성...", // string, 선택, 최대 2000자
+ "completed": false, // boolean, 기본 false
+ "priority": "high", // enum: "high"|"medium"|"low", 기본 "medium"
+ "category_id": ObjectId("..."), // ObjectId|null, 카테고리 참조
+ "tags": ["긴급", "문서"], // string[], 인라인 태그, 최대 10개
+ "due_date": ISODate("2026-02-11T00:00:00Z"), // datetime|null
+ "created_at": ISODate("2026-02-10T03:00:00Z"), // datetime, 자동 설정
+ "updated_at": ISODate("2026-02-10T03:00:00Z") // datetime, 자동 갱신
+}
+```
+
+**인덱스**:
+
+```javascript
+// 전문 검색 (F-017)
+db.todos.createIndex(
+ { title: "text", content: "text", tags: "text" },
+ { weights: { title: 10, tags: 5, content: 1 }, name: "text_search_index" }
+)
+
+// 카테고리 + 생성일 (F-002 카테고리 필터 + 정렬)
+db.todos.createIndex({ category_id: 1, created_at: -1 }, { name: "category_created" })
+
+// 완료 상태 + 생성일 (F-002 상태 필터 + 정렬)
+db.todos.createIndex({ completed: 1, created_at: -1 }, { name: "completed_created" })
+
+// 우선순위 + 생성일 (F-002 우선순위 필터 + 정렬)
+db.todos.createIndex({ priority: 1, created_at: -1 }, { name: "priority_created" })
+
+// 태그 (F-012 태그 필터링)
+db.todos.createIndex({ tags: 1 }, { name: "tags" })
+
+// 마감일 (F-002 마감일 정렬)
+db.todos.createIndex({ due_date: 1 }, { name: "due_date" })
+
+// 완료 상태 + 마감일 (F-018 대시보드 마감 임박)
+db.todos.createIndex({ completed: 1, due_date: 1 }, { name: "completed_due_date" })
+```
+
+#### categories 컬렉션 스키마
+
+```javascript
+{
+ "_id": ObjectId("..."),
+ "name": "업무", // string, 필수, 1~50자, unique
+ "color": "#EF4444", // string, hex color, 기본 "#6B7280"
+ "order": 0, // integer, 정렬 순서
+ "created_at": ISODate("2026-02-10T03:00:00Z")
+}
+```
+
+**인덱스**:
+
+```javascript
+// 이름 유니크 (F-007 중복 방지)
+db.categories.createIndex({ name: 1 }, { unique: true, name: "category_name_unique" })
+
+// 정렬 순서 (F-008 목록 조회)
+db.categories.createIndex({ order: 1 }, { name: "category_order" })
+```
+
+### 4.2 인덱스 전략
+
+#### Text Index (검색용)
+
+MongoDB text index는 컬렉션당 하나만 생성 가능하다. `title`, `content`, `tags` 세 필드를 하나의 text index로 묶고, 가중치를 설정하여 관련도 검색을 지원한다.
+
+```
+가중치: title(10) > tags(5) > content(1)
+```
+
+- `title`에 매칭되는 결과가 가장 높은 점수를 받는다.
+- `tags`에 정확히 일치하는 결과가 content 매칭보다 우선한다.
+- `{$meta: "textScore"}` 기준으로 정렬하면 관련도 순 정렬이 된다.
+
+#### Compound Index (필터링/정렬)
+
+필터링과 정렬이 동시에 발생하는 패턴에 compound index를 설정한다:
+
+| 쿼리 패턴 | 인덱스 | 설명 |
+|-----------|--------|------|
+| `completed=true` + `sort=created_at` | `{completed: 1, created_at: -1}` | 가장 빈번한 필터 조합 |
+| `category_id=X` + `sort=created_at` | `{category_id: 1, created_at: -1}` | 카테고리별 목록 |
+| `priority=high` + `sort=created_at` | `{priority: 1, created_at: -1}` | 우선순위별 목록 |
+| `completed=false` + `sort=due_date` | `{completed: 1, due_date: 1}` | 대시보드 마감 임박 |
+
+### 4.3 쿼리 패턴
+
+#### 할일 목록 조회 (F-002)
+
+```python
+# 필터 조건 구성
+query = {}
+if completed is not None:
+ query["completed"] = completed
+if category_id:
+ query["category_id"] = ObjectId(category_id)
+if priority:
+ query["priority"] = priority
+if tag:
+ query["tags"] = tag # multikey index 활용
+
+# 정렬 조건
+sort_direction = ASCENDING if order == "asc" else DESCENDING
+if sort == "priority":
+ # priority는 문자열이므로 커스텀 정렬 필요
+ # high=0, medium=1, low=2 매핑 -> aggregate 사용
+ pass
+else:
+ sort_key = [(sort, sort_direction)]
+
+# 페이지네이션
+skip = (page - 1) * limit
+cursor = collection.find(query).sort(sort_key).skip(skip).limit(limit)
+total = await collection.count_documents(query)
+```
+
+#### 태그 목록 집계 (F-013)
+
+```python
+pipeline = [
+ {"$unwind": "$tags"},
+ {"$group": {"_id": "$tags", "count": {"$sum": 1}}},
+ {"$sort": {"count": -1}},
+ {"$project": {"name": "$_id", "count": 1, "_id": 0}},
+]
+result = await collection.aggregate(pipeline).to_list(None)
+```
+
+#### 대시보드 통계 집계 (F-018)
+
+```python
+# Overview
+total = await todos.count_documents({})
+completed_count = await todos.count_documents({"completed": True})
+incomplete = total - completed_count
+completion_rate = (completed_count / total * 100) if total > 0 else 0
+
+# By Category (aggregate)
+pipeline_category = [
+ {"$group": {"_id": "$category_id", "count": {"$sum": 1}}},
+ {"$lookup": {
+ "from": "categories",
+ "localField": "_id",
+ "foreignField": "_id",
+ "as": "category"
+ }},
+ {"$unwind": {"path": "$category", "preserveNullAndEmptyArrays": True}},
+ {"$project": {
+ "category_id": {"$toString": "$_id"},
+ "name": {"$ifNull": ["$category.name", "미분류"]},
+ "color": {"$ifNull": ["$category.color", "#6B7280"]},
+ "count": 1,
+ "_id": 0,
+ }},
+]
+
+# By Priority
+pipeline_priority = [
+ {"$group": {"_id": "$priority", "count": {"$sum": 1}}},
+]
+
+# Upcoming Deadlines (미완료 + 마감일 있음 + 마감일 오름차순 + limit 5)
+upcoming = await todos.find({
+ "completed": False,
+ "due_date": {"$ne": None, "$gte": datetime.utcnow()},
+}).sort("due_date", ASCENDING).limit(5).to_list(5)
+```
+
+#### 카테고리 삭제 시 연쇄 처리 (F-010)
+
+```python
+# 1. 해당 카테고리의 모든 할일 -> category_id = null
+await todos.update_many(
+ {"category_id": ObjectId(category_id)},
+ {"$set": {"category_id": None, "updated_at": datetime.utcnow()}}
+)
+# 2. 카테고리 삭제
+await categories.delete_one({"_id": ObjectId(category_id)})
+```
+
+---
+
+## 5. API 상세 명세
+
+### 5.1 할일 생성 (F-001)
+
+**Request**:
+```http
+POST /api/todos
+Content-Type: application/json
+
+{
+ "title": "API 문서 작성",
+ "content": "Swagger UI 기반 API 문서를 작성하고 엔드포인트별 요청/응답 예시를 추가한다.",
+ "category_id": "65f1a2b3c4d5e6f7a8b9c0d1",
+ "tags": ["긴급", "문서"],
+ "priority": "high",
+ "due_date": "2026-02-11T00:00:00Z"
+}
+```
+
+**Response (201 Created)**:
+```json
+{
+ "id": "65f1a2b3c4d5e6f7a8b9c0d2",
+ "title": "API 문서 작성",
+ "content": "Swagger UI 기반 API 문서를 작성하고 엔드포인트별 요청/응답 예시를 추가한다.",
+ "completed": false,
+ "priority": "high",
+ "category_id": "65f1a2b3c4d5e6f7a8b9c0d1",
+ "category_name": "업무",
+ "category_color": "#EF4444",
+ "tags": ["긴급", "문서"],
+ "due_date": "2026-02-11T00:00:00Z",
+ "created_at": "2026-02-10T03:00:00Z",
+ "updated_at": "2026-02-10T03:00:00Z"
+}
+```
+
+**에러 (422)**:
+```json
+{
+ "detail": [
+ {
+ "loc": ["body", "title"],
+ "msg": "String should have at least 1 character",
+ "type": "string_too_short"
+ }
+ ]
+}
+```
+
+**에러 (404 - 카테고리 미존재)**:
+```json
+{
+ "detail": "카테고리를 찾을 수 없습니다"
+}
+```
+
+### 5.2 할일 목록 조회 (F-002)
+
+**Request**:
+```http
+GET /api/todos?page=1&limit=20&completed=false&priority=high&sort=due_date&order=asc
+```
+
+**Response (200 OK)**:
+```json
+{
+ "items": [
+ {
+ "id": "65f1a2b3c4d5e6f7a8b9c0d2",
+ "title": "API 문서 작성",
+ "content": "Swagger UI 기반...",
+ "completed": false,
+ "priority": "high",
+ "category_id": "65f1a2b3c4d5e6f7a8b9c0d1",
+ "category_name": "업무",
+ "category_color": "#EF4444",
+ "tags": ["긴급", "문서"],
+ "due_date": "2026-02-11T00:00:00Z",
+ "created_at": "2026-02-10T03:00:00Z",
+ "updated_at": "2026-02-10T03:00:00Z"
+ }
+ ],
+ "total": 45,
+ "page": 1,
+ "limit": 20,
+ "total_pages": 3
+}
+```
+
+### 5.3 할일 상세 조회 (F-003)
+
+**Request**:
+```http
+GET /api/todos/65f1a2b3c4d5e6f7a8b9c0d2
+```
+
+**Response (200 OK)**: Todo 객체 (5.1과 동일 구조)
+
+**에러 (422 - 잘못된 ID)**:
+```json
+{
+ "detail": "유효하지 않은 ID 형식입니다"
+}
+```
+
+### 5.4 할일 수정 (F-004)
+
+**Request**:
+```http
+PUT /api/todos/65f1a2b3c4d5e6f7a8b9c0d2
+Content-Type: application/json
+
+{
+ "title": "API 문서 작성 (v2)",
+ "priority": "medium",
+ "tags": ["문서", "api"]
+}
+```
+
+**Response (200 OK)**: 수정된 Todo 객체
+
+### 5.5 할일 삭제 (F-005)
+
+**Request**:
+```http
+DELETE /api/todos/65f1a2b3c4d5e6f7a8b9c0d2
+```
+
+**Response (204 No Content)**: 빈 응답
+
+### 5.6 할일 완료 토글 (F-006)
+
+**Request**:
+```http
+PATCH /api/todos/65f1a2b3c4d5e6f7a8b9c0d2/toggle
+```
+
+**Response (200 OK)**:
+```json
+{
+ "id": "65f1a2b3c4d5e6f7a8b9c0d2",
+ "completed": true
+}
+```
+
+### 5.7 일괄 작업 (F-019, F-020, F-021)
+
+**일괄 완료 (F-019)**:
+```http
+POST /api/todos/batch
+Content-Type: application/json
+
+{
+ "action": "complete",
+ "ids": ["65f1...d2", "65f1...d3", "65f1...d4"]
+}
+```
+
+**일괄 삭제 (F-020)**:
+```http
+POST /api/todos/batch
+Content-Type: application/json
+
+{
+ "action": "delete",
+ "ids": ["65f1...d2", "65f1...d3"]
+}
+```
+
+**일괄 카테고리 변경 (F-021)**:
+```http
+POST /api/todos/batch
+Content-Type: application/json
+
+{
+ "action": "move_category",
+ "ids": ["65f1...d2", "65f1...d3"],
+ "category_id": "65f1a2b3c4d5e6f7a8b9c0d1"
+}
+```
+
+**Response (200 OK)**:
+```json
+{
+ "action": "complete",
+ "processed": 3,
+ "failed": 0
+}
+```
+
+### 5.8 카테고리 CRUD
+
+**목록 조회 (F-008)**:
+```http
+GET /api/categories
+```
+
+```json
+[
+ {
+ "id": "65f1a2b3c4d5e6f7a8b9c0d1",
+ "name": "업무",
+ "color": "#EF4444",
+ "order": 0,
+ "todo_count": 12,
+ "created_at": "2026-02-10T03:00:00Z"
+ },
+ {
+ "id": "65f1a2b3c4d5e6f7a8b9c0d5",
+ "name": "개인",
+ "color": "#3B82F6",
+ "order": 1,
+ "todo_count": 8,
+ "created_at": "2026-02-10T03:00:00Z"
+ }
+]
+```
+
+**생성 (F-007)**:
+```http
+POST /api/categories
+Content-Type: application/json
+
+{
+ "name": "학습",
+ "color": "#10B981"
+}
+```
+
+**Response (201 Created)**:
+```json
+{
+ "id": "65f1a2b3c4d5e6f7a8b9c0d6",
+ "name": "학습",
+ "color": "#10B981",
+ "order": 2,
+ "todo_count": 0,
+ "created_at": "2026-02-10T03:00:00Z"
+}
+```
+
+**에러 (409 - 이름 중복)**:
+```json
+{
+ "detail": "이미 존재하는 카테고리 이름입니다"
+}
+```
+
+**수정 (F-009)**:
+```http
+PUT /api/categories/65f1a2b3c4d5e6f7a8b9c0d1
+Content-Type: application/json
+
+{
+ "name": "업무 프로젝트",
+ "color": "#F59E0B"
+}
+```
+
+**삭제 (F-010)**:
+```http
+DELETE /api/categories/65f1a2b3c4d5e6f7a8b9c0d1
+```
+**Response (204 No Content)**
+
+### 5.9 태그 목록 (F-013)
+
+**Request**:
+```http
+GET /api/tags
+```
+
+**Response (200 OK)**:
+```json
+[
+ { "name": "긴급", "count": 8 },
+ { "name": "문서", "count": 5 },
+ { "name": "회의", "count": 4 },
+ { "name": "학습", "count": 3 }
+]
+```
+
+### 5.10 검색 (F-017)
+
+**Request**:
+```http
+GET /api/search?q=API%20문서&page=1&limit=20
+```
+
+**Response (200 OK)**:
+```json
+{
+ "items": [
+ {
+ "id": "65f1a2b3c4d5e6f7a8b9c0d2",
+ "title": "API 문서 작성",
+ "content": "Swagger UI 기반 API 문서를 작성하고...",
+ "completed": false,
+ "priority": "high",
+ "category_id": "65f1a2b3c4d5e6f7a8b9c0d1",
+ "category_name": "업무",
+ "category_color": "#EF4444",
+ "tags": ["긴급", "문서"],
+ "due_date": "2026-02-11T00:00:00Z",
+ "created_at": "2026-02-10T03:00:00Z",
+ "updated_at": "2026-02-10T03:00:00Z"
+ }
+ ],
+ "total": 3,
+ "query": "API 문서",
+ "page": 1,
+ "limit": 20
+}
+```
+
+**에러 (422 - 검색어 누락)**:
+```json
+{
+ "detail": "검색어를 입력해주세요"
+}
+```
+
+### 5.11 대시보드 통계 (F-018)
+
+**Request**:
+```http
+GET /api/dashboard/stats
+```
+
+**Response (200 OK)**:
+```json
+{
+ "overview": {
+ "total": 50,
+ "completed": 30,
+ "incomplete": 20,
+ "completion_rate": 60.0
+ },
+ "by_category": [
+ { "category_id": "65f1...d1", "name": "업무", "color": "#EF4444", "count": 15 },
+ { "category_id": "65f1...d5", "name": "개인", "color": "#3B82F6", "count": 10 },
+ { "category_id": null, "name": "미분류", "color": "#6B7280", "count": 5 }
+ ],
+ "by_priority": {
+ "high": 10,
+ "medium": 25,
+ "low": 15
+ },
+ "upcoming_deadlines": [
+ { "id": "65f1...d2", "title": "API 문서 작성", "due_date": "2026-02-11T00:00:00Z", "priority": "high" },
+ { "id": "65f1...d7", "title": "디자인 리뷰", "due_date": "2026-02-12T00:00:00Z", "priority": "medium" }
+ ]
+}
+```
+
+### 5.12 에러 응답 형식
+
+모든 에러 응답은 다음 형식을 따른다:
+
+**단일 에러**:
+```json
+{
+ "detail": "에러 메시지 (한국어)"
+}
+```
+
+**검증 에러 (Pydantic, 422)**:
+```json
+{
+ "detail": [
+ {
+ "loc": ["body", "field_name"],
+ "msg": "에러 설명",
+ "type": "error_type"
+ }
+ ]
+}
+```
+
+**HTTP 상태 코드 규칙**:
+
+| 코드 | 의미 | 사용 |
+|------|------|------|
+| 200 | OK | 조회, 수정, 토글, 일괄 작업 성공 |
+| 201 | Created | 생성 성공 |
+| 204 | No Content | 삭제 성공 |
+| 404 | Not Found | 리소스 미존재 |
+| 409 | Conflict | 이름 중복 (카테고리) |
+| 422 | Unprocessable Entity | 입력 검증 실패 |
+| 500 | Internal Server Error | 서버 내부 오류 |
+
+---
+
+## 6. 캐싱 전략
+
+### 6.1 서버 측: Redis 캐싱
+
+대시보드 통계만 Redis 캐싱을 적용한다. 할일 목록/상세는 캐싱하지 않는다.
+
+```
+┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
+│ GET /dashboard │──────>│ Redis │──────>│ MongoDB │
+│ /stats │ │ (캐시) │ │ (집계) │
+└──────────────────┘ └──────────────┘ └──────────────┘
+ │ │ │
+ │ Cache Hit │ Cache Miss │
+ │<───── JSON 반환 ───────│ │
+ │ │ │
+ │ │── $aggregate ────────>│
+ │ │<── 결과 반환 ─────────│
+ │ │── SET (TTL 60s) ─────>│(Redis)
+ │<───── JSON 반환 ───────│ │
+```
+
+**캐시 무효화 트리거**:
+
+| 작업 | 무효화 여부 | 이유 |
+|------|------------|------|
+| 할일 생성 (POST /todos) | O | total, by_category, by_priority 변경 |
+| 할일 수정 (PUT /todos/{id}) | O | priority, category_id, due_date 변경 가능 |
+| 할일 삭제 (DELETE /todos/{id}) | O | total, by_category 변경 |
+| 할일 토글 (PATCH /todos/{id}/toggle) | O | completed 변경 -> completion_rate |
+| 일괄 작업 (POST /todos/batch) | O | 모든 통계 영향 |
+| 카테고리 CRUD | O | by_category 변경 |
+
+### 6.2 클라이언트 측: Tanstack Query 캐싱
+
+```typescript
+// 쿼리별 캐싱 전략
+const CACHE_CONFIG = {
+ todos: {
+ staleTime: 30 * 1000, // 30초 후 stale
+ gcTime: 5 * 60 * 1000, // 5분 후 GC
+ },
+ categories: {
+ staleTime: 60 * 1000, // 1분 후 stale
+ gcTime: 10 * 60 * 1000, // 10분 후 GC
+ },
+ tags: {
+ staleTime: 60 * 1000, // 1분 후 stale
+ gcTime: 10 * 60 * 1000, // 10분 후 GC
+ },
+ dashboard: {
+ staleTime: 60 * 1000, // 1분 (서버 Redis TTL과 동일)
+ gcTime: 5 * 60 * 1000, // 5분 후 GC
+ },
+ search: {
+ staleTime: 0, // 항상 fresh fetch
+ gcTime: 5 * 60 * 1000, // 5분 후 GC
+ },
+};
+```
+
+**Mutation 후 무효화 매트릭스**:
+
+| Mutation | todos.lists | todos.detail | categories | tags | dashboard |
+|----------|:-----------:|:------------:|:----------:|:----:|:---------:|
+| createTodo | O | - | O | O | O |
+| updateTodo | O | O | O | O | O |
+| deleteTodo | O | - | O | O | O |
+| toggleTodo | O | O | - | - | O |
+| batchAction | O | - | O | O | O |
+| createCategory | - | - | O | - | O |
+| updateCategory | O | O | O | - | O |
+| deleteCategory | O | O | O | - | O |
+
+---
+
+## 7. Docker 구성
+
+### 7.1 docker-compose.yml
+
+```yaml
+version: "3.8"
+
+services:
+ # --- Backend ---
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ container_name: todos2-backend
+ ports:
+ - "8000:8000"
+ environment:
+ - MONGODB_URL=mongodb://mongodb:27017
+ - MONGODB_DATABASE=todos2
+ - REDIS_URL=redis://redis:6379
+ - FRONTEND_URL=http://localhost:3000
+ depends_on:
+ mongodb:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ volumes:
+ - ./backend/app:/app/app # 개발 시 핫 리로드
+ networks:
+ - todos2-network
+ restart: unless-stopped
+
+ # --- Frontend ---
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ container_name: todos2-frontend
+ ports:
+ - "3000:3000"
+ environment:
+ - NEXT_PUBLIC_API_URL=http://localhost:8000
+ depends_on:
+ - backend
+ volumes:
+ - ./frontend/src:/app/src # 개발 시 핫 리로드
+ networks:
+ - todos2-network
+ restart: unless-stopped
+
+ # --- MongoDB ---
+ mongodb:
+ image: mongo:7.0
+ container_name: todos2-mongodb
+ ports:
+ - "27017:27017"
+ volumes:
+ - mongodb_data:/data/db
+ healthcheck:
+ test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - todos2-network
+ restart: unless-stopped
+
+ # --- Redis ---
+ redis:
+ image: redis:7-alpine
+ container_name: todos2-redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ command: redis-server --appendonly yes
+ networks:
+ - todos2-network
+ restart: unless-stopped
+
+volumes:
+ mongodb_data:
+ driver: local
+ redis_data:
+ driver: local
+
+networks:
+ todos2-network:
+ driver: bridge
+```
+
+### 7.2 Backend Dockerfile
+
+```dockerfile
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# 시스템 의존성
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+# Python 의존성
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# 소스 코드
+COPY app/ ./app/
+
+# 포트 노출
+EXPOSE 8000
+
+# 실행 (개발 모드: 핫 리로드)
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+```
+
+### 7.3 Backend requirements.txt
+
+```
+fastapi>=0.104
+uvicorn>=0.24
+motor>=3.6,<4.0
+pymongo>=4.9,<4.10
+pydantic>=2.5
+pydantic-settings>=2.1
+redis>=5.0
+python-dateutil>=2.8
+```
+
+> **호환성 주의**: `pymongo>=4.9,<4.10` + `motor>=3.6` 조합을 사용한다.
+> pymongo 4.9.x에서 `if not db:` 패턴이 `NotImplementedError`를 발생시키므로,
+> 반드시 `if db is None:` 패턴을 사용해야 한다.
+
+### 7.4 Frontend Dockerfile
+
+```dockerfile
+FROM node:20-alpine
+
+WORKDIR /app
+
+# 의존성 설치
+COPY package.json package-lock.json* ./
+RUN npm ci
+
+# 소스 코드
+COPY . .
+
+# 포트 노출
+EXPOSE 3000
+
+# 개발 모드 실행
+CMD ["npm", "run", "dev"]
+```
+
+### 7.5 환경변수
+
+**Backend (.env)**:
+
+```env
+MONGODB_URL=mongodb://mongodb:27017
+MONGODB_DATABASE=todos2
+REDIS_URL=redis://redis:6379
+FRONTEND_URL=http://localhost:3000
+```
+
+**Frontend (.env.local)**:
+
+```env
+NEXT_PUBLIC_API_URL=http://localhost:8000
+```
+
+---
+
+## 8. 개발 컨벤션
+
+### 8.1 파일 네이밍 규칙
+
+| 대상 | 규칙 | 예시 |
+|------|------|------|
+| **Python 모듈** | snake_case | `todo_service.py`, `dashboard_service.py` |
+| **Python 클래스** | PascalCase | `TodoService`, `CategoryCreate` |
+| **React 컴포넌트** | PascalCase.tsx | `TodoCard.tsx`, `SearchBar.tsx` |
+| **React Hooks** | camelCase (use 접두사) | `useTodos.ts`, `useCategories.ts` |
+| **TypeScript 타입** | PascalCase | `Todo`, `CategoryResponse` |
+| **API 경로** | kebab-case (단수/복수) | `/api/todos`, `/api/categories` |
+| **CSS 클래스** | Tailwind 유틸리티 | `className="flex items-center gap-2"` |
+| **환경변수** | SCREAMING_SNAKE_CASE | `MONGODB_URL`, `NEXT_PUBLIC_API_URL` |
+
+### 8.2 코드 스타일
+
+**Python (Backend)**:
+- Type hints 필수 사용
+- `async`/`await` 기반 비동기 코드
+- Pydantic v2 `model_dump()`, `model_validate()` 사용 (`dict()`, `parse_obj()` 금지)
+- MongoDB `_id` <-> API `id` 변환 시 `by_alias=True` 사용
+- docstring: 함수마다 한 줄 설명 + 비즈니스 규칙 번호 (F-XXX)
+
+**TypeScript (Frontend)**:
+- 모든 props에 TypeScript interface 정의
+- `"use client"` 지시어: 상태/이벤트가 있는 컴포넌트에만 사용
+- API 응답 타입은 `types/index.ts`에서 import
+- shadcn/ui 컴포넌트 커스터마이징은 원본 수정 없이 wrapping
+
+### 8.3 에러 처리 패턴
+
+**Backend**:
+
+```python
+from fastapi import HTTPException
+
+# 리소스 미존재
+async def get_todo(self, todo_id: str) -> TodoResponse:
+ if not ObjectId.is_valid(todo_id):
+ raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
+
+ doc = await self.collection.find_one({"_id": ObjectId(todo_id)})
+ if doc is None: # 주의: `if not doc:` 아닌 `if doc is None:`
+ raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
+
+ return self._to_response(doc)
+
+# 중복 (카테고리 이름)
+async def create_category(self, data: CategoryCreate) -> CategoryResponse:
+ existing = await self.collection.find_one({"name": data.name})
+ if existing is not None:
+ raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다")
+ # ...
+```
+
+**Frontend**:
+
+```typescript
+// hooks/useTodos.ts
+import { toast } from "@/components/ui/use-toast";
+
+export function useDeleteTodo() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (id: string) => apiClient.delete(`/api/todos/${id}`),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
+ queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
+ toast({ title: "할일이 삭제되었습니다" });
+ },
+ onError: (error: ApiError) => {
+ toast({
+ title: "삭제 실패",
+ description: error.detail,
+ variant: "destructive",
+ });
+ },
+ });
+}
+```
+
+### 8.4 마감일 알림 로직 (F-016, 프론트엔드)
+
+```typescript
+// lib/utils.ts
+export type DueDateStatus = "overdue" | "urgent" | "soon" | "normal" | null;
+
+export function getDueDateStatus(dueDate: string | null, completed: boolean): DueDateStatus {
+ if (!dueDate || completed) return null;
+
+ const now = new Date();
+ const due = new Date(dueDate);
+ const diffMs = due.getTime() - now.getTime();
+ const diffDays = diffMs / (1000 * 60 * 60 * 24);
+
+ if (diffDays < 0) return "overdue"; // 초과: 빨간색
+ if (diffDays <= 1) return "urgent"; // 1일 이내: 주황색
+ if (diffDays <= 3) return "soon"; // 3일 이내: 노란색
+ return "normal";
+}
+
+export function getDueDateLabel(status: DueDateStatus): string {
+ switch (status) {
+ case "overdue": return "초과";
+ case "urgent": return "임박";
+ case "soon": return "곧 마감";
+ default: return "";
+ }
+}
+
+export function getDueDateColor(status: DueDateStatus): string {
+ switch (status) {
+ case "overdue": return "bg-red-100 text-red-700";
+ case "urgent": return "bg-orange-100 text-orange-700";
+ case "soon": return "bg-yellow-100 text-yellow-700";
+ default: return "bg-gray-100 text-gray-500";
+ }
+}
+```
+
+### 8.5 우선순위 색상 규칙 (F-014)
+
+```typescript
+export const PRIORITY_CONFIG = {
+ high: { label: "높음", color: "bg-red-100 text-red-700", dotColor: "bg-red-500" },
+ medium: { label: "중간", color: "bg-yellow-100 text-yellow-700", dotColor: "bg-yellow-500" },
+ low: { label: "낮음", color: "bg-blue-100 text-blue-700", dotColor: "bg-blue-500" },
+} as const;
+```
+
+### 8.6 MongoDB _id 처리 패턴
+
+```python
+# Service 내부 변환 헬퍼
+def _doc_to_response(self, doc: dict) -> TodoResponse:
+ """MongoDB 문서 -> API 응답 변환"""
+ return TodoResponse(
+ id=str(doc["_id"]),
+ title=doc["title"],
+ content=doc.get("content"),
+ completed=doc["completed"],
+ priority=doc["priority"],
+ category_id=str(doc["category_id"]) if doc.get("category_id") else None,
+ tags=doc.get("tags", []),
+ due_date=doc.get("due_date"),
+ created_at=doc["created_at"],
+ updated_at=doc["updated_at"],
+ )
+```
+
+### 8.7 FastAPI 라우터 등록 순서
+
+```python
+# app/routers/todos.py
+router = APIRouter(prefix="/api/todos", tags=["todos"])
+
+# 고정 경로를 패턴 경로보다 먼저 등록
+@router.post("/batch", response_model=BatchResponse)
+async def batch_action(...): ...
+
+@router.get("", response_model=TodoListResponse)
+async def list_todos(...): ...
+
+@router.post("", response_model=TodoResponse, status_code=201)
+async def create_todo(...): ...
+
+# 패턴 경로는 아래에 배치
+@router.get("/{todo_id}", response_model=TodoResponse)
+async def get_todo(...): ...
+
+@router.put("/{todo_id}", response_model=TodoResponse)
+async def update_todo(...): ...
+
+@router.delete("/{todo_id}", status_code=204)
+async def delete_todo(...): ...
+
+@router.patch("/{todo_id}/toggle", response_model=ToggleResponse)
+async def toggle_todo(...): ...
+```
+
+---
+
+## 부록 A: 기능-컴포넌트 매핑
+
+21개 기능이 아키텍처의 어디에서 구현되는지 추적표:
+
+| 기능 ID | 기능명 | Backend Router | Backend Service | Frontend Page | Frontend Component |
+|---------|--------|----------------|-----------------|---------------|-------------------|
+| F-001 | 할일 생성 | todos.py | TodoService.create_todo | P-002 | TodoForm |
+| F-002 | 할일 목록 조회 | todos.py | TodoService.list_todos | P-002 | TodoList, TodoFilter |
+| F-003 | 할일 상세 조회 | todos.py | TodoService.get_todo | P-003 | TodoDetailForm |
+| F-004 | 할일 수정 | todos.py | TodoService.update_todo | P-003 | TodoDetailForm |
+| F-005 | 할일 삭제 | todos.py | TodoService.delete_todo | P-002 | TodoCard |
+| F-006 | 할일 완료 토글 | todos.py | TodoService.toggle_todo | P-002 | TodoCard |
+| F-007 | 카테고리 생성 | categories.py | CategoryService.create | P-004 | CategoryForm |
+| F-008 | 카테고리 목록 조회 | categories.py | CategoryService.list | P-004 | CategoryList, Sidebar |
+| F-009 | 카테고리 수정 | categories.py | CategoryService.update | P-004 | CategoryForm |
+| F-010 | 카테고리 삭제 | categories.py | CategoryService.delete | P-004 | CategoryItem |
+| F-011 | 태그 부여 | todos.py | TodoService (create/update) | P-002, P-003 | TagSelect, TagInput |
+| F-012 | 태그별 필터링 | todos.py | TodoService.list_todos | P-002 | TagBadge, TodoFilter |
+| F-013 | 태그 목록 조회 | tags.py | TodoService.get_tags | Sidebar | Sidebar (Popular Tags) |
+| F-014 | 우선순위 설정 | todos.py | TodoService (create/update) | P-002, P-003 | PrioritySelect |
+| F-015 | 마감일 설정 | todos.py | TodoService (create/update) | P-002, P-003 | DatePicker |
+| F-016 | 마감일 알림 표시 | - (프론트엔드 전용) | - | P-002 | DueDateBadge |
+| F-017 | 검색 | search.py | SearchService.search | P-005 | SearchBar, SearchResults |
+| F-018 | 대시보드 통계 | dashboard.py | DashboardService.get_stats | P-001 | StatsCards, Charts |
+| F-019 | 일괄 완료 처리 | todos.py (batch) | TodoService.batch_complete | P-002 | BatchActions |
+| F-020 | 일괄 삭제 | todos.py (batch) | TodoService.batch_delete | P-002 | BatchActions |
+| F-021 | 일괄 카테고리 변경 | todos.py (batch) | TodoService.batch_move | P-002 | BatchActions |
diff --git a/docs/FEATURE_SPEC.md b/docs/FEATURE_SPEC.md
new file mode 100644
index 0000000..9e9d8a3
--- /dev/null
+++ b/docs/FEATURE_SPEC.md
@@ -0,0 +1,789 @@
+# 기능 정의서 (Feature Specification)
+
+> 프로젝트: todos2 | 버전: 1.0.0 | 작성일: 2026-02-10
+
+---
+
+## 1. 기능 목록 (Feature Inventory)
+
+| # | 기능명 | 우선순위 | 카테고리 | 상태 |
+|---|--------|---------|---------|------|
+| F-001 | 할일 생성 | Must | 할일 CRUD | 미개발 |
+| F-002 | 할일 목록 조회 | Must | 할일 CRUD | 미개발 |
+| F-003 | 할일 상세 조회 | Must | 할일 CRUD | 미개발 |
+| F-004 | 할일 수정 | Must | 할일 CRUD | 미개발 |
+| F-005 | 할일 삭제 | Must | 할일 CRUD | 미개발 |
+| F-006 | 할일 완료 토글 | Must | 할일 CRUD | 미개발 |
+| F-007 | 카테고리 생성 | Must | 카테고리 | 미개발 |
+| F-008 | 카테고리 목록 조회 | Must | 카테고리 | 미개발 |
+| F-009 | 카테고리 수정 | Must | 카테고리 | 미개발 |
+| F-010 | 카테고리 삭제 | Must | 카테고리 | 미개발 |
+| F-011 | 태그 부여 | Must | 태그 | 미개발 |
+| F-012 | 태그별 필터링 | Must | 태그 | 미개발 |
+| F-013 | 태그 목록 조회 | Must | 태그 | 미개발 |
+| F-014 | 우선순위 설정 | Must | 우선순위 | 미개발 |
+| F-015 | 마감일 설정 | Must | 마감일 | 미개발 |
+| F-016 | 마감일 알림 표시 | Must | 마감일 | 미개발 |
+| F-017 | 검색 | Should | 검색 | 미개발 |
+| F-018 | 대시보드 통계 | Should | 대시보드 | 미개발 |
+| F-019 | 일괄 완료 처리 | Should | 일괄 작업 | 미개발 |
+| F-020 | 일괄 삭제 | Should | 일괄 작업 | 미개발 |
+| F-021 | 일괄 카테고리 변경 | Should | 일괄 작업 | 미개발 |
+
+---
+
+## 2. 기능 상세 정의
+
+---
+
+### F-001: 할일 생성
+
+- **설명**: 사용자가 제목, 내용, 카테고리, 태그, 우선순위, 마감일을 입력하여 새로운 할일을 생성한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| title | string | Y | 1~200자, 공백만 불가 | 할일 제목 |
+| content | string | N | 최대 2000자 | 할일 상세 내용 |
+| category_id | string | N | 유효한 카테고리 ObjectId | 분류 카테고리 |
+| tags | string[] | N | 각 태그 1~30자, 최대 10개 | 태그 목록 |
+| priority | enum | N | high / medium / low (기본: medium) | 우선순위 |
+| due_date | datetime | N | 현재 시각 이후 (생성 시점 기준) | 마감일 |
+
+#### 처리 규칙 (Business Rules)
+
+1. title 앞뒤 공백을 제거(trim)한다.
+2. tags 배열 내 중복 태그를 제거하고, 각 태그를 소문자로 정규화한다.
+3. category_id가 지정된 경우, 해당 카테고리가 존재하는지 검증한다.
+4. created_at, updated_at을 현재 시각(UTC)으로 설정한다.
+5. completed는 false로 초기화한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 201 Created | 생성된 Todo 객체 (id 포함) |
+| 제목 누락/검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 |
+| 카테고리 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 제목만 입력하여 할일을 생성할 수 있다
+- [ ] 모든 필드(제목, 내용, 카테고리, 태그, 우선순위, 마감일)를 입력하여 할일을 생성할 수 있다
+- [ ] 제목 없이 생성 시 422 에러가 반환된다
+- [ ] 201자 이상의 제목 입력 시 422 에러가 반환된다
+- [ ] 존재하지 않는 카테고리 ID 입력 시 404 에러가 반환된다
+- [ ] 생성 후 할일 목록에 즉시 반영된다
+- [ ] 태그 중복이 자동으로 제거된다
+
+---
+
+### F-002: 할일 목록 조회
+
+- **설명**: 필터링, 정렬, 페이지네이션을 적용하여 할일 목록을 조회한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| page | integer | N | >= 1, 기본: 1 | 페이지 번호 |
+| limit | integer | N | 1~100, 기본: 20 | 페이지당 항목 수 |
+| completed | boolean | N | true / false | 완료 상태 필터 |
+| category_id | string | N | 유효한 ObjectId | 카테고리 필터 |
+| priority | enum | N | high / medium / low | 우선순위 필터 |
+| tag | string | N | - | 태그 필터 |
+| sort | string | N | created_at / due_date / priority | 정렬 기준 (기본: created_at) |
+| order | enum | N | asc / desc (기본: desc) | 정렬 방향 |
+
+#### 처리 규칙 (Business Rules)
+
+1. 필터 조건이 복수인 경우 AND 조건으로 적용한다.
+2. 카테고리 필터 시 해당 category의 이름과 색상을 populate한다.
+3. 페이지네이션 응답에 총 개수(total)와 총 페이지 수(total_pages)를 포함한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | `{"items": [...], "total": N, "page": N, "limit": N, "total_pages": N}` |
+| 잘못된 파라미터 | 422 Unprocessable Entity | 필드별 에러 메시지 |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 필터 없이 전체 할일 목록을 조회할 수 있다
+- [ ] 완료/미완료 상태로 필터링할 수 있다
+- [ ] 카테고리별로 필터링할 수 있다
+- [ ] 우선순위별로 필터링할 수 있다
+- [ ] 태그별로 필터링할 수 있다
+- [ ] 페이지네이션이 정상 동작한다 (총 개수, 총 페이지 수 포함)
+- [ ] 정렬 기준(생성일, 마감일, 우선순위)과 방향(오름차순/내림차순)을 지정할 수 있다
+
+---
+
+### F-003: 할일 상세 조회
+
+- **설명**: 특정 할일의 전체 정보를 조회한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| id | string | Y | 유효한 ObjectId | 할일 ID |
+
+#### 처리 규칙 (Business Rules)
+
+1. 카테고리가 지정된 경우, 카테고리 이름과 색상을 함께 반환한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | Todo 객체 (카테고리 정보 포함) |
+| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` |
+| 잘못된 ID 형식 | 422 Unprocessable Entity | `{"detail": "유효하지 않은 ID 형식입니다"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 유효한 ID로 할일 상세 정보를 조회할 수 있다
+- [ ] 카테고리 정보가 함께 반환된다
+- [ ] 존재하지 않는 ID로 조회 시 404가 반환된다
+
+---
+
+### F-004: 할일 수정
+
+- **설명**: 기존 할일의 제목, 내용, 카테고리, 태그, 우선순위, 마감일을 수정한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| id | string (path) | Y | 유효한 ObjectId | 할일 ID |
+| title | string | N | 1~200자 | 할일 제목 |
+| content | string | N | 최대 2000자 | 할일 상세 내용 |
+| category_id | string \| null | N | 유효한 ObjectId 또는 null | 카테고리 (null이면 해제) |
+| tags | string[] | N | 각 태그 1~30자, 최대 10개 | 태그 목록 |
+| priority | enum | N | high / medium / low | 우선순위 |
+| due_date | datetime \| null | N | null이면 해제 | 마감일 |
+
+#### 처리 규칙 (Business Rules)
+
+1. 요청에 포함된 필드만 업데이트한다 (Partial Update).
+2. updated_at을 현재 시각(UTC)으로 갱신한다.
+3. category_id가 지정된 경우 해당 카테고리의 존재를 검증한다.
+4. tags 배열이 지정된 경우 중복 제거 및 소문자 정규화를 적용한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | 수정된 Todo 객체 |
+| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` |
+| 검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 제목만 수정할 수 있다
+- [ ] 카테고리를 변경할 수 있다
+- [ ] 카테고리를 null로 설정하여 해제할 수 있다
+- [ ] 태그를 추가/삭제할 수 있다
+- [ ] 우선순위를 변경할 수 있다
+- [ ] 마감일을 설정/해제할 수 있다
+- [ ] 수정 후 updated_at이 갱신된다
+- [ ] 존재하지 않는 할일 수정 시 404가 반환된다
+
+---
+
+### F-005: 할일 삭제
+
+- **설명**: 특정 할일을 영구 삭제한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| id | string | Y | 유효한 ObjectId | 할일 ID |
+
+#### 처리 규칙 (Business Rules)
+
+1. 물리 삭제를 수행한다.
+2. 삭제 전 확인 다이얼로그를 프론트엔드에서 표시한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 204 No Content | 빈 응답 |
+| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 할일을 삭제할 수 있다
+- [ ] 삭제 전 확인 다이얼로그가 표시된다
+- [ ] 삭제 후 목록에서 즉시 사라진다
+- [ ] 존재하지 않는 할일 삭제 시 404가 반환된다
+
+---
+
+### F-006: 할일 완료 토글
+
+- **설명**: 할일의 완료/미완료 상태를 전환한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| id | string (path) | Y | 유효한 ObjectId | 할일 ID |
+
+#### 처리 규칙 (Business Rules)
+
+1. 현재 completed 값의 반대값으로 토글한다.
+2. updated_at을 갱신한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | `{"id": "...", "completed": true/false}` |
+| 미존재 | 404 Not Found | `{"detail": "할일을 찾을 수 없습니다"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 체크박스 클릭으로 완료/미완료를 토글할 수 있다
+- [ ] 완료된 할일은 시각적으로 구분된다 (취소선, 흐림 처리 등)
+- [ ] 토글 후 대시보드 통계가 갱신된다
+
+---
+
+### F-007: 카테고리 생성
+
+- **설명**: 할일 분류를 위한 새 카테고리를 생성한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| name | string | Y | 1~50자, 고유 | 카테고리 이름 |
+| color | string | N | 유효한 hex color (기본: #6B7280) | 표시 색상 |
+
+#### 처리 규칙 (Business Rules)
+
+1. name 앞뒤 공백을 제거(trim)한다.
+2. 동일한 이름의 카테고리가 이미 존재하면 409 Conflict를 반환한다.
+3. order는 현재 최대값 + 1로 자동 설정한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 201 Created | 생성된 Category 객체 |
+| 이름 중복 | 409 Conflict | `{"detail": "이미 존재하는 카테고리 이름입니다"}` |
+| 검증 실패 | 422 Unprocessable Entity | 필드별 에러 메시지 |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 이름과 색상을 지정하여 카테고리를 생성할 수 있다
+- [ ] 이름만으로 생성 시 기본 색상이 적용된다
+- [ ] 중복 이름 생성 시 에러가 표시된다
+- [ ] 생성 후 사이드바 카테고리 목록에 즉시 반영된다
+
+---
+
+### F-008: 카테고리 목록 조회
+
+- **설명**: 전체 카테고리 목록을 조회한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+없음 (파라미터 없이 전체 조회)
+
+#### 처리 규칙 (Business Rules)
+
+1. order 필드 기준 오름차순으로 정렬한다.
+2. 각 카테고리에 속한 할일 수(todo_count)를 함께 반환한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | `[{"_id": "...", "name": "...", "color": "...", "order": N, "todo_count": N}]` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 전체 카테고리 목록을 조회할 수 있다
+- [ ] 각 카테고리에 할일 수가 표시된다
+- [ ] 정렬 순서대로 표시된다
+
+---
+
+### F-009: 카테고리 수정
+
+- **설명**: 기존 카테고리의 이름, 색상, 순서를 수정한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| id | string (path) | Y | 유효한 ObjectId | 카테고리 ID |
+| name | string | N | 1~50자, 고유 | 카테고리 이름 |
+| color | string | N | 유효한 hex color | 표시 색상 |
+| order | integer | N | >= 0 | 정렬 순서 |
+
+#### 처리 규칙 (Business Rules)
+
+1. 요청에 포함된 필드만 업데이트한다.
+2. name 변경 시 중복 검사를 수행한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | 수정된 Category 객체 |
+| 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` |
+| 이름 중복 | 409 Conflict | `{"detail": "이미 존재하는 카테고리 이름입니다"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 카테고리 이름을 변경할 수 있다
+- [ ] 카테고리 색상을 변경할 수 있다
+- [ ] 변경 사항이 해당 카테고리의 모든 할일 표시에 반영된다
+- [ ] 중복 이름으로 변경 시 에러가 표시된다
+
+---
+
+### F-010: 카테고리 삭제
+
+- **설명**: 카테고리를 삭제한다. 해당 카테고리에 속한 할일의 category_id는 null로 초기화된다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| id | string (path) | Y | 유효한 ObjectId | 카테고리 ID |
+
+#### 처리 규칙 (Business Rules)
+
+1. 카테고리 삭제 시, 해당 카테고리에 속한 모든 할일의 category_id를 null로 변경한다.
+2. 삭제 전 확인 다이얼로그에 영향받는 할일 수를 표시한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 204 No Content | 빈 응답 |
+| 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 카테고리를 삭제할 수 있다
+- [ ] 삭제 확인 다이얼로그에 영향받는 할일 수가 표시된다
+- [ ] 삭제 후 해당 카테고리의 할일들이 "미분류"로 표시된다
+- [ ] 사이드바에서 즉시 사라진다
+
+---
+
+### F-011: 태그 부여
+
+- **설명**: 할일 생성 또는 수정 시 태그를 부여한다. 콤마로 구분하여 다중 태그를 입력한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| tags | string[] | N | 각 1~30자, 최대 10개, 특수문자 불가 | 태그 배열 |
+
+#### 처리 규칙 (Business Rules)
+
+1. F-001(할일 생성), F-004(할일 수정)에서 tags 필드로 처리된다.
+2. 입력 태그를 소문자로 정규화하고, 중복을 제거한다.
+3. 기존 태그 자동완성을 위해 사용 중인 태그 목록을 제공한다.
+
+#### 출력 (Outputs)
+
+F-001, F-004의 출력과 동일 (Todo 객체에 tags 포함)
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 할일 생성/수정 폼에서 태그를 입력할 수 있다
+- [ ] 기존 태그 자동완성이 동작한다
+- [ ] 태그가 뱃지 형태로 표시된다
+- [ ] 태그를 개별 삭제할 수 있다 (x 버튼)
+
+---
+
+### F-012: 태그별 필터링
+
+- **설명**: 특정 태그가 부여된 할일만 필터링하여 조회한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| tag | string | Y | 존재하는 태그 | 필터링할 태그명 |
+
+#### 처리 규칙 (Business Rules)
+
+1. F-002(할일 목록 조회)의 tag 파라미터로 처리된다.
+2. 태그 뱃지 클릭 시 해당 태그로 필터링된다.
+
+#### 출력 (Outputs)
+
+F-002의 출력과 동일
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 태그 뱃지 클릭으로 해당 태그의 할일만 필터링된다
+- [ ] 필터 적용 중 태그명이 표시된다
+- [ ] 필터 해제 버튼으로 전체 목록으로 돌아갈 수 있다
+
+---
+
+### F-013: 태그 목록 조회
+
+- **설명**: 현재 사용 중인 모든 태그의 목록과 사용 횟수를 조회한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+없음
+
+#### 처리 규칙 (Business Rules)
+
+1. todos 컬렉션에서 tags 필드를 distinct로 추출한다.
+2. 각 태그의 사용 횟수(count)를 집계한다.
+3. 사용 횟수 내림차순으로 정렬한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | `[{"name": "업무", "count": 5}, ...]` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 사용 중인 모든 태그 목록을 조회할 수 있다
+- [ ] 각 태그의 사용 횟수가 표시된다
+- [ ] 할일이 없는 태그는 목록에 표시되지 않는다
+
+---
+
+### F-014: 우선순위 설정
+
+- **설명**: 할일에 높음(high), 중간(medium), 낮음(low) 3단계 우선순위를 설정한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| priority | enum | N | high / medium / low | 우선순위 (기본: medium) |
+
+#### 처리 규칙 (Business Rules)
+
+1. F-001(할일 생성), F-004(할일 수정)의 priority 필드로 처리된다.
+2. UI에서 색상으로 구분: high=빨강, medium=노랑, low=파랑.
+
+#### 출력 (Outputs)
+
+F-001, F-004의 출력과 동일
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 할일 생성/수정 시 우선순위를 선택할 수 있다
+- [ ] 우선순위별 색상 구분이 된다 (high=빨강, medium=노랑, low=파랑)
+- [ ] 기본 우선순위는 medium이다
+- [ ] 목록에서 우선순위별 필터링이 가능하다
+
+---
+
+### F-015: 마감일 설정
+
+- **설명**: 할일에 마감일을 설정한다. 달력 UI로 날짜를 선택한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| due_date | datetime | N | ISO 8601 형식 | 마감일 |
+
+#### 처리 규칙 (Business Rules)
+
+1. F-001(할일 생성), F-004(할일 수정)의 due_date 필드로 처리된다.
+2. null을 전송하면 마감일을 해제한다.
+
+#### 출력 (Outputs)
+
+F-001, F-004의 출력과 동일
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 달력 UI로 마감일을 선택할 수 있다
+- [ ] 마감일이 설정된 할일에 날짜가 표시된다
+- [ ] 마감일을 해제할 수 있다
+
+---
+
+### F-016: 마감일 알림 표시
+
+- **설명**: 마감일이 임박하거나 초과한 할일에 시각적 알림을 표시한다.
+- **우선순위**: Must
+
+#### 입력 (Inputs)
+
+없음 (프론트엔드 렌더링 로직)
+
+#### 처리 규칙 (Business Rules)
+
+1. 마감일 1일 이내: 주황색 "임박" 뱃지 표시
+2. 마감일 초과: 빨간색 "초과" 뱃지 표시
+3. 마감일 3일 이내: 노란색 "곧 마감" 표시
+4. 완료된 할일은 알림을 표시하지 않는다.
+
+#### 출력 (Outputs)
+
+UI 렌더링 (API 응답 없음)
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 마감 3일 이내 할일에 "곧 마감" 표시가 된다
+- [ ] 마감 1일 이내 할일에 "임박" 표시가 된다
+- [ ] 마감 초과 할일에 "초과" 표시가 된다
+- [ ] 완료된 할일에는 알림이 표시되지 않는다
+- [ ] 뱃지 색상이 긴급도에 따라 구분된다 (노랑/주황/빨강)
+
+---
+
+### F-017: 검색
+
+- **설명**: 제목, 내용, 태그를 기반으로 할일을 검색한다.
+- **우선순위**: Should
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| q | string | Y | 1~200자 | 검색 키워드 |
+| page | integer | N | >= 1, 기본: 1 | 페이지 번호 |
+| limit | integer | N | 1~100, 기본: 20 | 페이지당 항목 수 |
+
+#### 처리 규칙 (Business Rules)
+
+1. MongoDB text index를 활용한 전문 검색을 수행한다.
+2. 검색 대상: title, content, tags 필드.
+3. 검색 결과는 관련도(text score) 기준으로 정렬한다.
+4. 검색어가 태그와 정확히 일치하는 경우 해당 태그의 할일을 우선 표시한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | `{"items": [...], "total": N, "query": "...", "page": N, "limit": N}` |
+| 검색어 누락 | 422 Unprocessable Entity | `{"detail": "검색어를 입력해주세요"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 헤더 검색바에 키워드를 입력하여 검색할 수 있다
+- [ ] 제목에 포함된 키워드로 검색된다
+- [ ] 내용에 포함된 키워드로 검색된다
+- [ ] 태그명으로 검색된다
+- [ ] 검색 결과가 관련도순으로 정렬된다
+- [ ] 검색 결과가 없을 때 "결과 없음" 메시지가 표시된다
+- [ ] 검색 결과에서 검색어가 하이라이트된다
+
+---
+
+### F-018: 대시보드 통계
+
+- **설명**: 할일 현황을 한눈에 파악할 수 있는 대시보드 통계를 제공한다.
+- **우선순위**: Should
+
+#### 입력 (Inputs)
+
+없음
+
+#### 처리 규칙 (Business Rules)
+
+1. 통계 항목:
+ - 전체 할일 수, 완료 수, 미완료 수
+ - 완료율 (%)
+ - 카테고리별 할일 분포 (도넛 차트)
+ - 우선순위별 현황 (가로 막대 차트)
+ - 마감 임박 할일 목록 (상위 5개)
+2. Redis에 60초 TTL로 캐싱한다.
+3. 캐시 무효화: 할일 CUD 작업 시.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | 아래 JSON 구조 참고 |
+
+```json
+{
+ "overview": {
+ "total": 50,
+ "completed": 30,
+ "incomplete": 20,
+ "completion_rate": 60.0
+ },
+ "by_category": [
+ {"category_id": "...", "name": "업무", "color": "#EF4444", "count": 15}
+ ],
+ "by_priority": {
+ "high": 10,
+ "medium": 25,
+ "low": 15
+ },
+ "upcoming_deadlines": [
+ {"id": "...", "title": "...", "due_date": "...", "priority": "high"}
+ ]
+}
+```
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 전체/완료/미완료 수가 카드 형태로 표시된다
+- [ ] 완료율이 퍼센트로 표시된다
+- [ ] 카테고리별 할일 분포가 도넛 차트로 표시된다
+- [ ] 우선순위별 현황이 막대 차트로 표시된다
+- [ ] 마감 임박 할일 상위 5개가 리스트로 표시된다
+- [ ] 데이터가 없을 때 빈 상태 안내가 표시된다
+
+---
+
+### F-019: 일괄 완료 처리
+
+- **설명**: 여러 할일을 선택하여 한번에 완료 처리한다.
+- **우선순위**: Should
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| action | string | Y | "complete" | 작업 유형 |
+| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 |
+
+#### 처리 규칙 (Business Rules)
+
+1. 선택된 모든 할일의 completed를 true로 설정한다.
+2. 각 항목의 updated_at을 갱신한다.
+3. 존재하지 않는 ID는 무시하고 나머지를 처리한다.
+4. 처리된 수와 실패 수를 반환한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | `{"action": "complete", "processed": 5, "failed": 0}` |
+| ID 누락 | 422 Unprocessable Entity | `{"detail": "대상 할일을 선택해주세요"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 체크박스로 여러 할일을 선택할 수 있다
+- [ ] "일괄 완료" 버튼으로 선택된 할일을 한번에 완료할 수 있다
+- [ ] 처리 결과(성공 수)가 토스트로 표시된다
+- [ ] 처리 후 목록이 갱신된다
+
+---
+
+### F-020: 일괄 삭제
+
+- **설명**: 여러 할일을 선택하여 한번에 삭제한다.
+- **우선순위**: Should
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| action | string | Y | "delete" | 작업 유형 |
+| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 |
+
+#### 처리 규칙 (Business Rules)
+
+1. 선택된 모든 할일을 물리 삭제한다.
+2. 삭제 전 확인 다이얼로그를 표시한다.
+3. 존재하지 않는 ID는 무시한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | `{"action": "delete", "processed": 3, "failed": 0}` |
+| ID 누락 | 422 Unprocessable Entity | `{"detail": "대상 할일을 선택해주세요"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] "일괄 삭제" 버튼으로 선택된 할일을 한번에 삭제할 수 있다
+- [ ] 삭제 전 "N개의 할일을 삭제하시겠습니까?" 확인 다이얼로그가 표시된다
+- [ ] 처리 결과가 토스트로 표시된다
+- [ ] 삭제 후 목록이 갱신된다
+
+---
+
+### F-021: 일괄 카테고리 변경
+
+- **설명**: 여러 할일을 선택하여 한번에 카테고리를 변경한다.
+- **우선순위**: Should
+
+#### 입력 (Inputs)
+
+| 필드명 | 타입 | 필수 | 검증 규칙 | 설명 |
+|--------|------|------|-----------|------|
+| action | string | Y | "move_category" | 작업 유형 |
+| ids | string[] | Y | 1개 이상의 유효한 ObjectId | 대상 할일 ID 배열 |
+| category_id | string \| null | Y | 유효한 카테고리 ObjectId 또는 null | 변경할 카테고리 |
+
+#### 처리 규칙 (Business Rules)
+
+1. 선택된 모든 할일의 category_id를 지정된 값으로 변경한다.
+2. category_id가 null이면 "미분류"로 설정한다.
+3. 대상 카테고리의 존재를 검증한다.
+
+#### 출력 (Outputs)
+
+| 상황 | HTTP 상태 | 응답 |
+|------|-----------|------|
+| 성공 | 200 OK | `{"action": "move_category", "processed": 4, "failed": 0}` |
+| 카테고리 미존재 | 404 Not Found | `{"detail": "카테고리를 찾을 수 없습니다"}` |
+
+#### 수락 기준 (Acceptance Criteria)
+
+- [ ] 카테고리 선택 드롭다운으로 일괄 변경할 수 있다
+- [ ] "미분류"로 일괄 변경할 수 있다
+- [ ] 처리 결과가 토스트로 표시된다
+- [ ] 변경 후 목록이 갱신된다
+
+---
+
+## 3. API 엔드포인트 요약
+
+| Method | Path | 기능 ID | 설명 |
+|--------|------|---------|------|
+| POST | `/api/todos` | F-001 | 할일 생성 |
+| GET | `/api/todos` | F-002 | 할일 목록 조회 (필터/정렬/페이지네이션) |
+| GET | `/api/todos/{id}` | F-003 | 할일 상세 조회 |
+| PUT | `/api/todos/{id}` | F-004 | 할일 수정 |
+| DELETE | `/api/todos/{id}` | F-005 | 할일 삭제 |
+| PATCH | `/api/todos/{id}/toggle` | F-006 | 할일 완료 토글 |
+| POST | `/api/categories` | F-007 | 카테고리 생성 |
+| GET | `/api/categories` | F-008 | 카테고리 목록 조회 |
+| PUT | `/api/categories/{id}` | F-009 | 카테고리 수정 |
+| DELETE | `/api/categories/{id}` | F-010 | 카테고리 삭제 |
+| GET | `/api/tags` | F-013 | 태그 목록 조회 |
+| GET | `/api/search` | F-017 | 검색 |
+| GET | `/api/dashboard/stats` | F-018 | 대시보드 통계 |
+| POST | `/api/todos/batch` | F-019, F-020, F-021 | 일괄 작업 (완료/삭제/카테고리 변경) |
diff --git a/docs/PLAN.md b/docs/PLAN.md
new file mode 100644
index 0000000..ce5c6dd
--- /dev/null
+++ b/docs/PLAN.md
@@ -0,0 +1,259 @@
+# todos2 - 전략 기획안 (Strategic Plan)
+
+> 버전: 1.0.0 | 작성일: 2026-02-10 | 상태: 기획 완료
+
+## 1. 프로젝트 개요
+
+**todos2**는 단순 할일 관리를 넘어, 카테고리/태그/우선순위/마감일/검색/대시보드 기능을 갖춘 확장형 할일 관리 애플리케이션이다.
+
+### 1.1 목표
+- 할일의 체계적 분류 및 관리 (카테고리, 태그, 우선순위)
+- 마감일 기반 일정 관리 및 시각적 알림
+- 검색 및 필터링을 통한 빠른 할일 탐색
+- 대시보드를 통한 진행 현황 한눈에 파악
+- 일괄 작업을 통한 효율적인 다중 할일 처리
+
+### 1.2 대상 사용자
+- 개인 할일 관리가 필요한 사용자
+- 프로젝트별/카테고리별 작업 분류가 필요한 사용자
+
+---
+
+## 2. 핵심 기능
+
+| # | 기능 | 설명 | 우선순위 |
+|---|------|------|---------|
+| 1 | 할일 CRUD | 할일 생성, 조회, 수정, 삭제 | Must |
+| 2 | 카테고리 관리 | 카테고리 CRUD, 할일별 카테고리 분류 | Must |
+| 3 | 태그 시스템 | 할일에 다중 태그 부여, 태그별 필터링 | Must |
+| 4 | 우선순위 | 높음/중간/낮음 3단계 우선순위 설정 | Must |
+| 5 | 마감일 관리 | 마감일 설정, 임박/초과 알림 표시 | Must |
+| 6 | 검색 | 제목/내용/태그 기반 전문 검색 | Should |
+| 7 | 대시보드 | 완료율, 카테고리별/우선순위별 통계 차트 | Should |
+| 8 | 일괄 작업 | 다중 선택 후 완료/삭제/카테고리 변경 | Should |
+
+---
+
+## 3. 기술 스택
+
+### 3.1 백엔드
+| 기술 | 버전 | 용도 |
+|------|------|------|
+| Python | 3.11 | 런타임 |
+| FastAPI | >= 0.104 | REST API 프레임워크 |
+| Motor | >= 3.3 | MongoDB 비동기 드라이버 |
+| Pydantic v2 | >= 2.5 | 데이터 검증 및 직렬화 |
+| Uvicorn | >= 0.24 | ASGI 서버 |
+| Redis (aioredis) | >= 2.0 | 캐싱 (대시보드 통계) |
+
+### 3.2 프론트엔드
+| 기술 | 버전 | 용도 |
+|------|------|------|
+| Next.js | 15 (App Router) | React 프레임워크 |
+| TypeScript | 5.x | 타입 안정성 |
+| Tailwind CSS | 4.x | 유틸리티 기반 스타일링 |
+| shadcn/ui | latest | UI 컴포넌트 라이브러리 |
+| Recharts | 2.x | 대시보드 차트 |
+| Tanstack Query | 5.x | 서버 상태 관리 및 캐싱 |
+| Zustand | 5.x | 클라이언트 상태 관리 |
+
+### 3.3 인프라
+| 기술 | 용도 |
+|------|------|
+| MongoDB 7.0 | 메인 데이터베이스 |
+| Redis 7 | 캐싱 레이어 |
+| Docker Compose | 로컬 개발 환경 |
+
+---
+
+## 4. 프로젝트 구조
+
+```
+todos2/
+├── docker-compose.yml
+├── CLAUDE.md
+├── PLAN.md
+├── FEATURE_SPEC.md
+├── SCREEN_DESIGN.pptx
+├── SCREEN_DESIGN.md
+│
+├── backend/
+│ ├── Dockerfile
+│ ├── requirements.txt
+│ └── app/
+│ ├── __init__.py
+│ ├── main.py # FastAPI 앱 진입점
+│ ├── database.py # MongoDB 연결 설정
+│ ├── models/
+│ │ ├── __init__.py
+│ │ ├── todo.py # Todo 모델
+│ │ ├── category.py # Category 모델
+│ │ └── tag.py # Tag 모델
+│ ├── routers/
+│ │ ├── __init__.py
+│ │ ├── todos.py # 할일 CRUD API
+│ │ ├── categories.py # 카테고리 API
+│ │ ├── tags.py # 태그 API
+│ │ ├── search.py # 검색 API
+│ │ ├── dashboard.py # 대시보드 통계 API
+│ │ └── batch.py # 일괄 작업 API
+│ └── services/
+│ ├── __init__.py
+│ ├── todo_service.py
+│ ├── category_service.py
+│ ├── tag_service.py
+│ ├── search_service.py
+│ └── dashboard_service.py
+│
+└── frontend/
+ ├── Dockerfile
+ ├── package.json
+ ├── next.config.ts
+ ├── tailwind.config.ts
+ ├── tsconfig.json
+ └── src/
+ ├── app/
+ │ ├── layout.tsx # 루트 레이아웃
+ │ ├── page.tsx # 대시보드 (메인)
+ │ ├── todos/
+ │ │ └── page.tsx # 할일 목록
+ │ ├── categories/
+ │ │ └── page.tsx # 카테고리 관리
+ │ └── search/
+ │ └── page.tsx # 검색 결과
+ ├── components/
+ │ ├── layout/
+ │ │ ├── Header.tsx
+ │ │ ├── Sidebar.tsx
+ │ │ └── MainLayout.tsx
+ │ ├── todos/
+ │ │ ├── TodoCard.tsx
+ │ │ ├── TodoForm.tsx
+ │ │ ├── TodoList.tsx
+ │ │ ├── TodoFilter.tsx
+ │ │ └── BatchActions.tsx
+ │ ├── categories/
+ │ │ ├── CategoryList.tsx
+ │ │ └── CategoryForm.tsx
+ │ ├── tags/
+ │ │ ├── TagBadge.tsx
+ │ │ ├── TagSelect.tsx
+ │ │ └── TagManager.tsx
+ │ ├── dashboard/
+ │ │ ├── StatsCards.tsx
+ │ │ ├── CompletionChart.tsx
+ │ │ ├── CategoryChart.tsx
+ │ │ └── PriorityChart.tsx
+ │ └── search/
+ │ ├── SearchBar.tsx
+ │ └── SearchResults.tsx
+ ├── hooks/
+ │ ├── useTodos.ts
+ │ ├── useCategories.ts
+ │ ├── useTags.ts
+ │ ├── useDashboard.ts
+ │ └── useSearch.ts
+ ├── lib/
+ │ ├── api.ts # API 클라이언트
+ │ └── utils.ts # 유틸리티 함수
+ ├── store/
+ │ └── uiStore.ts # UI 상태 (사이드바, 필터 등)
+ └── types/
+ └── index.ts # TypeScript 타입 정의
+```
+
+---
+
+## 5. 데이터 모델
+
+### 5.1 Todo
+```json
+{
+ "_id": "ObjectId",
+ "title": "string (필수, 1~200자)",
+ "content": "string (선택, 최대 2000자)",
+ "completed": "boolean (기본: false)",
+ "priority": "enum: high | medium | low (기본: medium)",
+ "category_id": "ObjectId | null",
+ "tags": ["string"],
+ "due_date": "datetime | null",
+ "created_at": "datetime",
+ "updated_at": "datetime"
+}
+```
+
+### 5.2 Category
+```json
+{
+ "_id": "ObjectId",
+ "name": "string (필수, 1~50자, 고유)",
+ "color": "string (hex color, 기본: #6B7280)",
+ "order": "integer (정렬 순서)",
+ "created_at": "datetime"
+}
+```
+
+### 5.3 Tag (인라인)
+태그는 별도 컬렉션 없이 Todo 문서 내 배열로 관리한다.
+태그 목록은 todos 컬렉션에서 distinct 쿼리로 추출한다.
+
+---
+
+## 6. 개발 우선순위 (Phase별)
+
+### Phase 1: 핵심 기반 (MVP)
+> 목표: 기본 할일 관리가 가능한 최소 기능 제품
+
+1. 프로젝트 초기 세팅 (백엔드/프론트엔드 스캐폴딩)
+2. MongoDB 연결 및 데이터 모델 정의
+3. 할일 CRUD API + UI
+4. 카테고리 CRUD API + UI
+5. 기본 레이아웃 (Header, Sidebar, MainLayout)
+
+### Phase 2: 확장 기능
+> 목표: 분류/필터/우선순위로 할일 관리 고도화
+
+6. 태그 시스템 (태그 입력, 표시, 필터링)
+7. 우선순위 설정 (높음/중간/낮음)
+8. 마감일 설정 및 임박/초과 알림 UI
+9. 검색 기능 (제목/내용/태그)
+
+### Phase 3: 분석 및 효율
+> 목표: 통계 및 일괄 작업으로 생산성 향상
+
+10. 대시보드 통계 API
+11. 대시보드 차트 UI (완료율, 카테고리별, 우선순위별)
+12. 일괄 작업 (다중 선택, 일괄 완료/삭제/카테고리 변경)
+
+---
+
+## 7. API 설계 요약
+
+| Method | Endpoint | 설명 |
+|--------|----------|------|
+| GET | `/api/todos` | 할일 목록 (필터/페이지네이션) |
+| POST | `/api/todos` | 할일 생성 |
+| GET | `/api/todos/{id}` | 할일 상세 |
+| PUT | `/api/todos/{id}` | 할일 수정 |
+| DELETE | `/api/todos/{id}` | 할일 삭제 |
+| POST | `/api/todos/batch` | 일괄 작업 |
+| GET | `/api/categories` | 카테고리 목록 |
+| POST | `/api/categories` | 카테고리 생성 |
+| PUT | `/api/categories/{id}` | 카테고리 수정 |
+| DELETE | `/api/categories/{id}` | 카테고리 삭제 |
+| GET | `/api/tags` | 사용 중인 태그 목록 |
+| GET | `/api/search` | 검색 |
+| GET | `/api/dashboard/stats` | 대시보드 통계 |
+
+---
+
+## 8. 비기능 요구사항
+
+| 항목 | 기준 |
+|------|------|
+| API 응답 시간 | 95% 요청 200ms 이내 |
+| 페이지네이션 | 기본 20건, 최대 100건 |
+| 검색 | MongoDB text index 활용 |
+| 캐싱 | 대시보드 통계 Redis 캐시 (TTL 60초) |
+| CORS | 프론트엔드 도메인 허용 |
+| 에러 처리 | 표준 HTTP 상태 코드 + JSON 에러 응답 |
diff --git a/docs/SCREEN_DESIGN.md b/docs/SCREEN_DESIGN.md
new file mode 100644
index 0000000..57acb88
--- /dev/null
+++ b/docs/SCREEN_DESIGN.md
@@ -0,0 +1,312 @@
+# todos2 — 화면설계서
+
+> 자동 생성: `pptx_to_md.py` | 원본: `SCREEN_DESIGN.pptx`
+> 생성 시각: 2026-02-10 07:12
+> **이 파일을 직접 수정하지 마세요. PPTX를 수정 후 스크립트를 재실행하세요.**
+
+## 페이지 목록
+
+| ID | 페이지명 | 경로 | 설명 |
+|-----|---------|------|------|
+| P-001 | 대시보드 | `/` | 메인 페이지. 통계 카드, 차트, 마감 임박 목록 |
+| P-002 | 할일 목록 | `/todos` | 할일 CRUD, 필터링, 정렬, 일괄 작업 |
+| P-003 | 할일 상세/편집 | `/todos/[id]` | 할일 상세 보기 및 수정 폼 |
+| P-004 | 카테고리 관리 | `/categories` | 카테고리 CRUD, 색상 지정 |
+| P-005 | 검색 결과 | `/search` | 제목/내용/태그 기반 검색 결과 |
+
+---
+
+## P-001: 대시보드 (`/`)
+
+### 레이아웃
+
+[로고 todos2] | [검색바 _______________] | [알림]
+● 대시보드
+할일 목록
+카테고리 관리
+카테고리
+업무 (12)
+개인 (8)
+학습 (5)
+인기 태그
+#긴급
+#회의
+#프로젝트
+전체 할일50
+완료30
+미완료20
+완료율60%
+[카테고리별 분포 - 도넛 차트]업무 40% | 개인 30%학습 20% | 기타 10%
+[우선순위별 현황 - 막대 차트]high: 10 | medium: 25 | low: 15
+[마감 임박 할일]1. API 문서 작성 (D-1)2. 디자인 리뷰 (D-2)3. 테스트 코드 (D-3)
+
+| 컴포넌트 | 기능 | 상태 |
+| --- | --- | --- |
+| StatsCards | 전체/완료/미완료/완료율 카드 | loading, data, empty |
+| CategoryChart | 카테고리별 도넛 차트 | loading, data, empty |
+| PriorityChart | 우선순위별 막대 차트 | loading, data, empty |
+| UpcomingDeadlines | 마감 임박 할일 Top 5 | loading, data, empty |
+| Sidebar | 카테고리/태그 네비게이션 | default |
+
+### 컴포넌트
+
+| 컴포넌트 | Props | 상태 |
+|---------|-------|------|
+| `StatsCards` | stats | loading, empty, data |
+| `CategoryChart` | categoryData | loading, empty, data |
+| `PriorityChart` | priorityData | loading, empty, data |
+| `UpcomingDeadlines` | deadlines, onItemClick | loading, empty, data |
+| `Sidebar` | categories, tags, activePath | default |
+
+### 인터랙션
+
+| 트리거 | 동작 | 결과 |
+|--------|------|------|
+| 마감 임박 항목 클릭 | `router.push(/todos/{id})` | 해당 할일 상세 페이지로 이동 |
+| 사이드바 카테고리 클릭 | `router.push(/todos?category_id={id})` | 해당 카테고리의 할일 목록으로 이동 |
+| 사이드바 태그 클릭 | `router.push(/todos?tag={name})` | 해당 태그의 할일 목록으로 이동 |
+
+### 반응형: sm, md, lg
+
+---
+
+## P-002: 할일 목록 (`/todos`)
+
+### 레이아웃
+
+[로고 todos2] | [검색바 _______________] | [알림]
+대시보드
+● 할일 목록
+카테고리 관리
+필터:
+상태 ▾
+우선순위 ▾
+정렬 ▾
++ 새 할일
+3개 선택됨
+일괄 완료
+카테고리 변경
+일괄 삭제
+☐
+API 문서 작성
+업무
+#긴급
+D-1
+[수정] [삭제]
+☑
+회의록 정리
+업무
+#회의
+D-5
+[수정] [삭제]
+☐
+Next.js 학습
+학습
+#학습
+D-7
+[수정] [삭제]
+☑
+장보기 목록 작성
+개인
+#생활
+-
+[수정] [삭제]
+< 1 2 3 4 5 >
+
+| 컴포넌트 | 기능 | 상태 |
+| --- | --- | --- |
+| TodoFilter | 상태/우선순위/정렬 필터 | default, applied |
+| TodoList | 할일 카드 리스트 | loading, empty, error, data |
+| TodoCard | 개별 할일 행 | default, completed, overdue |
+| BatchActions | 일괄 작업 바 | hidden, visible |
+| TodoForm (Modal) | 할일 생성/수정 모달 | create, edit |
+| Pagination | 페이지 네비게이션 | default |
+
+### 컴포넌트
+
+| 컴포넌트 | Props | 상태 |
+|---------|-------|------|
+| `TodoFilter` | filters, onFilterChange | default, applied |
+| `TodoList` | todos, selectedIds, onToggle, onSelect, onEdit, onDelete | loading, empty, error, data |
+| `TodoCard` | todo, isSelected, onToggle, onSelect, onEdit, onDelete | default, completed, overdue |
+| `BatchActions` | selectedIds, categories, onBatchComplete, onBatchDelete, onBatchMove | hidden, visible |
+| `TodoForm` | mode, todo, categories, tags, onSubmit, onClose | create, edit |
+| `Pagination` | currentPage, totalPages, onPageChange | default |
+
+### 인터랙션
+
+| 트리거 | 동작 | 결과 |
+|--------|------|------|
+| "+ 새 할일" 버튼 클릭 | `openTodoForm(mode='create')` | 할일 생성 모달 열림 |
+| 체크박스 클릭 | `toggleTodo(id)` | 완료 상태 토글 |
+| 행 선택 체크박스 | `toggleSelect(id)` | 일괄 작업 대상에 추가/제거 |
+| 필터 변경 | `applyFilter(filters)` | 목록 재조회 |
+| "일괄 완료" 클릭 | `batchComplete(selectedIds)` | 선택된 할일 일괄 완료 |
+| "일괄 삭제" 클릭 | `batchDelete(selectedIds)` | 확인 후 일괄 삭제 |
+| "카테고리 변경" 클릭 | `batchMoveCategory(selectedIds, categoryId)` | 카테고리 선택 후 변경 |
+| 태그 뱃지 클릭 | `applyFilter({tag: tagName})` | 해당 태그로 필터링 |
+
+### 반응형: sm, md, lg
+
+---
+
+## P-003: 할일 상세/편집 (`/todos/[id]`)
+
+### 레이아웃
+
+[로고 todos2] | [검색바] | [알림]
+(Sidebar)
+할일 목록 > API 문서 작성
+제목 *
+API 문서 작성
+내용
+Swagger UI 기반 API 문서를 작성하고 엔드포인트별 요청/응답 예시를 추가한다.
+카테고리
+업무 ▾
+우선순위
+높음 ▾
+마감일
+2026-02-11 📅
+태그
+#긴급 ×
+#문서 ×
+태그 입력 (자동완성)...
+취소
+저장
+
+| 컴포넌트 | 기능 | 상태 |
+| --- | --- | --- |
+| TodoDetailForm | 할일 상세 폼 | loading, view, edit, saving |
+| CategorySelect | 카테고리 드롭다운 | default |
+| PrioritySelect | 우선순위 드롭다운 (색상) | default |
+| DatePicker | 달력 마감일 선택 | default, open |
+| TagInput | 태그 입력 + 자동완성 + 뱃지 | default, suggesting |
+
+### 컴포넌트
+
+| 컴포넌트 | Props | 상태 |
+|---------|-------|------|
+| `TodoDetailForm` | todo, categories, tags, onSave, onCancel, onDelete | loading, view, edit, saving |
+| `CategorySelect` | categories, selectedId, onChange | default |
+| `PrioritySelect` | selectedPriority, onChange | default |
+| `DatePicker` | selectedDate, onChange | default, open |
+| `TagInput` | tags, suggestions, onAdd, onRemove | default, suggesting |
+
+### 인터랙션
+
+| 트리거 | 동작 | 결과 |
+|--------|------|------|
+| "저장" 버튼 클릭 | `updateTodo(id, formData)` | 할일 업데이트 후 성공 토스트 |
+| "취소" 버튼 클릭 | `router.back()` | 이전 페이지로 이동 |
+| 태그 입력 키워드 타이핑 | `fetchTagSuggestions(keyword)` | 자동완성 드롭다운 표시 |
+| 태그 뱃지 × 클릭 | `removeTag(tagName)` | 태그 제거 |
+| 달력 아이콘 클릭 | `openDatePicker()` | 달력 팝업 표시 |
+
+### 반응형: sm, md, lg
+
+---
+
+## P-004: 카테고리 관리 (`/categories`)
+
+### 레이아웃
+
+[로고 todos2] | [검색바] | [알림]
+대시보드
+할일 목록
+● 카테고리 관리
+카테고리 관리
++ 새 카테고리
+업무
+12개 할일
+[색상] [수정] [삭제]
+개인
+8개 할일
+[색상] [수정] [삭제]
+학습
+5개 할일
+[색상] [수정] [삭제]
+건강
+3개 할일
+[색상] [수정] [삭제]
+새 카테고리 이름...
+추가
+
+| 컴포넌트 | 기능 | 상태 |
+| --- | --- | --- |
+| CategoryList | 카테고리 목록 | loading, empty, data |
+| CategoryItem | 개별 카테고리 행 | default, editing |
+| CategoryForm | 카테고리 생성/수정 인라인 폼 | create, edit |
+| ColorPicker | 카테고리 색상 선택기 | default, open |
+
+### 컴포넌트
+
+| 컴포넌트 | Props | 상태 |
+|---------|-------|------|
+| `CategoryList` | categories, onEdit, onDelete | loading, empty, data |
+| `CategoryItem` | category, onEdit, onDelete | default, editing |
+| `CategoryForm` | mode, category, onSubmit, onCancel | create, edit |
+| `ColorPicker` | selectedColor, onChange | default, open |
+
+### 인터랙션
+
+| 트리거 | 동작 | 결과 |
+|--------|------|------|
+| "+ 새 카테고리" 버튼 클릭 | `showCategoryForm(mode='create')` | 인라인 생성 폼 표시 |
+| "추가" 버튼 클릭 | `createCategory({name, color})` | 카테고리 생성 후 목록 갱신 |
+| "수정" 클릭 | `showCategoryForm(mode='edit', category)` | 해당 행이 수정 폼으로 전환 |
+| "삭제" 클릭 | `deleteCategory(id)` | 확인 다이얼로그 후 삭제 |
+| 색상 변경 클릭 | `openColorPicker(category)` | 색상 선택기 팝업 |
+
+### 반응형: sm, md, lg
+
+---
+
+## P-005: 검색 결과 (`/search`)
+
+### 레이아웃
+
+todos2
+API 문서 [×]
+[알림]
+(Sidebar)
+"API 문서" 검색 결과 (3건)
+API 문서 작성
+Swagger UI 기반 API 문서를 작성하고...
+업무
+D-1
+API 문서 리뷰
+팀원들과 API 문서 리뷰 미팅을...
+업무
+D-5
+REST API 문서화 학습
+OpenAPI 스펙과 자동화 도구를...
+학습
+D-14
+* 결과 없을 때: "검색 결과가 없습니다. 다른 키워드로 검색해보세요."
+
+| 컴포넌트 | 기능 | 상태 |
+| --- | --- | --- |
+| SearchBar | 헤더 내 검색 입력 + 클리어 | default, active, has_query |
+| SearchResults | 검색 결과 리스트 | loading, empty, data |
+| SearchResultItem | 개별 결과 (제목 하이라이트, 설명) | default |
+| Pagination | 결과 페이지네이션 | default |
+
+### 컴포넌트
+
+| 컴포넌트 | Props | 상태 |
+|---------|-------|------|
+| `SearchBar` | query, onSearch, onClear | default, active, has_query |
+| `SearchResults` | results, query, total, onItemClick | loading, empty, data |
+| `SearchResultItem` | result, query, onClick | default |
+| `Pagination` | currentPage, totalPages, onPageChange | default |
+
+### 인터랙션
+
+| 트리거 | 동작 | 결과 |
+|--------|------|------|
+| 검색바에 키워드 입력 후 Enter | `search(query)` | 검색 API 호출 후 결과 표시 |
+| 검색바 × 버튼 클릭 | `clearSearch()` | 검색어 클리어, 이전 페이지로 이동 |
+| 검색 결과 항목 클릭 | `router.push(/todos/{id})` | 해당 할일 상세 페이지로 이동 |
+| 페이지 번호 클릭 | `search(query, page)` | 해당 페이지의 검색 결과 |
+
+### 반응형: sm, md, lg
diff --git a/docs/SCREEN_DESIGN.pptx b/docs/SCREEN_DESIGN.pptx
new file mode 100644
index 0000000..6be6f2b
Binary files /dev/null and b/docs/SCREEN_DESIGN.pptx differ
diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md
new file mode 100644
index 0000000..fe382d8
--- /dev/null
+++ b/docs/TEST_REPORT.md
@@ -0,0 +1,320 @@
+# todos2 -- 테스트 보고서
+
+> 테스트 일시: 2026-02-10 10:30 KST
+> 테스트 환경: macOS Darwin 25.2.0, Python 3.11, Node 20
+> 테스터: Senior System Tester + DevOps (Claude Opus 4.6)
+
+---
+
+## 1. 백엔드 테스트
+
+### 1.1 구문 검증 (Python AST)
+
+| 파일 | 결과 |
+|------|------|
+| `app/__init__.py` | OK |
+| `app/config.py` | OK |
+| `app/database.py` | OK |
+| `app/main.py` | OK |
+| `app/models/__init__.py` | OK |
+| `app/models/category.py` | OK |
+| `app/models/common.py` | OK |
+| `app/models/todo.py` | OK |
+| `app/routers/__init__.py` | OK |
+| `app/routers/categories.py` | OK |
+| `app/routers/dashboard.py` | OK |
+| `app/routers/search.py` | OK |
+| `app/routers/tags.py` | OK |
+| `app/routers/todos.py` | OK |
+| `app/services/__init__.py` | OK |
+| `app/services/category_service.py` | OK |
+| `app/services/dashboard_service.py` | OK |
+| `app/services/search_service.py` | OK |
+| `app/services/todo_service.py` | OK |
+
+**결과: 19/19 파일 통과 (100%)**
+
+### 1.2 Import 정합성
+
+| 파일 | Import | 참조 대상 | 결과 |
+|------|--------|----------|------|
+| `app/main.py` | `from contextlib import asynccontextmanager` | stdlib | OK |
+| `app/main.py` | `from datetime import datetime` | stdlib | OK |
+| `app/main.py` | `from fastapi import FastAPI` | fastapi (requirements.txt) | OK |
+| `app/main.py` | `from fastapi.middleware.cors import CORSMiddleware` | fastapi | OK |
+| `app/main.py` | `from app.config import get_settings` | app/config.py | OK |
+| `app/main.py` | `from app.database import connect_db, disconnect_db` | app/database.py | OK |
+| `app/main.py` | `from app.routers import todos, categories, tags, search, dashboard` | app/routers/*.py | OK |
+| `app/config.py` | `from pydantic_settings import BaseSettings` | pydantic-settings (requirements.txt) | OK |
+| `app/config.py` | `from pydantic import Field` | pydantic (requirements.txt) | OK |
+| `app/config.py` | `from functools import lru_cache` | stdlib | OK |
+| `app/database.py` | `from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase` | motor (requirements.txt) | OK |
+| `app/database.py` | `import redis.asyncio as aioredis` | redis (requirements.txt) | OK |
+| `app/database.py` | `from app.config import get_settings` | app/config.py | OK |
+| `app/models/common.py` | `from bson import ObjectId` | pymongo (requirements.txt) | OK |
+| `app/models/common.py` | `from pydantic import BaseModel, BeforeValidator` | pydantic | OK |
+| `app/models/todo.py` | `from datetime import datetime` | stdlib | OK |
+| `app/models/todo.py` | `from enum import Enum` | stdlib | OK |
+| `app/models/todo.py` | `from pydantic import BaseModel, Field, field_validator` | pydantic | OK |
+| `app/models/todo.py` | `from bson import ObjectId` (inside validator) | pymongo | OK |
+| `app/models/category.py` | `from datetime import datetime` | stdlib | OK |
+| `app/models/category.py` | `from pydantic import BaseModel, Field, field_validator` | pydantic | OK |
+| `app/models/__init__.py` | `from app.models.common import ...` | app/models/common.py | OK |
+| `app/models/__init__.py` | `from app.models.todo import ...` | app/models/todo.py | OK |
+| `app/models/__init__.py` | `from app.models.category import ...` | app/models/category.py | OK |
+| `app/routers/__init__.py` | `from app.routers import todos, categories, tags, search, dashboard` | app/routers/*.py | OK |
+| `app/routers/todos.py` | `from fastapi import APIRouter, Depends, Query, Response, status` | fastapi | OK |
+| `app/routers/todos.py` | `from app.database import get_database, get_redis` | app/database.py | OK |
+| `app/routers/todos.py` | `from app.models.todo import ...` | app/models/todo.py | OK |
+| `app/routers/todos.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK |
+| `app/routers/categories.py` | `from app.database import get_database, get_redis` | app/database.py | OK |
+| `app/routers/categories.py` | `from app.models.category import ...` | app/models/category.py | OK |
+| `app/routers/categories.py` | `from app.services.category_service import CategoryService` | app/services/category_service.py | OK |
+| `app/routers/tags.py` | `from app.database import get_database` | app/database.py | OK |
+| `app/routers/tags.py` | `from app.models.todo import TagInfo` | app/models/todo.py | OK |
+| `app/routers/tags.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK |
+| `app/routers/search.py` | `from app.database import get_database` | app/database.py | OK |
+| `app/routers/search.py` | `from app.models.todo import SearchResponse` | app/models/todo.py | OK |
+| `app/routers/search.py` | `from app.services.search_service import SearchService` | app/services/search_service.py | OK |
+| `app/routers/dashboard.py` | `from app.database import get_database, get_redis` | app/database.py | OK |
+| `app/routers/dashboard.py` | `from app.models.todo import DashboardStats` | app/models/todo.py | OK |
+| `app/routers/dashboard.py` | `from app.services.dashboard_service import DashboardService` | app/services/dashboard_service.py | OK |
+| `app/services/__init__.py` | `from app.services.todo_service import TodoService` | app/services/todo_service.py | OK |
+| `app/services/__init__.py` | `from app.services.category_service import CategoryService` | app/services/category_service.py | OK |
+| `app/services/__init__.py` | `from app.services.search_service import SearchService` | app/services/search_service.py | OK |
+| `app/services/__init__.py` | `from app.services.dashboard_service import DashboardService` | app/services/dashboard_service.py | OK |
+| `app/services/todo_service.py` | `from bson import ObjectId` | pymongo | OK |
+| `app/services/todo_service.py` | `from fastapi import HTTPException` | fastapi | OK |
+| `app/services/todo_service.py` | `from motor.motor_asyncio import AsyncIOMotorDatabase` | motor | OK |
+| `app/services/todo_service.py` | `import redis.asyncio as aioredis` | redis | OK |
+| `app/services/todo_service.py` | `from app.models.todo import ...` | app/models/todo.py | OK |
+| `app/services/category_service.py` | `from app.models.category import ...` | app/models/category.py | OK |
+| `app/services/search_service.py` | `from app.models.todo import TodoResponse, SearchResponse` | app/models/todo.py | OK |
+| `app/services/dashboard_service.py` | `import json` | stdlib | OK |
+| `app/services/dashboard_service.py` | `from app.models.todo import ...` | app/models/todo.py | OK |
+
+**결과: 53/53 import 통과 (100%)**
+
+### 1.3 API 엔드포인트 매핑
+
+| 기능 (FEATURE_SPEC) | API 엔드포인트 | Router 파일 | 결과 |
+|---------------------|---------------|------------|------|
+| F-001: 할일 생성 | `POST /api/todos` | `routers/todos.py` (L62-68) | OK |
+| F-002: 할일 목록 조회 | `GET /api/todos` | `routers/todos.py` (L37-59) | OK |
+| F-003: 할일 상세 조회 | `GET /api/todos/{id}` | `routers/todos.py` (L71-77) | OK |
+| F-004: 할일 수정 | `PUT /api/todos/{id}` | `routers/todos.py` (L80-87) | OK |
+| F-005: 할일 삭제 | `DELETE /api/todos/{id}` | `routers/todos.py` (L90-97) | OK |
+| F-006: 할일 완료 토글 | `PATCH /api/todos/{id}/toggle` | `routers/todos.py` (L100-106) | OK |
+| F-007: 카테고리 생성 | `POST /api/categories` | `routers/categories.py` (L29-35) | OK |
+| F-008: 카테고리 목록 조회 | `GET /api/categories` | `routers/categories.py` (L21-26) | OK |
+| F-009: 카테고리 수정 | `PUT /api/categories/{id}` | `routers/categories.py` (L38-45) | OK |
+| F-010: 카테고리 삭제 | `DELETE /api/categories/{id}` | `routers/categories.py` (L48-55) | OK |
+| F-011: 태그 부여 | F-001/F-004의 `tags` 필드 | `models/todo.py` + `services/todo_service.py` | OK |
+| F-012: 태그별 필터링 | `GET /api/todos?tag=...` | `routers/todos.py` (L44) | OK |
+| F-013: 태그 목록 조회 | `GET /api/tags` | `routers/tags.py` (L10-16) | OK |
+| F-014: 우선순위 설정 | F-001/F-004의 `priority` 필드 | `models/todo.py` Priority enum | OK |
+| F-015: 마감일 설정 | F-001/F-004의 `due_date` 필드 | `models/todo.py` | OK |
+| F-016: 마감일 알림 표시 | 프론트엔드 UI 로직 | `lib/utils.ts` getDueDateStatus/Label/Color | OK |
+| F-017: 검색 | `GET /api/search?q=...` | `routers/search.py` (L10-19) | OK |
+| F-018: 대시보드 통계 | `GET /api/dashboard/stats` | `routers/dashboard.py` (L10-17) | OK |
+| F-019: 일괄 완료 처리 | `POST /api/todos/batch` (action=complete) | `routers/todos.py` (L28-34) | OK |
+| F-020: 일괄 삭제 | `POST /api/todos/batch` (action=delete) | `routers/todos.py` (L28-34) | OK |
+| F-021: 일괄 카테고리 변경 | `POST /api/todos/batch` (action=move_category) | `routers/todos.py` (L28-34) | OK |
+
+**결과: 21/21 기능 매핑 완료 (100%)**
+
+---
+
+## 2. 프론트엔드 테스트
+
+### 2.1 빌드 검증
+
+- Next.js 빌드: **OK** (이미 성공 확인됨)
+
+### 2.2 화면설계서 컴포넌트 매핑
+
+#### P-001: 대시보드 (`/`)
+
+| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 |
+|--------------------------|------|------|
+| `StatsCards` | `src/components/dashboard/StatsCards.tsx` | OK |
+| `CategoryChart` | `src/components/dashboard/CategoryChart.tsx` | OK |
+| `PriorityChart` | `src/components/dashboard/PriorityChart.tsx` | OK |
+| `UpcomingDeadlines` | `src/components/dashboard/UpcomingDeadlines.tsx` | OK |
+| `Sidebar` | `src/components/layout/Sidebar.tsx` | OK |
+| 페이지 | `src/app/page.tsx` | OK |
+
+#### P-002: 할일 목록 (`/todos`)
+
+| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 |
+|--------------------------|------|------|
+| `TodoFilter` | `src/components/todos/TodoFilter.tsx` | OK |
+| `TodoList` | `src/components/todos/TodoList.tsx` | OK |
+| `TodoCard` | `src/components/todos/TodoCard.tsx` | OK |
+| `BatchActions` | `src/components/todos/BatchActions.tsx` | OK |
+| `TodoForm` (Modal) | `src/components/todos/TodoForm.tsx` | OK |
+| `Pagination` | `src/components/common/Pagination.tsx` | OK |
+| 페이지 | `src/app/todos/page.tsx` | OK |
+
+#### P-003: 할일 상세/편집 (`/todos/[id]`)
+
+| 컴포넌트 (SCREEN_DESIGN) | 파일 | 결과 |
+|--------------------------|------|------|
+| `TodoDetailForm` | `src/components/todos/TodoDetailForm.tsx` | OK |
+| `CategorySelect` (inline select) | `TodoDetailForm.tsx` 내 `