Devin.KR

검색과 수정·삭제 - 번호로 찾아 고치기

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 분류별 예산을 넘으면 알려 주는 기능을 만들었다. 기록이 쌓이면 금액을 잘못 적었거나 같은 지출을 두 번 넣은 사실을 뒤늦게 발견한다. 이때 필요한 것은 기록을 다시 만드는 기능이 아니라, 원하는 기록을 찾아 안전하게 고치는 기능이다. 이번에는 메모에 들어 있는 낱말로 후보를 찾고, 고유 번호로 수정하거나 삭제한다.

이 장의 프로그램은 정해진 입력 목록을 차례로 처리한 뒤 바로 끝난다. 임시 폴더에 JSON 파일을 만들어 저장과 재실행에 해당하는 불러오기를 확인하므로, 앞 장의 파일을 준비하지 않아도 된다. 실행할 때마다 같은 시연 자료로 시작하며 출력도 같다.

  • 기록마다 다른 고유 번호를 붙이고 다음 번호를 파일에 함께 저장한다.
  • 메모에 검색어가 들어 있는 기록을 모두 찾는다.
  • 수정·삭제 대상을 먼저 보여 주고 확인된 작업만 반영한다.
  • 삭제한 번호를 다른 기록에 다시 붙이지 않는다.
  • 실행 결과와 변경 내용 비교로 AI가 만든 코드를 확인한다.

이번 장에서 만들 것

점심값을 9,000원으로 적었는데 실제로는 8,500원이었다고 하자. 기록 순서만 보고 두 번째 항목을 고르면, 앞의 기록을 삭제했을 때 대상이 달라질 수 있다. 화면에 보이는 순서와 기록의 신분을 구별해야 하는 이유다. 고유 번호는 그 기록을 가리키는 이름표이며, 화면에서 몇 번째로 보이는지와 관계없이 유지한다.

처음에는 커피, 점심, 커피 원두를 기록한다. 메모에서 ‘커피’를 찾으면 두 기록이 나온다. 이어서 커피 금액 수정은 취소하고, 점심 금액 수정은 승인한다. 첫 커피 기록을 삭제한 뒤 차를 추가하면 번호는 4번이다. 마지막에는 파일을 다시 읽어 남은 번호와 다음 번호를 확인한다.

이번 시연에서 기록이 바뀌는 순서
대상요청기대 상태
1번4,500원을 5,000원으로 수정하다 취소4,500원 유지
2번9,000원을 8,500원으로 수정번호는 2번 유지
1번확인 후 삭제목록에서 제거
새 기록차 3,000원 추가4번 부여

파일에는 기록 목록과 다음에 발급할 번호를 함께 넣는다. 삭제는 목록에서 기록 하나를 빼는 일이며, 남은 기록의 번호를 다시 매기는 일이 아니다. 날짜·분류·금액·메모를 저장하는 기존 형태에 번호와 다음 번호 상태를 더하는 셈이다.

1번을 삭제해도 2번과 3번은 유지되고 새 기록에는 4번을 붙인다

이 시연의 저장 파일은 실행 중에만 쓰는 임시 파일이다. 프로그램이 끝나면 임시 폴더도 정리된다. 따라서 여기서 확인하는 것은 한 번의 실행 안에서 저장한 상태를 다시 읽을 수 있는지다. 개인 기록을 실행 사이에 계속 보관하는 경로 설정은 이번 실습에 추가하지 않는다.

따라하기

아래 요청문을 순서대로 보낸다. AI의 답은 매번 다를 수 있으므로, 각 단계의 ‘답의 요지’는 비교 기준으로 읽는다. 최종적으로는 뒤의 완성 코드를 main.py에 넣어 같은 출력을 확인한다. 중간 답에서 아직 만들지 않은 기능이 빠져 있는 것은 괜찮지만, 이미 통과한 동작이 사라지면 다음 단계로 넘어가지 않는다.

단계 1. 실행 조건과 번호 규칙을 먼저 고정한다

① 보낼 요청문

