Devin.KR

AI 개발 · 심화

AI 에이전트 활용과 하네스 설계

검증 루프 자동화 - 완료 기준을 코드로

완료 기준을 실행 가능한 검사로 바꾸기, 실패하면 되돌리고 다시 시도, 같은 실패 반복 시 멈추고 사람에게 넘기기, 실행 성공과 요구 충족을 따로 확인, 검사를 고쳐서 통과시키는 일 막기, 완성 코드는 가짜 모델이 낸 수정안을 테스트로 검사해 채택·되돌림 기록을 출력한다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 명세를 계획과 작업으로 이어 붙였다. 이제 작업을 끝내는 조건을 하네스가 직접 확인하게 만든다. 모델이 “수정했다”라고 답하는 것과 요구를 충족하는 것은 별개의 일이다. 프로그램이 오류 없이 실행됐다는 사실도 요구 충족을 보장하지 않는다. 하네스는 수정안을 실행하고, 정해 둔 검사와 비교하고, 결과에 따라 채택하거나 되돌려야 한다.

이 장에서는 주문 금액을 계산하는 작은 프로그램을 고친다. 규칙 기반 가짜 모델이 수정 방향을 제안하면 하네스가 후보 코드를 만들고 검사한다. 첫 작업은 재시도 끝에 채택하고, 다음 작업은 같은 실패가 반복되어 사람에게 넘긴다. 성공 경로와 중단 경로를 한 번의 실행에서 함께 살펴본다.

  • 문장으로 적힌 완료 기준을 입력과 기대값이 있는 검사로 바꾼다.
  • 실행 성공과 요구 충족을 서로 다른 결과로 기록한다.
  • 실패한 수정안을 되돌리고 마지막 채택본에서 다시 시작한다.
  • 같은 실패의 반복을 판별하여 사람에게 넘길 시점을 정한다.
  • 수정 대상과 검사 기준을 분리하여 검사 자체를 바꾸는 우회를 막는다.

문제 상황

문서 정리 작업의 비용을 계산하는 기능을 만든다고 가정한다. 각 항목에는 단가와 수량이 있다. 단가가 10이고 수량이 3인 항목과 단가가 5이고 수량이 2인 항목의 합계는 40이다. 그런데 모델이 만든 첫 수정안은 단가만 더해서 15를 반환한다. 문법 오류는 없고 함수도 실행된다. 실행 결과만 확인하면 이 수정안을 채택할 수 있다.

담당자는 이어서 “음수 수량은 잘못된 입력이므로 거부한다”라는 요구를 추가한다. 모델은 이미 만든 합계 식을 다시 제안하지만 음수 검사를 넣지 않는다. 다시 요청해도 같은 제안을 돌려준다. 하네스가 재시도 횟수만 늘리면 같은 검사 실패와 파일 변경이 반복된다. 여기에는 더 좋은 요청문뿐 아니라 멈추는 규칙이 필요하다.

또 다른 문제는 검사까지 수정 대상으로 열어 두는 것이다. 기대값 40을 15로 바꾸면 잘못된 구현도 통과한다. 검사를 없애거나 실패를 성공으로 기록하는 방식도 있다. 따라서 작업을 수행하는 쪽은 구현 후보만 제안하고, 완료 기준은 하네스가 소유해야 한다. 기준이 잘못되었다고 판단한 경우에도 현재 수정안을 통과시키기 위해 기준을 즉석에서 바꾸지는 않는다.

작업 요청: 단가와 수량을 곱한 금액을 모두 더하라. 빈 목록의 합계는 0이다. 다음 작업에서는 음수 수량을 ValueError로 거부하라. 기존에 통과한 합계 검사도 계속 유지하라.

이 요청을 검사로 옮기는 과정에서 “거부한다”의 의미를 구체화했다. 예제에서는 특정 예외 종류를 발생시키는 것을 거부로 정의한다. 오류 메시지의 문구까지 요구하지는 않는다. 요구에 없는 문자열을 검사하면 구현 선택을 불필요하게 제한하고, 정작 중요한 동작을 놓칠 수 있다.

