Devin.KR

마무리 - 사용 설명서와 다음 기능 목록

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 함수와 이름을 정리했다면, 이제 앱을 다시 사용하는 데 필요한 안내를 남길 차례다. 코드를 만든 직후에는 실행 방법과 제한 사항을 기억하지만, 시간이 지나면 어느 파일을 실행해야 하는지부터 헷갈릴 수 있다. 마지막 작업은 기능을 더 붙이는 일이 아니라, 지금 만든 기능을 다른 날에도 확인할 수 있는 상태로 남기는 일이다.

이 장에서는 사용 설명서와 남은 일 목록을 만들고, 정해진 입력으로 가계부의 전체 흐름을 한 번 실행한다. 사용자 입력을 기다리지 않으며, 앞 장의 파일이 없어도 실행할 수 있다. 시연에 필요한 파일은 프로그램이 임시 폴더 안에 직접 만든다.

  • README에 실행 조건, 실행 방법, 기대 결과와 제한 사항을 적는다.
  • 기록부터 보고서 생성까지 이어지는 시연 순서를 정한다.
  • 실행 결과와 자동 검사로 설명서의 내용이 맞는지 확인한다.
  • 현재 남은 문제와 앞으로 추가할 기능을 구분한다.
  • AI가 바꾼 부분을 변경 전후 차이와 함께 검토한다.

이번 장에서 만들 것

완성할 것은 혼자 쓰는 가계부의 마무리 시연 프로그램이다. 실행하면 지출을 기록하고, JSON 파일에 저장했다가 불러온다. 잘못된 분류를 거절하고, 번호로 기록을 수정하고 삭제한다. 준비한 CSV 파일을 가져온 뒤 월별 합계와 예산 초과를 확인한다. 마지막으로 CSV와 HTML 보고서, README, 남은 일 목록을 만든다.

README는 프로그램을 처음 열었을 때 읽는 안내 파일이다. 이번 예제에서는 README.md라는 이름을 쓴다. 확장자 md는 제목과 목록 같은 표시를 간단한 문자로 적는 마크다운 형식을 뜻한다. 별도 도구가 없어도 일반 텍스트로 읽을 수 있다.

모든 산출물은 임시 폴더에 생기며 실행이 끝나면 지워진다. 이는 매번 같은 출발점에서 시연하기 위한 선택이다. 이 프로그램을 실제 지출을 계속 보관하는 앱으로 이해하면 안 된다. 설명서에도 이 차이를 명시한다.

이번 실행에서 만드는 파일과 확인 목적
파일담는 내용확인 목적
ledger.json저장 시점의 지출 목록저장 후 다시 읽어도 같은지 확인
incoming.csv가져올 지출 두 건외부 파일을 읽는 흐름 확인
export.csv마지막 지출 네 건내보낸 값을 다시 읽어 확인
report.html월별 합계와 기록 표화면 파일 생성과 문자 처리 확인
README.md실행 안내와 제한 사항다시 실행할 사람에게 조건 전달
NEXT.md남은 문제와 다음 기능확인할 일과 확장할 일 구분

따라하기

아래 요청문은 같은 AI 대화에 순서대로 보낸다. AI의 답은 매번 다를 수 있다. 각 단계의 답변 요지는 비교 기준이며, 글자 하나까지 같은 답을 기대하지 않는다. 이 장 끝의 완성 코드는 모든 단계를 합친 실행 기준이다. 단계별로 만든 코드가 다르게 출력된다면, 먼저 입력값과 실행 순서를 비교한다.

요청문은 작업 범위와 확인 방법을 함께 담는다. 답변이 그럴듯하다는 이유로 다음 단계로 넘어가지 않는다. 코드를 실행하고, 기대한 결과를 확인하고, 변경 전후 차이를 읽는 순서를 유지한다.

단계 1. 설명서의 독자와 실행 조건 정하기

① 보낼 요청문

혼자 쓰는 가계부 앱의 마무리 시연을 만들겠다. macOS 또는 Linux의 Python 3.12 이상에서 표준 라이브러리만 사용한다. main.py 한 파일을 python3 main.py로 실행하면 입력을 기다리지 않고 끝나야 한다. 우선 실행 조건을 검사하고 README.md 초안을 임시 폴더에 쓰는 코드를 만들어 달라. 임시 폴더는 종료할 때 지워진다는 제한을 설명서에 적어 달라. 현재 단계에서는 완료 메시지 한 줄만 출력해 달라.

② AI가 돌려줄 내용의 요지

AI의 답은 매번 다를 수 있다. Python 버전을 확인하고 임시 폴더를 만든다. 설명서에는 목적, 필요한 환경, 실행 명령, 종료 후 파일이 지워진다는 내용을 넣는다. 출력은 ‘설명서 초안 생성 완료’로 맞춘다.

③ 직접 확인할 것

터미널에서 python3 --version을 실행한다. 3.12 이상인지 확인한다. 이어서 python3 main.py를 실행해 ‘설명서 초안 생성 완료’가 한 번 나오고 종료되는지 확인한다.

