Devin.KR

분류 붙이기 - 정해진 분류와 잘못된 입력

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 지출을 JSON 파일에 저장하고 다시 읽었다. 이제 저장할 기록에 일정한 이름표를 붙인다. 같은 식사 비용을 어떤 날은 ‘식비’, 다른 날은 ‘밥값’으로 적으면 나중에 합계를 낼 때 서로 다른 분류로 묶인다. 이번에는 쓸 수 있는 분류를 미리 정하고, 목록에 없는 이름은 저장하기 전에 걸러 낸다.

만드는 앱은 혼자 쓰는 가계부다. 사용자에게 키보드 입력을 받는 대신 정해진 입력 목록을 차례로 처리한다. 잘못된 입력도 목록에 넣어 두므로 실행할 때마다 같은 성공 메시지와 거절 메시지를 확인할 수 있다. 앞 장의 파일이 없어도 이번 장의 main.py 하나로 실행된다.

  • 수입과 지출에 사용할 분류 목록을 고정한다.
  • 분류를 잘못 적으면 이유를 알리고 해당 기록을 저장하지 않는다.
  • 수입과 지출을 구분하고, 해당 구분에 맞는 분류인지 확인한다.
  • 저장한 기록을 다시 읽어 분류별 합계와 수입에서 지출을 뺀 차액을 구한다.

이번 장에서 만들 것

점심값을 ‘식비’로 저장한 다음, 저녁값을 ‘식삐’로 저장했다고 생각해 본다. 두 기록이 모두 들어가면 식비 합계가 실제보다 작게 보인다. 수입을 적으면서 실수로 ‘식비’를 붙이는 경우도 있다. 이름이 어딘가에 존재하는지만 확인하면 이런 조합을 놓친다.

이번 프로그램은 먼저 수입인지 지출인지 확인한다. 다음으로 그 구분에서 허용하는 분류를 확인한다. 두 검사를 통과한 기록만 목록에 넣는다. 잘못된 분류를 마음대로 고치지는 않는다. 무엇이 잘못되었고 어떤 값을 쓸 수 있는지 메시지로 보여 준다.

이번 프로그램에서 허용하는 구분과 분류
구분허용하는 분류합계에서의 처리
수입급여, 기타수입수입 합계에 더한다.
지출식비, 교통, 기타지출지출 합계에 더한다.

금액은 두 구분 모두 양의 정수로 적는다. 지출이라고 음수로 입력하지 않는다. 수입과 지출의 차이는 구분 값으로 나타내고, 마지막에 수입 합계에서 지출 합계를 뺀다. 이 규칙을 정해 두면 지출 금액의 부호를 다시 뒤집는 혼란을 줄일 수 있다.

시연에는 정상 입력 네 건과 잘못된 입력 세 건을 사용한다. 날짜는 모두 고정된 문자열이다. 날짜를 해석하거나 월별로 묶는 작업은 다음 장에서 다룬다. 이번에는 분류가 올바르게 붙고 합산되는지만 확인한다.

따라하기

아래 요청문을 순서대로 AI에 보낸다. 각 단계에서 받은 main.py를 저장하고 실행한 뒤 다음 단계로 넘어간다. AI가 돌려주는 문장과 코드 모양은 매번 다를 수 있다. 요청한 동작과 기대 결과가 같은지를 기준으로 판단한다. 결과가 어긋나면 해당 단계의 요청문에 실제 출력과 기대 출력을 덧붙여 다시 수정하도록 한다.

단계별로 기능을 쌓는 방식이 부담스럽다면 뒤의 완성 코드를 main.py로 저장한 뒤, 각 단계의 확인 항목을 그 코드에서 찾아보아도 된다. 어느 방식이든 AI가 설명한 내용만 읽고 통과시키지 않는다. 실행 결과와 파일 변경 내용을 직접 확인한다.

단계 1. 사용할 분류 목록을 고정한다

① 보낼 요청문

