- 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>
2649 lines
81 KiB
Markdown
2649 lines
81 KiB
Markdown
# 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 (
|
|
<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로 관리한다. 쿼리 키 설계:
|
|
|
|
```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<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 클라이언트
|
|
|
|
```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<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 타입 정의
|
|
|
|
```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 |
|