ToDo App - React FastApi 연동

0. DB

CREATE DATABASE IF NOT EXISTS todoapp;

1. BackEnd

콘다 가상환경 생성하고 라이브러리 설치

conda create -n todoapp
conda activate todoapp
conda install pip
cd backend/

/backend/requirements.txt 생성

fastapi
uvicorn
sqlalchemy
pymysql
cryptography
pydantic
python-multipart
fastapi-cors

라이브러리 설치

pip install -r requirements.txt

/backend/database.py

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

# MySQL 데이터베이스 URL
# 형식: mysql+pymysql://username:password@host:port/database_name
DATABASE_URL = "mysql+pymysql://root:1234@localhost:3306/todoapp"

# SQLAlchemy 엔진 생성
engine = create_engine(
    DATABASE_URL,
    echo=True,  # SQL 쿼리 로그 출력 (개발 시에만 True)
    pool_pre_ping=True,  # 연결 확인
    pool_recycle=300,  # 연결 재활용 시간
)

# 세션 로컬 클래스 생성
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# Base 클래스 생성
Base = declarative_base()

# 데이터베이스 세션 의존성
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

/backend/models.py

from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from database import Base

class Todo(Base):
    __tablename__ = "todos"

    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    title = Column(String(200), nullable=False, index=True)
    description = Column(String(500), nullable=True)
    completed = Column(Boolean, default=False, nullable=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

    def __repr__(self):
        return f"<Todo(id={self.id}, title='{self.title}', completed={self.completed})>"

/backend/schemas.py

from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional

# Todo 생성 시 사용할 스키마
class TodoCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200, description="할 일 제목")
    description: Optional[str] = Field(None, max_length=500, description="할 일 설명")

# Todo 업데이트 시 사용할 스키마
class TodoUpdate(BaseModel):
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=500)
    completed: Optional[bool] = None

# Todo 응답 시 사용할 스키마
class TodoResponse(BaseModel):
    id: int
    title: str
    description: Optional[str] = None
    completed: bool
    created_at: datetime
    updated_at: Optional[datetime] = None

    class Config:
        from_attributes = True  # SQLAlchemy 모델을 Pydantic 모델로 변환

# 여러 Todo를 응답할 때 사용할 스키마
class TodoListResponse(BaseModel):
    todos: list[TodoResponse]
    total: int

/backend/main.py

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List

import models
import schemas
from database import engine, get_db

# 데이터베이스 테이블 생성
models.Base.metadata.create_all(bind=engine)

# FastAPI 인스턴스 생성
app = FastAPI(
    title="Todo API",
    description="FastAPI로 만든 Todo 관리 API",
    version="1.0.0"
)

# CORS 설정
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173"],  # React 개발 서버 주소
    allow_credentials=True,
    allow_methods=["*"],  # 모든 HTTP 메서드 허용
    allow_headers=["*"],  # 모든 헤더 허용
)

# 루트 엔드포인트
@app.get("/")
async def root():
    return {"message": "Todo API 서버가 실행 중입니다!"}

# 모든 Todo 조회
@app.get("/todos", response_model=List[schemas.TodoResponse])
def get_todos(
    skip: int = 0,
    limit: int = 100,
    db: Session = Depends(get_db)
):
    """모든 Todo 항목을 조회합니다."""
    todos = db.query(models.Todo).offset(skip).limit(limit).all()
    return todos

# 특정 Todo 조회
@app.get("/todos/{todo_id}", response_model=schemas.TodoResponse)
def get_todo(todo_id: int, db: Session = Depends(get_db)):
    """특정 ID의 Todo 항목을 조회합니다."""
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    if not todo:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"ID {todo_id}인 Todo를 찾을 수 없습니다."
        )
    return todo

# 새로운 Todo 생성
@app.post("/todos", response_model=schemas.TodoResponse, status_code=status.HTTP_201_CREATED)
def create_todo(todo: schemas.TodoCreate, db: Session = Depends(get_db)):
    """새로운 Todo 항목을 생성합니다."""
    db_todo = models.Todo(**todo.dict())
    db.add(db_todo)
    db.commit()
    db.refresh(db_todo)
    return db_todo

# Todo 수정
@app.put("/todos/{todo_id}", response_model=schemas.TodoResponse)
def update_todo(
    todo_id: int,
    todo_update: schemas.TodoUpdate,
    db: Session = Depends(get_db)
):
    """특정 ID의 Todo 항목을 수정합니다."""
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    if not todo:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"ID {todo_id}인 Todo를 찾을 수 없습니다."
        )
    
    # 수정할 데이터만 업데이트
    update_data = todo_update.dict(exclude_unset=True)
    for key, value in update_data.items():
        setattr(todo, key, value)
    
    db.commit()
    db.refresh(todo)
    return todo

