Devin.KR

AI 개발 · 실무

바이브 코딩 실전 예제 - 장마다 하나씩 완성하는 12가지 프로그램

가계부 - sqlite3 로 월별·분류별 집계

테이블 설계 요청하기, 금액은 정수(원)로, 월별·분류별 GROUP BY, SQL 주입을 막는 매개변수, 메모리 DB

개발자KR · 원고 갱신

이 장에서 배우는 것

가계부의 어려움은 금액을 더하는 데만 있지 않다. 수입과 지출을 구분하고, 어느 달에 속한 거래인지 판단하며, 같은 분류의 금액을 빠짐없이 묶어야 한다. 거래가 늘어나도 계산 규칙을 한곳에서 유지할 수 있어야 한다. 이 장에서는 Python 표준 라이브러리인 sqlite3로 거래를 저장하고 월별·분류별 지출을 집계하는 프로그램을 만든다.

앞 장에서 저장된 웹 페이지의 구조를 읽었다면, 이번에는 직접 저장 구조를 정한다. AI에게 테이블 설계부터 요청하되, 제안받은 구조와 계산 결과를 실행으로 확인한다. 실습에서는 메모리 데이터베이스를 사용하므로 실행할 때마다 같은 자료로 시작하고 같은 결과로 끝난다.

  • 거래 한 건을 표현하는 테이블과 입력 검증 규칙을 설계한다.
  • 금액을 원 단위 정수로 저장하고 수입과 지출의 의미를 분리한다.
  • GROUP BY로 월별 지출과 월별·분류별 지출을 계산한다.
  • 매개변수 바인딩(parameter binding)으로 입력값과 SQL 문장을 분리한다.
  • 메모리 데이터베이스에서 경계값, 정렬, 되돌리기를 검사한다.

문제 상황

한 사람이 생활비를 정리한다고 하자. 기록에는 날짜, 수입인지 지출인지, 분류, 금액, 메모가 있다. 월말에는 그달에 얼마를 썼는지, 식비와 교통비 중 어디에 지출이 많았는지 확인한다. 거래를 메모 목록으로만 관리하면 합계를 낼 때마다 날짜를 고르고 분류별로 다시 더해야 한다.

여기서는 양수 금액과 거래 종류를 함께 저장한다. 급여 2,000,000원은 수입이며, 식비 12,000원은 지출이다. 지출을 음수로 저장하는 방식도 가능하지만, 거래 종류와 부호가 서로 어긋나는 입력을 피하려고 금액은 항상 양수로 제한한다. 환불과 취소는 이번 프로그램의 범위에 넣지 않는다. 이를 추가할 때는 기존 지출을 지울지, 반대 거래를 남길지부터 결정해야 한다.

완성 프로그램의 요구사항은 다음과 같다. 사용자의 실제 생활비를 입력받는 대신 고정된 예시 거래 여덟 건을 넣는다. 파일 입력은 없으며, 실행 중 네트워크에 접속하지 않는다.

  • 날짜는 실제로 존재하는 날짜이며 YYYY-MM-DD 형식이어야 한다.
  • 종류는 income 또는 expense이며, 금액은 0보다 큰 원 단위 정수여야 한다.
  • 분류는 앞뒤 공백을 제거한 뒤에도 내용이 있어야 한다.
  • 월별 집계는 지출만 포함하며, 월을 오름차순으로 출력한다.
  • 분류별 집계는 월 오름차순, 합계 내림차순, 분류 이름 오름차순으로 출력한다.
  • 검사는 별도 메모리 데이터베이스에서 수행하고, 보고서 자료와 섞지 않는다.

이 단계에서 출력 규칙까지 정하는 이유가 있다. 합계가 같을 때의 순서나 거래가 없는 달의 표시 여부를 남겨 두면 AI가 임의로 선택한다. 이번에는 지출이 있는 달과 분류만 표시한다. 거래가 없는 달을 0원으로 채우는 일은 별도의 달 목록이 필요한 확장 과제로 남긴다.

저장 구조를 먼저 요청한다

곧바로 “가계부를 만들어 달라”고 요청하면 화면이나 메뉴부터 만들어질 수 있다. 이 프로그램의 핵심은 저장 규칙과 집계이므로, 먼저 테이블과 검사 항목을 요청한다. 다음 요청문은 특정 제품의 기능에 기대지 않으며 여러 AI 도구에서 사용할 수 있다.