완료 기준을 관찰 가능한 결과로 바꾸기

완료 기준은 입력, 관찰 대상, 기대 결과로 나누면 코드로 옮기기 쉽다. “합계가 맞는다”보다 “이 두 항목을 넣으면 40을 반환한다”가 검사하기 쉽다. 정상 입력뿐 아니라 경계 입력과 잘못된 입력도 구분한다. 다만 사례 몇 개가 통과했다고 모든 입력에서 옳다는 뜻은 아니다. 검사 범위는 요구의 중요한 차이를 드러낼 만큼 선택해야 한다.

완료 기준을 입력과 기대 결과로 구체화한다
기준입력기대 결과검사 이름
빈 목록의 합계항목 없음0 반환empty
수량을 반영한 합계단가 10·수량 3, 단가 5·수량 240 반환quantity
음수 수량 거부단가 10·수량 -1ValueError 발생negative

검사 이름은 실패를 식별하는 안정된 표식이다. 예외 메시지나 임시 폴더 경로를 그대로 실패 식별에 사용하면 같은 원인도 서로 다른 실패로 보일 수 있다. 이 예제에서는 실행 단계와 실패한 검사 이름을 묶어 실패 지문(failure fingerprint)을 만든다. 같은 작업에서 같은 지문이 연속으로 나타나면 반복 실패로 센다.

검사 결과는 두 층으로 나눈다. 실행 층은 후보가 컴파일되는지, 별도 프로세스가 제한 시간 안에 종료되는지, 결과를 읽을 수 있는지 확인한다. 요구 층은 반환값과 예외 종류가 기준에 맞는지 확인한다. 검사 프로그램이 정상 종료하면서 “quantity 실패”를 보고할 수 있다. 이때 실행은 성공했지만 요구는 충족하지 못했다.

실행 성공과 요구 충족을 모두 확인한 후보만 채택한다

여기서는 각 사례에서 발생한 예외도 검사 프로그램이 관찰하는 결과로 취급한다. 기대한 ValueError는 통과가 되고, 정상 반환을 기대한 사례에서 발생한 예외는 요구 검사 실패가 된다. 반면 후보를 불러오는 과정의 오류나 검사 프로세스의 비정상 종료는 실행 실패다. 어느 층에서 문제가 생겼는지 구분하면 다음 수정 요청에 보낼 정보도 명확해진다.

채택본을 기준으로 재시도하고 멈추기

되돌림(rollback)의 기준은 작업 시작 시점의 파일이 아니라 마지막으로 채택한 내용이다. 첫 작업에서 합계 계산을 고친 뒤 두 번째 작업이 실패했다면, 합계 계산까지 되돌리면 안 된다. 하네스는 채택본을 별도 변수에 보관하고, 매 시도마다 그것을 출발점으로 삼는다. 후보가 모든 검사를 통과한 순간에만 채택본을 갱신한다.

실패한 파일을 그대로 두고 다시 수정하면 검증되지 않은 변경이 쌓일 수 있다. 어느 변경이 어떤 실패를 만들었는지 추적하기도 어려워진다. 이 예제는 시도마다 후보 하나를 적용하고 검사한 뒤, 실패하면 즉시 채택본을 다시 쓴다. 따라서 반복 실패로 중단해도 작업 파일은 마지막 채택 상태다.

실패한 후보는 채택본으로 되돌리고 같은 실패가 두 번 이어지면 사람에게 넘긴다

중단 규칙은 두 가지다. 같은 실패가 두 번 연속 나타나면 사람에게 넘기고, 실패 내용이 계속 달라져도 한 작업에서 세 번까지만 시도한다. 첫 규칙은 진전 없는 반복을 끊고, 두 번째 규칙은 서로 다른 실패를 오가며 작업이 끝나지 않는 상황을 막는다. 이 숫자는 예제의 동작을 읽기 쉽게 정한 정책값이다. 실제 작업에서는 검사 비용과 실패의 성격에 맞게 조정한다.

