Devin.KR

종합 실습 - 명세에서 검증 기록까지 할 일 관리 도구 완성

개발자KR 조회 0

이 장에서 배우는 것

작은 할 일 도구도 기능을 붙이다 보면 여러 판단이 얽힌다. 저장한 내용을 다시 읽어야 하고, 검색의 범위를 정해야 하며, 마감 알림이 언제 나타나는지도 결정해야 한다. AI에게 “쓸 만하게 완성해 달라”고 요청하는 것만으로는 이런 판단이 드러나지 않는다. 마지막 실습에서는 앞 장에서 익힌 방법을 한 작업에 모아 쓴다. 사람이 요구와 경계를 정하고, AI에게 구현을 맡기고, 실행으로 확인한 결과를 기록한다.

이 장의 도구는 혼자 사용하는 메모 겸 할 일 목록이다. 제목과 메모를 파일에 저장하고, 두 필드에서 검색하며, 정해진 날짜를 기준으로 마감 알림을 만든다. 알림은 실행 결과에 표시하는 문장이다. 백그라운드에서 기다리거나 운영체제 알림을 보내는 기능은 포함하지 않는다. 완성 코드는 임시 폴더에서 시연한 뒤 바로 끝나므로 반복 실행해도 기존 개인 파일을 건드리지 않는다.

  • 위임할 구현과 사람이 결정할 동작을 구분해 한 장짜리 명세를 만든다.
  • 저장·검색·마감 알림을 작은 단계로 구현하고 단계마다 확인 조건을 붙인다.
  • 정상 입력과 잘못된 입력을 시험하고 실패 원인을 좁혀 수정한다.
  • 다음 작업에 필요한 지침과 맥락을 추려 요청·결정·검증 기록을 남긴다.

문제 상황

지금까지 키워 온 도구에는 제목과 완료 여부가 있다. 사용하다 보니 할 일을 등록할 때 이유도 적고 싶어졌다. “장보기”라는 제목에는 “우유와 사과”라는 메모를 붙이고, “원고 검토”에는 마감일을 붙인다. 프로그램을 다시 실행해도 이 내용이 남아 있어야 한다. 목록이 길어지면 제목뿐 아니라 메모 안의 단어로도 찾고 싶다.

이 요구를 한꺼번에 넘기면 AI는 나름의 빈칸을 채울 수 있다. 검색에서 대소문자를 구분하거나, 완료한 항목에도 알림을 붙이거나, 저장 파일을 읽지 못했을 때 빈 목록으로 바꿀 수 있다. 각각은 구현하기 쉬운 선택이지만 사용자가 기대한 동작과 다를 수 있다. 파일이 손상됐는데 빈 목록으로 조용히 시작하면 사용자는 저장한 내용이 사라졌다고 생각할 수 있다.

먼저 다음처럼 요청을 구체화한다. 아래 대화는 실제 서비스의 응답을 옮긴 것이 아니라 이 실습을 위해 만든 예시다.

요청: 혼자 쓰는 할 일 도구를 완성한다. 제목·메모·마감일·완료 여부를 JSON 파일에 저장한다. 제목과 메모를 함께 검색하고, 미완료 항목만 마감 알림에 포함한다. Python 3.12 이상의 표준 라이브러리만 사용한다. 구현 전에 경계 조건과 검증 계획을 제시하라.

AI의 답 예시: 마감일은 날짜만 저장하고, 알림은 실행할 때 목록으로 출력하는 방식으로 구현하겠다. 빈 검색어의 처리, 완료 항목의 검색 포함 여부, 손상된 파일의 처리 기준을 먼저 명세에 적겠다.

이 답에서 확인할 것은 말투나 자신감이 아니다. 아직 결정되지 않은 사항을 드러냈는지, 외부 패키지나 네트워크가 필요 없는 구조를 제안했는지 살핀다. 구현을 시작하기 전에 다음 표처럼 결정을 닫는다.

구현 전에 사람이 확정한 동작과 확인 조건
항목결정확인 조건
저장형식 버전과 할 일 목록을 JSON으로 저장한다.다시 읽은 목록이 저장 전 목록과 같다.
검색제목과 메모를 검색하며 완료 항목도 포함한다.앞뒤 공백과 영문 대소문자가 결과를 바꾸지 않는다.
빈 검색어전체 목록을 반환하지 않고 거부한다.공백뿐인 검색어에서 ValueError가 발생한다.
마감 알림미완료 항목 중 지연·오늘·내일 마감을 표시한다.완료 항목과 모레 이후 항목은 빠진다.
잘못된 파일없는 파일은 빈 목록, 손상된 파일은 오류로 처리한다.손상된 파일을 빈 목록으로 숨기지 않는다.

명세를 계획과 작은 확인 단계로 바꾸기

