AI 탐구노트

To-Do 리스트 만들어보기 본문

DIY 테스트

To-Do 리스트 만들어보기

42morrow 2024. 10. 25. 10:39

1.들어가며

아주 예전 코딩의 시작은 어떤 프로그래밍 언어를 사용하건 'hello, world!'를 콘솔에 찍어보는 것이었습니다. 찾아보니 이 행위는 1978년에 출판된 C 프로그래밍 책인 "The C Programming Language"에서 비롯된 것이라고 합니다. 사실 그 전에도 어셈블리, 베이식, 포트란, 등등 다양한 언어가 있었고 콘솔 출력은 당연히 할 수 있는 것이었는데, 뭔가 C 프로그래밍 언어의 출현이 가져올 상황을 미리 알기라도 한 듯 새로운 세계를 향한 인사를 한 것 같이 되어 버렸습니다. 

 

2.개발 대상

그런데, 최근에는 Native나 Web 기반의 UI 프로그램으로 개발을 시작하는 분들이 많아지다보니 과거의 Hello World를 찍던 프로그램이 이젠 할일 목록 (To-Do List)를 만들어보는 것으로 대체하는 사람들이 많이 생긴 것 같습니다. 간단하지만 뭔가 쓸모 있어 보이고 멋져 보이고... 그래서 저도 한번 따라서 Python과 Javascript를 이용해 간단하게 To-Do 리스트를 만들어 봤습니다. 다만, 직접은 아니구요... ChatGPT한테 이런저런 요구사항을 반영시켜가며 시행착오를 거친 것이죠.

 

 

3.To-Do 리스트 요구사항

사실 맨 처음 ChatGPT로 개발을 시켜본 것이라 시행착오가 많았습니다. 인터넷에 올라온 이런저런 설명들을 봐가며 진행한 것이고 ChatGPT가 어수룩한 요구사항 정의는 제대로 이해하지 못하는 (아니면 속으로는 무시하거나 ^^;) 경우가 많아서 그랬던 것 같습니다. ChatGPT에게 작업을 부탁하고 이를 해결해나가는 과정에서 '아... 이런 과정을 거쳐 인간은 향후 우리가 필요로 하게 될 커뮤니케이션 방식을 학습하는거구나...' 하는 생각이 들었습니다. 

 

제가 요구한 사항은 대략 다음과 같았습니다. 

  • 웹앱 형태로, python + html + javascript로 개발해야 함
  • 브라우저 출력은 반응형으로 크기 조절이 되어야 함
  • 생성된 파일을 한번에 받을 수 있도록 zip으로 묶어서 링크를 전달해야 함
  • 각 항목은 불릿으로 표시, 글 끝에 삭제 버튼을 넣어야 하고, 재사용을 위해 '즐겨찾기' 아이콘 버튼 필요함
  • 목록은 드래그해서 위치를 변경할 수 있도록 해야함
  • 목록 추가나 삭제 시 포커스가 다시 입력 창으로 자동 적용되어야 함
  • 입력 창에서 한번에 여럿을 입력할 수 있도록 해야 함. ';'로 구분

 


4.결과물 

4.1 파일구조

만들어진 결과물의 파일 구조는 다음과 같습니다. 전형적인 웹앱의 형태를 띄고 있습니다. 서버는 FastAPI를 이용해서 기능이 호출되면 이를 처리하도록 되어 있구요.  

todo/
├─ app.py
├─ static
│    └─ script.js
│    └─ style.css
└─ templates
     └─ index.html

 

4.2 코드

app.py

여기서는 FastAPI를 이용해 RESTful API를 처리하는 웹 어플 서버 역할을 수행합니다. '등록/삭제/수정/순서변경/즐겨찾기처리' 등의 기능을 제공합니다. 

from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from typing import List
import json

app = FastAPI()

app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")

todo_list = []

class TodoItem(BaseModel):
    text: str
    completed: bool
    favorite: bool = False

    def dict(self):
        return {"text": self.text, "completed": self.completed, "favorite": self.favorite}

class TodoOrder(BaseModel):
    todos: List[TodoItem]

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    return templates.TemplateResponse("index.html", {"request": request, "todos": [item.dict() for item in todo_list]})