파일이 실제로 써졌는지도 코드에서 읽어 확인하게 한다. 폴더 경로를 출력하면 실행마다 결과가 달라지므로, 이번 시연에서는 경로 대신 일정한 완료 문장을 쓴다.

④ 체크포인트

설명서가 프로그램의 실행 방식과 일치하면 다음 단계로 간다. 실제로는 설치하지 않는 패키지나 사용하지 않는 키를 안내문에 넣었다면 삭제한다.

단계 2. 전체 시연에 쓸 입력 고정하기

① 보낼 요청문

앞 코드를 이어서 수정해 달라. 분류는 식비, 교통, 도서로 정한다. 2026-10-01 식비 8000원 점심, 2026-10-02 교통 1500원 버스, 2026-09-30 식비 6000원 저녁을 순서대로 기록한다. 번호는 1부터 붙인다. 여가 분류로 기록하려는 시도는 거절하고 번호도 소비하지 않게 한다. 기록 세 건을 JSON에 저장했다가 읽고, 저장 전과 같은지 검사해 달라. 오늘 날짜는 사용하지 말라.

② AI가 돌려줄 내용의 요지

AI의 답은 매번 다를 수 있다. 기록을 검사하는 함수를 두고, 검사를 통과한 경우에만 목록에 추가한다. JSON을 읽은 결과와 원래 목록을 비교한다. 알 수 없는 분류의 오류는 정해진 문장으로 보여 준다.

③ 직접 확인할 것

python3 main.py를 실행한다. ‘기록: 3건’, ‘입력 거절: 분류는 식비, 교통, 도서 중 하나여야 한다.’, ‘저장·불러오기: 일치’가 순서대로 나오는지 확인한다.

실패한 입력 뒤의 목록 길이도 확인한다. 오류 문장만 출력하고 잘못된 기록을 목록에 남겼다면 원하는 동작이 아니다. 저장 파일은 프로그램이 만들기 때문에 따로 내려받을 자료가 없다.

④ 체크포인트

같은 명령을 두 번 실행해도 세 건에서 출발하고 같은 문장이 나오면 다음 단계로 간다.

단계 3. 수정과 삭제를 시연 순서에 넣기

① 보낼 요청문

JSON에서 불러온 목록으로 계속 시연해 달라. 번호 1의 금액을 9000원, 메모를 ‘<점심 & 차>’로 바꾸고 번호 3은 삭제한다. 번호는 목록 위치와 구분하고, 다음 번호는 삭제 후에도 4로 이어져야 한다. 없는 번호를 수정하거나 삭제하면 오류를 내게 한다. 수정과 삭제가 끝나면 ‘수정·삭제: 1번 수정, 3번 삭제’를 출력해 달라.

② AI가 돌려줄 내용의 요지

AI의 답은 매번 다를 수 있다. 번호로 찾는 함수를 공유한다. 수정 전에는 새 값을 검사하고, 삭제는 찾은 기록을 제거한다. 다음 번호를 따로 유지해 삭제한 번호가 다시 쓰이지 않게 한다.

③ 직접 확인할 것

python3 main.py를 실행한다. 수정·삭제 완료 문장을 확인한다. 코드의 정해진 입력과 변경 부분을 비교해 1번은 남아 있고 3번만 지워졌는지 확인한다.

변경 전후 차이를 뜻하는 diff도 읽는다. 코드 편집기의 변경 비교 기능을 사용하거나, 버전 관리 중인 파일이라면 git diff -- main.py로 살펴본다. 버전 관리를 사용하지 않는다면 수정 전 사본과 비교해도 된다. 이 단계에서는 분류 목록이나 날짜 조건이 뜻밖에 바뀌지 않았는지 본다.

④ 체크포인트

번호와 목록의 순서를 서로 다른 값으로 다루고 있으면 다음 단계로 간다. 삭제 후 목록 길이에 1을 더해 번호를 만드는 방식은 이번 흐름에 맞지 않는다.

단계 4. 가져오기부터 HTML 생성까지 연결하기

① 보낼 요청문

임시 폴더에 CSV 입력 파일을 직접 만들어 달라. 열은 date, category, amount, memo이며 두 행은 2026-10-03 식비 4000원 간식과 2026-10-04 도서 12000원 파이썬 책이다. 가져오기 전에 모든 행을 검사한다. 이어서 식비 검색, 2026-10 월별 합계, 식비 예산 12000원 초과 경고, CSV 내보내기, HTML 보고서 생성을 차례로 실행한다. HTML에 넣는 메모의 특수 문자는 안전하게 표시해 달라.

② AI가 돌려줄 내용의 요지

AI의 답은 매번 다를 수 있다. 준비한 CSV를 읽고 검사한 뒤 두 건을 추가한다. 식비는 두 건이며 월 합계는 26500원이다. 식비 합계 13000원은 예산보다 1000원 많다. HTML에서는 메모를 태그가 아닌 글자로 표시하도록 변환한다.

③ 직접 확인할 것

python3 main.py를 실행한다. ‘가져오기: 2건’, ‘검색: 식비 2건’, ‘월별 합계: 2026-10 26500원’, ‘예산 경고: 식비 1000원 초과’가 나오는지 확인한다.

