이 회고를 쓰고 나서 바로 뭔가를 해봐야겠다는 생각이 들어 냅다 레포를 하나 팠다. 이런 망설임을 줄이려면 몇 시간 안에 구현 가능한 걸 만들어봐야겠다고 생각했고, 그래서 웹 대신에 콘솔 어플로 한정했다. 웹은 하다보면 뭔가 계속 성에 안 차서 며칠 걸릴 것 같았다.
여튼 이전에 만들었던 프로젝트인 OneStep이 생각나서 투두 콘솔 어플리케이션을 만들어 봐야겠다 싶었다. 2시간 안에는 가능하겠지 싶었고, 내 집중력이 그 정도는 해 주겠지 싶었다.
코드는 여기에 올려두었다. 처음엔 되게 간단하다고 생각했고 고민할 점이 많이 없겠거니 싶었는데 하다보니 고민할 점도 많았고, 슬슬 집에 가야 해서 구현하지 못한 부분도 있었다(기능상은 아니고 테스트 코드였는데 이건 내일 추가할 예정이다).
✅ 코드 소개
이 투두 어플리케이션은 투두 CRD(create, read, delete)를 할 수 있다. Update도 넣는다면 넣을 수 있었는데 굳이 싶어서 일단은 추가하지 않았다.
원래는 하나의 파일에서 모든 걸 작성해볼까 싶었는데, 그래도 개발을 아예 처음 접하는 사람은 아닌 만큼 클래스나 모듈로 좀 나눠 보고 싶었다. 그래서 1차 결과물은 다음과 같은 파일로 구성되었다.
- main.py : 투두 어플리케이션을 실행하는 파일
- todo.py : 투두 클래스 파일
- todo_application.py : 여러 투두들을 담아야 하는 투두 저장소의 인터페이스 파일
- todo_memory_application.py : TodoApplication 인터페이스를 구현한, 메모리 기반으로 동작하는 투두 저장소 클래스
- todo_service.py : 투두를 직접 저장하는 TodoApplication과 콘솔 출력 등 부가적인 기능을 담당하는 부분을 나눠 두고 싶어서 만든 클래스. 유저의 입력을 받고 적합한 TodoApplication의 메소드를 호출하는 역할을 한다.
하나씩 코드를 보자.
# main.py
from todo_memory_application import TodoMemoryApplication
from todo_service import TodoService
def main():
todo_application = TodoMemoryApplication()
todo_service = TodoService(todo_application)
todo_service.run()
if __name__ == "__main__":
main()
# todo.py
from datetime import datetime
class Todo:
def __init__(self, title: str, id: int):
self.id = id
self.title = title
self.created_at = datetime.now()
def __str__(self):
return self.title
원래는 Todo에서 title 속성만 넣을 예정이었으나, 투두를 삭제할 때 혹시라도 title값이 같은 경우가 있다면 식별이 필요할 것 같아서 id를 추가했다. 그리고 구현하진 않았는데 투두 목록을 보여줄 때 투두가 생성된 순/역순으로 정렬하면 좋을 것 같아 created_at 필드도 추가했다.
# todo_application.py
from todo import Todo
from typing import List
from abc import ABC, abstractmethod
class TodoApplication(ABC):
@abstractmethod
def add(self, todo_title: str) -> Todo:
pass
@abstractmethod
def remove(self, todo_id: int) -> None:
pass
@abstractmethod
def list(self) -> List[Todo]:
pass
@abstractmethod
def get_todo_by_id(self, todo_id: int) -> Todo | None:
pass
그 다음엔 인터페이스로 TodoApplication 클래스를 추가했다. 사실 이 부분은 GPT의 도움을 받았다. 자바에서는 인터페이스를 별도로 정의할 수 있으나 파이썬에서는 인터페이스라는 개념이 생소했다.
예제 코드에 따르면 abc 모듈에서 ABC, abstractmethod 데코레이터를 import 해 와서 사용하면 되는 것으로 이해했다. ABC를 상속받으면 해당 클래스는 인터페이스로 동작하고, 해당 클래스 안에서 @abstractmethod 데코레이터를 사용하면 된다고 이해했다.
# todo_memory_application.py
from todo import Todo
from typing import List
from datetime import datetime
from todo_application import TodoApplication
class TodoMemoryApplication(TodoApplication):
def __new__(cls):
if not hasattr(cls, "instance"):
cls.instance = super(TodoMemoryApplication, cls).__new__(cls)
return cls.instance
def __init__(self):
self.todo_id: int = 1
self.todos: List[Todo] = []
self.created_at = datetime.now()
def add(self, todo_title: str) -> Todo:
todo = Todo(todo_title, self.todo_id)
self.todos.append(todo)
self.todo_id += 1
return todo
def remove(self, todo_id: int) -> None:
todo_to_be_removed = self.get_todo_by_id(todo_id)
if todo_to_be_removed is not None:
self.todos.remove(todo_to_be_removed)
else:
print("[Exception] Todo not found")
def list(self) -> List[Todo]:
return self.todos
def __str__(self) -> str:
return self.created_at.isoformat()
def get_todo_by_id(self, todo_id: int) -> Todo | None:
for todo in self.todos:
if todo.id == todo_id:
return todo
return None
이제는 TodoApplication 인터페이스를 상속받아 TodoMemoryApplication을 구현했다. 여기서는 싱글톤 패턴을 사용하였다. 현재 구현한 콘솔 어플리케이션은 로그인 기능이 없고, 따라서 한 유저만 사용하기 때문에 여러 개의 TodoApplication 클래스가 생성될 일이 없었다.
# todo_service.py
from todo import Todo
from todo_application import TodoApplication
class TodoService:
# Singleton
def __new__(cls, todo_application):
if not hasattr(cls, "instance"):
cls.instance = super(TodoService, cls).__new__(cls)
return cls.instance
def __init__(self, todo_application: TodoApplication):
print("=====Todo Console Program=====")
self.todo_application = todo_application
def __del__(self):
print("=====Goodbye=====")
def run(self):
while True:
print("1. Add Todo")
print("2. List Todos")
print("3. Remove Todo")
print("4. Exit")
choice = input("Enter your choice: ")
if choice == "1":
self.add()
elif choice == "2":
self.list()
elif choice == "3":
self.remove()
elif choice == "4":
break
else:
print("[Exception] Invalid choice")
def add(self):
todo_title = input("Enter todo title: ")
if todo_title == "":
print("[Exception] Title cannot be empty")
else:
self.todo_application.add(todo_title)
print(f"Todo with title '{todo_title}' added successfully")
def list(self):
todos = self.todo_application.list()
print("=====Todo List=====")
for todo in todos:
print(f"{todo.id}. {todo.title}")
print("===================")
def remove(self):
try:
todo_id = int(input("Enter todo id: "))
self.todo_application.remove(todo_id)
except ValueError:
print("[Exception] please enter a valid integer id")
return
except Exception as e:
print(f"[Exception] {e}")
return
마지막은 TodoApplication을 호출하면서 유저의 입력을 받고 콘솔 출력을 담당하는 TodoService 클래스이다. 해당 클래스도 마찬가지로 2개 이상 생성될 필요가 없기에 싱글톤 패턴으로 구현하였다.
사실 처음엔 TodoApplication 인터페이스를 생각하지 못했었는데, TodoService를 구현하면서 인터페이스에 대한 니즈가 생겼다. 그 전에 TodoMemoryApplication만 있었을 때에, TodoService와 TodoMemoryApplication 간의 참조 관계를 어떻게 하면 좋을지 애매했기 때문이다.
원래는 TodoService의 멤버 변수(필드)로 TodoMemoryApplication을 두려고 했는데, 이러면 TodoService의 코드가 TodoMemoryApplication에 의존하게 된다는 단점이 있었다. 그래서 이 부분을 인터페이스로 풀어보고자 했다.
이후에 테스트 코드도 1차로 추가해봤다. 여전히 커버되지 않은 부분이 있다고 생각되지만, 우선 조금이라도 추가해 보았다.
핵심 로직이 들어있는 TodoService와 Todo(Memory)Application 코드를 대상으로 테스트를 작성했다.
# test_todo_application.py
from todo_memory_application import TodoMemoryApplication
import pytest
from datetime import datetime
@pytest.fixture
def todo_application():
return TodoMemoryApplication()
@pytest.fixture(scope="function", autouse=True)
def todo_application_initialize(todo_application):
todo_application.__init__()
yield
def test_singleton(todo_application):
todo_application_2 = TodoMemoryApplication()
assert todo_application == todo_application_2
def test_add_todo(todo_application):
todo_application.add("Buy groceries")
assert len(todo_application.todos) == 1
def test_remove_todo(todo_application):
todo_application.add("Buy groceries")
todo_application.remove(1)
assert todo_application.todos == []
def test_list_todos(todo_application):
todo_application.add("Buy groceries")
todo_application.add("Clean the house")
assert len(todo_application.list()) == 2
is_test_successful = True
for todo in todo_application.list():
if todo.title not in ["Buy groceries", "Clean the house"]:
is_test_successful = False
break
assert is_test_successful
def test_list_todos_initialized(todo_application):
assert todo_application.list() == []
assert todo_application.todo_id == 1
assert todo_application.created_at is not None
# test_todo_service.py
from todo_service import TodoService
from unittest.mock import patch
import pytest
from test_todo_application import todo_application
@pytest.fixture
def todo_service(todo_application):
return TodoService(todo_application=todo_application)
def test_singleton(todo_service):
todo_service_2 = TodoService(todo_application)
assert todo_service == todo_service_2
def test_when_menu_input_is_invalid(todo_service, capsys):
with patch("builtins.input", return_value=5):
todo_service.run()
result = capsys.readouterr()
assert "[Exception] Invalid choice" in result.out
처음에는 unittest와 pytest 중 어떤 것을 사용해야 할지 모르겠었는데, 결과적으로 unittest의 mock, patch 기능과 pytest의 fixture 기능이 모두 유용했기에 섞어서 사용하게 되었다. pytest는 unittest 기반의 코드를 인식할 수 있다고 해서, 메소드는 pytest 기반으로(test_ 로 시작하는 이름) 짜되 중간에 patch 기능 등은 unittest에서 빌려왔다.
✅ 신경 쓴 점
- 인터페이스 구현해서 TodoService와 TodoApplication 간의 종속성을 줄였다.
- 타입 힌팅(type hinting)을 사용해 봤다.
✅ 개선하면 좋을 점
- Exception 처리를 지금처럼 print 문으로 하기보단, 별도 Exception 클래스를 만드는 것이 확장성이나 유지보수 면에서 좋을 것 같다.
- TodoService와 TodoApplication 간의 참조관계를 정의할 또 다른 디자인패턴은 없을까 고민해보면 좋겠다.
- 테스트 코드를 못 짰는데 이것도 한번 짜 보면 좋겠다.
✅ 의문점
- def main()에서 if __name__ == '__main__'인 것과 def main 없이 바깥에서 실행하는 것은 무슨 차이일지 궁금하다.
- abc 패키지의 ABC와 abstractmethod의 동작 원리가 궁금하다.
- TodoService에서 TodoMemoryApplication을 참조하는데, 이걸 중간에 바꿔치기하는 방법은 없나? 마치 Spring의 bean creation & dependency injection와 같이 말이다... 이 부분은 프레임워크와 연관이 있을 것 같다.
- Todo도 일단은 클래스로 만들었는데 인터페이스로 구현했으면 좋았으려나 싶다. 그러나 한편으로는 담을 데이터를 정의할 모델 클래스를 인터페이스로 구현하는 게 가능한지 의문이다.
'개발 일기장 > 개발 일지' 카테고리의 다른 글
[Review] 파이썬으로 2시간 동안 투두 콘솔 어플리케이션 만들기 (1) | 2025.03.04 |
---|---|
20250227 TIL - Top Down Processing (0) | 2025.02.27 |
20250224 TIL (0) | 2025.02.24 |
20250221 TIL (0) | 2025.02.21 |
20250220 TIL (0) | 2025.02.20 |