Python 3.12 이상과 표준 라이브러리 sqlite3만 사용해 가계부 예제를 작성하라. 먼저 거래 테이블의 열과 제약 조건을 설명하고, 이어서 main.py 한 파일을 제시하라. 날짜, 종류, 분류, 원 단위 정수 금액, 메모를 저장한다. 종류는 income 또는 expense, 금액은 양수다. 날짜는 실제 달력 날짜인 YYYY-MM-DD 문자열만 받는다.

연결은 :memory:로 만들고, 고정 자료 여덟 건을 넣어 월별 지출과 월별·분류별 지출을 출력하라. 수입은 지출 합계에서 제외하라. 정렬 기준도 SQL에 명시하라. 입력값을 SQL 문자열에 끼워 넣지 말고 자리표시자로 전달하라. 빈 데이터, 연도 경계, 잘못된 날짜, 정수가 아닌 금액, SQL처럼 보이는 메모, 트랜잭션 되돌리기를 검사하라. 실행 명령은 python3 main.py이며 매번 같은 출력으로 끝나야 한다.

테이블의 기본 키(primary key)는 거래를 구분하는 정수 id다. 지금은 수정과 삭제 기능이 없지만, 나중에 거래를 가리킬 때 날짜와 금액의 조합을 쓰지 않도록 둔다. 같은 날 같은 금액을 두 번 지출할 수도 있으므로 거래 내용 전체를 고유하게 제한하지 않는다.

거래 테이블의 열과 저장 규칙
열저장 형태규칙과 용도
idINTEGER거래 식별자이며 기본 키다.
spent_onTEXT수입과 지출 모두의 거래일이다. YYYY-MM-DD로 통일한다.
kindTEXTincome 또는 expense만 허용한다.
categoryTEXT앞뒤 공백을 제거한 분류이며 빈 문자열은 거부한다.
amountINTEGER0보다 큰 원 단위 정수다.
memoTEXT빈 문자열을 허용하는 메모다.

금액에 실수를 쓰지 않는 이유는 원 단위 계산에 소수 부분이 필요 없기 때문이다. 입력부터 정수를 요구하면 합계를 구한 뒤 반올림 기준을 고민할 일이 줄어든다. 12,000원이라는 표시의 쉼표는 출력할 때만 붙인다. 데이터베이스에는 12000을 저장한다.

SQLite에서는 열에 INTEGER를 선언했다고 모든 입력이 정수로만 제한되는 것은 아니다. 완성 코드에는 저장된 값의 자료형을 확인하는 CHECK도 둔다. 다만 데이터베이스가 값을 변환할 수 있으므로, 호출자가 처음부터 정수를 전달했는지는 Python에서 확인한다. 특히 bool은 int의 하위 형식이므로 isinstance만 사용하면 True가 금액으로 통과할 수 있다. 여기서는 type(amount) is int로 검사한다.

날짜와 금액을 검증한 거래만 테이블에 저장하고 표시용 쉼표는 출력할 때 붙인다

검증을 Python과 SQL 양쪽에 두는 목적은 다르다. Python 검증은 잘못된 입력을 저장 전에 설명하기 쉽다. SQL 제약은 테이블이 받아들일 수 있는 값의 범위를 지킨다. 날짜의 실제 존재 여부는 Python의 date가 검사한다. 이 예제는 쓰기 경로를 add_entry 하나로 모으므로 날짜도 그 함수에서 통일한다. 다른 프로그램이 같은 파일에 직접 쓰게 된다면 그 경로의 날짜 검증도 설계해야 한다.

집계 조건과 매개변수를 분리한다

그룹화(GROUP BY)는 여러 거래를 같은 기준끼리 묶는 연산이다. 월별 보고서에서는 거래일의 앞 일곱 글자를 월로 사용한다. 저장 날짜를 YYYY-MM-DD로 통일했으므로 앞 일곱 글자는 항상 YYYY-MM이다. 연도까지 포함하므로 서로 다른 해의 1월이 섞이지 않는다.