정답을 암산으로도 비교한다. 식비 9000원과 4000원, 교통 1500원, 도서 12000원을 더하면 26500원이다. 삭제한 9월 기록은 이 합계에 들어가지 않는다. 검색은 분류가 정확히 같은 기록을 찾는 기능이며, 메모에서 단어를 찾는 기능으로 설명하지 않는다.

④ 체크포인트

내보낸 CSV를 다시 읽어 네 건을 확인하고, HTML에 변환된 메모가 들어 있으면 다음 단계로 간다.

전체 시연은 고정 입력에서 시작해 검증된 산출물로 끝난다

단계 5. 설명서에 실제 실행 결과 반영하기

① 보낼 요청문

지금 코드의 동작을 기준으로 README.md를 완성해 달라. 목적, 실행 환경, 실행 명령, 시연 순서, 생성 파일, 기대 결과, 제한 사항을 적는다. 일반적인 대화형 가계부처럼 설명하지 말고 정해진 입력을 쓰는 시연이라고 적는다. 코드에서 생성 파일을 읽고 필요한 내용이 있는지 확인한다. 설명서를 추가하면서 기존 계산 결과는 바꾸지 말라.

② AI가 돌려줄 내용의 요지

AI의 답은 매번 다를 수 있다. 실행 명령은 python3 main.py다. 월 합계 26500원, 식비 초과 1000원, 자동 검사 통과가 기대 결과로 들어간다. 임시 파일 삭제, 고정 입력, 저장 시점의 한계도 함께 설명한다.

③ 직접 확인할 것

python3 main.py를 실행한다. 앞 단계와 같은 금액이 나오는지 확인한다. README를 쓰는 부분의 문장을 읽고 명령, 파일 이름, 기대 결과가 코드와 맞는지 비교한다.

이번 JSON 저장은 수정과 가져오기 전에 한 번 수행된다. 따라서 ledger.json이 최종 네 건을 담는다고 쓰면 설명이 틀린다. 파일 이름만 보고 역할을 추측하지 않고, 실제로 어느 시점에 쓰는지 확인해야 한다.

④ 체크포인트

설명서만 읽어도 무엇을 실행하고 무엇을 확인할지 알 수 있으면 다음 단계로 간다.

단계 6. 남은 문제와 다음 기능 나누기

① 보낼 요청문

NEXT.md를 만들어 달라. ‘남은 문제’에는 현재 동작의 제한과 확인 기준을 적고, ‘다음 기능’에는 새로 추가할 기능과 완료 기준을 적는다. 영구 저장, 반복 가져오기, 메모 검색, 사용자가 여는 보고서 보관을 다룬다. 이번에는 목록만 작성하고 새 기능을 구현하지 말라. README에도 같은 제한을 반영해 달라.

② AI가 돌려줄 내용의 요지

AI의 답은 매번 다를 수 있다. 종료 후 파일이 지워지는 점과 같은 CSV를 다시 가져오면 중복된다는 점을 남은 문제로 적는다. 메모 검색과 보고서 보관은 다음 기능으로 적는다. 각 항목에는 확인 가능한 완료 기준을 붙인다.

③ 직접 확인할 것

python3 main.py를 실행해 ‘문서 생성: README.md, NEXT.md’를 확인한다. NEXT.md를 만드는 문자열에서 문제와 기능이 나뉘어 있는지 읽는다. 이번 변경에서 저장 경로나 검색 동작이 몰래 바뀌지 않았는지도 비교한다.

우선순위는 기능 이름의 인상보다 사용 목적에 맞춰 정한다. 이 앱을 실제 기록 보관에 쓸 예정이라면 영구 저장을 먼저 다룬다. 화면 모양을 바꾸기 전에 데이터가 다음 실행에도 남는지 확인할 필요가 있기 때문이다.

④ 체크포인트

각 항목에 ‘어떻게 확인하면 끝났다고 할 수 있는가’가 적혀 있으면 다음 단계로 간다.

문제와 다음 기능을 확인 가능한 문장으로 바꾸기
구분현재 상태 또는 요청완료 기준
남은 문제종료하면 기록이 지워짐다시 실행해도 저장한 기록을 읽음
남은 문제같은 CSV를 다시 읽으면 중복됨같은 파일을 두 번 읽어도 건수가 같음
다음 기능메모 단어 검색‘책’ 검색으로 해당 메모만 나옴
다음 기능보고서를 보관해 열기종료 후 지정한 HTML 파일을 열 수 있음

단계 7. 실행과 검사로 마무리하기

① 보낼 요청문

지금까지의 코드를 main.py 한 파일로 합쳐 달라. 앞 장 파일 없이 실행되어야 한다. 자동 검사는 저장 일치, 번호 목록, 월 합계, 식비 합계, 검색 건수, HTML 문자 변환, CSV 왕복, 문서 내용을 확인한다. 검사는 조건이 맞지 않으면 실행을 실패시켜야 한다. 실행을 두 번 했을 때 같은 출력이 나오게 하고, 마지막에 검사 8개 통과를 출력해 달라. 실행하지 않았다면 실행했다고 말하지 말라.