혼자 쓰는 가계부의 main.py를 만들어 줘. Python 3.12 이상 표준 라이브러리만 사용해. 수입 분류는 급여와 기타수입, 지출 분류는 식비와 교통과 기타지출로 고정해. CATEGORIES라는 이름에 구분별 분류를 보관하고, 정한 순서대로 목록을 출력해. 사용자 입력, 네트워크 호출, 현재 시각은 사용하지 마. python3 main.py로 실행하면 바로 끝나게 해.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 구분을 열쇠로 삼아 분류 목록을 찾는 딕셔너리를 만들고, 수입과 지출의 분류를 차례로 출력하는 코드가 돌아올 것이다.

딕셔너리는 이름과 값을 짝지어 보관하는 자료다. 여기서는 ‘수입’이라는 이름에 수입 분류들을 연결한다. 분류들을 괄호 안에 묶은 튜플은 여러 값을 순서대로 보관하는 자료다. 이 프로그램은 실행 중에 분류 목록을 바꾸지 않는다.

③ 직접 확인할 것

실행 명령: python3 main.py

기대 결과: 수입에는 급여, 기타수입이 표시되고 지출에는 식비, 교통, 기타지출이 표시된다. 실행할 때마다 순서가 같다.

④ 체크포인트

허용 목록이 코드 한곳에 모여 있으면 다음 단계로 간다. 같은 목록을 검사 부분과 출력 부분에 각각 따로 적었다면 하나의 CATEGORIES를 함께 사용하도록 고친다. 목록이 두 군데 있으면 한쪽만 바꾸기 쉽다.

단계 2. 오타 분류를 저장 전에 거절한다

① 보낼 요청문

현재 main.py에 validate_record 함수를 추가해. 함수는 기록 한 건을 받아 잘못된 분류이면 ValueError로 이유를 알려 줘. 우선 지출의 식비 12000원과 식삐 5000원을 정해진 입력으로 확인해. 유효성 검사를 통과한 기록만 records 목록에 추가해. 거절 메시지에는 입력한 분류와 허용 목록을 함께 보여 줘. 오타를 자동으로 바꾸지 마.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 분류가 목록에 포함되는지 검사하는 함수와, 검사에 실패하면 메시지를 출력하고 다음 입력을 처리하는 코드가 돌아올 것이다.

유효성 검사는 입력이 정한 규칙에 맞는지 살피는 일이다. ValueError는 값이 규칙에 맞지 않음을 알리는 예외다. 예외가 발생하면 평소 실행 순서에서 벗어나 오류 처리 부분으로 이동한다. 이번에는 그곳에서 거절 이유를 출력한다.

③ 직접 확인할 것

실행 명령: python3 main.py

기대 결과: 식비는 기록되고 식삐는 거절된다. 거절 메시지에는 식삐와 함께 식비, 교통, 기타지출이 표시된다. 저장 대상 목록에는 한 건만 남는다.

④ 체크포인트

메시지만 출력하고 잘못된 기록까지 목록에 넣지는 않았는지 확인한다. 검사하는 줄이 목록에 추가하는 줄보다 먼저 있어야 한다. 식삐를 식비로 바꿔 저장하는 코드가 들어갔다면 요청과 다르므로 제거한다.

단계 3. 수입과 지출의 조합을 검사한다

① 보낼 요청문

현재 코드를 확장해 먼저 kind가 수입 또는 지출인지 검사하고, 그다음 해당 구분에 속하는 category인지 검사해. 금액 amount는 0보다 큰 정수만 허용하고 True 같은 참거짓 값은 금액으로 받지 마. 입력 목록은 급여 수입 3000000원, 식비 지출 12000원, 교통 지출 1500원, 식비 지출 8000원, 식삐 지출 5000원, 구분이 저축인 기타수입 10000원, 식비 수입 3000원으로 해. 날짜는 모두 2026-10-01로 고정해. 허용 목록 출력은 빼고 각 입력의 기록 또는 거절 결과를 한 줄씩 출력해.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 구분 검사, 구분에 맞는 분류 검사, 금액 검사가 순서대로 추가된다. 일곱 입력 가운데 앞의 네 건만 기록되는 코드가 돌아올 것이다.