집계 순서는 먼저 WHERE로 지출을 고르고, 다음으로 월과 분류를 묶고, 마지막으로 SUM으로 금액을 더하는 방식이다. 월별 합계는 월만 묶는다. 분류별 합계는 월과 분류를 함께 묶는다. GROUP BY 자체는 출력 순서를 보장하지 않으므로 ORDER BY를 별도로 쓴다. 같은 합계에서는 분류 이름을 기준으로 순서를 정한다.

수입을 제외한 거래를 월과 분류로 묶은 뒤 합계를 계산하고 출력 순서를 정한다

입력값은 SQL 문장의 일부로 조립하지 않는다. 메모에 작은따옴표가 들어갈 수 있고, SQL 명령처럼 보이는 문장이 들어갈 수도 있다. 이를 문자열 보간으로 붙이면 문장 구조가 달라지거나 구문 오류가 날 수 있다. SQL 주입(SQL injection)을 막기 위해 SQL은 고정하고 값은 execute의 두 번째 인자로 전달한다.

자리표시자 ?는 값이 들어갈 위치다. 열 이름이나 정렬 방향을 대신하는 문법은 아니다. 이번 프로그램의 열과 정렬 기준은 코드에 고정한다. 종류 조건도 값이므로 한 원소 튜플인 ("expense",)로 전달한다. 끝의 쉼표가 없으면 튜플이 아니라 문자열을 괄호로 감싼 표현이 된다.

메모리 데이터베이스는 sqlite3.connect(":memory:")로 만든다. 연결을 닫으면 자료가 사라진다. 따라서 실제 가계부의 영구 저장소로 쓰기보다는, 예시 실행과 검사에 적합하다. 일반적인 :memory: 연결은 연결마다 별도 데이터베이스를 가지므로 검사 자료가 보고서로 흘러들지 않도록 분리하기도 쉽다.

검사 결과로 수정 범위를 좁힌다

AI의 첫 결과에서는 “실행된다”와 “요구사항을 만족한다”를 나누어 확인한다. 다음은 초안에서 발견한 문제와 수정 방향이다. 단순히 오류가 났다고 되돌려 보내지 않고, 어떤 입력에서 어떤 규칙을 어겼는지 전달한다.

AI 초안에서 발견한 문제와 수정 근거
문제발견 방법고친 방향
월을 MM으로만 묶어 다른 해의 같은 달이 합쳐졌다.2025년 1월과 2026년 1월을 함께 넣었다.YYYY-MM 전체를 집계 키로 사용했다.
날짜 길이만 검사하여 2026-02-30도 저장했다.존재하지 않는 날짜와 한 자리 월을 넣었다.date.fromisoformat과 다시 만든 문자열을 함께 비교했다.
금액을 실수로 받고 True도 정수로 인정했다.1.5와 True를 각각 전달했다.원 단위 int만 허용하고 양수인지 확인했다.
메모를 SQL 문자열에 직접 넣었다.작은따옴표와 SQL처럼 보이는 메모를 저장했다.모든 입력값을 자리표시자로 전달했다.
같은 합계의 분류 순서가 정해져 있지 않았다.초안의 ORDER BY와 요구사항을 비교했다.합계 다음에 분류 이름을 정렬 기준으로 추가했다.

수정 요청에서도 이미 맞는 부분과 바꿀 부분을 구분한다. 전체를 다시 생성하면 검토한 코드까지 달라질 수 있으므로, 실패 사례를 검사 함수에 추가하고 관련 함수만 수정하도록 요청한다.

초안의 검사에서 다른 연도의 같은 달이 합쳐지고 2026-02-30이 저장되었다. 월 키를 YYYY-MM으로 바꾸고 날짜 검증을 강화하라. amount는 bool과 float를 거부하라. 메모 저장은 자리표시자로 바꾸고 입력 문자열이 그대로 조회되는지 검사하라. 분류별 정렬에는 합계가 같을 때의 분류 이름 기준을 추가하라. 보고서 형식은 유지하고 수정한 함수와 추가 검사 내용을 설명하라.

완성 코드의 검사는 빈 테이블부터 시작한다. 그다음 서로 다른 연도의 1월 거래를 넣어 월 키를 확인한다. 잘못된 입력 다섯 종류는 모두 ValueError가 나야 한다. SQL처럼 보이는 메모는 원문 그대로 조회되어야 하며 거래 건수도 유지되어야 한다. 마지막으로 거래를 넣다가 일부러 예외를 발생시켜 해당 거래가 되돌려졌는지 확인한다.