혼자 쓰는 가계부의 검색·수정·삭제 기능을 main.py 한 파일로 만들어 줘. Python 3.12 이상 표준 라이브러리만 사용하고 사용자 입력은 받지 마. tempfile.TemporaryDirectory 안에 JSON 파일을 직접 만들고, 정해진 시연을 마치면 종료해. 기록은 id, date, category, amount, memo를 가진다. 저장 상태는 records와 next_id를 가진다. 번호는 1부터 시작하고 삭제한 번호는 재사용하지 마. 우선 빈 상태를 저장하고 다시 읽는 부분부터 작성해 줘.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 빈 상태는 records가 빈 목록이고 next_id가 1이다. 파일이 있으면 읽고, 없으면 빈 상태를 반환한다. 저장과 불러오기를 맡는 함수를 따로 둔다.

③ 직접 확인할 것

터미널에서 다음 명령을 실행한다.

python3 main.py

이 단계에서는 상태를 출력하도록 요청해 records가 빈 목록이고 next_id가 1인지 확인한다. 임시 폴더의 실제 경로는 실행마다 달라질 수 있으므로 출력에 넣지 않는다. 파일 저장은 UTF-8로 하고, 다시 읽은 메모가 한글 그대로인지 이후 단계에서도 확인한다.

④ 체크포인트

파일이 없는 첫 실행에서도 오류 없이 빈 상태를 만들고, 저장한 두 항목을 다시 읽으면 다음 단계로 간다. next_id를 저장하지 않고 실행할 때마다 1로 초기화한 답이라면 여기서 고친다.

단계 2. 세 기록에 서로 다른 번호를 붙인다

① 보낼 요청문

빈 상태에 기록을 추가하는 add 함수를 만들어 줘. next_id를 새 기록의 id로 쓰고, 추가한 다음 next_id를 1 늘려 저장해. 날짜는 모두 2026-10-01이다. 식비 4500원 메모 ‘아침 커피’, 식비 9000원 메모 ‘점심 식사’, 생활 12000원 메모 ‘커피 원두’를 순서대로 추가해. 분류는 식비와 생활만 허용하고 금액은 양의 정수만 허용해. 기록 한 줄을 일정한 형식으로 만드는 line 함수도 만들어 줘.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 추가 함수는 현재 next_id를 사용하고 다음 값을 저장한다. 세 기록의 번호는 1, 2, 3이며, 다음 번호는 4다. 잘못된 금액이나 분류는 목록을 바꾸기 전에 거절한다.

③ 직접 확인할 것

python3 main.py

초기 기록 세 줄에 1번, 2번, 3번이 붙는지 확인한다. 같은 날짜나 같은 낱말이 있어도 번호는 달라야 한다. AI가 len(records) + 1로 번호를 만들었다면 수정 요청을 보낸다. len은 목록의 길이를 세는 함수이며, 삭제 이후에는 다음에 써야 할 번호를 알려 주지 못한다.

④ 체크포인트

세 기록이 입력 순서대로 나오고, 저장된 next_id가 4이면 다음 단계로 간다. 번호 발급을 금액 검증보다 먼저 처리해 잘못된 입력에도 번호가 소비되는지 함께 살펴본다.

단계 3. 메모에서 검색하고 번호로 대상을 찾는다

① 보낼 요청문

메모 검색 함수 search를 추가해 줘. 검색어의 양끝 공백을 제거하고, 검색어가 메모에 포함되면 결과에 넣어 줘. 같은 낱말이 여러 기록에 있으면 모두 반환해. 빈 검색어는 빈 결과를 반환해. ‘커피’를 검색한 결과를 출력하고 ‘택시’ 검색 결과의 개수도 출력해. 별도로 find 함수를 만들어 id가 같은 기록 하나를 찾고, 없으면 None을 반환해.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 메모 검색은 여러 후보를 돌려주고, 번호 조회는 한 대상을 돌려준다. 검색 결과의 위치를 원본 목록의 위치로 취급하지 않는다. 커피 검색에서는 1번과 3번이 나온다.

③ 직접 확인할 것