구분부터 검사하는 순서가 중요하다. ‘저축’이라는 구분은 CATEGORIES에 없으므로 곧바로 그 이름으로 분류 목록을 찾으면 실패한다. 먼저 사용할 수 있는 구분인지 확인하면 프로그램이 정한 안내 문장으로 거절할 수 있다.

③ 직접 확인할 것

실행 명령: python3 main.py

기대 결과: 기록 메시지 네 줄과 거절 메시지 세 줄이 나온다. 식삐는 지출 분류 목록과 함께 거절되고, 저축은 구분 오류로 거절되며, 수입에 붙인 식비도 거절된다.

④ 체크포인트

‘식비’라는 이름이 존재해도 수입에는 붙일 수 없어야 한다. 지출 금액 12000을 -12000으로 바꾸지 않았는지도 확인한다. 금액은 양수로 유지하고 구분이 의미를 결정하게 한다.

단계 4. 분류별 합계를 만든다

① 보낼 요청문

현재 main.py에 summarize 함수를 추가해. CATEGORIES에 있는 모든 분류의 합계를 0으로 준비하고, 정상 기록의 amount를 해당 kind와 category에 더해. 입력이 없는 분류도 0원으로 출력해. 수입, 지출 순서로 각각 분류별 합계와 구분 합계를 출력하고, 마지막에 수입에서 지출을 뺀 차액을 출력해. 날짜로 묶지는 마.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 수입과 지출 아래에 각각 분류별 합계를 담는 딕셔너리를 만들고, 기록을 하나씩 읽어 더하는 코드가 돌아올 것이다.

합계의 출발점을 0으로 정하면 기록이 없는 분류도 보고서에 남는다. 이번 입력에는 기타수입과 기타지출이 없지만 두 항목을 모두 표시한다. 보고서를 볼 때 항목이 빠진 것인지 사용액이 없는 것인지 구별하기 쉽다.

③ 직접 확인할 것

실행 명령: python3 main.py

기대 결과: 식비 20000원, 교통 1500원, 지출 합계 21500원이다. 수입 합계는 3000000원이고 차액은 2978500원이다. 기타수입과 기타지출은 각각 0원이다.

④ 체크포인트

식비 두 건이 마지막 금액인 8000원으로 덮어써지지 않고 20000원으로 더해지면 다음 단계로 간다. 거절된 식삐 5000원이 지출 합계에 섞이지 않았는지도 손으로 계산해 확인한다.

단계 5. JSON 저장과 다시 읽기를 연결한다

① 보낼 요청문

앞 장 파일이 없어도 실행되도록 JSON 저장과 불러오기를 현재 main.py에 포함해. tempfile.TemporaryDirectory로 임시 폴더를 만들고 그 안에 records.json을 빈 목록으로 직접 저장한 뒤 읽어. 정상 입력 처리 후 목록을 다시 저장하고, 파일에서 새로 읽은 목록으로만 합계를 계산해. JSON은 UTF-8로 읽고 쓰며 한국어가 그대로 보이게 해. 임시 경로는 출력하지 마. 폴더 밖에는 가계부 파일을 만들지 마.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 빈 파일 준비, 불러오기, 입력 처리, 저장, 다시 불러오기, 합계 출력 순서로 연결된 코드가 돌아올 것이다. 임시 폴더는 처리가 끝나면 정리된다.

이번 시연의 목적은 이전 실행의 기록을 계속 쌓는 일이 아니다. 매번 같은 자료를 준비해 저장과 읽기의 연결을 확인하는 일이다. 따라서 두 번 실행해도 기록 수가 늘지 않는다. 임시 폴더의 이름은 실행할 때 달라질 수 있으므로 화면에는 표시하지 않는다.

③ 직접 확인할 것

실행 명령: python3 main.py