트랜잭션(transaction)은 여러 변경을 함께 확정하거나 되돌리는 단위다. with conn 블록이 정상 종료되면 변경을 확정하고, 예외로 끝나면 진행 중인 변경을 되돌린다. 이 블록은 연결을 닫지는 않는다. 코드에서는 finally에서 close를 호출해 연결 수명까지 명시한다.

수정 후에는 먼저 문법과 경고를 검사하고 실제 출력을 확인한다. 터미널 명령은 다음 순서로 실행한다.

python3 -W error -m py_compile main.py
python3 -W error main.py

버전 관리의 차이 비교(diff)에서는 날짜 검사, 자리표시자, GROUP BY, ORDER BY가 요청대로 바뀌었는지 읽는다. 검사 성공 문구만 보고 끝내지 않고, 고정 자료에서 손으로 구한 70,000원과 50,000원이 출력과 맞는지도 대조한다. 이 두 합계는 SQL 구현과 별개로 계산할 수 있는 확인 기준이다.

완성 코드

다음 코드를 main.py로 저장한다. 입력 파일을 사용하지 않으므로 임시 폴더도 필요 없다. 검사 연결과 보고서 연결은 각각 생성하고 각각 닫는다.

import sqlite3
from datetime import date


def create_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("""
        CREATE TABLE entries (
            id INTEGER PRIMARY KEY,
            spent_on TEXT NOT NULL,
            kind TEXT NOT NULL
                CHECK (kind IN ('income', 'expense')),
            category TEXT NOT NULL
                CHECK (length(trim(category)) > 0),
            amount INTEGER NOT NULL
                CHECK (typeof(amount) = 'integer' AND amount > 0),
            memo TEXT NOT NULL
        )
    """)
    return conn


def add_entry(conn, spent_on, kind, category, amount, memo=""):
    if not isinstance(spent_on, str):
        raise ValueError("날짜는 문자열이어야 한다.")
    try:
        parsed = date.fromisoformat(spent_on)
    except ValueError:
        raise ValueError("실제 날짜를 YYYY-MM-DD로 입력해야 한다.") from None
    if parsed.isoformat() != spent_on:
        raise ValueError("날짜 형식은 YYYY-MM-DD여야 한다.")
    if kind not in ("income", "expense"):
        raise ValueError("종류는 income 또는 expense여야 한다.")
    if type(amount) is not int or amount <= 0:
        raise ValueError("금액은 0보다 큰 원 단위 정수여야 한다.")
    if not isinstance(category, str) or not category.strip():
        raise ValueError("분류는 비어 있을 수 없다.")
    if not isinstance(memo, str):
        raise ValueError("메모는 문자열이어야 한다.")
    conn.execute(
        """
        INSERT INTO entries (spent_on, kind, category, amount, memo)
        VALUES (?, ?, ?, ?, ?)
        """,
        (spent_on, kind, category.strip(), amount, memo),
    )


def monthly_totals(conn):
    return conn.execute(
        """
        SELECT substr(spent_on, 1, 7) AS month, SUM(amount) AS total
        FROM entries
        WHERE kind = ?
        GROUP BY substr(spent_on, 1, 7)
        ORDER BY month ASC
        """,
        ("expense",),
    ).fetchall()


def category_totals(conn):
    return conn.execute(
        """
        SELECT substr(spent_on, 1, 7) AS month,
               category, SUM(amount) AS total
        FROM entries
        WHERE kind = ?
        GROUP BY substr(spent_on, 1, 7), category
        ORDER BY month ASC, total DESC, category ASC
        """,
        ("expense",),
    ).fetchall()


def check_equal(actual, expected):
    if actual != expected:
        raise AssertionError(f"기대값 {expected!r}, 실제값 {actual!r}")