python3 main.py

‘커피’에서는 두 줄, ‘택시’에서는 0건이 나와야 한다. 이번 검색은 문장을 언어적으로 분석하는 기능이 아니다. 검색어가 메모의 연속된 글자 안에 들어 있는지만 본다. 따라서 ‘커피’는 ‘커피 원두’에도 일치한다. 이 기준을 요청문에 써 두면 AI가 임의로 검색 규칙을 넓히는 일을 줄일 수 있다.

④ 체크포인트

검색 결과에 원래 번호가 보이고, 없는 검색어도 오류 없이 처리하면 다음 단계로 간다. 검색에서 첫 결과만 반환한다면 여러 기록을 찾도록 고친다.

단계 4. 수정 전후를 보여 주고 취소를 지킨다

① 보낼 요청문

금액만 바꾸는 edit 함수를 추가해 줘. 번호로 대상을 찾고 없으면 ‘대상 없음: 번호번’을 출력해. 대상이 있으면 원본을 복사해 후보를 만든 뒤 새 금액을 검증해. 수정 전과 수정 후 후보를 출력하고, confirmed가 False면 ‘수정 취소’만 출력한 뒤 원본과 파일을 그대로 둬. True면 후보를 반영하고 저장해. 1번을 5000원으로 바꾸는 요청은 취소하고, 2번을 8500원으로 바꾸는 요청은 승인하는 순서로 시연해.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 수정 후보를 별도로 만들어 출력한다. 확인값이 참일 때만 원본 기록을 갱신한다. 수정해도 id, 날짜, 분류, 메모는 유지한다.

③ 직접 확인할 것

python3 main.py

1번의 수정 후보에는 5,000원이 보이지만 취소 후 조회하면 4,500원이어야 한다. 2번은 수정 전 9,000원, 수정 후 8,500원이 나오고 ‘수정 완료’가 이어져야 한다. 확인값은 실제 사용자 입력을 대신하는 정해진 값이다. False는 취소, True는 승인을 뜻하며, 이번 프로그램은 키보드 응답을 기다리지 않는다.

④ 체크포인트

취소가 출력 문구뿐 아니라 실제 상태에도 반영되어야 한다. 후보를 만들 때 원본을 먼저 고치지 않았는지 코드를 읽고, 취소 뒤 1번을 다시 출력해 확인하면 다음 단계로 간다.

단계 5. 삭제 전 대상을 보여 주고 번호를 유지한다

① 보낼 요청문

delete 함수를 추가해 줘. 삭제 전에 대상 한 줄을 출력하고 confirmed가 False면 취소해. True일 때만 제거하고 저장해. 1번 삭제를 승인한 뒤 없는 99번도 삭제 요청해. 이어서 같은 날짜의 식비 3000원, 메모 ‘오후 차’를 추가해. 새 번호를 출력하고 남은 기록의 번호를 다시 매기지 마.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 삭제 대상은 고유 번호로 조회한다. 없는 번호는 안내하고 끝낸다. 삭제 후 남는 번호는 2, 3이고, 다음 추가 기록은 4번이다.

③ 직접 확인할 것

python3 main.py

삭제 대상에 1번 아침 커피가 출력되어야 한다. 99번 요청에는 ‘대상 없음: 99번’이 나오고 실행이 계속되어야 한다. 새 기록이 3번이면 기존 3번과 충돌한다. 새 기록이 1번이면 삭제한 번호를 재사용한 것이다. 두 경우 모두 다음 번호 저장 방식을 다시 확인한다.

④ 체크포인트

새 번호가 4번이고 기존 2번·3번이 유지되면 다음 단계로 간다. 목록 위치에 번호에서 1을 뺀 값을 넣는 코드가 없는지도 확인한다. 삭제 이후에는 번호와 위치가 달라진다.

수정 후보를 보여 준 뒤 승인하면 저장하고 취소하면 원본을 유지한다

단계 6. 다시 읽은 상태와 예산 경고를 확인한다

① 보낼 요청문

