Files
todos2/docs/ARCHITECTURE.md
jungwoo choi 074b5133bf feat: 풀스택 할일관리 앱 구현 (통합 모달 + 간트차트)
- Backend: FastAPI + MongoDB + Redis (카테고리, 할일 CRUD, 파일 첨부, 검색, 대시보드)
- Frontend: Next.js 15 + Tailwind + React Query + Zustand
- 통합 TodoModal: 생성/수정 모달 통합, 탭 구조 (기본/태그와 첨부)
- 간트차트: 카테고리별 할일 타임라인 시각화
- TodoCard: 제목/카테고리/우선순위/태그/첨부 한줄 표시
- Docker Compose 배포 (Frontend:3010, Backend:8010, MongoDB:27021, Redis:6391)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:45:03 +09:00

81 KiB

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)                    │
└─────────────────────────────────────────────────┘

의존성 주입 패턴:

# 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
# 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)

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)

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)

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)

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)

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)

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)

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)

# 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)

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)

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에 저장
# 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):

// app/layout.tsx
import { Providers } from "@/components/providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

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로 관리한다. 쿼리 키 설계:

// 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,
};

캐싱 설정:

// 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 후 캐시 무효화:

// 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 상태

// 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<UIState>((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 클라이언트

// 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<T>(
    method: string,
    path: string,
    options?: {
      body?: unknown;
      params?: Record<string, string | number | boolean | undefined>;
    }
  ): Promise<T> {
    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<T>(path: string, params?: Record<string, string | number | boolean | undefined>) {
    return this.request<T>("GET", path, { params });
  }

  post<T>(path: string, body?: unknown) {
    return this.request<T>("POST", path, { body });
  }

  put<T>(path: string, body?: unknown) {
    return this.request<T>("PUT", path, { body });
  }

  patch<T>(path: string, body?: unknown) {
    return this.request<T>("PATCH", path, { body });
  }

  delete<T>(path: string) {
    return this.request<T>("DELETE", path);
  }
}

export const apiClient = new ApiClient(API_BASE_URL);

3.6 타입 정의

// 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 컬렉션 스키마

// 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, 자동 갱신
}

인덱스:

// 전문 검색 (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 컬렉션 스키마

{
  "_id": ObjectId("..."),
  "name": "업무",              // string, 필수, 1~50자, unique
  "color": "#EF4444",          // string, hex color, 기본 "#6B7280"
  "order": 0,                  // integer, 정렬 순서
  "created_at": ISODate("2026-02-10T03:00:00Z")
}

인덱스:

// 이름 유니크 (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)

# 필터 조건 구성
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)

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)

# 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)

# 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:

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):

{
  "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):

{
  "detail": [
    {
      "loc": ["body", "title"],
      "msg": "String should have at least 1 character",
      "type": "string_too_short"
    }
  ]
}

에러 (404 - 카테고리 미존재):

{
  "detail": "카테고리를 찾을 수 없습니다"
}

5.2 할일 목록 조회 (F-002)

Request:

GET /api/todos?page=1&limit=20&completed=false&priority=high&sort=due_date&order=asc

Response (200 OK):

{
  "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:

GET /api/todos/65f1a2b3c4d5e6f7a8b9c0d2

Response (200 OK): Todo 객체 (5.1과 동일 구조)

에러 (422 - 잘못된 ID):

{
  "detail": "유효하지 않은 ID 형식입니다"
}

5.4 할일 수정 (F-004)

Request:

PUT /api/todos/65f1a2b3c4d5e6f7a8b9c0d2
Content-Type: application/json

{
  "title": "API 문서 작성 (v2)",
  "priority": "medium",
  "tags": ["문서", "api"]
}

Response (200 OK): 수정된 Todo 객체

5.5 할일 삭제 (F-005)

Request:

DELETE /api/todos/65f1a2b3c4d5e6f7a8b9c0d2

Response (204 No Content): 빈 응답

5.6 할일 완료 토글 (F-006)

Request:

PATCH /api/todos/65f1a2b3c4d5e6f7a8b9c0d2/toggle

Response (200 OK):

{
  "id": "65f1a2b3c4d5e6f7a8b9c0d2",
  "completed": true
}

5.7 일괄 작업 (F-019, F-020, F-021)

일괄 완료 (F-019):

POST /api/todos/batch
Content-Type: application/json

{
  "action": "complete",
  "ids": ["65f1...d2", "65f1...d3", "65f1...d4"]
}

일괄 삭제 (F-020):

POST /api/todos/batch
Content-Type: application/json

{
  "action": "delete",
  "ids": ["65f1...d2", "65f1...d3"]
}

일괄 카테고리 변경 (F-021):

POST /api/todos/batch
Content-Type: application/json

{
  "action": "move_category",
  "ids": ["65f1...d2", "65f1...d3"],
  "category_id": "65f1a2b3c4d5e6f7a8b9c0d1"
}

Response (200 OK):

{
  "action": "complete",
  "processed": 3,
  "failed": 0
}

5.8 카테고리 CRUD

목록 조회 (F-008):

GET /api/categories
[
  {
    "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):

POST /api/categories
Content-Type: application/json

{
  "name": "학습",
  "color": "#10B981"
}

Response (201 Created):

{
  "id": "65f1a2b3c4d5e6f7a8b9c0d6",
  "name": "학습",
  "color": "#10B981",
  "order": 2,
  "todo_count": 0,
  "created_at": "2026-02-10T03:00:00Z"
}

에러 (409 - 이름 중복):

{
  "detail": "이미 존재하는 카테고리 이름입니다"
}

수정 (F-009):

PUT /api/categories/65f1a2b3c4d5e6f7a8b9c0d1
Content-Type: application/json

{
  "name": "업무 프로젝트",
  "color": "#F59E0B"
}

삭제 (F-010):

DELETE /api/categories/65f1a2b3c4d5e6f7a8b9c0d1

Response (204 No Content)

5.9 태그 목록 (F-013)

Request:

GET /api/tags

Response (200 OK):

[
  { "name": "긴급", "count": 8 },
  { "name": "문서", "count": 5 },
  { "name": "회의", "count": 4 },
  { "name": "학습", "count": 3 }
]

5.10 검색 (F-017)

Request:

GET /api/search?q=API%20문서&page=1&limit=20

Response (200 OK):

{
  "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 - 검색어 누락):

{
  "detail": "검색어를 입력해주세요"
}

5.11 대시보드 통계 (F-018)

Request:

GET /api/dashboard/stats

Response (200 OK):

{
  "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 에러 응답 형식

모든 에러 응답은 다음 형식을 따른다:

단일 에러:

{
  "detail": "에러 메시지 (한국어)"
}

검증 에러 (Pydantic, 422):

{
  "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 캐싱

// 쿼리별 캐싱 전략
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

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

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

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):

MONGODB_URL=mongodb://mongodb:27017
MONGODB_DATABASE=todos2
REDIS_URL=redis://redis:6379
FRONTEND_URL=http://localhost:3000

Frontend (.env.local):

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:

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:

// 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, 프론트엔드)

// 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)

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 처리 패턴

# 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 라우터 등록 순서

# 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