def run_checks():
    conn = create_connection()
    try:
        check_equal(monthly_totals(conn), [])
        check_equal(category_totals(conn), [])
        with conn:
            add_entry(conn, "2025-01-31", "expense", "식비", 700)
            add_entry(conn, "2026-01-01", "expense", "식비", 300)
            add_entry(conn, "2026-01-02", "income", "급여", 1000)
        check_equal(
            monthly_totals(conn),
            [("2025-01", 700), ("2026-01", 300)],
        )

        invalid = [
            ("2026-02-30", "expense", "식비", 1),
            ("2026-2-01", "expense", "식비", 1),
            ("2026-02-01", "expense", "식비", True),
            ("2026-02-01", "expense", "식비", 1.5),
            ("2026-02-01", "expense", "   ", 1),
        ]
        for args in invalid:
            try:
                add_entry(conn, *args)
            except ValueError:
                pass
            else:
                raise AssertionError(f"거부해야 하는 입력: {args!r}")

        memo = "장보기'); DROP TABLE entries; --"
        with conn:
            add_entry(conn, "2026-01-03", "expense", "기타", 1, memo)
        stored = conn.execute(
            "SELECT memo FROM entries WHERE memo = ?", (memo,)
        ).fetchone()
        check_equal(stored, (memo,))
        check_equal(
            conn.execute("SELECT COUNT(*) FROM entries").fetchone(),
            (4,),
        )

        try:
            with conn:
                add_entry(conn, "2026-01-04", "expense", "취소", 99)
                raise ValueError("되돌리기 검사")
        except ValueError:
            pass
        check_equal(
            conn.execute("SELECT COUNT(*) FROM entries").fetchone(),
            (4,),
        )

        with conn:
            add_entry(conn, "2026-01-05", "expense", "가", 10)
            add_entry(conn, "2026-01-06", "expense", "나", 10)
        check_equal(
            category_totals(conn),
            [
                ("2025-01", "식비", 700),
                ("2026-01", "식비", 300),
                ("2026-01", "가", 10),
                ("2026-01", "나", 10),
                ("2026-01", "기타", 1),
            ],
        )
    finally:
        conn.close()


def main():
    run_checks()
    print("검사: 통과")
    rows = [
        ("2026-01-01", "income", "급여", 2000000, "1월 급여"),
        ("2026-01-03", "expense", "식비", 12000, "점심"),
        ("2026-01-05", "expense", "식비", 8000, "간식"),
        ("2026-01-10", "expense", "교통", 50000, "교통비"),
        ("2026-02-01", "income", "급여", 2000000, "2월 급여"),
        ("2026-02-02", "expense", "식비", 15000, "점심"),
        ("2026-02-04", "expense", "식비", 5000, "간식"),
        ("2026-02-10", "expense", "교통", 30000, "교통비"),
    ]
    conn = create_connection()
    try:
        check_equal(monthly_totals(conn), [])
        with conn:
            for row in rows:
                add_entry(conn, *row)

        print("[월별 지출]")
        for month, total in monthly_totals(conn):
            print(f"{month} | {total:,}원")
        print("[월별·분류별 지출]")
        for month, category, total in category_totals(conn):
            print(f"{month} | {category} | {total:,}원")
    finally:
        conn.close()


if __name__ == "__main__":
    main()

줄별 해설

import 두 줄은 저장 기능과 날짜 검증 기능을 가져온다. 현재 날짜를 읽지 않으므로 실행 날짜가 달라져도 결과는 같다. date는 거래일의 달력상 유효성을 확인하는 데만 사용한다.

create_connection은 연결을 열고 테이블을 만든 뒤 연결 객체를 반환한다. 새 메모리 데이터베이스이므로 테이블이 이미 있는 상황을 처리하지 않는다. NOT NULL은 값의 누락을 막고 CHECK는 종류, 빈 분류, 양수 금액이라는 조건을 확인한다. id는 INSERT에서 생략하여 SQLite가 값을 정하도록 둔다.

add_entry의 날짜 검사는 문자열 여부, 해석 가능 여부, 표기 일치 여부를 차례로 확인한다. fromisoformat이 받아들일 수 있는 다른 날짜 표기라도 isoformat 결과와 다르면 거부한다. 이렇게 해야 SQL의 앞 일곱 글자 추출이 모든 행에서 같은 뜻을 가진다.