모든 작업 뒤 JSON 파일을 load로 다시 읽어 state를 교체해. 다시 읽은 기록 전체와 다음 번호를 출력해. 앞에서 쓰던 분류별 예산 확인도 포함해 식비 한도 10000원과 비교하고, 한도를 넘었을 때 초과액을 출력해. 이번 시연에서는 식비만 확인해. 실행 경로와 현재 시각은 출력하지 마.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 저장 파일에서 복원한 기록은 2번 8,500원, 3번 12,000원, 4번 3,000원이다. 다음 번호는 5이며 식비 합계는 11,500원이므로 1,500원 초과 안내가 나온다.

③ 직접 확인할 것

python3 main.py

최종 목록을 단순히 현재 목록에서 출력하는지, load를 다시 호출한 결과에서 출력하는지 확인한다. 앞의 경우는 파일 저장을 확인한 것이 아니다. 수정과 삭제가 저장되지 않았다면 다시 읽은 뒤 이전 기록이 나타날 수 있다. 식비 합계는 삭제된 커피를 제외하고 수정된 점심값과 새 차값을 더해야 한다.

④ 체크포인트

최종 목록과 다음 번호, 예산 초과액이 모두 맞으면 다음 단계로 간다. 기존 예산 기능을 복잡하게 확장하기보다, 기록 변경 이후에도 기존 계산이 맞는지만 확인한다.

단계 7. 검사와 변경 내용 비교를 마친다

① 보낼 요청문

완성된 main.py를 검토해 줘. 취소한 수정이 원본을 바꾸지 않는지, 없는 번호 요청이 상태를 바꾸지 않는지, 삭제 뒤 4번을 발급하는지, 다시 읽은 next_id가 5인지 확인하는 assert를 넣어 줘. assert는 조건이 거짓이면 실행을 멈추는 확인문이다. 바뀐 함수와 이유를 짧게 설명하고, 관련 없는 기능이 바뀌었는지도 알려 줘.

② AI가 돌려줄 내용의 요지

답은 매번 다를 수 있다. 상태를 확인하는 조건문을 시연 중간과 마지막에 넣는다. 수정 후보의 복사, 확인 뒤 저장, 번호 유지가 핵심 변경이라고 설명한다. 실제 변경 내용은 사용자가 비교해서 확인해야 한다.

③ 직접 확인할 것

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

첫 명령은 코드를 실행하기 전에 문법과 컴파일 중 경고를 확인한다. 성공하면 출력 없이 끝난다. 둘째 명령은 시연과 확인문을 실행한다. 편집기의 변경 내용 비교(diff) 화면에서도 이전 코드와 새 코드를 나란히 본다. 특히 원본을 바꾸는 줄이 승인 검사 뒤에 있는지, 삭제 함수가 next_id를 줄이지 않는지 직접 읽는다.

④ 체크포인트

컴파일이 경고 없이 끝나고, 아래 실행 결과와 줄마다 일치하며, 변경 범위를 설명할 수 있으면 실습을 마친다. assert가 통과했다는 사실은 여기서 정한 사례를 통과했다는 뜻이다. 모든 입력에 대한 보증으로 해석하지 않는다.

완성 코드

다음 전체를 main.py로 저장한다. 금액 수정만 지원하도록 범위를 좁혔다. 분류와 메모를 고치는 기능까지 한꺼번에 넣으면 확인해야 할 경우가 늘어나므로, 이번에는 대상 선택과 확인 절차에 집중한다.

import json
import tempfile
from pathlib import Path

CATEGORIES = ("식비", "생활")


def load(path):
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))
    return {"records": [], "next_id": 1}


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


def validate(category, amount):
    if category not in CATEGORIES:
        raise ValueError("허용하지 않는 분류")
    if type(amount) is not int or amount <= 0:
        raise ValueError("금액은 양의 정수여야 한다")


def add(path, state, day, category, amount, memo):
    validate(category, amount)
    record = {
        "id": state["next_id"],
        "date": day,
        "category": category,
        "amount": amount,
        "memo": memo,
    }
    state["records"].append(record)
    state["next_id"] += 1
    save(path, state)
    return record["id"]