② AI가 돌려줄 내용의 요지

AI의 답은 매번 다를 수 있다. 여덟 조건을 차례로 확인하고, 하나라도 틀리면 오류를 낸다. 실행마다 달라지는 임시 경로나 현재 시각은 출력하지 않는다. 실제 실행 여부와 예상 결과를 구분해 설명한다.

③ 직접 확인할 것

python3 -W error -c "from pathlib import Path; compile(Path('main.py').read_text(encoding='utf-8'), 'main.py', 'exec')"를 실행한다. 경고나 오류 없이 끝나는지 확인한다. 이어서 python3 -W error main.py를 두 번 실행하고 아래 실행 결과와 비교한다.

첫 명령은 코드를 실행하지 않고 문법을 검사하며, 경고도 오류로 취급한다. 두 번째 명령은 실제 파일 읽기와 계산을 수행한다. 검사 통과 문장만 붙여 놓은 코드는 확인이 아니다. 조건이 틀리면 멈추는 코드가 있는지 함께 읽는다.

④ 체크포인트

두 번의 출력이 같고, 여덟 검사가 통과하고, 변경 비교에서 설명서와 코드의 약속이 일치하면 이 책의 시연을 마친다. 아래 코드는 이 단계까지 합친 기준이다. 실제 환경에서 실행한 결과는 독자가 직접 확인해 남긴다.

완성 코드

다음 내용을 main.py로 저장한다. 표준 라이브러리는 Python에 기본으로 들어 있는 기능 모음이다. 이 코드는 그 기능만 사용한다. 시연 데이터는 모두 코드에 있으며, 별도 입력 파일이나 인터넷 연결을 요구하지 않는다.

import csv
import json
import sys
from datetime import date
from html import escape
from pathlib import Path
from tempfile import TemporaryDirectory

CATEGORIES = ("식비", "교통", "도서")
MONTH = "2026-10"
BUDGETS = {"식비": 12000}
CSV_FIELDS = ["date", "category", "amount", "memo"]


def validate(day, category, amount, memo):
    parsed = date.fromisoformat(day)
    if parsed.isoformat() != day:
        raise ValueError("날짜는 YYYY-MM-DD 형식이어야 한다.")
    if category not in CATEGORIES:
        raise ValueError("분류는 식비, 교통, 도서 중 하나여야 한다.")
    if type(amount) is not int or amount <= 0:
        raise ValueError("금액은 양의 정수여야 한다.")
    if not isinstance(memo, str):
        raise ValueError("메모는 문자열이어야 한다.")


def add_record(records, next_id, day, category, amount, memo):
    validate(day, category, amount, memo)
    records.append({
        "id": next_id,
        "date": day,
        "category": category,
        "amount": amount,
        "memo": memo,
    })
    return next_id + 1


def find_record(records, number):
    for record in records:
        if record["id"] == number:
            return record
    raise ValueError(f"없는 번호: {number}")


def update_record(records, number, amount, memo):
    record = find_record(records, number)
    validate(record["date"], record["category"], amount, memo)
    record.update(amount=amount, memo=memo)


def delete_record(records, number):
    records.remove(find_record(records, number))


def save_json(path, records):
    text = json.dumps(records, ensure_ascii=False, indent=2)
    path.write_text(text, encoding="utf-8")


def load_json(path):
    return json.loads(path.read_text(encoding="utf-8"))


def write_csv(path, rows):
    with path.open("w", encoding="utf-8", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=CSV_FIELDS)
        writer.writeheader()
        for row in rows:
            writer.writerow({key: row[key] for key in CSV_FIELDS})


def read_csv(path):
    with path.open("r", encoding="utf-8", newline="") as file:
        reader = csv.DictReader(file)
        if reader.fieldnames != CSV_FIELDS:
            raise ValueError("CSV 열 이름과 순서가 맞지 않는다.")
        rows = []
        for row in reader:
            if None in row or any(value is None for value in row.values()):
                raise ValueError("CSV 열 개수가 맞지 않는다.")
            amount = int(row["amount"])
            validate(row["date"], row["category"], amount, row["memo"])
            rows.append({
                "date": row["date"],
                "category": row["category"],
                "amount": amount,
                "memo": row["memo"],
            })
        return rows


def import_csv(path, records, next_id):
    rows = read_csv(path)
    for row in rows:
        next_id = add_record(
            records, next_id, row["date"], row["category"],
            row["amount"], row["memo"],
        )
    return next_id, len(rows)


def search_category(records, category):
    return [row for row in records if row["category"] == category]


def monthly_totals(records, month):
    totals = {category: 0 for category in CATEGORIES}
    for row in records:
        if row["date"][:7] == month:
            totals[row["category"]] += row["amount"]
    return totals