기대 결과: 다시 읽은 기록은 네 건이다. 분류별 합계는 앞 단계와 같다. 같은 명령으로 다시 실행해도 여덟 건으로 늘지 않고 네 건이다.

④ 체크포인트

합계 함수에 전달하는 값이 파일에서 새로 읽은 목록인지 확인한다. 저장 전의 records를 그대로 합산하면 저장과 불러오기가 잘못 연결되어도 화면만 보고 알아차리기 어렵다.

단계 6. 검사와 변경 비교로 마무리한다

① 보낼 요청문

현재 main.py의 동작을 유지하면서 assert로 확인을 추가해. 거절한 직후 기록 수가 늘지 않았는지, JSON으로 저장했다 읽은 목록이 원래 목록과 같은지, 기록 네 건과 거절 세 건인지, 분류별 합계와 차액이 기대값인지 확인해. 0원, 음수, 문자열 금액, True, 알 수 없는 구분과 분류도 별도로 검사하되 이 추가 검사는 기록 목록을 바꾸거나 출력하지 않게 해. 검사 성공 시 마지막에 검사 통과를 출력해. 수정 전후 diff에서 바뀐 부분과 이유도 설명해.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 잘못된 값을 거절하는지 확인하는 작은 함수와, 기록 수 및 합계에 대한 assert가 추가될 것이다. 실행하지 않은 AI는 실제로 검사했다고 말하는 대신 사용자가 확인할 명령과 기대 결과를 제시해야 한다.

assert는 조건이 참인지 확인하는 문장이다. 조건이 거짓이면 실행이 멈추므로 예상과 다른 계산을 알아차릴 수 있다. diff는 수정 전후의 차이를 보여 주는 비교다. 편집기의 변경 비교 화면에서 검사 함수와 확인 문장 외에 어떤 부분이 달라졌는지 살핀다. 분류나 시연 금액까지 바뀌었다면 요청한 변경인지 따져 본다.

③ 직접 확인할 것

문법과 경고 확인 명령: python3 -W error -m py_compile main.py

실행 명령: python3 main.py

기대 결과: 첫 명령은 오류나 경고 없이 끝난다. 둘째 명령은 아래 실행 결과와 같고, 마지막 줄에 검사 통과가 표시된다. assert를 건너뛰는 -O 옵션은 붙이지 않는다.

④ 체크포인트

AI가 제안한 검사를 읽고, 실제 실행에서 통과했으며, 변경 비교에서 의도한 수정만 확인했으면 마무리한다. 아래 코드는 그 확인을 직접 해 볼 수 있도록 모든 부분을 포함한 완성본이다.

구분과 분류 검사를 통과한 기록만 목록에 추가된다

완성 코드

다음 전체를 main.py로 저장한다. 외부 파일이나 패키지는 필요하지 않다. 기록 한 건은 날짜 date, 구분 kind, 분류 category, 금액 amount를 가진 딕셔너리다. 이 시연에서는 프로그램이 만든 JSON 파일만 읽으므로, 손상되거나 임의로 편집한 파일을 복구하는 기능은 포함하지 않는다.

import json
import tempfile
from pathlib import Path

CATEGORIES = {
    "수입": ("급여", "기타수입"),
    "지출": ("식비", "교통", "기타지출"),
}

DEMO_INPUTS = [
    ("수입", "급여", 3000000),
    ("지출", "식비", 12000),
    ("지출", "교통", 1500),
    ("지출", "식비", 8000),
    ("지출", "식삐", 5000),
    ("저축", "기타수입", 10000),
    ("수입", "식비", 3000),
]


def validate_record(record):
    kind = record["kind"]
    category = record["category"]
    amount = record["amount"]
    if kind not in CATEGORIES:
        raise ValueError(
            f"구분 '{kind}'은 사용할 수 없다. 허용: 수입, 지출"
        )
    if category not in CATEGORIES[kind]:
        allowed = ", ".join(CATEGORIES[kind])
        raise ValueError(
            f"{kind} 분류 '{category}'은 사용할 수 없다. 허용: {allowed}"
        )
    if type(amount) is not int or amount <= 0:
        raise ValueError("금액은 0보다 큰 정수여야 한다.")


