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

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

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 |