# Todo 삭제
@app.delete("/todos/{todo_id}")
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
    """특정 ID의 Todo 항목을 삭제합니다."""
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    if not todo:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"ID {todo_id}인 Todo를 찾을 수 없습니다."
        )
    
    db.delete(todo)
    db.commit()
    return {"message": f"ID {todo_id}인 Todo가 성공적으로 삭제되었습니다."}

# 완료 상태 토글
@app.patch("/todos/{todo_id}/toggle", response_model=schemas.TodoResponse)
def toggle_todo_completion(todo_id: int, db: Session = Depends(get_db)):
    """Todo의 완료 상태를 토글합니다."""
    todo = db.query(models.Todo).filter(models.Todo.id == todo_id).first()
    if not todo:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"ID {todo_id}인 Todo를 찾을 수 없습니다."
        )
    
    todo.completed = not todo.completed
    db.commit()
    db.refresh(todo)
    return todo

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
uvicorn main:app --reload

2. FrontEnd

1. node.js 설치

설치 후 버전 확인

node -v
npm -v

2. 프로젝트 생성

vite+react+tailwindcss 프로젝트 생성

vite로 리액트 프로젝트 생성, 워킹디렉토리로 이동, 라이브러리 설치

npm create vite@latest
# 프로젝트명 frontend, react, javascript
cd frontend
npm i
npm install tailwindcss @tailwindcss/vite
npm install axios

vite.config.js 파일 설정

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
})

src/index.css에 기존 내용 제거 후 추가

@import "tailwindcss";

src/main.jsx

import { useState, useEffect } from 'react'
import axios from 'axios'

const API_URL = 'http://localhost:8000'