def make_report(records, month):
    totals = monthly_totals(records, month)
    body = []
    for row in records:
        if row["date"][:7] != month:
            continue
        cells = [
            row["id"], row["date"], row["category"],
            row["amount"], row["memo"],
        ]
        body.append(
            "<tr>" + "".join(
                f"<td>{escape(str(value))}</td>" for value in cells
            ) + "</tr>"
        )
    return (
        '<!doctype html><html lang="ko"><head>'
        '<meta charset="utf-8"><title>월별 보고서</title>'
        '</head><body>'
        f"<h1>{escape(month)} 가계부</h1>"
        f"<p>합계: {sum(totals.values())}원</p>"
        "<table><caption>월별 지출</caption><thead><tr>"
        "<th>번호</th><th>날짜</th><th>분류</th>"
        "<th>금액</th><th>메모</th>"
        "</tr></thead><tbody>"
        + "".join(body)
        + "</tbody></table></body></html>"
    )


def write_documents(folder):
    readme = """# 가계부 마무리 시연

## 목적
정해진 입력으로 기록부터 보고서 생성까지 한 번 실행한다.

## 실행 환경과 방법
macOS 또는 Linux, Python 3.12 이상이 필요하다.
외부 패키지, API 키, 네트워크 연결은 사용하지 않는다.
main.py가 있는 폴더에서 python3 main.py를 실행한다.

## 시연 순서
기록 → 잘못된 분류 거절 → JSON 저장과 불러오기
→ 번호로 수정과 삭제 → CSV 가져오기 → 분류 검색
→ 월별 합계 → 예산 경고 → CSV와 HTML 생성 → 검사

## 생성 파일
ledger.json: 수정과 가져오기 전의 세 건을 저장한다.
incoming.csv: 가져올 두 건의 입력이다.
export.csv: 마지막 네 건을 내보낸다. 번호는 포함하지 않는다.
report.html: 2026-10의 지출과 합계를 담는다.
README.md: 이 실행 안내다.
NEXT.md: 남은 문제와 다음 기능 목록이다.

## 기대 결과
월별 합계: 2026-10 26500원
예산 경고: 식비 1000원 초과
검사: 8개 통과

## 제한 사항
대화형 입력 대신 정해진 값을 사용한다.
임시 폴더의 파일은 종료할 때 삭제된다.
ledger.json은 최종 상태를 자동으로 저장하지 않는다.
같은 CSV를 다시 가져오면 기록이 중복된다.
검색은 분류 일치만 지원한다.
손상된 저장 파일의 복구와 동시 실행은 다루지 않는다.
"""
    next_steps = """# 남은 일

## 남은 문제
- 영구 저장: 종료 후 기록이 사라진다.
  완료 기준: 다시 실행해도 저장한 기록을 읽는다.
- 반복 가져오기: 같은 파일을 읽으면 중복된다.
  완료 기준: 같은 파일을 두 번 읽어도 건수가 같다.

## 다음 기능
- 메모 단어 검색
  완료 기준: '책'을 검색하면 해당 메모의 기록만 나온다.
- HTML 보고서 보관
  완료 기준: 종료 후에도 지정한 HTML 파일을 열 수 있다.
"""
    (folder / "README.md").write_text(readme, encoding="utf-8")
    (folder / "NEXT.md").write_text(next_steps, encoding="utf-8")


def run_checks(folder, records, saved, loaded):
    totals = monthly_totals(records, MONTH)
    exported = read_csv(folder / "export.csv")
    expected_rows = [
        {key: row[key] for key in CSV_FIELDS} for row in records
    ]
    report = (folder / "report.html").read_text(encoding="utf-8")
    readme = (folder / "README.md").read_text(encoding="utf-8")
    next_steps = (folder / "NEXT.md").read_text(encoding="utf-8")
    checks = [
        ("저장 일치", saved == loaded),
        ("번호 목록", [row["id"] for row in records] == [1, 2, 4, 5]),
        ("월 합계", sum(totals.values()) == 26500),
        ("식비 합계", totals["식비"] == 13000),
        ("검색 건수", len(search_category(records, "식비")) == 2),
        ("HTML 문자", "&lt;점심 &amp; 차&gt;" in report
         and "<점심 & 차>" not in report),
        ("CSV 왕복", exported == expected_rows),
        ("문서 내용", all(text in readme for text in (
            "python3 main.py", "26500원", "1000원", "8개 통과",
            "종료할 때 삭제",
        )) and all(text in next_steps for text in (
            "남은 문제", "다음 기능", "완료 기준",
        ))),
    ]
    for name, passed in checks:
        if not passed:
            raise AssertionError(f"검사 실패: {name}")
    return len(checks)


