20. 미니 프로젝트: 도서 관리 CLI

🎯 이 장의 목표
  • 중급편에서 배운 것을 하나의 실제 프로그램에 통합한다.
  • 클래스·모듈 분리·컴프리헨션·타입 힌트·파일 입출력·예외 처리·테스트를 한자리에서 본다.
  • "어떻게 설계하고 나눌지"를 직접 경험한다.

무엇을 만드나

콘솔에서 동작하는 도서 관리 프로그램(CLI)을 만듭니다. 책을 추가·검색하고, 읽음 표시를 하고, 통계를 보고, 데이터를 파일에 저장·불러옵니다.

지금까지 배운 것들이 어떻게 어우러지는지 보세요.

기능사용하는 중급 개념
Book 클래스11장 클래스, 12장 매직 메서드(__str__)
Library 클래스11장 클래스, 캡슐화
검색·필터13장 컴프리헨션
파일에 keep16장 JSON 입출력
없는 파일 대비초급 10장 예외 처리
함수 시그니처18장 타입 힌트
파일 분리15장 모듈
동작 검증19장 테스트
📌 핵심
이 장의 모든 코드는 실제로 실행해 검증했습니다. 직접 따라 만들면서, 각 부분이 어느 장에서 배운 것인지 떠올려 보세요.

설계: 파일을 어떻게 나눌까

먼저 구조를 정합니다. 15장에서 배운 대로 역할별로 파일을 나눕니다.

TEXT
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__)을 둡니다.

PYTHON
# 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로 복원합니다.
  • @classmethodcls: from_dict는 "딕셔너리로부터 새 Book을 만드는" 메서드입니다. 객체가 아니라 클래스 자체에서 호출하는 메서드라 self 대신 cls(클래스)를 받고, @classmethod를 붙입니다. cls(...)Book(...)과 같습니다.
  • __str__/__repr__: 12장에서 배운 매직 메서드로 출력을 다듬습니다. {self.title!r}!rrepr 형식(따옴표 포함)으로 표시하라는 뜻입니다.
📎 @classmethod는 이 책에서 처음 나옵니다. "객체 하나가 아니라 클래스 전체와 관련된 메서드"에 씁니다. 여기서 from_dict는 특정 책 객체가 아니라 "데이터로부터 책을 만드는 공장" 역할이라 클래스 메서드가 적합합니다. (더 깊은 내용은 고급 주제로, 지금은 "객체 만드는 대안 생성자"로 이해하면 됩니다.)

2단계: Library 클래스 (library.py)

책들의 모음을 관리합니다. 추가·검색·통계·저장·불러오기를 담당합니다.

PYTHON
# 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장)으로 조건에 맞는 책만 골라냅니다. findor로 제목·저자 양쪽을 검사하고, .lower()로 대소문자를 무시합니다.
  • stats: sum(1 for b in self.books if b.read)는 "읽은 책 개수"를 세는 제너레이터 표현식(13·14장)입니다.
  • save/load: JSON 입출력(16장). 저장 시 to_dict로 변환하고, 불러올 때 from_dict로 복원합니다.
  • loadtry/except FileNotFoundError: 예외 처리(초급 10장). 처음 실행해 파일이 없어도 에러 없이 빈 목록으로 시작합니다.
  • 타입 힌트(-> list[Book], list[Book]): 18장. 각 메서드가 무엇을 받고 돌려주는지 분명합니다.

3단계: 동작시켜 보기

두 클래스가 잘 어우러지는지 확인합니다.

PYTHON
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())

실행 결과:

TEXT
=== 전체 목록 ===
[· 안 읽음] 파이썬 입문 — 김파이
[· 안 읽음] 자료구조 — 이구조
[✓ 읽음] 알고리즘 — 박알고

=== '파이' 검색 ===
[· 안 읽음] 파이썬 입문 — 김파이

=== 통계 ===
{'total': 3, 'read': 1, 'unread': 2}

저장하면 books.json에 이렇게 기록됩니다.

JSON
[
  {
    "title": "파이썬 입문",
    "author": "김파이",
    "read": false
  },
  ...
]

4단계: CLI 메인 (main.py)

사용자와 상호작용하는 메뉴 루프입니다. 초급편의 while True + 입력 처리 패턴을 씁니다.

PYTHON
# 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장에서 배운 테스트로 핵심 기능을 검증합니다. 코드를 고쳐도 이 테스트가 통과하면 안심할 수 있습니다.

PYTHON
# 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장):

TEXT
$ 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. Libraryby_author(author) 메서드를 추가해, 특정 저자의 책만 리스트로 돌려주세요. (컴프리헨션 활용)

확장 3. Bookrating(별점, 1~5) 속성을 추가하세요. __init__에 기본값을 주고, to_dict/from_dict도 함께 수정해야 합니다.

확장 4. 통계에 "읽은 비율(%)"을 추가하세요. stats()read / total * 100 계산을 넣되, total이 0일 때 ZeroDivisionError를 조심하세요.

확장 5. by_author나 새 기능에 대한 pytest 테스트를 작성하세요.

<details>

<summary>💡 확장 2 예시 답안</summary>

PYTHON
# library.py의 Library 클래스에 추가
def by_author(self, author: str) -> list[Book]:
    return [b for b in self.books if b.author == author]
PYTHON
# 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. 중급 용어 보강