명세는 원하는 결과를 적고, 계획은 그 결과에 도달하는 순서를 적는다. 이번 작업에서는 저장을 먼저 확인한다. 저장이 불안정한 상태에서 검색과 알림을 붙이면 검색 결과가 틀린 것인지, 원래 자료를 잘못 읽은 것인지 구분하기 어렵다. 검색과 알림은 파일을 직접 열지 않고 이미 읽은 목록을 받도록 나눈다.

첫 단계에서는 파일에 넣을 필드를 정하고 입력 검사 함수를 만든다. 각 항목에는 양의 정수 번호, 비어 있지 않은 제목, 문자열 메모, 마감일, 참 또는 거짓인 완료 여부가 있어야 한다. 마감일이 없을 때는 빈 문자열 대신 None을 쓴다. 서로 다른 표현을 섞지 않아야 검색이나 알림에서 예외 처리를 줄일 수 있다.

둘째 단계에서는 저장하고 다시 읽는 왕복을 확인한다. JSON은 문자열·숫자·목록 같은 값을 텍스트로 표현하는 자료 형식이다. 읽을 수 있는 JSON이라고 해서 우리 도구가 받아도 되는 자료라는 뜻은 아니다. 필드가 빠졌거나 번호가 중복된 자료도 문법상 JSON일 수 있으므로 형식 검사를 따로 한다.

셋째 단계에서는 검색을 붙인다. 검색어의 앞뒤 공백을 없애고, 영문 대소문자의 차이를 줄인 다음 제목과 메모에서 부분 문자열을 찾는다. 넷째 단계에서는 마감 알림을 붙인다. 마지막으로 전체 시연을 실행하고 실제 통과한 검사 수와 출력 내용을 기록한다. 중간 확인 지점, 즉 체크포인트마다 조건을 통과해야 다음 단계로 간다.

저장과 검색과 알림을 각각 확인한 뒤 전체 실행 결과를 작업 기록에 연결한다

사람에게 남길 일도 분명하다. 어떤 항목을 알림에 넣을지는 사람이 결정한다. 파일 교체나 문자열 비교의 구현은 AI에게 맡길 수 있다. 기존 파일을 새 형식으로 바꾸는 작업은 사용자 자료에 영향을 주므로 별도 작업으로 남긴다. 이 실습은 임시 폴더의 새 파일만 사용한다. 기능이 작다는 이유로 자료 변경까지 같은 범위로 묶지 않는다.

구현 요청: 먼저 저장과 읽기만 작성하고, 왕복 확인과 손상된 파일 확인을 함께 제시하라. 그 단계가 확인되면 검색을 추가한다. 기존 개인 파일의 이름이나 위치를 추측하지 말고 임시 폴더를 사용하라.

확인 뒤의 요청: 저장 확인을 통과했다. 검색은 제목과 메모에 적용한다. 완료 항목도 검색 결과에 포함한다. 검색 결과는 번호 오름차순으로 반환하라.

이렇게 나누면 변경 전후의 차이인 diff도 읽기 쉬워진다. 검색을 요청한 단계에서 파일 삭제 코드나 새로운 의존성이 생겼다면 이유를 확인할 수 있다. 실행이 성공했다는 사실만으로 변경 범위가 적절하다고 판단하지 않는다. 실행은 동작의 증거이고, diff 검토는 무엇을 바꿨는지 확인하는 증거다.

검증과 디버깅을 작업 안에 넣기

테스트는 구현이 끝난 뒤 덧붙이는 의식이 아니라 명세의 문장을 확인 가능한 조건으로 바꾸는 일이다. “저장이 된다”는 문장은 너무 넓다. “저장한 목록을 다시 읽으면 각 필드의 값이 같다”로 바꾸면 비교할 대상이 생긴다. “알림이 맞다”도 “기준일보다 앞선 미완료 항목에는 지연이 붙고, 완료 항목은 제외된다”로 나눈다.

날짜 계산에는 경계값이 필요하다. 오늘, 내일, 모레를 함께 넣어야 내일까지 포함한다는 규칙을 확인할 수 있다. 이 실습에서는 기준일을 2026년 10월 8일로 고정한다. 실제 시계를 읽으면 같은 코드를 내일 실행했을 때 출력이 달라지므로 책의 예제에서 확인하기 어렵다. 운영용으로 바꾸더라도 날짜를 함수 밖에서 정해 전달하는 구조를 유지하면 시험할 때 고정값을 넣을 수 있다.

완료된 항목은 검색과 알림에서 서로 다르게 취급한다. 검색은 보관한 내용을 찾는 기능이므로 완료 항목을 포함한다. 알림은 앞으로 처리할 일을 보여 주므로 완료 항목을 제외한다. 두 기능에 같은 필터를 무심코 적용하면 명세를 어기게 된다. 그래서 각 기능의 확인 조건을 따로 둔다.