def line(record):
    return (
        f'{record["id"]}번 | {record["date"]} | '
        f'{record["category"]} | {record["amount"]:,}원 | '
        f'{record["memo"]}'
    )


def search(state, keyword):
    keyword = keyword.strip()
    if not keyword:
        return []
    return [
        record for record in state["records"]
        if keyword in record["memo"]
    ]


def find(state, record_id):
    for record in state["records"]:
        if record["id"] == record_id:
            return record
    return None


def edit(path, state, record_id, amount, confirmed):
    record = find(state, record_id)
    if record is None:
        print(f"대상 없음: {record_id}번")
        return
    candidate = record.copy()
    candidate["amount"] = amount
    validate(candidate["category"], candidate["amount"])
    print("수정 전: " + line(record))
    print("수정 후: " + line(candidate))
    if not confirmed:
        print("수정 취소")
        return
    record.update(candidate)
    save(path, state)
    print("수정 완료")


def delete(path, state, record_id, confirmed):
    record = find(state, record_id)
    if record is None:
        print(f"대상 없음: {record_id}번")
        return
    print("삭제 대상: " + line(record))
    if not confirmed:
        print("삭제 취소")
        return
    state["records"].remove(record)
    save(path, state)
    print("삭제 완료")


def check_budget(state, category, limit):
    total = sum(
        record["amount"] for record in state["records"]
        if record["category"] == category
    )
    if total > limit:
        print(f"예산 경고: {category} {total - limit:,}원 초과")


def main():
    with tempfile.TemporaryDirectory() as folder:
        path = Path(folder) / "ledger.json"
        state = load(path)
        day = "2026-10-01"
        inputs = [
            ("식비", 4500, "아침 커피"),
            ("식비", 9000, "점심 식사"),
            ("생활", 12000, "커피 원두"),
        ]
        for category, amount, memo in inputs:
            add(path, state, day, category, amount, memo)

        print("[초기 기록]")
        for record in state["records"]:
            print(line(record))

        print("[검색: 커피]")
        for record in search(state, "커피"):
            print(line(record))
        print(f'검색: 택시 / {len(search(state, "택시"))}건')
        assert search(state, "   ") == []

        print("[수정]")
        edit(path, state, 1, 5000, False)
        assert find(state, 1)["amount"] == 4500
        assert find(load(path), 1)["amount"] == 4500
        print("취소 후: " + line(find(state, 1)))
        edit(path, state, 2, 8500, True)

        print("[삭제]")
        delete(path, state, 1, True)
        before = json.dumps(state, ensure_ascii=False)
        delete(path, state, 99, True)
        assert json.dumps(state, ensure_ascii=False) == before

        print("[새 기록]")
        new_id = add(path, state, day, "식비", 3000, "오후 차")
        assert new_id == 4
        print(f"새 번호: {new_id}번")

        state = load(path)
        assert [record["id"] for record in state["records"]] == [2, 3, 4]
        assert find(state, 2)["amount"] == 8500
        assert state["next_id"] == 5
        print("[다시 불러온 기록]")
        for record in state["records"]:
            print(line(record))
        print(f'다음 번호: {state["next_id"]}')
        check_budget(state, "식비", 10000)


if __name__ == "__main__":
    main()

줄별 해설

위에서 아래로 실행에 필요한 역할을 따라 읽는다. def로 시작하는 부분은 함수 정의다. 정의할 때는 처리 방법을 준비하고, main에서 함수 이름을 호출할 때 실제 작업을 수행한다.

완성 코드를 위에서 아래로 읽는 순서
부분하는 일직접 볼 지점
import와 CATEGORIES파일 처리 도구와 허용 분류를 준비한다.외부 패키지를 불러오지 않는다.
load와 saveJSON 상태를 읽고 쓴다.records와 next_id를 함께 저장한다.
validate와 add값을 검사한 뒤 번호를 발급한다.검사가 목록 변경보다 앞선다.
line, search, find표시와 대상 조회를 맡는다.검색 결과에도 원래 번호를 쓴다.
edit와 delete대상을 보여 주고 승인된 작업을 반영한다.취소 분기의 return 뒤에 변경이 있다.
main정해진 요청을 실행하고 결과를 확인한다.마지막 출력 전에 파일을 다시 읽는다.