금액과 분류 검사에서는 잘못된 값을 조용히 바꾸지 않는다. 1.5를 int로 잘라 1로 저장하면 입력 오류를 숨긴다. 반면 분류의 앞뒤 공백은 의미 없는 차이로 정했으므로 strip으로 제거한다. “식 비”처럼 내부 공백이 있는 이름은 별도 분류로 남는다.

INSERT 실행은 SQL 문자열과 다섯 값을 분리한다. 메모에 작은따옴표가 있어도 값으로 전달되므로 SQL 구조를 바꾸지 않는다. 함수 안에서는 commit을 호출하지 않는다. 여러 거래를 한 번에 저장할지 결정하는 책임을 호출자에게 두기 위해서다.

monthly_totals는 expense만 선택한 뒤 YYYY-MM별로 SUM을 계산한다. fetchall은 결과를 튜플 목록으로 가져온다. 거래가 없으면 집계 그룹도 없으므로 빈 목록을 반환한다. 이 경우와 GROUP BY 없는 단일 SUM의 결과는 구분해야 한다.

category_totals는 묶는 기준에 category를 추가한다. 같은 월 안에서 합계가 큰 분류부터 보여 주고, 동점이면 이름으로 정렬한다. SELECT에서 붙인 month와 total이라는 별칭은 ORDER BY를 읽기 쉽게 만든다.

check_equal은 기대값과 실제값을 비교한다. 다르면 AssertionError를 발생시켜 실행을 멈춘다. 일반 assert 문 대신 명시적인 조건문을 사용했으므로 Python의 최적화 실행 옵션 때문에 검사가 생략되지 않는다.

run_checks는 운영 자료와 분리된 연결에서 검사를 수행한다. SQL처럼 보이는 메모를 저장한 뒤 원문과 거래 건수를 확인한다. 되돌리기 검사에서는 새 거래 하나를 넣은 직후 예외를 발생시키고, 거래 수가 네 건 그대로인지 확인한다. 분류별 검사는 합계가 같은 “가”와 “나”를 넣어 정렬의 마지막 기준까지 확인한다.

main은 검사가 모두 끝난 뒤 보고서용 연결을 만든다. 처음 월별 결과가 빈 목록인지 확인하므로 검사 자료와 분리되었다는 점도 점검한다. 예시 거래는 하나의 with conn 안에서 저장한다. 출력의 :, 형식은 저장값을 바꾸지 않고 화면에만 천 단위 쉼표를 붙인다.

실행 결과

macOS 또는 Linux 터미널에서 main.py가 있는 폴더로 이동한 뒤 실행한다.

python3 main.py

예상 출력은 다음과 같다. 검사에 실패하면 이 보고서 대신 예외가 발생하므로 실패를 성공 출력으로 오해하지 않는다.

검사: 통과
[월별 지출]
2026-01 | 70,000원
2026-02 | 50,000원
[월별·분류별 지출]
2026-01 | 교통 | 50,000원
2026-01 | 식비 | 20,000원
2026-02 | 교통 | 30,000원
2026-02 | 식비 | 20,000원

1월 식비는 12,000원과 8,000원을 더한 20,000원이다. 여기에 교통비 50,000원을 더하면 월별 지출 70,000원이 된다. 2월도 식비 20,000원과 교통비 30,000원으로 50,000원이다. 두 달의 급여는 지출 보고서에서 제외된다.

실행을 마치면 데이터베이스는 남지 않는다. 다시 실행하면 검사 자료와 예시 자료를 새로 만들기 때문에 거래가 누적되지 않는다. 이 성질이 예제의 출력 재현성을 만든다. 실제 기록을 보존하려면 파일 데이터베이스와 보관 정책을 함께 추가해야 한다.

실무에서 자주 틀리는 것

금액을 변환하면서 오류를 숨긴다

다음 코드는 실행되지만, 소수 금액을 버리고 논릿값을 숫자로 바꾼다. 잘못된 입력을 거부해야 하는 요구사항에 맞지 않는다.

def wrong_amount(value):
    return int(value)


assert wrong_amount(1.9) == 1
assert wrong_amount(True) == 1

고친 코드는 입력의 형식을 먼저 확인한다. 검사 실패를 호출자가 처리할 수 있도록 예외로 알린다.

def checked_amount(value):
    if type(value) is not int or value <= 0:
        raise ValueError("양의 정수 금액이 필요하다.")
    return value