사람에게 넘기는 것은 작업 완료가 아니다. 넘기는 기록에는 작업 이름, 마지막 실패 검사, 시도한 수정 방향, 유지된 채택본이 있어야 한다. 이 프로그램에서는 실행 기록이 그 역할을 한다. 다음 작업으로 넘어가는 대신 전체 작업 목록을 멈추므로, 음수 수량 거부가 해결된 것처럼 표시되지 않는다.

검사 기준을 수정 경로에서 분리하기

가짜 모델은 파일 경로나 검사 내용을 받지 않는다. 작업 이름, 시도 번호, 이전 실패 이름을 받아 제한된 수정 방향만 반환한다. 하네스는 그 방향을 미리 정한 코드로 바꾼다. 첫 작업의 첫 시도에는 단가만 더하고, quantity 실패를 관찰한 다음에는 수량을 곱한다. 두 번째 작업에서는 음수 검사 없는 식을 계속 제안한다.

가짜 모델의 답과 하네스의 판단을 분리한다
상황가짜 모델의 답실제 검사판단
합계 작업 첫 시도단가만 더하는 수정 방향quantity 실패되돌림
합계 작업 재시도수량을 곱하는 수정 방향두 검사 통과채택
음수 작업 두 시도수량을 곱하는 기존 수정 방향negative 연속 실패되돌린 뒤 사람에게 넘김

검사 사례는 JSON으로 저장하고 검사 실행기는 별도 파일로 저장한다. 두 파일의 해시(hash)를 후보 적용 전후에 확인한다. 하네스의 수정 함수는 구현 파일 하나에만 내용을 쓴다. 검사 파일이 바뀌었다면 검사 결과가 좋아도 채택하지 않고 실행을 멈춘다. 해시는 변경을 탐지하는 수단이며, 검사 내용의 타당성을 증명하는 수단은 아니다.

이 구조는 모델의 수정 권한을 좁히는 연습이다. 별도 프로세스와 임시 폴더만으로 임의의 Python 코드를 안전하게 격리할 수 있는 것은 아니다. 예제는 하네스가 정한 두 가지 코드 조각만 실행하므로 그 범위 안에서 동작한다. 자유로운 코드를 실행하는 환경의 접근 제한은 별도로 마련해야 한다. 여기서는 검사 기준의 소유자와 수정 대상을 분리하는 원리에 집중한다.

차이 비교(diff)도 채택 과정에 포함한다. 프로그램은 삭제된 줄과 추가된 줄을 출력하고, 허용한 수정 방향인지 검사한다. 출력 자체가 사람의 검토를 대신하지는 않는다. 실행 후 독자는 식이 어떻게 바뀌었는지와 검사 결과를 함께 확인할 수 있다. 파일이 바뀌지 않은 제안도 기록하기 때문에 같은 답이 반복되는 상황이 드러난다.

완성 코드

다음 전체 코드를 main.py로 저장한다. 표준 라이브러리만 사용하며, 실행 중 만드는 파일은 모두 임시 폴더 안에 둔다. 실제 모델 호출 대신 fake_model 함수가 수정 방향을 반환한다. 프로그램 내부의 후보 코드와 검사 실행기 문자열은 임시 파일에 기록되어 실제로 실행되는 Python 코드다.

import difflib
import hashlib
import json
from pathlib import Path
import subprocess
import sys
import tempfile


SOURCES = {
    "initial": (
        "def total(items):\n"
        "    return 0\n"
    ),
    "unit_only": (
        "def total(items):\n"
        "    return sum(item['price'] for item in items)\n"
    ),
    "with_quantity": (
        "def total(items):\n"
        "    return sum(item['price'] * item['qty'] for item in items)\n"
    ),
}