실패 보고 예시: 기준일이 2026-10-08이고, 미완료 항목의 마감일이 2026-10-09일 때 내일 마감 알림을 기대했다. 실제 결과에서는 이 항목이 빠졌다. 날짜 차이가 1인 항목을 포함하는 조건인지 확인하라.

AI의 답 예시: 날짜 차이에 대한 비교가 1 미만이면 내일이 제외된다. 조건을 1 이하로 수정하고 오늘·내일·모레 사례를 다시 확인하겠다.

이 보고에는 재현에 필요한 입력, 기대한 결과, 실제 결과가 들어 있다. “알림이 이상하다”보다 확인할 범위가 좁다. 그렇다고 AI의 가설을 바로 사실로 받아들이지는 않는다. 조건을 수정한 diff를 읽고, 같은 입력으로 다시 실행하고, 모레 항목이 새로 섞이지 않았는지도 확인한다. 수정은 원래 실패를 없애면서 이미 맞던 동작을 유지해야 한다.

잘못된 입력도 시험한다. 날짜처럼 보이는 문자열이라도 존재하지 않는 날짜일 수 있다. Python에서는 True가 정수처럼 취급되는 경우가 있으므로 항목 번호에는 실제 정수형만 허용한다. 저장 자료가 단순하더라도 파일에서 읽은 값의 종류를 확인할 이유가 있다. 검사에 실패하면 자료를 고쳐 저장하는 대신 오류를 내도록 한다.

지침과 맥락을 줄여 작업 기록 남기기

다음 세션에 모든 대화를 다시 전달할 필요는 없다. 현재 명세, 확인한 코드, 남은 실패, 짧은 지침이 있으면 이어 갈 수 있다. 맥락은 AI가 판단할 때 참고하는 정보다. 너무 많은 옛 대화가 들어가면 이미 취소한 요구와 현재 요구가 함께 보일 수 있다. 전달할 자료를 줄이되 결정을 잃지 않는 것이 중요하다.

프로젝트 지침 예시: Python 3.12 이상과 표준 라이브러리만 사용한다. 네트워크 호출, 외부 패키지, 비밀값을 추가하지 않는다. 시연은 임시 폴더를 사용하고 기준일은 고정한다. 파일 자료는 읽기와 쓰기 전에 검사한다. 변경 뒤에는 관련 확인 조건을 실행하고 실제 결과만 기록한다.

다음 작업용 맥락 예시: 저장 형식 버전은 1이다. 검색에는 완료 항목을 포함하고 알림에서는 제외한다. 지연·오늘·내일 마감만 표시한다. 현재 시연에서는 15개 확인 조건을 실행한다. 사람의 코드 diff 검토 결과는 별도 기록해야 한다.

지침 파일은 반복되는 규칙을 두는 곳이다. 특정 오류의 긴 추적 내용은 지침보다 작업 기록에 두는 편이 낫다. “메모 검색이 빠져서 수정했다”는 지난 작업의 이력이고, “검색 대상은 제목과 메모다”는 앞으로도 지킬 규칙이다. 둘을 구분하면 지침이 오래된 사건으로 길어지는 일을 줄일 수 있다.

작업 기록에는 요청, 결정, 검증을 남긴다. 요청에는 원하는 변화가 들어간다. 결정에는 여러 가능한 동작 중 무엇을 택했는지가 들어간다. 검증에는 실행한 조건과 결과가 들어간다. AI가 “확인했다”고 답한 문장만 옮기지 않는다. 사람이 아직 검토하지 않은 항목은 미확인으로 남긴다. 아래 프로그램도 자동 확인 결과와 사람의 diff 검토를 구분한다.

보안 범위도 기록으로 남긴다. 저장 경로는 프로그램이 만든 임시 폴더 안에서 정하고, 제목과 메모를 명령이나 Python 코드로 실행하지 않는다. JSON을 읽을 때도 자료로만 해석한다. 이 예제에는 네트워크 호출과 비밀값이 없으며, 외부 패키지 이름을 AI의 제안만 믿고 설치할 단계도 없다.

요청과 결정과 실제 검증을 연결하고 사람의 diff 검토는 별도 확인으로 남긴다
다음 작업에 넘길 정보와 작업 기록에 남길 정보
자료담을 내용역할
현재 명세저장 형식, 검색 범위, 알림 범위다음 변경의 기준이 된다.
프로젝트 지침의존성 제한, 입력 검사, 실행 원칙반복되는 선택을 일관되게 만든다.
작업 기록요청, 선택 이유, 실패와 재확인 결과지금 코드에 이른 이유를 보존한다.
검토 기록실제로 살핀 diff와 남은 질문자동 테스트가 대신하지 못한 확인을 남긴다.

완성 코드