@app.post("/add")
async def add_todo(request: Request, todo_items: str = Form(...)):
    items = json.loads(todo_items)
    for item in items:
        todo_list.append(TodoItem(text=item, completed=False, favorite=False))
    return RedirectResponse(url="/", status_code=303)

@app.delete("/delete/{todo_id}")
async def delete_todo(todo_id: int):
    if 0 <= todo_id < len(todo_list):
        del todo_list[todo_id]
    return {"message": "Item deleted"}

@app.post("/update_order")
async def update_order(order: TodoOrder):
    global todo_list
    todo_list = [TodoItem(**todo.dict()) for todo in order.todos]
    return {"message": "Order updated"}

@app.get("/favorites")
async def get_favorites():
    favorites = [todo.dict() for todo in todo_list if todo.favorite]
    return JSONResponse(content={"favorites": favorites})

 

 

scripts.js

앞서 언급한 요구사항들을 처리하는 함수들로 구성되어 있습니다.

document.addEventListener('DOMContentLoaded', (event) => {
    const todoList = document.getElementById('todo-list');
    const todoInput = document.getElementById('todo-input');
    const todoForm = document.getElementById('todo-form');
    const favoritesModal = document.getElementById('favorites-modal');
    const favoritesList = document.getElementById('favorites-list');
    const showFavoritesButton = document.getElementById('show-favorites');
    const closeModalButton = document.querySelector('.close');

    todoForm.addEventListener('submit', (event) => {
        event.preventDefault();
        const todoItems = todoInput.value.trim().split(';').map(item => item.trim()).filter(item => item);
        if (todoItems.length > 0) {
            fetch('/add', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    'todo_items': JSON.stringify(todoItems)
                })
            }).then(response => {
                if (response.ok) {
                    window.location.reload();
                }
            });
            todoInput.value = '';
        }
        todoInput.focus();
    });

    todoList.addEventListener('dragover', (event) => {
        event.preventDefault();
        const dragging = document.querySelector('.dragging');
        const afterElement = getDragAfterElement(todoList, event.clientY);
        if (afterElement == null) {
            todoList.appendChild(dragging);
        } else {
            todoList.insertBefore(dragging, afterElement);
        }
    });

    todoList.addEventListener('dragstart', (event) => {
        event.target.classList.add('dragging');
    });

    todoList.addEventListener('dragend', (event) => {
        event.target.classList.remove('dragging');
        updateTodoOrder();
    });

    showFavoritesButton.addEventListener('click', (event) => {
        fetch('/favorites')
            .then(response => response.json())
            .then(data => {
                favoritesList.innerHTML = '';
                data.favorites.forEach((favorite, index) => {
                    const li = document.createElement('li');
                    li.textContent = favorite.text;
                    li.addEventListener('click', () => {
                        todoInput.value = favorite.text;
                        favoritesModal.style.display = 'none';
                    });
                    favoritesList.appendChild(li);
                });
                favoritesModal.style.display = 'block';
            });
    });

    closeModalButton.addEventListener('click', () => {
        favoritesModal.style.display = 'none';
    });

    window.addEventListener('click', (event) => {
        if (event.target == favoritesModal) {
            favoritesModal.style.display = 'none';
        }
    });

    function getDragAfterElement(container, y) {
        const draggableElements = [...container.querySelectorAll('li:not(.dragging)')];

        return draggableElements.reduce((closest, child) => {
            const box = child.getBoundingClientRect();
            const offset = y - box.top - box.height / 2;
            if (offset < 0 && offset > closest.offset) {
                return { offset: offset, element: child };
            } else {
                return closest;
            }
        }, { offset: Number.NEGATIVE_INFINITY }).element;
    }

    function updateTodoOrder() {
        const todos = [...todoList.querySelectorAll('li')].map((li) => ({
            text: li.querySelector('.todo-text').textContent.trim(),
            completed: li.classList.contains('completed'),
            favorite: li.querySelector('.favorite-icon i').classList.contains('fas')
        }));
        fetch('/update_order', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ todos: todos })
        });
    }

    window.toggleComplete = function(event, index) {
        const li = event.target.closest('li');
        li.classList.toggle('completed');
        const icon = li.querySelector('.checkbox i');
        if (li.classList.contains('completed')) {
            icon.classList.remove('fa-square');
            icon.classList.add('fa-check-square');
        } else {
            icon.classList.remove('fa-check-square');
            icon.classList.add('fa-square');
        }
        updateTodoOrder();
    }

    window.toggleFavorite = function(event, index) {
        event.preventDefault();
        const li = event.target.closest('li');
        const icon = li.querySelector('.favorite-icon i');
        icon.classList.toggle('fas');
        icon.classList.toggle('far');
        updateTodoOrder();
    }

    window.deleteItem = function(event, index) {
        event.preventDefault();
        const li = event.target.closest('li');
        li.remove();
        fetch(`/delete/${index}`, {
            method: 'DELETE'
        }).then(() => {
            updateTodoOrder();
        });
    }
});

 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>To-Do List</title>
    <link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