def main():
    if sys.version_info < (3, 12):
        raise RuntimeError("Python 3.12 이상이 필요하다.")

    with TemporaryDirectory() as temporary:
        folder = Path(temporary)
        records = []
        next_id = 1
        initial = [
            ("2026-10-01", "식비", 8000, "점심"),
            ("2026-10-02", "교통", 1500, "버스"),
            ("2026-09-30", "식비", 6000, "저녁"),
        ]
        for values in initial:
            next_id = add_record(records, next_id, *values)
        print(f"기록: {len(records)}건")

        try:
            next_id = add_record(
                records, next_id, "2026-10-05", "여가", 3000, "산책"
            )
        except ValueError as error:
            print(f"입력 거절: {error}")

        saved = [row.copy() for row in records]
        save_json(folder / "ledger.json", records)
        loaded = load_json(folder / "ledger.json")
        if saved != loaded:
            raise AssertionError("저장 내용이 다르다.")
        print("저장·불러오기: 일치")
        records = [row.copy() for row in loaded]

        update_record(records, 1, 9000, "<점심 & 차>")
        delete_record(records, 3)
        print("수정·삭제: 1번 수정, 3번 삭제")

        incoming = [
            {"date": "2026-10-03", "category": "식비",
             "amount": 4000, "memo": "간식"},
            {"date": "2026-10-04", "category": "도서",
             "amount": 12000, "memo": "파이썬 책"},
        ]
        write_csv(folder / "incoming.csv", incoming)
        next_id, count = import_csv(
            folder / "incoming.csv", records, next_id
        )
        print(f"가져오기: {count}건")
        found = search_category(records, "식비")
        print(f"검색: 식비 {len(found)}건")

        totals = monthly_totals(records, MONTH)
        print(f"월별 합계: {MONTH} {sum(totals.values())}원")
        for category, limit in BUDGETS.items():
            excess = totals[category] - limit
            if excess > 0:
                print(f"예산 경고: {category} {excess}원 초과")

        write_csv(folder / "export.csv", records)
        print(f"내보내기: {len(records)}건")
        report = make_report(records, MONTH)
        (folder / "report.html").write_text(report, encoding="utf-8")
        print("HTML 생성: report.html")

        write_documents(folder)
        print("문서 생성: README.md, NEXT.md")
        count = run_checks(folder, records, saved, loaded)
        print(f"검사: {count}개 통과")

    print("종료: 임시 파일 정리 완료")


if __name__ == "__main__":
    main()

줄별 해설

이 코드에서는 새 기능의 원리보다 설명서와 실행의 연결을 중심으로 읽는다. 다음 표는 코드에 등장하는 순서대로 중요한 줄을 짚는다. 앞에서 만든 기능을 포함한 이유는 별도 파일 없이 전체 시연을 재현하기 위해서다.

코드의 주요 줄과 마무리 점검에서의 역할
코드의 부분하는 일직접 볼 점
sys.version_info실행 가능한 Python 버전 확인설명서와 코드의 최소 버전이 같음
CATEGORIES, MONTH, BUDGETS시연의 기준값 고정현재 날짜에 따라 결과가 바뀌지 않음
validate날짜, 분류, 금액과 메모 검사기록 추가와 수정 전에 호출함
next_id + 1성공한 기록 뒤에 다음 번호 반환실패하거나 삭제해도 번호를 되돌리지 않음
saved와 loaded저장 전후 값 비교수정 전 상태를 따로 보존함
rows = read_csv(path)추가 전에 입력 전체 읽기와 검사뒤 행이 틀렸을 때 앞 행만 추가하지 않음
escape(str(value))HTML에 들어갈 문자를 변환메모의 꺾쇠괄호를 글자로 표시함
raise AssertionError검사 실패 시 실행 중단성공 문장만 출력하는 검사가 아님
with TemporaryDirectory()시연 파일의 수명 관리범위를 나가면 파일이 정리됨

validate의 금액 조건에서 type(amount) is not int를 쓰는 이유는 정수인 값만 받기 위해서다. CSV에서 읽은 금액은 먼저 int로 변환한다. ‘abc’처럼 정수로 바꿀 수 없는 문자는 변환 과정에서 오류가 난다. 이번 시연 파일은 코드가 직접 쓰지만, 파일을 읽는 함수에도 검사를 둔다.

saved를 만들 때 각 기록을 row.copy()로 복사한다. 목록만 복사하고 그 안의 기록을 함께 참조하면 나중의 수정이 비교 기준에도 영향을 줄 수 있다. 이 기록의 값은 문자열과 정수뿐이므로 각 기록을 한 번 복사하면 충분하다. loaded에서도 같은 방식으로 작업 목록을 분리한다.

read_csv는 입력을 모두 검사해 rows에 모은 다음 반환한다. import_csv는 그 뒤에 기록을 추가한다. 이번 코드에서 검사 오류 때문에 일부 행만 먼저 반영되는 상황을 피하는 구조다. 다만 전원 중단이나 디스크 오류까지 처리하는 저장 장치는 아니다. 설명서에 적은 복구 제한을 이 검사만으로 해결했다고 해석하지 않는다.

CSV_FIELDS에는 번호가 없다. 내보내기는 날짜, 분류, 금액, 메모를 교환하는 기능이며 원래 번호를 복원하는 백업 기능은 아니다. 다시 가져온 행에는 새 번호를 붙인다. 이 차이는 사용자가 파일을 어떻게 믿고 사용할지에 영향을 주므로 README에 적는다.

run_checks는 여덟 개의 조건을 확인한다. 여기서 ‘8개’는 여덟 기능 전체를 모든 입력으로 검사했다는 뜻이 아니다. 문서 내용 검사처럼 여러 조건을 묶은 항목도 있다. 없는 번호, 깨진 JSON, 다양한 잘못된 날짜 등은 별도의 검사 대상으로 남는다. 검사 개수보다 무엇을 확인했는지 읽는 일이 중요하다.