CASES = [
    {"name": "empty", "items": [], "expected": 0},
    {
        "name": "quantity",
        "items": [
            {"price": 10, "qty": 3},
            {"price": 5, "qty": 2},
        ],
        "expected": 40,
    },
    {
        "name": "negative",
        "items": [{"price": 10, "qty": -1}],
        "raises": "ValueError",
    },
]

TASKS = [
    {"name": "합계 계산", "checks": ["empty", "quantity"]},
    {
        "name": "음수 수량 거부",
        "checks": ["empty", "quantity", "negative"],
    },
]

RUNNER = """import json
import runpy
import sys

function = runpy.run_path(sys.argv[1])["total"]
with open(sys.argv[2], encoding="utf-8") as stream:
    cases = json.load(stream)
selected = json.loads(sys.argv[3])
failed = []

for case in cases:
    if case["name"] not in selected:
        continue
    try:
        actual = function(case["items"])
    except Exception as error:
        ok = (
            "raises" in case
            and type(error).__name__ == case["raises"]
        )
    else:
        ok = (
            "raises" not in case
            and actual == case["expected"]
        )
    if not ok:
        failed.append(case["name"])

print(json.dumps({"failed": failed}, ensure_ascii=False))
"""


def fake_model(task_name, attempt, observation):
    if task_name == "합계 계산":
        if attempt == 1:
            return {"edit": "unit_only"}
        if "quantity" in observation:
            return {"edit": "with_quantity"}
    return {"edit": "with_quantity"}


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def check_integrity(protected):
    if any(digest(path) != expected
           for path, expected in protected.items()):
        raise RuntimeError("검사 기준 변경 감지")


def show_diff(before, after):
    changes = []
    for line in difflib.unified_diff(
        before.splitlines(), after.splitlines(), lineterm=""
    ):
        if line.startswith(("---", "+++")):
            continue
        if line.startswith(("-", "+")):
            changes.append(line)
    if changes:
        print("  변경 검토:")
        for line in changes:
            print("   ", line)
    else:
        print("  변경 검토: 변경 없음")