</head>
<body>
    <div class="container">
        <h1>To-Do List</h1>
        <form id="todo-form">
            <input type="text" id="todo-input" name="todo_item" placeholder="Enter new tasks separated by semicolons">
            <button type="submit">Add</button>
            <button type="button" id="show-favorites">Select</button>
        </form>
        <ul id="todo-list">
            {% for todo in todos %}
                <li draggable="true" ondragstart="drag(event)" id="todo-{{ loop.index0 }}" class="{{ 'completed' if todo['completed'] else '' }}">
                    <span class="checkbox" onclick="toggleComplete(event, {{ loop.index0 }})">
                        <i class="far {{ 'fa-check-square' if todo['completed'] else 'fa-square' }}"></i>
                    </span>
                    <span class="todo-text">{{ todo['text'] }}</span>
                    <div class="action-buttons">
                        <a href="#" onclick="toggleFavorite(event, {{ loop.index0 }})" class="favorite-icon">
                            <i class="far {{ 'fa-heart' if not todo['favorite'] else 'fas fa-heart' }}"></i>
                        </a>
                        <a href="#" onclick="deleteItem(event, {{ loop.index0 }})" class="delete-icon">
                            <i class="fas fa-trash-alt"></i>
                        </a>
                    </div>
                </li>
            {% endfor %}
        </ul>
        <div id="favorites-modal" class="modal">
            <div class="modal-content">
                <span class="close">&times;</span>
                <ul id="favorites-list"></ul>
            </div>
        </div>
    </div>
    <script src="{{ url_for('static', path='/script.js') }}"></script>
</body>
</html>

 

전체 코드는 아래 첨부된 파일과 같습니다. 

todo.zip
0.01MB

 

 

5.실행 테스트

$ uvicorn app:app --reload

 

실행 결과는 다음과 같습니다. DB 저장을 통해 history 관리도 되지 않고 UI도 투박하고... 하지만, 빠릿빠릿하게 작동하는 데모같아 마음에 듭니다. 

 

 

그림 : 등록, 삭제, 즐겨찾기 등의 기능이 반영된 To-Do List

 

 

그림 : select를 눌렀을 때 즐겨찾기에 등록된 항목을 선택할 수 있음

 

 


6.후기

'Hello, World!'를 구현하고 뿌듯할 사람은 별로 없을 겁니다. 이제 시작이니까요. (이것이 제가 AI로 뭔가를 만들어본 첫 사례였습니다!) 하지만 저는 약간의 뿌듯함을 느꼈습니다. 직접 코딩을 한 것이 아니라 요구사항을 말하고 이것이 반영된 코드를 받고 이를 테스트해 보고 등등의 계속되는 시행착오를 통해, AI Chatbot과는 이런 식으로 커뮤니케이션을 해야하는구나 하는 것을 약간은 알게 된 것 같아서입니다. 실제로 개발을 시켜보니, AI는 많은 개발 경험(학습을 통해)을 가진 똑똑한 개발자였고, 저는 초기에는 요구사항을 대충 알려주고 이후에 지속적으로 수정 요구를 하는 프로젝트 상의 고객 현업 담당자였다고 생각이 되었거든요. 

 

앞으로도 AI를 이용한 다양한 장난감 만들기는 계속 됩니다~

 

 

'DIY 테스트' 카테고리의 다른 글

군중(Crowd) 카운팅  (1) 2024.10.29
차량 번호판 인식  (6) 2024.10.28
[Roboflow] Soccer AI 실행 테스트  (4) 2024.10.23
Whisper Turbo 로컬 설치 및 테스트  (4) 2024.10.22
Whisper를 이용한 실시간 음성인식  (6) 2024.10.18