def save_records(path, records):
    with path.open("w", encoding="utf-8") as file:
        json.dump(records, file, ensure_ascii=False, indent=2)


def load_records(path):
    with path.open("r", encoding="utf-8") as file:
        return json.load(file)


def summarize(records):
    totals = {
        kind: {category: 0 for category in categories}
        for kind, categories in CATEGORIES.items()
    }
    for record in records:
        validate_record(record)
        totals[record["kind"]][record["category"]] += record["amount"]
    return totals


def check_rejections():
    invalid_values = [
        ("지출", "식비", 0),
        ("지출", "식비", -1),
        ("지출", "식비", "1000"),
        ("지출", "식비", True),
        ("저축", "기타수입", 1000),
        ("지출", "식삐", 1000),
        ("수입", "식비", 1000),
    ]
    for kind, category, amount in invalid_values:
        record = {
            "date": "2026-10-01",
            "kind": kind,
            "category": category,
            "amount": amount,
        }
        try:
            validate_record(record)
        except ValueError:
            pass
        else:
            raise AssertionError(f"拒否されなかった入力: {record}")


def main():
    check_rejections()
    with tempfile.TemporaryDirectory() as folder:
        path = Path(folder) / "records.json"
        save_records(path, [])
        records = load_records(path)
        rejected = 0

        for kind, category, amount in DEMO_INPUTS:
            record = {
                "date": "2026-10-01",
                "kind": kind,
                "category": category,
                "amount": amount,
            }
            before_count = len(records)
            try:
                validate_record(record)
            except ValueError as error:
                rejected += 1
                assert len(records) == before_count
                print(f"거절: {error}")
            else:
                records.append(record)
                print(f"기록: {kind} / {category} / {amount}원")

        save_records(path, records)
        loaded = load_records(path)
        assert loaded == records
        assert len(loaded) == 4
        assert rejected == 3
        print(f"다시 읽은 기록: {len(loaded)}건")
        print(f"거절한 입력: {rejected}건")

        totals = summarize(loaded)
        assert totals == {
            "수입": {"급여": 3000000, "기타수입": 0},
            "지출": {"식비": 20000, "교통": 1500, "기타지출": 0},
        }
        for kind, categories in totals.items():
            print(f"[{kind}]")
            for category, amount in categories.items():
                print(f"{category}: {amount}원")
            print(f"{kind} 합계: {sum(categories.values())}원")

        income = sum(totals["수입"].values())
        expense = sum(totals["지출"].values())
        balance = income - expense
        assert balance == 2978500
        print(f"수입에서 지출을 뺀 차액: {balance}원")
        print("검사 통과")


if __name__ == "__main__":
    main()

줄별 해설

import로 시작하는 세 줄은 필요한 도구를 불러온다. json은 목록과 딕셔너리를 JSON 파일로 바꾸고 되돌린다. tempfile은 시연용 임시 폴더를 만들고 정리한다. Path는 폴더와 파일 이름을 이어 파일 경로를 만든다. 모두 Python에 들어 있는 표준 라이브러리다.

CATEGORIES와 DEMO_INPUTS는 규칙과 시연 자료다. CATEGORIES는 허용 목록이고 DEMO_INPUTS는 실제 처리할 일곱 입력이다. 두 자료를 분리해 두면 허용 목록을 늘리는 일과 시연 사례를 바꾸는 일을 구별할 수 있다. 여기서는 두 자료의 작성 순서를 그대로 출력 순서로 사용한다.

validate_record의 앞부분은 기록에서 구분, 분류, 금액을 꺼낸다. 이어지는 not in은 지정한 값이 자료 안에 없는지 확인한다. 구분이 허용되지 않으면 즉시 ValueError를 발생시키므로 그 아래의 분류 조회로 넘어가지 않는다. 분류 검사는 전체 분류가 아닌 CATEGORIES[kind] 안에서만 수행한다.