assert checked_amount(12000) == 12000

서로 다른 연도의 같은 달을 합친다

월 숫자만 추출하면 2025년 1월과 2026년 1월을 구분하지 못한다. 다음은 잘못된 집계 키를 만드는 코드다.

def wrong_month(day):
    return day[5:7]


assert wrong_month("2025-01-31") == wrong_month("2026-01-01")

고친 키에는 연도도 포함한다. 단, 이 방식은 저장 전에 날짜 표기를 통일했다는 조건에서 사용한다. 문자열을 자르는 일 자체가 날짜 검증을 대신하지는 않는다.

def checked_month(day):
    return day[:7]


assert checked_month("2025-01-31") != checked_month("2026-01-01")

작은따옴표를 직접 붙여 SQL을 만든다

다음 함수는 실행 가능한 Python이지만, 반환한 SQL에는 메모의 작은따옴표가 그대로 섞인다. 저장할 문장을 만드는 방식이 잘못되었다.

def wrong_insert(memo):
    return f"INSERT INTO notes (memo) VALUES ('{memo}')"


assert "O'Brien" in wrong_insert("O'Brien")

고친 코드는 값을 별도로 전달한다. 따옴표를 수작업으로 치환하는 규칙을 추가하는 대신, 데이터베이스 인터페이스에 값 처리를 맡긴다.

import sqlite3

conn = sqlite3.connect(":memory:")
try:
    conn.execute("CREATE TABLE notes (memo TEXT NOT NULL)")
    with conn:
        conn.execute("INSERT INTO notes (memo) VALUES (?)", ("O'Brien",))
    assert conn.execute("SELECT memo FROM notes").fetchone() == ("O'Brien",)
finally:
    conn.close()

확정 블록이 연결까지 닫는다고 생각한다

다음 코드는 블록이 끝나도 연결이 열려 있다. 여러 연결을 반복해서 만드는 프로그램이라면 연결 수명을 놓치기 쉽다.

import sqlite3

conn = sqlite3.connect(":memory:")
with conn:
    conn.execute("CREATE TABLE sample (value INTEGER)")
assert conn.execute("SELECT COUNT(*) FROM sample").fetchone() == (0,)
conn.close()

고친 구조는 확정 범위와 연결 종료를 각각 드러낸다. 처리 도중 예외가 발생해도 finally가 연결을 닫는다. 완성 코드도 이 구조를 사용한다.

import sqlite3

conn = sqlite3.connect(":memory:")
try:
    with conn:
        conn.execute("CREATE TABLE sample (value INTEGER)")
        conn.execute("INSERT INTO sample VALUES (?)", (1,))
    assert conn.execute("SELECT COUNT(*) FROM sample").fetchone() == (1,)
finally:
    conn.close()

한눈에 보기

저장과 집계를 검토할 때 확인할 기준
대상선택한 규칙확인 방법
금액원 단위 양의 정수True, 1.5, 0, 음수를 거부하는지 확인한다.
날짜실제 날짜인 YYYY-MM-DD존재하지 않는 날짜와 다른 표기를 넣는다.
월별 집계YYYY-MM로 지출만 묶기다른 연도의 같은 달과 수입을 함께 넣는다.
분류별 집계월·분류로 묶고 정렬 명시합계가 같은 분류의 순서를 확인한다.
SQL 입력?와 값 튜플 사용작은따옴표가 든 메모를 저장하고 조회한다.
연결 수명검사와 보고서를 분리하고 닫기새 연결이 비어 있는지 검사한다.

SQLite의 자료형과 연결 동작을 더 확인하려면 SQLite 자료형 설명과 Python sqlite3 문서를 참고할 수 있다. 문서의 사실을 확인한 뒤에도 이 프로그램의 요구사항은 자체 검사로 검증한다.

연습 문제

