이번 편의 목표: 검증·저장·조회·월별 합계·테스트를 갖춘 도구를 완성합니다.
마지막 편에서는 종료해도 남는 지출 기록기를 만듭니다. 앞 편의 메뉴에서 직접 입력하는 방식과 달리, 이번에는 터미널 명령 하나로 한 작업을 실행합니다. 명령행 인수는 실행할 때 프로그램에 전달하는 값입니다. 정해진 명령으로 재현하기 쉬워 테스트나 반복 실행에도 유용합니다.
완성할 명령을 먼저 보자
ZIP의 `project` 폴더로 이동한 뒤 실행합니다. `python`은 1편에서 확인한 자신의 실행 명령으로 바꿉니다.
python expense_tracker.py add --date 2026-10-01 --item "커피" --amount 4500 python expense_tracker.py add --date 2026-10-02 --item "점심" --amount 8000 python expense_tracker.py list python expense_tracker.py summary --month 2026-10 python -m unittest discover -s tests -v
처음 두 명령은 한 건씩 저장합니다. 조회에는 두 건이 나오고 월별 합계는 `12,500원`입니다. 같은 추가 명령을 다시 실행하면 같은 거래를 한 번 더 기록합니다. 중복 명령을 자동으로 합치는 설계는 아닙니다. 기본 파일은 스크립트 옆의 `data/expenses.csv`입니다. 새 폴더에서 따라 하면 기존 파일과 섞이지 않습니다.
다른 위치에 저장하려면 전체 옵션인 `--file`을 하위 명령 앞에 둡니다.
python expense_tracker.py --file practice.csv add --date 2026-10-03 --item "책" --amount 18000 python expense_tracker.py --file practice.csv list
직접 지정한 상대 경로는 현재 작업 디렉터리 기준입니다. 날짜는 `YYYY-MM-DD`, 월은 `YYYY-MM`로 엄격하게 받습니다. 날짜의 실존 여부와 금액의 양수 여부도 검사합니다. 영수증 원본이나 실명 등은 이 실습 자료에 넣을 필요가 없습니다.
명령행 파서는 argparse가 담당한다
이번에 새로 등장하는 `argparse`는 표준 라이브러리입니다. 필요한 인수, 선택지, 도움말을 선언하면 `--help`와 잘못된 명령 안내를 처리합니다. `add`, `list`, `summary`는 하위 명령입니다. `parse_args()`의 결과를 읽어 이미 배운 함수들을 호출합니다.
파일을 작은 조각으로 복사하다 빠뜨리지 않도록 전체 실행 코드는 이 글 맨 아래에 수록하고, 동일한 코드를 `project/expense_tracker.py`에도 제공합니다. 예제 코드에 사용하는 핵심 함수는 다음과 같습니다.
함수 — 역할 validate_date — 날짜 모양과 실제 달력 날짜 확인 validate_item — 비어 있지 않은 단일 행 항목 확인 load_expenses — 헤더와 각 행을 검사하며 CSV 읽기 save_expenses — 임시 파일에 쓴 뒤 정상 완료 시 교체 summarize — 전체 또는 지정 월 건수·합계 계산 main — 명령을 해석하고 사용자 결과 출력
파일이 없으면 빈 목록으로 시작합니다. 기존 파일의 헤더나 행이 깨져 있으면 자동으로 초기화하지 않고 오류를 알립니다. 저장할 때는 같은 폴더의 임시 파일에 먼저 쓴 뒤 교체해, 쓰기 도중 실패하면 기존 파일을 보존하도록 했습니다. 이는 여러 프로세스가 동시에 수정하는 문제까지 해결하는 잠금 기능은 아닙니다.
테스트로 확인할 경계
동봉한 테스트는 임시 폴더에서 정상 저장·재로딩, 한글과 쉼표가 있는 항목, 잘못된 날짜와 금액, 헤더 오류, 손상된 행, 월별 합계를 확인합니다. 저장 실패 때 기존 파일이 보존되는지도 검사합니다. 사용자 안내는 터미널에서 정상 명령과 실패 명령을 실제로 실행해 별도로 확인할 수 있습니다.
지출 항목이 `=` 등으로 시작하면 스프레드시트 수식으로 오해될 수 있어 이 학습용 도구에서는 거부합니다. 일반적인 임의 CSV를 위한 완전한 보안 변환기는 아닙니다. 이 프로젝트의 범위는 개인이 순차적으로 실행하는 작은 원 단위 지출 기록기입니다. 여러 사용자의 동시 입력이나 거래 수정 이력이 필요해지면 SQLite 같은 저장소와 별도의 식별자를 검토할 단계입니다.
직접 해 보기
9월 자료 한 건과 10월 자료 두 건을 저장한 뒤 `summary --month 2026-10`이 10월 자료만 더하는지 확인하세요. 파일에 `2026-02-30`을 직접 넣었다면 조용히 건너뛰지 않고 오류가 나야 합니다. 실습할 때는 복사본 파일을 사용하세요.
해설
제공한 샘플 세 건에서 9월 지출은 `3,000원`, 10월 지출은 `4,500원`과 `8,000원`입니다. 따라서 전체 합계는 `15,500원`, 10월 합계는 `12,500원`입니다. 학습의 마지막 확인은 코드를 읽는 일이 아니라 종료 후 다시 실행해 같은 자료가 조회되는지 보는 것입니다.
이제 변수, 조건문, 반복문, 리스트, 딕셔너리, 함수가 각각 왜 필요했는지 한 프로그램 안에서 설명할 수 있습니다. 다음 학습은 자신의 반복 업무에서 입력·검증·처리·저장·출력을 찾아 작은 도구 하나로 옮기는 일로 시작하면 됩니다.
전체 실행 코드
아래 코드를 `expense_tracker.py`로 저장합니다. 같은 코드가 ZIP의 `project` 폴더에 있습니다.
"""Python 3.11+ 표준 라이브러리로 만든 단일 사용자 지출 기록기."""
import argparse
import csv
from datetime import date
from pathlib import Path
import re
import sys
import tempfile
FIELDS = ["date", "item", "amount"]
DEFAULT_FILE = Path(__file__).resolve().parent / "data" / "expenses.csv"
def validate_date(text):
if not isinstance(text, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", text):
raise ValueError("날짜는 YYYY-MM-DD 형식이어야 합니다.")
try:
return date.fromisoformat(text).isoformat()
except ValueError:
raise ValueError("실제 달력에 있는 날짜를 입력해 주세요.") from None
def validate_month(text):
if not isinstance(text, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}", text):
raise ValueError("월은 YYYY-MM 형식이어야 합니다.")
validate_date(text + "-01")
return text
def validate_item(text):
if not isinstance(text, str):
raise ValueError("항목은 문자열이어야 합니다.")
text = text.strip()
if not text or any(ord(char) < 32 or ord(char) == 127 for char in text):
raise ValueError("항목은 비어 있지 않은 한 줄이어야 합니다.")
if text.startswith(("=", "+", "-", "@")):
raise ValueError("항목 첫 글자에 =, +, -, @를 사용할 수 없습니다.")
return text
def validate_amount(amount):
if type(amount) is not int or amount <= 0:
raise ValueError("금액은 0보다 큰 정수여야 합니다.")
return amount
def validate_row(row):
return {
"date": validate_date(row["date"]),
"item": validate_item(row["item"]),
"amount": validate_amount(row["amount"]),
}
def load_expenses(path):
path = Path(path)
rows = []
try:
file = path.open("r", encoding="utf-8-sig", newline="")
except FileNotFoundError:
return rows
with file:
reader = csv.DictReader(file, strict=True)
if reader.fieldnames != FIELDS:
raise ValueError("CSV 헤더는 date,item,amount 순서여야 합니다.")
for row in reader:
try:
if set(row) != set(FIELDS) or any(value is None for value in row.values()):
raise ValueError("열 개수가 맞지 않습니다.")
row["amount"] = int(row["amount"])
rows.append(validate_row(row))
except (ValueError, TypeError, KeyError) as error:
raise ValueError(f"CSV {reader.line_num}행: {error}") from error
return rows
def save_expenses(path, rows):
path = Path(path)
checked = [validate_row(row) for row in rows]
path.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8-sig", newline="",
dir=path.parent, prefix=".expenses-", suffix=".tmp", delete=False,
) as file:
temporary = Path(file.name)
writer = csv.DictWriter(file, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(checked)
temporary.replace(path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def summarize(rows, month=None):
if month is not None:
month = validate_month(month)
selected = [row for row in rows if month is None or row["date"].startswith(month + "-")]
return len(selected), sum(row["amount"] for row in selected)
def build_parser():
parser = argparse.ArgumentParser(description="CSV 지출 기록기")
parser.add_argument("--file", type=Path, default=DEFAULT_FILE, help="지출 CSV 경로")
commands = parser.add_subparsers(dest="command", required=True)
add = commands.add_parser("add", help="지출 한 건 추가")
add.add_argument("--date", required=True, help="YYYY-MM-DD")
add.add_argument("--item", required=True, help="지출 항목")
add.add_argument("--amount", required=True, type=int, help="원 단위 양의 정수")
commands.add_parser("list", help="모든 지출 조회")
summary = commands.add_parser("summary", help="건수와 합계")
summary.add_argument("--month", help="YYYY-MM, 생략하면 전체")
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
try:
rows = load_expenses(args.file)
if args.command == "add":
row = validate_row({"date": args.date, "item": args.item, "amount": args.amount})
rows.append(row)
save_expenses(args.file, rows)
print(f"저장했습니다: {row['date']} / {row['item']} / {row['amount']:,}원")
elif args.command == "list":
if not rows:
print("아직 기록이 없습니다.")
for number, row in enumerate(rows, start=1):
print(f"{number}. {row['date']} / {row['item']} / {row['amount']:,}원")
else:
count, total = summarize(rows, args.month)
print(f"{args.month or '전체'} / {count}건 / 합계 {total:,}원")
except (OSError, UnicodeError, csv.Error, ValueError) as error:
print(f"오류: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
COMMENTS
댓글 0
이 글에 대한 의견이나 질문을 남겨 주세요.
등록된 댓글이 없습니다.