20. 미니 프로젝트: 도서 관리 CLI
- 중급편에서 배운 것을 하나의 실제 프로그램에 통합한다.
- 클래스·모듈 분리·컴프리헨션·타입 힌트·파일 입출력·예외 처리·테스트를 한자리에서 본다.
- "어떻게 설계하고 나눌지"를 직접 경험한다.
무엇을 만드나
콘솔에서 동작하는 도서 관리 프로그램(CLI)을 만듭니다. 책을 추가·검색하고, 읽음 표시를 하고, 통계를 보고, 데이터를 파일에 저장·불러옵니다.
지금까지 배운 것들이 어떻게 어우러지는지 보세요.
| 기능 | 사용하는 중급 개념 |
|---|---|
Book 클래스 | 11장 클래스, 12장 매직 메서드(__str__) |
Library 클래스 | 11장 클래스, 캡슐화 |
| 검색·필터 | 13장 컴프리헨션 |
| 파일에 keep | 16장 JSON 입출력 |
| 없는 파일 대비 | 초급 10장 예외 처리 |
| 함수 시그니처 | 18장 타입 힌트 |
| 파일 분리 | 15장 모듈 |
| 동작 검증 | 19장 테스트 |
설계: 파일을 어떻게 나눌까
먼저 구조를 정합니다. 15장에서 배운 대로 역할별로 파일을 나눕니다.
book_project/ ├── book.py ← Book 클래스 (책 한 권) ├── library.py ← Library 클래스 (책들의 관리) ├── main.py ← CLI 메인 (사용자 상호작용) └── test_library.py ← 테스트
flowchart TD
Main["main.py<br/>사용자 메뉴·입력"]:::user --> Lib["library.py<br/>Library 클래스<br/>추가·검색·통계·저장"]:::proc
Lib --> Book["book.py<br/>Book 클래스<br/>책 한 권"]:::data
Lib -.저장/불러오기.-> JSON["books.json"]:::result
Test["test_library.py"]:::proc -.검증.-> Lib
classDef user fill:#fff3b0,stroke:#e0a800,color:#5c4500
classDef proc fill:#7fd8d8,stroke:#2a9d8f,color:#14532d
classDef data fill:#a8dadc,stroke:#457b9d,color:#1d3557
classDef result fill:#b8e6c1,stroke:#34a853,color:#14532d
Book이, 여러 권의 관리(추가·검색·저장)는 Library가 맡습니다. 이렇게 책임을 나누면 각 파일이 작고 명확해집니다.1단계: Book 클래스 (book.py)
책 한 권을 표현합니다. 제목·저자·읽음 여부를 담고, JSON 저장을 위한 변환 메서드와 보기 좋은 출력(__str__)을 둡니다.
# book.py """도서 한 권을 나타내는 모듈.""" class Book: def __init__(self, title: str, author: str, read: bool = False) -> None: self.title = title self.author = author self.read = read def mark_read(self) -> None: """읽음으로 표시.""" self.read = True def to_dict(self) -> dict: """JSON 저장용 딕셔너리로 변환.""" return {"title": self.title, "author": self.author, "read": self.read} @classmethod def from_dict(cls, data: dict) -> "Book": """딕셔너리에서 Book 객체를 복원.""" return cls(data["title"], data["author"], data["read"]) def __str__(self) -> str: status = "✓ 읽음" if self.read else "· 안 읽음" return f"[{status}] {self.title} — {self.author}" def __repr__(self) -> str: return f"Book({self.title!r}, {self.author!r}, read={self.read})"
몇 가지 새 요소를 짚어봅시다.
to_dict/from_dict: 객체 ↔ 딕셔너리 변환. JSON은 딕셔너리만 저장할 수 있으니, 저장 전에to_dict로 바꾸고 불러온 뒤from_dict로 복원합니다.@classmethod와cls:from_dict는 "딕셔너리로부터 새 Book을 만드는" 메서드입니다. 객체가 아니라 클래스 자체에서 호출하는 메서드라self대신cls(클래스)를 받고,@classmethod를 붙입니다.cls(...)는Book(...)과 같습니다.__str__/__repr__: 12장에서 배운 매직 메서드로 출력을 다듬습니다.{self.title!r}의!r은repr형식(따옴표 포함)으로 표시하라는 뜻입니다.
📎@classmethod는 이 책에서 처음 나옵니다. "객체 하나가 아니라 클래스 전체와 관련된 메서드"에 씁니다. 여기서from_dict는 특정 책 객체가 아니라 "데이터로부터 책을 만드는 공장" 역할이라 클래스 메서드가 적합합니다. (더 깊은 내용은 고급 주제로, 지금은 "객체 만드는 대안 생성자"로 이해하면 됩니다.)
2단계: Library 클래스 (library.py)
책들의 모음을 관리합니다. 추가·검색·통계·저장·불러오기를 담당합니다.
# library.py """도서 컬렉션을 관리하는 모듈.""" import json from book import Book class Library: def __init__(self) -> None: self.books: list[Book] = [] def add(self, title: str, author: str) -> Book: book = Book(title, author) self.books.append(book) return book def find(self, keyword: str) -> list[Book]: """제목이나 저자에 keyword가 든 책을 찾는다.""" kw = keyword.lower() return [b for b in self.books if kw in b.title.lower() or kw in b.author.lower()] def unread(self) -> list[Book]: """안 읽은 책 목록.""" return [b for b in self.books if not b.read] def stats(self) -> dict: """전체·읽음·안읽음 권수.""" total = len(self.books) read = sum(1 for b in self.books if b.read) return {"total": total, "read": read, "unread": total - read} def save(self, path: str) -> None: data = [b.to_dict() for b in self.books] with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def load(self, path: str) -> None: try: with open(path, encoding="utf-8") as f: data = json.load(f) self.books = [Book.from_dict(d) for d in data] except FileNotFoundError: self.books = [] # 파일이 없으면 빈 상태로 시작
여기서 배운 것들이 한꺼번에 보입니다.
find·unread: 컴프리헨션(13장)으로 조건에 맞는 책만 골라냅니다.find는or로 제목·저자 양쪽을 검사하고,.lower()로 대소문자를 무시합니다.stats:sum(1 for b in self.books if b.read)는 "읽은 책 개수"를 세는 제너레이터 표현식(13·14장)입니다.save/load: JSON 입출력(16장). 저장 시to_dict로 변환하고, 불러올 때from_dict로 복원합니다.load의try/except FileNotFoundError: 예외 처리(초급 10장). 처음 실행해 파일이 없어도 에러 없이 빈 목록으로 시작합니다.- 타입 힌트(
-> list[Book],list[Book]): 18장. 각 메서드가 무엇을 받고 돌려주는지 분명합니다.
3단계: 동작시켜 보기
두 클래스가 잘 어우러지는지 확인합니다.
from library import Library lib = Library() lib.add("파이썬 입문", "김파이") lib.add("자료구조", "이구조") algo = lib.add("알고리즘", "박알고") algo.mark_read() # 알고리즘은 읽음 처리 print("=== 전체 목록 ===") for book in lib.books: print(book) # __str__ 덕분에 보기 좋게 print("\n=== '파이' 검색 ===") for book in lib.find("파이"): print(book) print("\n=== 통계 ===") print(lib.stats())
실행 결과:
=== 전체 목록 ===
[· 안 읽음] 파이썬 입문 — 김파이
[· 안 읽음] 자료구조 — 이구조
[✓ 읽음] 알고리즘 — 박알고
=== '파이' 검색 ===
[· 안 읽음] 파이썬 입문 — 김파이
=== 통계 ===
{'total': 3, 'read': 1, 'unread': 2}
저장하면 books.json에 이렇게 기록됩니다.
[
{
"title": "파이썬 입문",
"author": "김파이",
"read": false
},
...
]
4단계: CLI 메인 (main.py)
사용자와 상호작용하는 메뉴 루프입니다. 초급편의 while True + 입력 처리 패턴을 씁니다.
# main.py """도서 관리 CLI 메인 프로그램.""" from library import Library DATA_FILE = "books.json" def print_menu() -> None: print("\n=== 도서 관리 ===") print("1) 추가 2) 목록 3) 검색 4) 통계 5) 종료") def main() -> None: lib = Library() lib.load(DATA_FILE) # 시작 시 저장된 데이터 불러오기 while True: print_menu() choice = input("선택: ").strip() if choice == "1": title = input("제목: ") author = input("저자: ") lib.add(title, author) lib.save(DATA_FILE) # 변경 즉시 저장 print("추가됨!") elif choice == "2": for book in lib.books: print(book) elif choice == "3": kw = input("검색어: ") for book in lib.find(kw): print(book) elif choice == "4": print(lib.stats()) elif choice == "5": print("안녕히 가세요!") break else: print("1~5만 입력하세요") if __name__ == "__main__": # 15장: 직접 실행할 때만 main() 호출 main()
if __name__ == "__main__":(15장)로 "이 파일을 직접 실행할 때만 메뉴를 띄우게" 했습니다. 다른 곳에서 import main 해도 메뉴가 멋대로 뜨지 않습니다.
5단계: 테스트 (test_library.py)
19장에서 배운 테스트로 핵심 기능을 검증합니다. 코드를 고쳐도 이 테스트가 통과하면 안심할 수 있습니다.
# test_library.py from library import Library from book import Book def test_add(): lib = Library() lib.add("책1", "저자1") assert len(lib.books) == 1 def test_find(): lib = Library() lib.add("파이썬", "김씨") lib.add("자바", "이씨") assert len(lib.find("파이")) == 1 # 제목 매치 assert len(lib.find("씨")) == 2 # 두 저자 모두 '씨' def test_stats(): lib = Library() b = lib.add("책", "저자") b.mark_read() lib.add("책2", "저자2") assert lib.stats() == {"total": 2, "read": 1, "unread": 1} def test_book_str(): b = Book("제목", "작가", read=True) assert "✓ 읽음" in str(b)
pytest로 실행하면(19장):
$ pytest test_library.py -v test_library.py::test_add PASSED test_library.py::test_find PASSED test_library.py::test_stats PASSED test_library.py::test_book_str PASSED ===== 4 passed =====
돌아보기: 무엇을 했나
작은 프로그램 하나에 중급편의 거의 모든 개념이 들어갔습니다.
flowchart LR
OOP["클래스·매직메서드<br/>11·12장"]:::a --> P["📚 도서 관리 CLI"]:::core
Comp["컴프리헨션<br/>13·14장"]:::a --> P
Mod["모듈 분리<br/>15장"]:::a --> P
File["JSON 입출력<br/>16장"]:::a --> P
Type["타입 힌트<br/>18장"]:::a --> P
Test["테스트<br/>19장"]:::a --> P
classDef a fill:#a8dadc,stroke:#457b9d,color:#1d3557
classDef core fill:#b8e6c1,stroke:#34a853,color:#14532d
이것이 실전 코드의 모습입니다. 개별 문법을 아는 것과, 그것을 조합해 동작하는 프로그램으로 만드는 것은 다른 능력입니다. 후자는 직접 만들어봐야만 늘어납니다.
🧪 직접 확장해보기
이 프로젝트를 발판으로, 다음 기능을 직접 추가해 보세요. 정답은 없습니다 — 배운 것을 응용하는 연습입니다.
확장 1. 메뉴에 "읽음 표시" 기능(6번)을 추가하세요. 제목으로 책을 찾아 mark_read()를 호출합니다.
확장 2. Library에 by_author(author) 메서드를 추가해, 특정 저자의 책만 리스트로 돌려주세요. (컴프리헨션 활용)
확장 3. Book에 rating(별점, 1~5) 속성을 추가하세요. __init__에 기본값을 주고, to_dict/from_dict도 함께 수정해야 합니다.
확장 4. 통계에 "읽은 비율(%)"을 추가하세요. stats()에 read / total * 100 계산을 넣되, total이 0일 때 ZeroDivisionError를 조심하세요.
확장 5. by_author나 새 기능에 대한 pytest 테스트를 작성하세요.
<details>
<summary>💡 확장 2 예시 답안</summary>
# library.py의 Library 클래스에 추가 def by_author(self, author: str) -> list[Book]: return [b for b in self.books if b.author == author]
# test_library.py에 테스트 추가 def test_by_author(): lib = Library() lib.add("책A", "김작가") lib.add("책B", "김작가") lib.add("책C", "이작가") assert len(lib.by_author("김작가")) == 2
</details>
이 장에서 배운 것
- 실전 프로그램은 여러 개념의 조합이다. 클래스·모듈·컴프리헨션·파일·예외·타입 힌트·테스트가 한 프로젝트에 어우러진다.
- 책임에 따라 파일을 나눈다: 데이터 한 단위(
Book), 그 모음의 관리(Library), 사용자 상호작용(main), 검증(test). - 객체 ↔ 딕셔너리 변환(
to_dict/from_dict)으로 객체를 JSON에 저장한다.@classmethod는 대안 생성자로 쓴다. - 개별 문법을 아는 것과 조합해 동작하는 프로그램을 만드는 것은 다르다. 직접 만들어야 는다.
◀️ 이전 장: 19. 가상환경·코드 스타일·테스트 | ▶️ 다음: 98. 중급 용어 보강