각 문제는 먼저 기대 결과를 적고 수정한다. AI에게 확장을 요청할 때도 기능 이름만 전달하지 말고, 기존 동작과 새 경계값을 함께 제시한다.

  1. 월별 지출과 별도로 월별 잔액을 계산하라. 수입에서 지출을 뺀 값이며, 예시 자료의 기대값은 1월 1,930,000원과 2월 1,950,000원이다.
  2. 2026년 1월의 지출만 가져오는 함수를 추가하라. 시작일은 포함하고 다음 달 시작일은 제외하는 범위를 사용하라. 1월 31일과 2월 1일을 검사 자료에 넣어라.
  3. 금액 0과 -1, 종류 transfer를 거부하는 검사를 추가하라. 분류가 " 식비 "일 때는 "식비"로 저장되는지도 확인하라.
  4. 메모리 저장소를 파일 저장소로 바꿀 때 생기는 요구사항을 정리하라. 실습 검사는 tempfile의 임시 폴더 안에서 수행하고, 연결을 닫았다가 다시 열어도 거래가 남는지 확인하라.

다음은 첫 번째 확장 과제를 AI에게 보내는 요청문이다.

기존 main.py에 월별 잔액 함수를 추가하라. income은 더하고 expense는 빼며, YYYY-MM별로 묶어 월 오름차순으로 반환하라. 금액 저장 규칙과 기존 지출 보고서는 유지하라. 고정 자료의 기대 잔액은 1월 1,930,000원, 2월 1,950,000원이다. 수입만 있는 달과 지출만 있는 달의 검사도 추가하라. 수정 전후 차이를 검토할 수 있도록 바뀐 함수와 검사 이유를 설명하라.

정답과 해설

  1. 잔액은 CASE로 거래 종류에 따라 금액의 부호를 정한 뒤 합산한다. 기존 지출 함수와 달리 WHERE에서 수입을 제거하면 안 된다. 지출만 있는 달은 음수, 수입만 있는 달은 양수가 되어야 한다. 다음 함수는 기존 연결을 받아 사용한다.

    def monthly_balances(conn):
        return conn.execute("""
            SELECT substr(spent_on, 1, 7) AS month,
                   SUM(CASE WHEN kind = 'income'
                            THEN amount ELSE -amount END) AS balance
            FROM entries
            GROUP BY substr(spent_on, 1, 7)
            ORDER BY month ASC
        """).fetchall()
    
  2. 날짜 표기가 통일되어 있으면 문자열 비교의 순서와 날짜 순서가 일치한다. 종료일을 포함하지 않는 반열린 구간을 사용하면 달의 마지막 날짜를 직접 계산할 필요가 없다. 1월 31일은 포함되고 2월 1일은 제외되어야 한다.

    def january_expenses(conn):
        return conn.execute(
            """
            SELECT spent_on, category, amount
            FROM entries
            WHERE kind = ? AND spent_on >= ? AND spent_on < ?
            ORDER BY spent_on ASC, id ASC
            """,
            ("expense", "2026-01-01", "2026-02-01"),
        ).fetchall()
    
  3. invalid 목록에 금액 0, 금액 -1, 종류 transfer인 튜플을 추가한다. 기존 add_entry가 이 입력들을 거부하므로 검증 함수를 바꿀 필요는 없다. 공백 제거 검사는 별도 정상 거래를 저장한 뒤 SELECT로 category를 읽어 "식비"와 비교한다. 검사 자료를 늘리면 거래 수를 확인하는 기대값도 함께 검토해야 한다.

  4. 파일 경로, 기존 테이블의 존재, 반복 실행 시 자료 중복, 보관과 백업이 새 요구사항이 된다. 테이블을 다시 만들지 않는 것과 거래가 중복 저장되지 않는 것은 다른 문제다. 다음 검사는 임시 경로에 저장한 자료가 연결 종료 뒤에도 남는지만 확인한다. 실제 예시의 테이블 설계는 유지하고 연결 대상부터 바꾸는 편이 검토하기 쉽다.

    import sqlite3
    from pathlib import Path
    from tempfile import TemporaryDirectory
    
    with TemporaryDirectory() as folder:
        path = Path(folder) / "ledger.db"
        conn = sqlite3.connect(path)
        try:
            with conn:
                conn.execute("CREATE TABLE sample (amount INTEGER NOT NULL)")
                conn.execute("INSERT INTO sample VALUES (?)", (12000,))
        finally:
            conn.close()
    
        conn = sqlite3.connect(path)
        try:
            actual = conn.execute("SELECT amount FROM sample").fetchall()
            assert actual == [(12000,)]
        finally:
            conn.close()
    
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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