def verify(candidate, runner, cases, names):
    try:
        compile(
            candidate.read_text(encoding="utf-8"),
            "<candidate>",
            "exec",
        )
    except SyntaxError:
        return "compile", ()

    try:
        result = subprocess.run(
            [
                sys.executable,
                "-I",
                "-B",
                str(runner),
                str(candidate),
                str(cases),
                json.dumps(names),
            ],
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=5,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return "timeout", ()
    except OSError:
        return "launch", ()

    if result.returncode != 0:
        return "process", ()

    try:
        report = json.loads(result.stdout)
        failed = report["failed"]
        if not isinstance(failed, list):
            raise ValueError("잘못된 검사 결과")
        if any(not isinstance(name, str) or name not in names
               for name in failed):
            raise ValueError("알 수 없는 검사 이름")
    except (ValueError, KeyError, TypeError):
        return "report", ()

    return "ok", tuple(failed)


def main():
    accepted = SOURCES["initial"]
    completed = 0

    with tempfile.TemporaryDirectory() as directory:
        root = Path(directory)
        candidate = root / "working.py"
        runner = root / "checks.py"
        cases = root / "criteria.json"

        candidate.write_text(accepted, encoding="utf-8")
        runner.write_text(RUNNER, encoding="utf-8")
        cases.write_text(
            json.dumps(CASES, ensure_ascii=False),
            encoding="utf-8",
        )
        protected = {
            runner: digest(runner),
            cases: digest(cases),
        }

        for task in TASKS:
            print(f"작업: {task['name']}")
            observation = ()
            previous_failure = None
            repeated = 0
            adopted = False

            for attempt in range(1, 4):
                check_integrity(protected)
                proposal = fake_model(
                    task["name"], attempt, observation
                )
                edit = proposal.get("edit")
                if edit not in ("unit_only", "with_quantity"):
                    raise ValueError("허용하지 않은 수정 방향")

                source = SOURCES[edit]
                print(f"  시도 {attempt}: 제안={edit}")
                show_diff(accepted, source)
                candidate.write_text(source, encoding="utf-8")

                try:
                    status, failed = verify(
                        candidate, runner, cases, task["checks"]
                    )
                    check_integrity(protected)
                finally:
                    candidate.write_text(accepted, encoding="utf-8")

                if status == "ok":
                    print("  실행: 성공")
                    if failed:
                        print("  요구: 실패 (" + ", ".join(failed) + ")")
                    else:
                        print("  요구: 충족")
                else:
                    print(f"  실행: 실패 ({status})")
                    print("  요구: 확인하지 못함")

                if status == "ok" and not failed:
                    candidate.write_text(source, encoding="utf-8")
                    accepted = source
                    completed += 1
                    adopted = True
                    print("  채택")
                    break

                print("  되돌림: 마지막 채택본 복원")
                fingerprint = (status, tuple(sorted(failed)))
                if fingerprint == previous_failure:
                    repeated += 1
                else:
                    repeated = 1
                previous_failure = fingerprint
                observation = failed if status == "ok" else (status,)

                if repeated >= 2:
                    print("  사람에게 넘김: 같은 실패 2회 연속")
                    break
            else:
                print("  사람에게 넘김: 시도 한도 3회")

            if not adopted:
                break

        check_integrity(protected)
        assert candidate.read_text(encoding="utf-8") == accepted
        print(f"완료 작업: {completed}/{len(TASKS)}")
        print("최종 파일: 마지막 채택본 유지")
        print("검사 기준: 변경 없음")


if __name__ == "__main__":
    main()

줄별 해설

상단의 import 문은 차이 비교, 해시 계산, JSON 처리, 경로 처리, 별도 프로세스 실행, 임시 폴더 생성을 준비한다. sys.executable은 현재 프로그램을 실행한 Python을 검사에도 사용하게 한다. 이렇게 하면 셸에서 다른 Python 이름을 찾는 과정에 의존하지 않는다.

SOURCES는 초기 구현과 허용된 후보 구현을 담는다. 초기 구현은 어떤 입력에도 0을 반환한다. unit_only는 단가만 더하고, with_quantity는 단가에 수량을 곱한다. 가짜 모델이 문자열 전체를 자유롭게 작성하는 대신 이 중 수정 방향 하나를 고르게 하여 실행할 코드의 범위를 작게 유지한다.

CASES의 각 딕셔너리는 하나의 완료 기준이다. expected가 있는 사례는 반환값을 비교하고, raises가 있는 사례는 예외 종류를 비교한다. TASKS의 첫 작업은 두 사례를 선택하고 두 번째 작업은 세 사례를 선택한다. 두 번째 작업에 기존 사례를 포함한 것이 회귀 검사(regression test)다. 새 요구를 고치면서 이전 동작을 망가뜨리는지 함께 확인한다.

RUNNER는 임시 폴더에 저장될 검사 실행기다. runpy.run_path로 후보에서 total 함수를 가져오고, 선택된 사례마다 호출한다. 예외가 나면 사례가 기대한 예외인지 확인한다. 정상 반환이면 expected와 비교한다. 실패한 이름만 JSON으로 출력하므로 임시 경로나 예외의 긴 설명이 실패 지문에 섞이지 않는다.

fake_model은 작업 이름과 관찰 내용에 반응한다. 합계 작업의 첫 시도는 잘못된 방향을 내고, quantity 실패가 들어오면 수량을 반영하는 방향을 낸다. 음수 작업에서는 기존 방향만 반복한다. 이것은 관찰에 따라 답이 바뀌는 경우와 같은 답이 반복되는 경우를 결정적으로 재현하기 위한 규칙이다.

digest와 check_integrity는 보호 파일의 변경 여부를 확인한다. 처음 기록한 해시와 현재 해시를 비교하며, 달라지면 예외를 발생시킨다. 보호 대상은 검사 실행기와 사례 파일이다. 비교할 기대 해시는 메모리에 보관하므로 후보 수정 함수가 파일 내용을 바꿔도 기준값을 함께 갱신하는 경로가 없다.

show_diff는 두 구현을 줄 단위로 비교한다. 파일명 머리말과 위치 표시는 생략하고 실제 삭제·추가 줄만 보여 준다. 함수 정의가 같으면 반환식만 출력된다. 두 번째 작업의 후보는 채택본과 같으므로 “변경 없음”이 나온다. 수정했다고 주장하는 답도 실제 파일 차이를 확인해야 하는 이유다.

verify의 compile 호출은 실행 전에 문법을 검사한다. 이어서 별도 Python 프로세스에서 검사 실행기를 호출한다. -I는 격리 모드로 Python 실행 환경의 일부 영향을 줄이고, -B는 바이트코드 캐시 생성을 막는다. timeout은 검사 프로세스가 끝나지 않는 경우를 제한한다. 이 옵션들은 임의 코드의 파일 접근 권한을 제한하는 기능은 아니다.

verify는 실행 상태와 실패 이름을 함께 반환한다. compile, timeout, launch, process, report는 실행 단계의 실패다. ok와 비어 있지 않은 실패 목록은 실행 성공과 요구 실패의 조합이다. 결과 JSON의 형태도 검사하므로 잘못된 보고를 정상 통과로 받아들이지 않는다. 예제의 실행기는 신뢰하는 하네스 코드이며, 자유로운 후보의 출력 조작까지 막는 일반적인 통신 구조를 구현한 것은 아니다.

main의 accepted는 마지막 채택본이다. 임시 폴더를 만든 뒤 구현 파일, 검사 실행기, 사례 파일을 그 안에 기록한다. 보호 파일의 해시를 저장하고 작업 목록을 순서대로 처리한다. 임시 폴더 이름은 출력하지 않으므로 실행마다 이름이 달라져도 예상 출력은 바뀌지 않는다.

내부 반복문의 range(1, 4)는 최대 세 번의 시도를 뜻한다. 매번 수정 방향을 확인하고 차이를 출력한 뒤 후보를 쓴다. verify와 보호 파일 확인을 감싼 finally는 후보를 채택본으로 되돌린다. 검사가 정상적으로 끝난 경우뿐 아니라 검사 기준 변경이 발견된 경우에도 구현 파일 복원을 시도한다.

실행과 요구가 모두 통과하면 후보를 다시 쓰고 accepted를 갱신한다. 그 전에는 채택본이 바뀌지 않는다. 실패하면 상태와 정렬된 실패 이름으로 지문을 만들고 연속 횟수를 계산한다. 지문이 달라지면 횟수는 1로 돌아간다. 따라서 서로 다른 실패 두 개를 같은 실패의 반복으로 오해하지 않는다.

반복문 뒤의 else는 세 번을 모두 소진하고도 break하지 않았을 때 실행된다. 작업이 채택되지 않았다면 바깥 작업 반복도 중단한다. 마지막 assert는 파일과 채택본이 같은지 개발 중 확인하는 장치다. 검사 기준 보호와 채택 판단을 이 assert에 맡기지는 않는다. 최종 출력의 완료 작업 수가 미완료 상태를 분명하게 드러낸다.

실행 결과

터미널에서 다음 명령을 실행한다. main.py는 독자가 저장한 프로그램 파일이며, 프로그램이 실행 중 읽고 쓰는 작업 파일은 임시 폴더 안에 생성된다.

python3 main.py

예상 출력은 다음과 같다. 첫 실패는 실행 오류가 아니라 계산 요구의 실패다. 음수 작업에서는 파일 변경 없이 같은 실패가 두 번 나타나므로 사람에게 넘긴다.

작업: 합계 계산
  시도 1: 제안=unit_only
  변경 검토:
    -    return 0
    +    return sum(item['price'] for item in items)
  실행: 성공
  요구: 실패 (quantity)
  되돌림: 마지막 채택본 복원
  시도 2: 제안=with_quantity
  변경 검토:
    -    return 0
    +    return sum(item['price'] * item['qty'] for item in items)
  실행: 성공
  요구: 충족
  채택
작업: 음수 수량 거부
  시도 1: 제안=with_quantity
  변경 검토: 변경 없음
  실행: 성공
  요구: 실패 (negative)
  되돌림: 마지막 채택본 복원
  시도 2: 제안=with_quantity
  변경 검토: 변경 없음
  실행: 성공
  요구: 실패 (negative)
  되돌림: 마지막 채택본 복원
  사람에게 넘김: 같은 실패 2회 연속
완료 작업: 1/2
최종 파일: 마지막 채택본 유지
검사 기준: 변경 없음

마지막 채택본은 정상 합계 계산을 수행하지만 음수 수량 거부 요구는 충족하지 못한다. 따라서 완료 작업은 1/2다. 이 실행 결과를 전체 성공으로 요약하면 안 된다. 사람은 negative 검사와 반복된 수정 방향을 보고 입력 검사를 추가할지, 요구를 다시 확인할지 결정한다. 임시 폴더는 종료 시 제거되므로 이 예제의 기록은 터미널 출력에 남는다.

실무에서 자주 틀리는 것

프로세스 종료 코드만 보고 채택한다

다음 함수는 검사 실행기가 실패 사례를 보고해도 정상 종료했다면 True를 반환한다. 검사 실행 성공을 요구 충족으로 오해한 것이다.

def should_adopt_wrong(returncode, failed):
    return returncode == 0

검사 실행 상태와 실패 목록을 함께 확인해야 한다. 이 함수에 값을 넘기기 전에 결과 보고의 형식도 검증해야 한다.

def should_adopt(status, failed):
    return status == "ok" and not failed

실패한 후보를 다음 시도의 출발점으로 삼는다

다음 함수는 후보를 쓰고 실패를 반환하지만 파일을 복원하지 않는다. 호출자가 다음 시도에서 이 파일을 읽으면 실패한 변경을 이어받는다.

def apply_wrong(path, source, run_checks):
    path.write_text(source, encoding="utf-8")
    return run_checks(path)

채택본을 명시적으로 받아 실패와 예외 때 복원한다. 아래 함수는 성공한 후보만 파일에 남긴다. 검사 함수의 반환값은 실행과 요구를 함께 판단한 불리언이라고 가정한다.

def apply_candidate(path, source, accepted, run_checks):
    passed = False
    try:
        path.write_text(source, encoding="utf-8")
        passed = run_checks(path)
        return passed
    finally:
        if not passed:
            path.write_text(accepted, encoding="utf-8")

기대값을 실제 결과에 맞춰 바꾼다

다음 코드는 검사를 수행하는 대신 기준을 결과에 맞춘다. 이후의 일치 비교는 계산이 맞다는 증거가 되지 않는다.

def check_wrong(case, actual):
    case["expected"] = actual
    return actual == case["expected"]

검사 함수는 기대값을 읽기만 한다. 기대값을 바꿀 필요가 생겼다면 요구 검토와 기준 변경을 별도 작업으로 기록한다.

def check_value(case, actual):
    return actual == case["expected"]

총 실패 횟수를 같은 실패 횟수로 취급한다

실패가 두 번 있었다는 이유만으로 반복 실패라고 판단하면, 문법 오류를 고친 뒤 계산 오류로 이동한 상황도 진전 없는 반복으로 분류한다.

def count_wrong(total_failures):
    return total_failures + 1

연속 반복을 세려면 이전 지문과 현재 지문을 비교한다. 전체 시도 한도는 이 횟수와 별도로 유지한다.

def count_repeated(previous, current, repeated):
    if previous == current:
        return repeated + 1
    return 1

한눈에 보기

검증 루프에서 각 판단이 맡는 역할을 정리한다
판단확인 대상통과 조건실패 시 행동
변경 검토수정 방향과 파일 차이허용된 구현 수정적용 전 중단
실행 확인컴파일·종료·결과 형식상태가 ok복원 후 재시도 판단
요구 확인반환값·예외 종류실패 목록이 비어 있음복원 후 관찰 전달
기준 보호검사 실행기·사례 파일저장한 해시와 일치복원 후 예외로 중단
반복 제한실패 지문·시도 횟수계속할 여유가 있음사람에게 넘김

하네스가 소유하는 완료 기준은 모델의 설명보다 먼저 판단 근거가 된다. 모델의 제안은 후보이고, 채택본은 실행과 요구 검사를 모두 통과한 결과다. 다음 장에서 도구 연결 구조를 바꾸더라도 이 구분은 유지된다. 도구가 결과를 반환했다는 사실과 작업이 완료됐다는 사실은 같은 의미가 아니다.

연습 문제

  1. 단가가 7이고 수량이 0인 항목의 합계가 0인지 검사하는 zero_quantity 사례를 추가하라. 두 작업에서 모두 실행되도록 설정하고, 잘못된 첫 후보에서 어떤 검사 이름이 실패하는지 설명하라.
  2. 음수 수량 거부를 구현하는 reject_negative 수정 방향을 추가하라. 가짜 모델이 음수 작업의 첫 시도에서 기존 방향을 내고, negative 실패를 관찰한 다음 새 방향을 내도록 바꾸라.
  3. 실패 지문이 quantity, negative, negative 순서로 나타났다고 가정하라. 각 시도의 연속 반복 횟수와 사람에게 넘기는 시점을 설명하라.
  4. 후보의 결과가 모두 맞더라도 criteria.json이 변경되었다면 왜 채택하지 않아야 하는지 설명하라. 검사 기준을 실제로 수정해야 할 때 어떤 기록을 남길지도 제안하라.

정답과 해설

  1. CASES에 name이 zero_quantity, items가 단가 7·수량 0인 목록, expected가 0인 사례를 추가한다. TASKS의 두 checks 목록에도 이름을 넣는다. unit_only는 7을 반환하므로 quantity와 zero_quantity가 함께 실패한다. with_quantity는 두 사례 모두 통과한다. 수량을 반영한다는 같은 요구를 서로 다른 입력으로 확인하는 검사다.

  2. SOURCES에 다음 실행 가능한 소스를 값으로 추가한다. 문자열로 넣을 때에는 기존 SOURCES와 같은 방식으로 줄바꿈을 보존한다.

    def total(items):
        for item in items:
            if item["qty"] < 0:
                raise ValueError("수량은 음수일 수 없다")
        return sum(item["price"] * item["qty"] for item in items)
    

    허용 수정 방향 목록에도 reject_negative를 추가한다. fake_model의 음수 작업 분기에서 observation에 negative가 있으면 새 방향을 반환하게 한다. 첫 시도는 되돌리고 두 번째 시도는 세 검사를 모두 통과하여 채택한다. 완료 작업은 2/2가 된다. 예외 메시지는 검사 대상이 아니며 예외 종류와 기존 합계 동작을 확인한다.

  3. 연속 반복 횟수는 1, 1, 2다. 두 번째 실패는 이전 지문과 다르므로 1로 다시 시작한다. 세 번째 실패에서 같은 지문이 두 번 이어져 사람에게 넘긴다. 이 예제에서는 세 번째가 시도 한도와도 겹치지만 반복 실패 분기의 break가 먼저 실행되므로 같은 실패 두 번이라는 이유가 출력된다.

  4. 기준이 바뀌었다면 통과 결과가 원래 요구를 확인한 것인지 알 수 없기 때문이다. 예를 들어 기대값을 후보의 잘못된 결과로 바꾸었을 수도 있다. 기준 변경은 기존 요구, 변경 이유, 새 입력과 기대 결과, 영향을 받는 작업을 따로 기록하고 검토한다. 변경된 기준을 승인한 뒤 새 검증 실행을 시작하며, 이전 실패를 새 기준 아래의 성공으로 소급해서 바꾸지 않는다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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