export default function App() {
  const [todos, setTodos] = useState([])
  const [newTodo, setNewTodo] = useState('')
  const [newDescription, setNewDescription] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  // 모달 상태
  const [isModalOpen, setIsModalOpen] = useState(false)
  const [selectedTodo, setSelectedTodo] = useState(null)

  // Todo 목록 조회
  const fetchTodos = async () => {
    try {
      setLoading(true)
      const response = await axios.get(`${API_URL}/todos`)
      setTodos(response.data)
    } catch (err) {
      setError('할 일을 불러오는데 실패했습니다.')
      console.error(err)
    } finally {
      setLoading(false)
    }
  }

  // Todo 추가
  const addTodo = async (e) => {
    e.preventDefault()
    if (!newTodo.trim()) return

    try {
      const response = await axios.post(`${API_URL}/todos`, {
        title: newTodo.trim(),
        description: newDescription.trim() || undefined
      })
      setTodos([...todos, response.data])
      setNewTodo('')
      setNewDescription('')
      setError('')
    } catch (err) {
      setError('할 일 추가에 실패했습니다.')
      console.error(err)
    }
  }

  // 커스텀 모달 열기
  const openDeleteModal = (todo) => {
    setSelectedTodo(todo)
    setIsModalOpen(true)
  }

  // 모달에서 삭제 확정
  const confirmDelete = async () => {
    if (!selectedTodo) return
    try {
      await axios.delete(`${API_URL}/todos/${selectedTodo.id}`)
      setTodos(todos.filter(todo => todo.id !== selectedTodo.id))
      setSelectedTodo(null)
      setIsModalOpen(false)
    } catch (err) {
      setError('할 일 삭제에 실패했습니다.')
      console.error(err)
    }
  }

  // Todo 완료 상태 토글
  const toggleTodo = async (id) => {
    try {
      const response = await axios.patch(`${API_URL}/todos/${id}/toggle`)
      setTodos(todos.map(todo => todo.id === id ? response.data : todo))
    } catch (err) {
      setError('상태 변경에 실패했습니다.')
      console.error(err)
    }
  }

  useEffect(() => {
    fetchTodos()
  },[])

  return (
    <>
      <div className="min-h-screen bg-gray-50 py-8 flex flex-col items-center justify-center">
        <div className="w-full max-w-2xl px-4 py-8 bg-white rounded-lg shadow-md">
          {/* 헤더 */}
          <header className="text-center mb-8">
            <h1 className="text-4xl font-bold text-gray-900 mb-2">📝 Todo App</h1>
          </header>

          {/* 에러 메시지 */}
          {error && (
            <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
              <div className="flex justify-between items-center">
                <span>{error}</span>
                <button 
                  onClick={() => setError('')}
                  className="text-red-500 hover:text-red-700 font-bold"
                >
                  ×
                </button>
              </div>
            </div>
          )}

          {/* Todo 추가 폼 */}
          <div className="bg-white rounded-lg shadow-md p-6 mb-8">
            <form onSubmit={addTodo}>
              <div className="mb-4">
                <input
                  type="text"
                  placeholder="할 일을 입력하세요..."
                  value={newTodo}
                  onChange={(e) => setNewTodo(e.target.value)}
                  className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                />
              </div>
              <div className="mb-4">
                <textarea
                  placeholder="설명 (선택사항)"
                  value={newDescription}
                  onChange={(e) => setNewDescription(e.target.value)}
                  rows={2}
                  className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
                />
              </div>
              <button
                type="submit"
                disabled={!newTodo.trim()}
                className="w-full bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 rounded-lg transition duration-200"
              >
                   추가
              </button>
            </form>
          </div>

          {/* 로딩 */}
          {loading && (
            <div className="text-center py-8">
              <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
              <p className="mt-2 text-gray-600">로딩 ...</p>
            </div>
          )}

          {/* Todo 목록 */}
          <div className="bg-white rounded-lg shadow-md">
            <div className="px-6 py-4 border-b border-gray-200">
              <h2 className="text-xl font-semibold text-gray-900">
                  목록 ({todos.length})
              </h2>
            </div>

            {todos.length === 0 ? (
              <div className="px-6 py-12 text-center">
                <div className="text-6xl mb-4">📝</div>
                <p className="text-gray-500 text-lg"> 일이 없습니다</p>
                <p className="text-gray-400 mt-1">새로운  일을 추가해보세요!</p>
              </div>
            ) : (
              <div className="divide-y divide-gray-200">
                {todos.map((todo) => (
                  <div key={todo.id} className="px-6 py-4 hover:bg-gray-50 transition duration-150">
                    <div className="flex items-start space-x-3">
                      {/* 체크박스 */}
                      <input
                        type="checkbox"
                        checked={todo.completed}
                        onChange={() => toggleTodo(todo.id)}
                        className="mt-1 h-5 w-5 text-blue-600 rounded focus:ring-blue-500"
                      />
                      
                      {/* Todo 내용 */}
                      <div className="flex-1 min-w-0">
                        <h3 className={`text-lg font-medium ${
                          todo.completed 
                            ? 'line-through text-gray-500' 
                            : 'text-gray-900'
                        }`}>
                          {todo.title}
                        </h3>
                        
                        {todo.description && (
                          <p className={`mt-1 text-sm ${
                            todo.completed ? 'text-gray-400' : 'text-gray-600'
                          }`}>
                            {todo.description}
                          </p>
                        )}
                        
                        <div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
                          <span>
                            생성일: {new Date(todo.created_at).toLocaleDateString('ko-KR')}
                          </span>
                          {todo.completed && (
                            <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-medium">
                              완료
                            </span>
                          )}
                        </div>
                      </div>
                      
                      {/* 커스텀 모달 열기 버튼 */}
                      <button
                        onClick={() => openDeleteModal(todo)}
                        className="text-red-500 hover:text-red-700 hover:bg-red-50 p-2 rounded-lg transition duration-200"
                        title="삭제"
                      >
                        <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
                        </svg>
                      </button>
                    </div>
                  </div>
                ))}
              </div>
            )}
          </div>

          {/* 커스텀 모달 */}
          {isModalOpen && (
            <div className="fixed inset-0 flex items-center justify-center bg-black/40 z-50">
              <div className="bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
                <h2 className="text-xl font-semibold text-gray-900 mb-4">
                  정말 삭제하시겠습니까?
                </h2>
                <p className="text-gray-600 mb-6">
                  "{selectedTodo?.title}"  일이 삭제됩니다.  작업은 되돌릴  없습니다.
                </p>
                <div className="flex justify-end space-x-3">
                  <button
                    onClick={() => setIsModalOpen(false)}
                    className="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 transition"
                  >
                    취소
                  </button>
                  <button
                    onClick={confirmDelete}
                    className="px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition"
                  >
                    삭제
                  </button>
                </div>
              </div>
            </div>
          )}

          {/* 푸터 */}
          <footer className="mt-12 text-center text-gray-500 text-sm">
            <p>React 19 + JavaScript + Tailwind CSS + Vite 7</p>
          </footer>
        </div>
      </div>
    </>
  )
}

빌드 프로세스 시작

npm run dev