Python 강좌 / 최종 실습: SQLite 기반 지출 관리 프로그램 완성하기
최종 실습은 지출의 날짜·분류·금액을 입력하고 SQLite에 저장한 뒤 목록과 분류별 합계를 조회하는 CLI 프로그램입니다. 초기화·입력·조회·검증·트랜잭션을 명확히 나누어 앞서 배운 기능을 연결합니다.
기능 범위와 데이터 규칙
금액은 원 단위 비음수 정수, 날짜는 YYYY-MM-DD, 분류는 빈 문자열이 아니어야 합니다. 결제·은행 연동이나 여러 사용자 인증 기능은 포함하지 않습니다. add는 호출할 때마다 새 지출 한 건을 추가하므로 같은 명령을 반복하면 실제로 행이 늘어납니다.
관련 동작과 지원 조건은 Python: sqlite3에서 확인할 수 있습니다.
새 실습 폴더에 ledger.py를 저장합니다. Python 3.12 이상과 표준 라이브러리만 사용합니다. --db로 별도 DB 경로를 지정할 수 있으며 기본값 ledger.db는 현재 폴더 아래입니다. 다른 실습·업무 파일을 지정하지 않습니다.
저장 규칙과 자동 테스트은 sqlite3로 데이터 저장하고 매개변수 쿼리 사용하기 및 자동 테스트 작성하기: unittest와 경계값 검사에서 사용하는 규칙과 연결됩니다.
전체 프로그램
ledger.py 파일의 전체 내용입니다.
import argparse
import sqlite3
from datetime import date
from pathlib import Path
MAX_AMOUNT = 1_000_000_000_000
def amount_value(text):
try:
value = int(text)
except ValueError as error:
raise argparse.ArgumentTypeError("expected an integer") from error
if not 0 <= value <= MAX_AMOUNT:
raise argparse.ArgumentTypeError("amount must be between 0 and 1000000000000")
return value
def date_value(text):
try:
parsed = date.fromisoformat(text)
except ValueError as error:
raise argparse.ArgumentTypeError("invalid date") from error
if parsed.isoformat() != text:
raise argparse.ArgumentTypeError("use YYYY-MM-DD")
return text
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--db", type=Path, default=Path("ledger.db"))
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("init")
add = commands.add_parser("add")
add.add_argument("day", type=date_value)
add.add_argument("category")
add.add_argument("amount", type=amount_value)
commands.add_parser("list")
commands.add_parser("summary")
args = parser.parse_args()
if args.command != "init" and not args.db.is_file():
parser.error("database not initialized")
if args.command == "add" and not args.category.strip():
parser.error("category is required")
con = sqlite3.connect(args.db, autocommit=False)
try:
if args.command == "init":
with con:
con.execute("CREATE TABLE IF NOT EXISTS expenses (id INTEGER PRIMARY KEY, day TEXT NOT NULL, category TEXT NOT NULL CHECK(length(trim(category))>0), amount INTEGER NOT NULL CHECK(amount BETWEEN 0 AND 1000000000000))")
print("initialized")
elif args.command == "add":
with con:
con.execute("INSERT INTO expenses(day,category,amount) VALUES(?,?,?)", (args.day, args.category.strip(), args.amount))
print("added")
elif args.command == "list":
for row in con.execute("SELECT id,day,category,amount FROM expenses ORDER BY day,id"):
print(*row)
else:
totals = {}
for category, amount in con.execute("SELECT category,amount FROM expenses"):
totals[category] = totals.get(category, 0) + amount
for category, total in sorted(totals.items()):
print(category, total)
finally:
con.close()
if __name__ == "__main__":
main()
초기화·입력·조회 순서
python ledger.py init
python ledger.py add 2026-10-17 food 3500
python ledger.py add 2026-10-17 food 8000
python ledger.py add 2026-10-17 transport 2500
python ledger.py list
python ledger.py summary
초기화는 initialized, 입력은 매번 added를 출력합니다. 목록은 id·날짜·분류·금액 순서이며 마지막 합계는 다음과 같아야 합니다.
food 11500
transport 2500
init은 기존 테이블을 지우지 않습니다. 처음 상태부터 반복하려면 새 폴더나 --db로 새 파일을 사용하고 기존 파일을 무작정 삭제하지 않습니다.
실패 조건과 저장 결과 확인
- 2026-02-30을 입력해 DB 저장 전에 거부되는지 확인합니다.
- 음수 금액과 빈 분류도 거부한 뒤 목록이 늘지 않았는지 확인합니다.
- 프로그램을 종료한 후 다시 list를 실행해 데이터가 남아 있는지 확인합니다.
- 새 DB에서 동일한 세 건을 넣어 합계가 재현되는지 확인합니다.
SQL에 값 매개변수를 사용하고 DB에도 금액 제약을 둡니다. Python 검증과 저장소 제약은 서로를 보완합니다. 예외가 발생하면 with 연결이 롤백하고 finally가 연결을 닫습니다. 단, 스키마 변경·백업·복원·동시 사용자 관리는 추가 설계가 필요한 별도 범위입니다.
프로그램을 나눌 때의 기준
현재 파일은 구조를 한눈에 보기 위한 전체 예제입니다. 기능이 커지면 인수 해석, 입력 검증, DB 접근과 집계를 별도 모듈로 분리하고 기존 동작을 테스트로 고정합니다. import만으로 DB를 만들거나 사용자 입력을 받지 않도록 main 진입점을 유지합니다.
실제 개인 지출을 저장한다면 파일 권한·백업·공유 위치를 확인합니다. 보고서 숫자만 맞는지뿐 아니라 잘못된 입력이 저장되지 않았는지와 재실행의 의미까지 검증해야 완성도를 판단할 수 있습니다.
처리 흐름은 인수 해석 → 날짜·금액 검증 → DB 존재 확인 → 트랜잭션 쓰기 또는 조회 → 출력입니다. amount_value는 0~1,000,000,000,000 정수, date_value는 실제 존재하는 YYYY-MM-DD, main은 명령 선택과 연결 수명을 맡습니다. summary는 SQL SUM 대신 Python 정수로 누적하여 SQLite 합계 오버플로를 피합니다. 조회는 스트리밍하지만 카테고리별 합계 dict의 메모리는 카테고리 수에 비례합니다. 기존 DB의 CHECK는 자동 갱신되지 않으므로 새 DB로 실습합니다. 아래 자동 검증은 임시 DB에서 정상 저장, 잘못된 날짜·음수·상한 초과 거부, 목록과 합계를 함께 확인합니다.
아래 전체 코드를 check_ledger.py로 저장하고 같은 실습 폴더에서 python check_ledger.py로 실행합니다. 먼저 ledger.py도 같은 폴더에 준비합니다.
import subprocess
import sys
import tempfile
from pathlib import Path
program = Path("ledger.py").resolve()
with tempfile.TemporaryDirectory() as folder:
db = Path(folder) / "test.db"
def run(*args):
return subprocess.run([sys.executable, str(program), "--db", str(db), *args], capture_output=True, text=True)
assert run("init").returncode == 0
assert run("add", "2026-10-17", "food", "8000").returncode == 0
assert run("add", "2026-02-30", "food", "8000").returncode != 0
assert run("add", "2026-10-17", "food", "-1").returncode != 0
assert run("add", "2026-10-17", "food", "1000000000001").returncode != 0
result = run("summary")
assert result.returncode == 0 and result.stdout.strip() == "food 8000"
assert len(run("list").stdout.splitlines()) == 1
print("integration checks passed")
예상 결과:
integration checks passed
이 동작과 적용 조건의 공식 설명: SQLite: sum and total