다음 내용을 main.py에 저장한다. 입력 검사, 파일 저장, 검색, 알림을 함수로 나누고 마지막에 한 번의 시연으로 묶었다. 확인 조건이 실패하면 그 자리에서 예외가 발생하므로 “통과” 기록을 출력하지 않는다. 임시 폴더는 실행이 끝나면 정리된다. 따라서 이 파일은 기능과 검증 과정을 재현하는 완성 시연이며, 매일 사용할 영구 보관용 실행 화면은 아니다.

import json
from datetime import date
from pathlib import Path
from tempfile import TemporaryDirectory


TODAY = date(2026, 10, 8)
FIELDS = {"id", "title", "note", "due", "done"}


def parse_due(value):
    if value is None:
        return None
    if not isinstance(value, str):
        raise ValueError("마감일은 문자열 또는 None이어야 한다.")
    try:
        parsed = date.fromisoformat(value)
    except ValueError as exc:
        raise ValueError("마감일이 올바르지 않다.") from exc
    if parsed.isoformat() != value:
        raise ValueError("마감일은 YYYY-MM-DD 형식이어야 한다.")
    return parsed


def validate_tasks(tasks):
    if not isinstance(tasks, list):
        raise ValueError("할 일 목록은 리스트여야 한다.")
    seen = set()
    for task in tasks:
        if not isinstance(task, dict) or set(task) != FIELDS:
            raise ValueError("할 일 필드가 맞지 않는다.")
        task_id = task["id"]
        if type(task_id) is not int or task_id <= 0:
            raise ValueError("번호는 양의 정수여야 한다.")
        if task_id in seen:
            raise ValueError("번호가 중복됐다.")
        seen.add(task_id)
        if not isinstance(task["title"], str) or not task["title"].strip():
            raise ValueError("제목은 비어 있지 않은 문자열이어야 한다.")
        if not isinstance(task["note"], str):
            raise ValueError("메모는 문자열이어야 한다.")
        if type(task["done"]) is not bool:
            raise ValueError("완료 여부는 참 또는 거짓이어야 한다.")
        parse_due(task["due"])


def save_tasks(path, tasks):
    validate_tasks(tasks)
    payload = {"version": 1, "tasks": tasks}
    text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
    pending = path.with_name(path.name + ".pending")
    pending.write_text(text, encoding="utf-8")
    pending.replace(path)


def load_tasks(path):
    try:
        text = path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return []
    try:
        payload = json.loads(text)
    except json.JSONDecodeError as exc:
        raise ValueError("저장 파일의 JSON 문법이 잘못됐다.") from exc
    if not isinstance(payload, dict) or set(payload) != {"version", "tasks"}:
        raise ValueError("저장 파일의 구조가 맞지 않는다.")
    if type(payload["version"]) is not int or payload["version"] != 1:
        raise ValueError("지원하지 않는 저장 형식이다.")
    validate_tasks(payload["tasks"])
    return payload["tasks"]


def search_tasks(tasks, keyword):
    validate_tasks(tasks)
    if not isinstance(keyword, str) or not keyword.strip():
        raise ValueError("검색어를 입력해야 한다.")
    key = keyword.strip().casefold()
    matches = [
        task for task in tasks
        if key in task["title"].casefold() or key in task["note"].casefold()
    ]
    return sorted(matches, key=lambda task: task["id"])


def reminders(tasks, today):
    validate_tasks(tasks)
    selected = []
    for task in tasks:
        if task["done"] or task["due"] is None:
            continue
        due = parse_due(task["due"])
        days = (due - today).days
        if days > 1:
            continue
        if days < 0:
            label = f"지연 {-days}일"
        elif days == 0:
            label = "오늘 마감"
        else:
            label = "내일 마감"
        selected.append((due, task["id"], task["title"], label))
    selected.sort(key=lambda item: (item[0], item[1]))
    return [f"{label} | {title}" for _, _, title, label in selected]


def check(condition, name, passed):
    if not condition:
        raise AssertionError(name)
    passed.append(name)


def expect_value_error(action, name, passed):
    try:
        action()
    except ValueError:
        passed.append(name)
    else:
        raise AssertionError(name)