load의 path.exists()는 파일이 있는지 확인한다. 첫 호출에서는 파일이 없으므로 빈 상태를 반환한다. add가 처음 저장한 이후부터는 같은 경로의 파일을 읽을 수 있다. save의 ensure_ascii=False는 한글을 읽기 쉬운 형태로 저장하게 한다. indent=2는 파일 안의 들여쓰기를 정하며 기록의 내용에는 영향을 주지 않는다.

validate는 허용 분류와 금액을 검사한다. type(amount) is not int는 금액의 자료형이 정수인지 확인한다. 이 조건은 참·거짓 값인 True와 False가 금액으로 들어오는 경우도 거절한다. 검사에 실패하면 ValueError라는 오류를 내고 실행을 멈춘다. 이번 시연 목록에는 올바른 값만 넣었으므로 오류 출력은 없다.

add에서 record는 한 기록의 항목을 담는 사전이다. 사전은 이름과 값을 짝지어 담는 자료형이다. 새 번호를 넣고 목록에 추가한 뒤 next_id를 늘린다. 두 상태를 한 번에 save에 전달하므로, 다음에 읽을 때도 발급 순서를 이어 갈 수 있다.

line은 출력 모양을 한곳에서 정한다. 금액 부분의 쉼표 형식은 8500을 8,500으로 보여 준다. 화면에 보이는 쉼표는 저장 금액을 문자로 바꾸지 않는다. 파일에는 계산할 수 있는 정수가 들어간다.

search는 검색어의 양끝 공백을 없앤 뒤 메모를 확인한다. 빈 문자열은 어떤 메모에도 포함된 것으로 판단되므로, 앞에서 빈 결과를 반환하는 처리가 필요하다. find는 번호가 같을 때 그 기록을 반환하고, 끝까지 없으면 ‘값이 없음’을 나타내는 None을 반환한다.

edit의 record.copy()는 원본과 별개인 후보 사전을 만든다. 현재 기록의 값은 정수와 문자열이므로 이 복사로 금액 후보를 분리할 수 있다. 후보를 검증하고 전후 모습을 출력한 다음, 승인되었을 때만 update로 원본에 반영한다. 반대로 candidate = record라고 쓰면 두 이름이 같은 사전을 가리켜 취소 전에 원본이 바뀐다.

delete의 remove는 찾아 둔 기록을 목록에서 제거한다. 이 함수에는 번호를 바꾸거나 next_id를 줄이는 줄이 없다. 마지막의 main 호출은 파일을 직접 실행했을 때 시연을 시작하게 한다. with 블록을 벗어나면 임시 폴더와 그 안의 파일이 정리된다.

실행 결과

실행 명령은 다음과 같다.

python3 main.py

위 코드를 그대로 저장했을 때의 예상 출력은 다음과 같다. assert는 조건을 통과하면 아무것도 출력하지 않는다. 실제 실행 결과를 이 목록과 대조한다.

[초기 기록]
1번 | 2026-10-01 | 식비 | 4,500원 | 아침 커피
2번 | 2026-10-01 | 식비 | 9,000원 | 점심 식사
3번 | 2026-10-01 | 생활 | 12,000원 | 커피 원두
[검색: 커피]
1번 | 2026-10-01 | 식비 | 4,500원 | 아침 커피
3번 | 2026-10-01 | 생활 | 12,000원 | 커피 원두
검색: 택시 / 0건
[수정]
수정 전: 1번 | 2026-10-01 | 식비 | 4,500원 | 아침 커피
수정 후: 1번 | 2026-10-01 | 식비 | 5,000원 | 아침 커피
수정 취소
취소 후: 1번 | 2026-10-01 | 식비 | 4,500원 | 아침 커피
수정 전: 2번 | 2026-10-01 | 식비 | 9,000원 | 점심 식사
수정 후: 2번 | 2026-10-01 | 식비 | 8,500원 | 점심 식사
수정 완료
[삭제]
삭제 대상: 1번 | 2026-10-01 | 식비 | 4,500원 | 아침 커피
삭제 완료
대상 없음: 99번
[새 기록]
새 번호: 4번
[다시 불러온 기록]
2번 | 2026-10-01 | 식비 | 8,500원 | 점심 식사
3번 | 2026-10-01 | 생활 | 12,000원 | 커피 원두
4번 | 2026-10-01 | 식비 | 3,000원 | 오후 차
다음 번호: 5
예산 경고: 식비 1,500원 초과