allowed를 만드는 줄은 허용 분류 사이에 쉼표와 공백을 넣어 한 문장으로 연결한다. 검사에 사용한 목록을 안내 메시지에도 사용하므로 허용 규칙과 안내가 어긋날 가능성을 줄인다. 입력한 분류도 메시지에 넣어 무엇을 고쳐야 하는지 보여 준다.

금액 검사 줄은 값의 종류가 정확히 int인지 확인한다. int는 정수를 뜻한다. Python의 True는 일부 정수 검사에서 정수처럼 취급될 수 있으므로 여기서는 type(amount) is not int로 구별한다. or는 앞 조건이 참이면 뒤 조건을 계산하지 않는다. 덕분에 문자열 금액에 0과의 크기 비교를 시도하지 않고 거절한다.

save_records와 load_records는 각각 파일 쓰기와 읽기를 맡는다. with 안에서 연 파일은 해당 부분의 실행이 끝나면 닫힌다. ensure_ascii=False는 JSON 파일 안의 한국어를 글자 그대로 기록하게 한다. indent=2는 파일을 열어 살펴볼 때 항목이 구분되도록 들여쓰기를 넣는다.

summarize의 totals는 구분 안에 분류별 합계를 둔다. 중괄호 안의 for는 목록을 순회하며 딕셔너리를 만드는 표현이다. 모든 분류를 0으로 준비한 뒤 +=로 금액을 누적한다. 이 기호는 기존 값에 오른쪽 값을 더해 다시 저장한다는 뜻이다. 합산 직전에도 검사해 규칙에 맞지 않는 기록을 그대로 합계에 넣지 않는다.

check_rejections는 추가로 준비한 잘못된 입력을 검사한다. try 안에서 검사를 시도하고 ValueError가 발생하면 except로 이동한다. pass는 그 자리에서 더 할 일이 없다는 뜻이다. 예외 없이 통과하면 else가 실행되어 AssertionError로 검사의 실패를 알린다. 이 함수는 기록 목록을 받지 않으므로 시연 기록을 추가하지 않는다.

main의 임시 폴더 부분은 빈 JSON 파일을 먼저 쓰고 읽는다. 이어지는 반복에서 각 입력을 검사한다. 실패하면 거절 수만 늘리고, 성공한 경우에만 else 안의 append로 목록 끝에 기록을 붙인다. before_count와 assert는 거절 전후의 기록 수가 같은지 확인한다.

다시 읽기 이후의 assert들은 파일에 쓴 값과 읽은 값, 기록 수, 거절 수, 분류별 합계를 확인한다. 화면에 합계가 그럴듯하게 보여도 다른 분류에 금액이 들어갔을 수 있으므로 totals 전체를 기대한 딕셔너리와 비교한다. 마지막 조건문은 main.py를 직접 실행했을 때 main을 호출한다.

저장한 네 기록을 다시 읽어 분류별 합계를 구하면 식비는 20000원이 된다

실행 결과

터미널에서 main.py를 저장한 폴더로 이동한 상태에서 실행한다. 이 글에는 실제 터미널을 실행한 기록을 첨부하지 않았다. 다음은 완성 코드에 대응하는 예상 출력이며, 독자의 환경에서 직접 실행해 일치하는지 확인해야 한다.

문법과 경고 확인: python3 -W error -m py_compile main.py

실행: python3 main.py

첫 명령은 문제가 없으면 아무것도 출력하지 않는다. 이 명령은 코드를 컴파일해 문법 등을 확인하지만 main 함수를 실행하지는 않는다. 실제 기록 처리와 assert는 둘째 명령으로 확인한다. 컴파일 과정에서 실행용 가계부 파일과 별개로 __pycache__ 폴더가 만들어질 수 있다.