def main():
    tasks = [
        {"id": 1, "title": "장보기", "note": "우유와 사과",
         "due": "2026-10-08", "done": False},
        {"id": 2, "title": "원고 검토", "note": "저장과 검색 설명",
         "due": "2026-10-07", "done": False},
        {"id": 3, "title": "산책", "note": "강변",
         "due": "2026-10-09", "done": False},
        {"id": 4, "title": "책 반납", "note": "반납 완료",
         "due": "2026-10-08", "done": True},
        {"id": 5, "title": "Python 복습", "note": "파일 읽기",
         "due": None, "done": False},
        {"id": 6, "title": "사진 정리", "note": "주말 작업",
         "due": "2026-10-10", "done": False},
    ]
    passed = []
    with TemporaryDirectory(prefix="vibe-tasks-") as folder:
        root = Path(folder)
        data_path = root / "tasks.json"
        check(load_tasks(data_path) == [], "없는 파일", passed)
        save_tasks(data_path, tasks)
        loaded = load_tasks(data_path)
        check(loaded == tasks, "저장 왕복", passed)
        check("장보기" in data_path.read_text(encoding="utf-8"),
              "한글 저장", passed)
        changed = [dict(task) for task in loaded]
        changed[0]["note"] = "우유와 사과와 빵"
        save_tasks(data_path, changed)
        check(load_tasks(data_path) == changed, "기존 파일 교체", passed)
        save_tasks(data_path, loaded)
        check([t["id"] for t in search_tasks(loaded, "장보기")] == [1],
              "제목 검색", passed)
        check([t["id"] for t in search_tasks(loaded, "  검색  ")] == [2],
              "메모와 공백 검색", passed)
        check([t["id"] for t in search_tasks(loaded, "PYTHON")] == [5],
              "대소문자 검색", passed)
        check([t["id"] for t in search_tasks(loaded, "반납")] == [4],
              "완료 항목 검색", passed)
        expect_value_error(lambda: search_tasks(loaded, "   "),
                           "빈 검색어 거부", passed)
        alerts = reminders(loaded, TODAY)
        expected = [
            "지연 1일 | 원고 검토",
            "오늘 마감 | 장보기",
            "내일 마감 | 산책",
        ]
        check(alerts == expected, "알림 경계와 제외 조건", passed)
        expect_value_error(lambda: parse_due("2026-02-30"),
                           "존재하지 않는 날짜 거부", passed)
        expect_value_error(lambda: parse_due("20261008"),
                           "날짜 표기 검사", passed)
        duplicate = loaded + [dict(loaded[0])]
        expect_value_error(lambda: save_tasks(data_path, duplicate),
                           "중복 번호 거부", passed)
        check(load_tasks(data_path) == loaded,
              "잘못된 저장 요청 뒤 원본 유지", passed)
        broken = root / "broken.json"
        broken.write_text("{", encoding="utf-8")
        expect_value_error(lambda: load_tasks(broken),
                           "손상된 파일 거부", passed)

        records = [
            ("요청", "저장·검색·마감 알림을 묶어 완성한다."),
            ("결정", "기준일은 2026-10-08, 알림은 내일까지다."),
            ("결정", "검색에는 완료 항목을 포함한다."),
            ("검증", f"자동 확인 {len(passed)}개 통과"),
            ("검토", "사람의 코드 diff 검토는 별도 확인이 필요하다."),
        ]
        record_path = root / "work-log.json"
        record_path.write_text(
            json.dumps(records, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
        saved_records = json.loads(record_path.read_text(encoding="utf-8"))

        print(f"기준일: {TODAY.isoformat()}")
        print(f"저장 확인: {len(loaded)}개")
        print("검색어: 검색")
        for task in search_tasks(loaded, "검색"):
            print(f'{task["id"]} | {task["title"]} | {task["note"]}')
        print("마감 알림:")
        for alert in alerts:
            print(alert)
        print(f"자동 확인: {len(passed)}개 통과")
        print(f"작업 기록: {len(saved_records)}개 저장·읽기 완료")
        for kind, message in saved_records:
            print(f"{kind}: {message}")


if __name__ == "__main__":
    main()

줄별 해설

맨 위의 네 가져오기 문장은 모두 표준 라이브러리다. json은 자료를 파일용 텍스트로 바꾸고, date는 날짜를 계산한다. Path는 파일 경로를 다루는 객체이며, TemporaryDirectory는 실행 동안 사용할 임시 폴더를 만든다. TODAY는 시연의 기준일이고 FIELDS는 항목에 필요한 필드 이름의 집합이다. 집합은 같은 값을 중복해서 담지 않는 자료 구조다.

parse_due는 None이면 마감일이 없다는 뜻으로 그대로 반환한다. 문자열이면 날짜로 해석하고, 다시 YYYY-MM-DD 문자열로 만들었을 때 원래 값과 같은지 확인한다. 날짜 해석 함수가 받아들이는 표기가 여러 가지여도 이 도구의 저장 표기는 하나로 제한한다. 존재하지 않는 날짜와 표기가 다른 날짜를 모두 거부하는 이유다.

validate_tasks는 목록 전체를 검사한다. 각 항목의 필드 집합이 FIELDS와 같은지 비교하므로 빠진 필드와 예상하지 않은 필드를 모두 찾는다. seen에는 이미 나온 번호를 넣어 중복을 확인한다. 번호와 완료 여부에는 type 비교를 사용한다. 번호 1과 참을 섞거나 완료 여부에 숫자 0을 넣는 자료를 받아들이지 않기 위해서다.

save_tasks는 검사를 먼저 실행한다. 검사가 끝나야 파일 쓰기를 시작하므로 중복 번호가 있는 요청이 기존 파일을 덮지 않는다. version은 저장 형식의 버전이다. 앞으로 구조가 달라질 때 어느 규칙으로 읽어야 하는지 구분할 수 있다. ensure_ascii=False는 한글을 파일에서 직접 읽을 수 있게 하고, UTF-8은 한글을 저장하고 읽는 문자 인코딩을 맞춘다.

실제 쓰기는 .pending 파일에 한 뒤 replace로 대상 파일을 교체한다. 작성 중인 내용을 곧바로 기존 파일에 쓰는 위험을 줄이는 방법이다. 두 파일은 같은 폴더에 있다. 이 예제는 한 프로세스만 실행하는 조건이며, 여러 실행이 동시에 저장하거나 전원이 끊겼을 때의 복구까지 보장하는 구조는 아니다. 그 요구가 생기면 별도 명세와 확인이 필요하다.

load_tasks에서 없는 파일만 빈 목록으로 바뀐다. 잘못된 JSON 문법은 ValueError로 바꿔 알려 주고, 그 뒤에 바깥 구조와 버전을 검사한다. 권한 문제 같은 다른 파일 오류는 숨기지 않고 전달한다. 마지막으로 내부 목록까지 검사해야 읽기가 끝난다. “파일을 열었다”와 “사용 가능한 자료를 읽었다”는 서로 다른 확인이다.

search_tasks의 casefold는 대소문자 차이를 줄이는 문자열 변환이다. 제목과 메모를 각각 검사하므로 두 필드의 경계를 가로지르는 문자열이 우연히 일치하지 않는다. sorted의 key에는 정렬 기준을 돌려주는 짧은 함수인 lambda를 썼다. 번호가 작은 항목부터 반환하므로 저장 목록의 순서에 의존하지 않는다.

reminders는 완료 항목과 마감일 없는 항목을 먼저 건너뛴다. 날짜 차이가 1보다 큰 경우도 제외한다. 남은 항목에 지연·오늘·내일 문구를 붙이고 마감일과 번호 순서로 정렬한다. 기준일을 인자로 받으므로 실제 시계를 읽는 부분이 기능 안에 섞이지 않는다. 같은 자료와 기준일이면 같은 알림이 나온다.

check는 조건이 거짓일 때 AssertionError를 발생시키고, 참일 때만 이름을 passed에 넣는다. expect_value_error는 요청한 오류가 실제로 발생했을 때만 이름을 넣는다. 다른 종류의 예외까지 성공으로 처리하지 않는다. 마지막 검사 수는 미리 적어 둔 성공 문구가 아니라 실행 중 쌓인 목록의 길이다.

main의 changed는 각 사전을 새로 복사한 목록이다. 복사본의 메모만 바꿔 저장 교체를 확인하고 원래 내용을 다시 저장한다. 마지막 부분에서는 작업 기록을 JSON 파일에 쓰고 다시 읽어 출력한다. 기록 파일도 임시 폴더 안에 있으므로 실행 뒤 사라진다. 오래 남길 기록은 이번 출력과 실제 검토 내용을 별도 보관해야 한다.

실행 결과

터미널에서 다음 명령을 실행한다. 첫 줄은 Python 문법을 검사해 바이트코드 파일을 만드는 명령이며 정상일 때 별도 출력이 없다. 둘째 줄이 시연과 자동 확인을 실행한다. 문법 검사는 기능이 맞다는 증거를 대신하지 않으므로 두 단계를 모두 확인한다.

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

첫 명령이 성공하고 둘째 명령의 모든 확인 조건이 통과하면 다음 출력이 나온다. 임시 폴더 이름은 매번 달라도 출력하지 않으므로 결과는 같다. 아래 결과와 자신의 실행 결과를 비교한 뒤에 작업 기록의 검증 항목을 확정한다.

기준일: 2026-10-08
저장 확인: 6개
검색어: 검색
2 | 원고 검토 | 저장과 검색 설명
마감 알림:
지연 1일 | 원고 검토
오늘 마감 | 장보기
내일 마감 | 산책
자동 확인: 15개 통과
작업 기록: 5개 저장·읽기 완료
요청: 저장·검색·마감 알림을 묶어 완성한다.
결정: 기준일은 2026-10-08, 알림은 내일까지다.
결정: 검색에는 완료 항목을 포함한다.
검증: 자동 확인 15개 통과
검토: 사람의 코드 diff 검토는 별도 확인이 필요하다.

15개가 통과해도 모든 가능한 입력을 확인했다는 뜻은 아니다. 이 숫자는 코드에 담은 확인 조건의 수다. 특히 사람의 diff 검토가 끝났다는 뜻으로 바꾸면 안 된다. 실제 작업에서는 추가한 import, 파일 경로, 오류 처리, 날짜 경계 조건을 변경 전 코드와 비교하고, 검토한 사실과 남은 질문을 따로 적는다.

실무에서 자주 틀리는 것

읽기 실패를 모두 빈 목록으로 바꾸기

다음 코드는 오류를 숨기는 잘못된 처리의 실행 예시다. 손상된 파일을 새 파일과 같은 상태로 취급하면 이후 저장에서 원래 자료를 덮을 수 있다.

import json

text = "{"
try:
    tasks = json.loads(text)
except Exception:
    tasks = []
print(tasks)

고친 예시는 손상된 자료라는 사실을 유지한다. 완성 코드에서는 없는 파일만 따로 처리한다. 오류 종류를 좁혀 잡아야 사용자에게 알려야 할 문제를 성공처럼 바꾸지 않는다.

import json

text = "{"
try:
    tasks = json.loads(text)
except json.JSONDecodeError:
    print("손상된 자료이므로 저장 작업을 중단한다.")
else:
    print(tasks)

검색과 알림에 같은 완료 필터 쓰기

완료된 항목을 검색에서 제외하면 과거 메모를 찾을 수 없다. 다음 예시는 “반납”을 찾으면서도 결과를 빈 목록으로 만든다.

tasks = [{"title": "책 반납", "done": True}]
found = [
    task["title"] for task in tasks
    if not task["done"] and "반납" in task["title"]
]
print(found)

검색에서는 완료 여부 조건을 뺀다. 완료 여부에 따른 제외는 알림 함수에 남긴다. 같은 자료를 쓰는 기능이라도 목적이 다르면 조건도 달라진다.

tasks = [{"title": "책 반납", "done": True}]
found = [
    task["title"] for task in tasks
    if "반납" in task["title"]
]
print(found)

내일 마감의 경계를 빠뜨리기

날짜 차이가 1 미만이라는 조건은 내일을 포함하지 않는다. 요구를 코드로 옮길 때 “내일까지”라는 말을 비교 기호와 연결해 읽어야 한다.

from datetime import date

today = date(2026, 10, 8)
due = date(2026, 10, 9)
days = (due - today).days
print(days < 1)

내일까지 포함하려면 1 이하를 쓴다. 이 조건만으로 알림 전체가 완성되는 것은 아니며 완료 여부와 마감일 유무도 별도로 확인해야 한다.

from datetime import date

today = date(2026, 10, 8)
due = date(2026, 10, 9)
days = (due - today).days
print(days <= 1)

실행하지 않은 검증을 성공으로 기록하기

성공 문구를 고정하면 확인 조건이 실행됐는지 알 수 없다. 다음 예시는 검사를 하지 않았는데도 통과를 출력한다.

print("저장 왕복 확인 통과")

고친 예시는 실제 저장과 읽기를 비교한다. 자동 확인이 성공했다는 기록과 사람이 코드를 읽었다는 기록도 분리한다. 자신이 하지 않은 확인을 완료로 표시하지 않는 것이 작업 기록의 기본이다.

import json
from pathlib import Path
from tempfile import TemporaryDirectory

expected = [{"id": 1, "title": "장보기"}]
with TemporaryDirectory() as folder:
    path = Path(folder) / "tasks.json"
    path.write_text(json.dumps(expected), encoding="utf-8")
    actual = json.loads(path.read_text(encoding="utf-8"))
    if actual != expected:
        raise AssertionError("저장 왕복 실패")
    print("저장 왕복 확인 통과")

한눈에 보기

완성 도구에서 앞 장의 방법이 쓰인 자리
방법이번 작업의 적용남길 증거
위임 판단동작의 경계는 사람이 정하고 구현을 맡긴다.확정한 명세
계획과 작은 단계입력 검사, 저장, 검색, 알림 순서로 확인한다.단계별 확인 결과
테스트왕복 저장과 검색 범위와 날짜 경계를 비교한다.실제 실행 출력
디버깅입력·기대·실제를 적고 같은 조건으로 재확인한다.실패와 수정 기록
지침과 맥락반복 규칙과 현재 결정을 추려 전달한다.짧은 지침과 현재 명세
보안자료를 검사하고 임시 폴더에서만 시연한다.경로와 입력 처리 검토
diff 검토변경 범위와 파일 처리 코드를 사람이 읽는다.검토한 사실과 남은 질문

완성의 기준은 기능 이름이 모두 등장하는 데 있지 않다. 정한 동작을 구현하고, 확인 가능한 조건을 실행하고, 아직 확인하지 않은 부분까지 드러내야 한다. 이 도구는 그 과정을 작은 규모로 보여 준다. 다른 기능을 더할 때도 같은 기록 구조를 이어 쓸 수 있다.

연습 문제

  1. 검색 결과에 완료 여부를 표시하려고 한다. 검색 대상을 바꾸지 않는다는 조건을 포함해 AI에게 보낼 요청과 확인 조건 두 가지를 작성하라.
  2. 알림 범위를 내일까지에서 이틀 뒤까지로 넓히려 한다. 완성 코드에서 바꿀 비교 조건과 추가할 문구를 적고, 기존 자료에서 예상되는 알림 순서를 작성하라.
  3. 저장 파일의 version이 2인 경우를 시험하려 한다. 파일을 만드는 코드와 ValueError를 기대하는 코드를 main의 자동 확인 부분에 추가하라. 확인 조건 수가 어떻게 바뀌는지도 적어라.
  4. 작업 기록을 보관하고 다음 세션에 넘기려 한다. 임시 시연과 실제 보관을 구분한 요청문을 작성하고, 다음 세션에 꼭 전달할 정보 세 가지를 골라라.

정답과 해설

검색 출력만 바꾸는 요청

요청 예시: 검색 출력에 완료 또는 미완료 문구를 추가하라. 검색 대상은 계속 제목과 메모이며, 완료 항목도 검색 결과에 포함한다. 정렬 기준과 저장 형식은 바꾸지 않는다. 출력 변경과 관련된 코드만 수정하라.

첫 확인 조건은 “반납” 검색에서 번호 4가 계속 나오고 완료로 표시되는 것이다. 둘째는 “검색” 검색에서 번호 2가 나오고 미완료로 표시되는 것이다. 기존 검사도 다시 실행해 검색 대상이 달라지지 않았는지 확인한다. 반환 자료를 바꿀 필요 없이 출력 줄에서 완료 여부를 읽으면 된다.

이틀 뒤까지 포함하는 알림

제외 조건을 days가 2보다 큰 경우로 바꾸고, days가 1인 경우와 2인 경우를 구분한다. reminders 안의 날짜 분기 부분은 다음처럼 바꿀 수 있다. 아래 코드는 분기 자체를 독립적으로 실행해 확인하는 예시다.

for days in [-1, 0, 1, 2, 3]:
    if days > 2:
        continue
    if days < 0:
        label = f"지연 {-days}일"
    elif days == 0:
        label = "오늘 마감"
    elif days == 1:
        label = "내일 마감"
    else:
        label = "이틀 뒤 마감"
    print(label)

기존 자료의 예상 순서는 원고 검토, 장보기, 산책, 사진 정리다. 문구는 각각 지연 1일, 오늘 마감, 내일 마감, 이틀 뒤 마감이다. expected 목록과 작업 기록의 알림 범위도 함께 고친다. 구현만 바꾸고 명세와 기록을 그대로 두면 다음 작업에서 기준이 갈린다.

지원하지 않는 버전 확인

다음 줄을 main의 임시 폴더 안에서 records를 만들기 전에 넣는다. root, passed, load_tasks, expect_value_error를 이미 정의한 완성 코드의 해당 위치에서 실행하는 추가 코드다.

unsupported = root / "unsupported.json"
unsupported.write_text(
    json.dumps({"version": 2, "tasks": []}),
    encoding="utf-8",
)
expect_value_error(
    lambda: load_tasks(unsupported),
    "지원하지 않는 버전 거부",
    passed,
)

확인 조건은 16개가 된다. 기록의 검증 문구는 목록 길이로 만들기 때문에 실제 통과 수를 따라간다. 예상 출력의 자동 확인 줄과 검증 줄도 16개로 바뀐다. 빈 목록이 들어 있더라도 지원하지 않는 버전을 거부하는지 확인하는 것이 이 시험의 목적이다.

시연과 영구 보관을 구분한 요청

요청 예시: 현재 임시 폴더 시연은 유지한다. 별도 작업으로 내가 지정한 보관 폴더에 작업 기록을 남기는 실행 방식을 설계하라. 기존 파일의 덮어쓰기 기준과 저장 실패 시 동작을 먼저 제시하라. 기록에 실제 실행 결과와 사람의 검토 상태를 구분해 담아라.

다음 세션에는 현재 명세, 반복 규칙을 담은 지침, 마지막 검증 결과와 남은 검토를 전달한다. 긴 대화 전체보다 현재 코드와 이 자료를 함께 주는 편이 판단 기준을 찾기 쉽다. 기록의 영구 보관은 경로와 덮어쓰기라는 새 결정을 포함하므로 임시 폴더를 고정 경로로 바꾸는 것만으로 끝내지 않는다.

마지막 작업에서도 AI의 답은 확인할 대상이다. 요청을 좁히고, 결정을 적고, 작은 변경을 읽고, 같은 조건으로 실행한다. 그 결과를 작업 기록에 남기면 다음 기능을 추가하는 자신도 지금의 판단을 다시 이해할 수 있다.

댓글 0

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

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