문서 검사도 일부 문구가 존재하는지 확인하는 수준이다. 명령이 들어 있다는 사실만으로 설명 전체가 맞는 것은 아니다. 문장과 실제 동작을 사람이 비교하는 일이 남는다. 자동 검사, 실행 결과 확인, 변경 비교는 서로 맡은 역할이 다르다.

실행 결과와 자동 검사와 문서 검토를 함께 통과해야 마무리할 수 있다

실행 결과

main.py를 저장한 폴더에서 다음 명령을 실행한다. 프로그램은 질문하지 않고 종료된다. 임시 폴더의 이름은 출력하지 않으므로 실행마다 비교할 내용이 같다.

python3 main.py

코드와 대조할 예상 출력은 다음과 같다. 실제 실행에서 다른 줄이 나오면 해당 단계의 입력과 조건부터 확인한다.

기록: 3건
입력 거절: 분류는 식비, 교통, 도서 중 하나여야 한다.
저장·불러오기: 일치
수정·삭제: 1번 수정, 3번 삭제
가져오기: 2건
검색: 식비 2건
월별 합계: 2026-10 26500원
예산 경고: 식비 1000원 초과
내보내기: 4건
HTML 생성: report.html
문서 생성: README.md, NEXT.md
검사: 8개 통과
종료: 임시 파일 정리 완료

마지막 줄은 임시 폴더를 사용하는 범위를 빠져나온 뒤 출력한다. 따라서 종료 후 현재 폴더에서 report.html이나 README.md를 찾을 수 없는 것이 이번 코드의 정상 동작이다. 실제 보관 기능으로 바꾸려면 저장 위치와 덮어쓰기 규칙을 먼저 정해야 한다. 이번 마무리에서는 그 작업을 다음 기능 목록에 남긴다.

보고서 파일을 만드는 기능은 파일 내용으로 검사한다. 브라우저에서의 모양을 직접 검토한 것은 아니다. 나중에 보고서 보관 기능을 붙이면 저장한 파일을 열어 제목과 표가 읽기 좋은지도 별도로 확인한다.

실무에서 자주 틀리는 것

기대값을 화면에 쓰고 검사했다고 생각하기

다음 코드는 실제 계산과 상관없이 통과라고 말한다. 안내 문구로는 쓸 수 있지만 검사로 쓸 수 없다.

total = 26000
print("월 합계 검사 통과")

고친 코드는 실제 값과 기대값을 비교한다. 아래의 26500은 정해진 입력을 손으로 합산해서 얻은 값이다. 실패할 때는 성공 문장이 나오기 전에 멈춘다.

total = sum([9000, 1500, 4000, 12000])
if total != 26500:
    raise AssertionError("월 합계가 다르다.")
print("월 합계 검사 통과")

AI에게 검사 추가를 요청했다면 출력 문장이 아니라 비교 조건이 추가되었는지 diff에서 확인한다.

기능 목록을 실제 실행 방법처럼 적기

설명서가 프로그램보다 앞서가면 독자는 없는 입력창이나 남아 있지 않은 파일을 찾게 된다. 다음 문장은 이번 코드와 맞지 않는 안내다.

guide = "실행 후 지출을 입력하면 종료 뒤에도 자동 보관된다."
print(guide)

고친 문장은 현재 실행 방식과 보관 범위를 설명한다. 더 편리해 보이는 문장보다 확인한 동작을 적는 편이 낫다.

guide = (
    "정해진 지출을 시연한다. "
    "생성 파일은 임시 폴더에 있으며 종료할 때 삭제된다."
)
print(guide)

희망하는 동작은 NEXT.md에 적는다. README에는 현재 사용할 수 있는 동작을 적는다.

삭제 뒤에 목록 길이로 번호 만들기

다음 코드는 번호 3이 남아 있는데도 다음 번호를 3으로 정한다. 삭제한 뒤의 건수와 지금까지 발급한 번호는 다르다.

records = [{"id": 1}, {"id": 3}]
next_id = len(records) + 1
print(next_id)

이번 프로그램처럼 다음 번호를 별도로 유지하면 삭제가 번호 발급에 영향을 주지 않는다. 아래는 번호를 한 번 더 발급하는 작은 예다.

records = [{"id": 1}, {"id": 3}]
next_id = 4
records.append({"id": next_id})
next_id += 1
print(next_id)

이 방식으로 영구 저장을 추가할 때는 기록 목록뿐 아니라 다음 번호도 저장해야 한다. 현재 JSON 파일이 그 상태까지 저장한다고 설명해서는 안 된다.

남은 일을 동사만으로 적기

‘검색 개선’이라는 항목은 무엇이 끝나야 지울 수 있는지 알기 어렵다. 시간이 지난 뒤 다시 AI에게 요청할 때도 범위가 흔들린다.

task = "검색 개선"
print(task)

대상과 결과를 적으면 작은 작업으로 나눌 수 있다. 다음 문장은 입력과 기대 결과를 함께 정한다.

task = (
    "메모에서 단어를 찾는다. "
    "완료 기준: '책'을 검색하면 '파이썬 책' 기록만 나온다."
)
print(task)