기록: 수입 / 급여 / 3000000원
기록: 지출 / 식비 / 12000원
기록: 지출 / 교통 / 1500원
기록: 지출 / 식비 / 8000원
거절: 지출 분류 '식삐'은 사용할 수 없다. 허용: 식비, 교통, 기타지출
거절: 구분 '저축'은 사용할 수 없다. 허용: 수입, 지출
거절: 수입 분류 '식비'은 사용할 수 없다. 허용: 급여, 기타수입
다시 읽은 기록: 4건
거절한 입력: 3건
[수입]
급여: 3000000원
기타수입: 0원
수입 합계: 3000000원
[지출]
식비: 20000원
교통: 1500원
기타지출: 0원
지출 합계: 21500원
수입에서 지출을 뺀 차액: 2978500원
검사 통과

추가 거절 검사는 화면에 줄을 더하지 않는다. 따라서 출력에 보이는 거절 세 건은 DEMO_INPUTS에서 나온 결과다. 프로그램이 끝나면 임시 가계부 파일도 정리된다. 이 코드를 개인 기록 보관용으로 사용하려면 저장 위치와 파일 유지 방식을 별도로 바꿔야 한다.

실무에서 자주 틀리는 것

아래 짧은 코드들은 각각 독립적으로 실행할 수 있는 비교 예다. main.py에 추가하지 말고, 어떤 결과를 만드는지 살펴본다. ‘틀린 코드’는 문법 오류라는 뜻이 아니라 이번 가계부의 규칙과 다른 결과를 낸다는 뜻이다.

모든 분류를 한꺼번에 검사한다

틀린 코드는 수입에 식비를 붙여도 True를 출력한다. 분류 이름이 전체 목록 어딘가에 있기 때문이다.

categories = {"수입": ("급여",), "지출": ("식비",)}
kind, category = "수입", "식비"
all_categories = categories["수입"] + categories["지출"]
print(category in all_categories)

고친 코드는 구분을 먼저 확인하고 해당 목록만 검사한다. 같은 조합에 False를 출력한다.

categories = {"수입": ("급여",), "지출": ("식비",)}
kind, category = "수입", "식비"
print(kind in categories and category in categories[kind])

and는 앞 조건이 거짓이면 뒤 조건을 계산하지 않는다. 알려지지 않은 구분으로 딕셔너리를 조회하는 것도 피할 수 있다. 완성 코드에서는 거절 이유를 구체적으로 보여 주려고 두 검사를 나누었다.

반복할 때 합계를 덮어쓴다

틀린 코드는 마지막 식비인 8000만 남긴다. 같은 분류를 찾았다는 사실과 금액을 누적한다는 동작은 별개다.

total = 0
for amount in (12000, 8000):
    total = amount
print(total)

고친 코드는 기존 합계에 금액을 더하므로 20000을 출력한다.

total = 0
for amount in (12000, 8000):
    total += amount
print(total)

합계 변수의 0 초기화도 반복문 바깥에 있어야 한다. 반복문 안에서 매번 0으로 만들면 앞에서 더한 금액이 사라진다. 두 건 이상을 넣어 확인해야 이런 실수를 발견하기 쉽다.

오타를 기타지출로 바꿔 버린다

틀린 코드는 식삐를 기타지출로 출력한다. 입력은 성공한 것처럼 보이지만 사용자가 의도한 식비 합계에는 들어가지 않는다.

allowed = ("식비", "교통", "기타지출")
category = "식삐"
if category not in allowed:
    category = "기타지출"
print(category)

고친 코드는 입력값을 유지한 채 거절을 알린다. 완성 코드에서는 이 자리에 예외를 사용해 목록 추가도 막는다.

allowed = ("식비", "교통", "기타지출")
category = "식삐"
if category not in allowed:
    print(f"거절: '{category}' / 허용: {', '.join(allowed)}")
else:
    print(f"사용 가능: {category}")