결과가 다르면 처음 달라진 줄을 찾는다. 검색부터 다르면 시연 메모와 검색 함수를, 취소 후 금액부터 다르면 후보 복사를 살펴본다. 마지막 번호만 다르면 삭제와 추가에서 next_id를 다루는 줄을 확인한다. 여러 곳을 한꺼번에 고치기보다 원인을 하나씩 좁힌다.

실무에서 자주 틀리는 것

목록 길이를 다음 번호로 사용한다

다음은 잘못된 번호 발급 함수다. 1번을 삭제해 목록 길이가 2가 되면 3을 반환한다. 이미 남아 있는 3번과 번호가 겹친다.

def issue_id_wrong(state):
    return len(state["records"]) + 1

고친 함수는 저장해 둔 다음 번호를 사용한다. 이 함수를 실제 추가 처리에 적용한다면 기존 add의 번호 증가 줄을 중복 실행하지 않도록 함께 정리해야 한다.

def issue_id_fixed(state):
    record_id = state["next_id"]
    state["next_id"] += 1
    return record_id

가장 큰 번호에 1을 더하는 방식도 이번 규칙을 충분히 지키지 못한다. 마지막 번호의 기록을 삭제하면 그 번호를 다시 사용할 수 있기 때문이다. 발급 이력을 이어 주는 next_id를 별도로 보관한다.

수정 후보가 원본과 같은 사전을 가리킨다

잘못된 함수는 후보를 만든 것처럼 보이지만 같은 사전에 새 이름만 붙인다. 따라서 호출하는 순간 원본 금액도 바뀐다.

def make_candidate_wrong(record, amount):
    candidate = record
    candidate["amount"] = amount
    return candidate

고친 함수는 복사본의 금액을 바꾼다. 승인 전후를 나누려면 후보를 만드는 순간부터 원본과 분리해야 한다.

def make_candidate_fixed(record, amount):
    candidate = record.copy()
    candidate["amount"] = amount
    return candidate

번호를 목록 위치로 바꿔서 조회한다

잘못된 함수는 번호가 2이면 목록의 두 번째 항목을 반환한다. 1번을 삭제한 뒤 두 번째 항목은 3번이므로, 다른 기록을 고칠 수 있다.

def lookup_wrong(state, record_id):
    return state["records"][record_id - 1]

고친 함수는 기록에 저장된 id를 비교한다. 찾지 못한 경우도 값으로 알려 주므로 호출한 쪽에서 안내 문구를 출력할 수 있다.

def lookup_fixed(state, record_id):
    for record in state["records"]:
        if record["id"] == record_id:
            return record
    return None

확인하기 전에 파일에 반영한다

다음 잘못된 함수는 취소해도 이미 파일에 새 금액을 쓴 뒤다. 화면에서 ‘취소’라고 말하는 것만으로는 상태가 되돌아가지 않는다.

def apply_wrong(path, state, record, candidate, confirmed):
    record.update(candidate)
    save(path, state)
    if not confirmed:
        return

고친 함수는 확인 조건을 먼저 검사한다. 완성 코드처럼 대상과 후보를 보여 준 뒤 이 순서로 반영한다.

def apply_fixed(path, state, record, candidate, confirmed):
    if not confirmed:
        return
    record.update(candidate)
    save(path, state)

