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