기타지출은 사용자가 그 분류를 선택했을 때 쓰는 항목이다. 잘못된 이름을 숨기는 용도로 쓰지 않는다. 입력을 고칠 기회를 주는 편이 나중에 합계를 해석하기 쉽다.

한눈에 보기

분류를 붙일 때 지켜야 할 규칙과 확인 방법
대상규칙직접 확인할 결과
구분수입과 지출만 허용한다.저축은 거절된다.
분류해당 구분의 목록에서 찾는다.식삐와 수입의 식비는 거절된다.
금액양의 정수로 적는다.0, 음수, 문자열, True는 거절된다.
기록 추가검사 성공 뒤에만 추가한다.정상 기록 네 건만 남는다.
저장과 읽기임시 파일에서 다시 읽어 합산한다.읽은 목록이 저장 전 목록과 같다.
분류별 합계0에서 시작해 금액을 누적한다.식비는 20000원이고 기타지출은 0원이다.

규칙을 바꿀 때는 허용 목록, 시연 입력, 기대값의 관계를 함께 살핀다. 실행이 끝났다는 사실만으로 계산이 맞았다고 판단하지 않는다. 어떤 입력이 거절되었고 어떤 값이 합계에 들어갔는지를 확인한다. 다음 장에서는 이 기록에 있는 날짜를 사용해 월별 합계를 만든다.

연습 문제

  1. 지출의 허용 분류 끝에 ‘생활’을 추가하라. 시연 입력에는 아무것도 추가하지 않는다. 보고서와 합계 검사에서 무엇을 바꿔야 하는지 설명하라.
  2. 완성 코드의 시연 입력 끝에 지출, 식비, 3000원 한 건을 추가하라. 기록 수, 거절 수, 식비 합계, 지출 합계, 차액을 먼저 계산한 뒤 코드와 검사를 수정하라.
  3. 수입, 교통, 2000원이라는 입력은 왜 거절되어야 하는가. 기존 시연 출력은 유지하면서 추가 거절 검사에 이 사례를 넣어 확인하라.
  4. 기타지출 0원을 기록해도 되는지 현재 규칙으로 판단하라. 0원이 거절되는데도 보고서의 기타지출이 0원으로 표시되는 이유를 설명하라.

정답과 해설

  1. CATEGORIES의 지출 튜플 끝에 생활을 추가한다. summarize는 허용 목록으로 0을 준비하므로 보고서에 생활: 0원이 자동으로 표시된다. totals를 비교하는 기대 딕셔너리에도 생활의 값 0을 추가해야 한다. 입력은 그대로이므로 기록 수와 지출 합계는 바뀌지 않는다. 식삐의 거절 메시지에 나오는 허용 목록에도 생활이 나타나야 한다. 실행 후 이 변경들을 확인한다.

  2. 정상 기록은 다섯 건, 거절 입력은 세 건이다. 식비는 23000원, 지출 합계는 24500원, 차액은 2975500원이다. DEMO_INPUTS에 입력을 추가하고 기록 수 assert, totals의 식비 기대값, 차액 assert를 바꾼다. 지출 합계 출력은 합계에서 계산하므로 숫자를 직접 고칠 필요가 없다. 수정 비교에서 다른 입력 금액이 바뀌지 않았는지 확인한다.

  3. 교통은 지출 분류이므로 수입에서는 사용할 수 없다. check_rejections의 invalid_values에 수입, 교통, 2000의 조합을 추가한다. 이 검사는 DEMO_INPUTS를 처리하지 않으므로 화면에 표시되는 거절 수는 세 건 그대로다. 실행이 검사 통과까지 도달하면 추가한 사례도 거절되었다는 뜻이다.

  4. 금액은 0보다 큰 정수여야 하므로 0원 기록은 거절된다. 보고서의 0원은 0원짜리 기록을 저장했다는 뜻이 아니다. 해당 분류에 정상 기록이 없어 합계가 초기값 0에 머물렀다는 뜻이다. 입력 금액의 허용 규칙과 합계의 표시값을 구별해야 한다.

댓글 0

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

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