이 예제는 파일 쓰기가 성공하는 정상 상황을 다룬다. 저장 실패를 복구하는 기능까지 구현한 것은 아니다. 오류가 났다면 ‘완료’ 문구만 믿지 말고 파일을 다시 읽은 상태를 확인한다.

한눈에 보기

검색·수정·삭제를 확인하는 기준
기능지켜야 할 규칙이번 결과
번호 발급next_id를 쓰고 증가시킨다.삭제 후 새 기록은 4번이다.
메모 검색포함된 검색어로 후보를 모두 찾는다.커피는 1번과 3번이다.
수정 취소후보만 만들고 원본은 유지한다.1번은 4,500원이다.
수정 승인확인 뒤 변경하고 저장한다.2번은 8,500원이다.
삭제남은 번호를 다시 매기지 않는다.2번과 3번이 유지된다.
다시 읽기파일에서 기록과 다음 번호를 복원한다.남은 번호는 2, 3, 4다.

AI에게 수정 요청을 보낼 때는 대상 선택 기준, 확인 시점, 취소 후 상태를 함께 쓴다. 답이 그럴듯해 보여도 실행 결과와 실제 변경 줄을 확인한다. 다음에 데이터를 다른 형식으로 주고받더라도, 이번에 정한 번호 규칙을 먼저 분명히 해 두면 어떤 기록을 가리키는지 설명하기 쉬워진다.

연습 문제

  1. 완성 코드의 커피 검색어를 양끝에 공백이 있는 ‘ 커피 ’로 바꾼다. 검색 결과가 같은지 실행해서 확인하고 이유를 설명한다.
  2. 1번을 삭제하는 줄 바로 앞에 3번 삭제를 취소하는 호출을 추가한다. 취소 직후 3번이 목록과 저장 파일에 모두 남아 있음을 assert로 확인한다.
  3. 새 4번 기록을 추가한 직후 그 기록을 승인하여 삭제한다. 이어서 같은 날짜에 식비 2000원, 메모 ‘저녁 간식’을 추가한다. 새 번호와 다음 번호를 예측하고 필요한 확인문을 고친다.

정답과 해설

1번. 검색 결과는 1번과 3번으로 같다. search의 strip이 양끝 공백을 제거한 뒤 ‘커피’를 비교하기 때문이다. 메모 중간의 공백을 없애는 기능은 아니므로 ‘커 피’를 검색하면 같은 결과가 나오지 않는다. 요청문에서 정한 검색 범위를 실행으로 구별해 본다.

2번. 다음 세 줄을 기존 1번 삭제 호출 바로 앞에 넣는다. 함수 안에서 대상 출력과 취소 안내가 먼저 나오므로 예상 출력에도 두 줄이 추가된다.

        delete(path, state, 3, False)
        assert find(state, 3) is not None
        assert find(load(path), 3) is not None

메모리에 남아 있는지와 저장 파일에 남아 있는지는 다른 확인이다. 두 조건을 함께 검사하면 취소 분기에서 파일만 잘못 바꾸는 경우도 찾는 데 도움이 된다. 원래의 최종 번호 확인문은 그대로 통과해야 한다.

3번. 새 번호는 5번이며, 그다음 번호는 6이다. 4번을 삭제해도 발급 순서는 뒤로 가지 않는다. 기존 ‘새 번호’ 출력 뒤, 마지막 load 앞에 다음 줄을 추가한다.

        delete(path, state, 4, True)
        snack_id = add(path, state, day, "식비", 2000, "저녁 간식")
        assert snack_id == 5

마지막 기록 번호 확인은 [2, 3, 5]로, next_id 확인은 6으로 고친다. 최종 식비는 점심 8,500원과 간식 2,000원을 합한 10,500원이므로 예산 초과액은 500원이다. 조건문을 새 결과에 맞추기 전에 이 계산을 먼저 해 본다. 확인문을 바꿔 오류를 숨기는 대신, 요청한 변경 때문에 어떤 결과가 달라져야 하는지 설명하고 실행 결과와 비교한다.

댓글 0

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

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