목록에 적었다는 이유로 구현을 시작하지 않는다. 다음 작업을 시작할 때 이 완료 기준을 요청문과 검사에 함께 옮긴다.

한눈에 보기

마무리의 기준은 코드 파일이 있다는 사실이 아니라, 실행할 사람에게 필요한 약속이 맞아 있는가다. 아래 항목을 실행 결과와 함께 확인한다.

가계부 시연을 마칠 때 확인할 약속
대상남길 내용확인 방법
실행 안내환경과 python3 main.py적힌 명령으로 직접 실행
기대 결과합계, 경고, 검사 결과정해진 출력과 줄별 비교
데이터 범위저장 시점과 임시 파일 수명파일을 쓰는 위치와 순서 읽기
자동 검사여덟 항목과 실패 조건비교식과 오류 발생 부분 검토
남은 문제현재 제한과 완료 기준재현 조건이 구체적인지 확인
다음 기능추가할 동작과 기대 결과요청문으로 옮길 수 있는지 확인
변경 내역이번 요청으로 바뀐 부분diff에서 관계없는 변경 확인

이 책에서 만든 시연은 작은 기능을 요청하고 직접 확인하는 연습이었다. 다음 기능을 붙일 때도 같은 방식으로 시작한다. 현재 동작을 실행해 기준을 남기고, 작은 변경을 요청하고, 결과와 차이를 확인한다. 사용 설명서와 남은 일 목록은 그 작업을 다시 이어 갈 출발점이다.

연습 문제

  1. README에 “ledger.json에는 최종 네 건이 저장된다”라고 적혀 있다고 하자. 완성 코드의 저장 순서를 근거로 문장을 고쳐라.
  2. 식비 예산을 12000원에서 15000원으로 바꾸면 어떤 출력 줄이 사라지는가. 계산 결과와 함께, 설명서와 검사에서 다시 볼 부분을 적어라.
  3. 없는 번호 99를 삭제할 때 목록이 바뀌지 않는지 확인하려 한다. 실행 가능한 작은 검사 코드를 작성하라. 완성 코드의 delete_record 함수를 호출하되 시연 목록을 손상시키지 않는 방법을 생각하라.
  4. “CSV 개선”을 NEXT.md에 넣으려 한다. 반복 가져오기로 범위를 좁히고, 정해진 입력과 완료 기준을 포함한 문장으로 고쳐라.

정답과 해설

1. 저장 파일의 시점을 적는다. “ledger.json에는 수정과 CSV 가져오기 전의 세 건이 저장된다”로 고친다. save_json은 update_record와 import_csv보다 먼저 호출된다. 마지막 네 건은 export.csv에 들어가며 번호 열은 포함하지 않는다. 저장 파일을 최종 백업이라고 안내하지 않는 이유다.

2. 식비 경고 줄이 사라진다. 식비 합계는 13000원으로 그대로다. 15000원보다 2000원 적으므로 초과 조건이 거짓이 된다. ‘예산 경고: 식비 1000원 초과’가 출력되지 않는다. 월 합계 26500원은 변하지 않는다. README의 기대 결과와 문서 검사에서 찾는 ‘1000원’을 함께 수정해야 한다. 경고가 없는 경우를 확인하는 검사도 추가할 수 있다. 이 변경 뒤에는 검사를 먼저 실행하고 실제 출력도 다시 비교한다.

3. 복사한 작은 목록으로 실패와 보존을 함께 확인한다. 다음 코드는 완성 코드의 함수 정의 뒤에 덧붙여 별도로 실행할 수 있다. 번호가 없다는 오류가 나지 않아도 검사 실패로 처리한다.

def check_missing_number():
    sample = [{
        "id": 1,
        "date": "2026-10-01",
        "category": "식비",
        "amount": 9000,
        "memo": "점심",
    }]
    before = [row.copy() for row in sample]
    try:
        delete_record(sample, 99)
    except ValueError as error:
        if str(error) != "없는 번호: 99":
            raise AssertionError("오류 안내가 다르다.") from error
    else:
        raise AssertionError("없는 번호를 거절하지 않았다.")
    if sample != before:
        raise AssertionError("실패한 삭제가 목록을 바꿨다.")
    print("없는 번호 검사 통과")


check_missing_number()

오류가 발생했다는 사실과 데이터가 보존되었다는 사실을 따로 확인한다. 예제의 본 실행 목록 대신 sample을 사용하므로 원래 합계와 번호에 영향을 주지 않는다.

4. 반복 입력과 기대 건수를 적는다. “같은 CSV의 중복 가져오기를 막는다. 빈 목록에서 incoming.csv의 두 건을 두 번 가져온 뒤에도 기록은 두 건이어야 한다”로 쓸 수 있다. 다만 어떤 행을 같은 기록으로 판단할지도 다음 요청에서 정해야 한다. 날짜와 금액이 같아도 서로 다른 지출일 수 있기 때문이다. 완료 기준을 먼저 적으면, 필요한 판단 규칙과 아직 정하지 않은 부분이 드러난다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.