Devin.KR

평가와 관측 - 기록·추적·회귀 확인

개발자KR 조회 0

이 장에서 배우는 것

에이전트가 한 번 일을 끝냈다는 사실과, 같은 종류의 일을 계속 맡길 수 있다는 판단은 다르다. 작업 목록이 모두 완료로 표시되어도 결과에 중복이 남아 있을 수 있다. 지침을 고쳐 중복을 없앴더니 이번에는 항목의 순서가 바뀔 수 있다. 실행이 끝났다는 표시만으로는 이런 변화를 알아보기 어렵다.

이 장에서는 작은 하네스에 평가와 관측을 붙인다. 평가(evaluation)는 정해 둔 사례와 기준으로 결과를 판단하는 작업이다. 관측 가능성(observability)은 실행 중 남긴 기록을 통해 결과가 나온 이유를 설명할 수 있는 성질이다. 둘을 함께 갖추면 “점수가 올랐다”와 “어디에서 무엇이 달라졌다”를 구분해서 말할 수 있다.

  • 입력, 기대 결과, 채점 기준을 분리한 평가 세트를 만든다.
  • 자동으로 확인할 조건과 사람이 판단할 조건을 나누고, 미검토 상태를 표시한다.
  • 단계별 추적 기록으로 결과가 달라진 지점을 찾는다.
  • 두 하네스를 같은 사례로 실행하여 개선과 회귀를 함께 확인한다.
  • 일정과 이벤트로 실행할 때 비용 한도와 실패 알림을 적용한다.

문제 상황

작업 요청을 쉼표로 구분해 받는 작은 하네스를 운영한다고 가정한다. 사용자가 “초안 작성, 검토 요청”을 입력하면 하네스는 작업 목록을 만들고 검증한다. 실제 업무 도구에 작업을 등록하기 전, 목록을 정리하는 부분만 떼어 평가하는 상황이다. 앞 장에서 다룬 권한과 입력 경계는 유지하고, 이번에는 정리 결과가 요구에 맞는지 확인하는 데 집중한다.

첫 번째 버전은 항목 양쪽의 공백을 제거한다. 평범한 입력에서는 잘 동작하지만 “검토, 검토, 발송”을 받으면 같은 작업을 두 번 남긴다. 빈 문자열을 받으면 작업이 없는 목록 대신 빈 항목 하나를 만든다. 개발자는 이 문제를 고치려고 두 번째 버전에서 빈 항목을 제거하고 집합을 사용해 중복을 없앤다.

수정 후 중복과 빈 입력은 처리된다. 그런데 집합에서 나온 항목을 정렬하는 바람에 요청 순서가 바뀐다. “초안 작성, 검토 요청”처럼 입력 순서와 정렬 순서가 다른 입력에서는 결과가 달라진다. 전체 성공 사례는 늘었지만, 이전에 통과하던 사례 하나가 실패한다. 새 버전의 평균 점수만 보면 이 변화를 놓치기 쉽다.

작업 목록을 정리한다. 항목 양쪽의 공백을 제거하고, 빈 항목을 버린다. 같은 항목이 반복되면 처음 나온 항목만 남긴다. 남은 항목의 입력 순서를 유지한다.

이 문장은 하네스가 따라야 할 지침이다. 하지만 지침을 써 두는 것만으로 동작이 보장되지는 않는다. 지침을 확인 가능한 사례로 바꾸고, 실행 결과를 비교해야 한다. 규칙 기반 가짜 모델을 쓰는 이유도 여기에 있다. 이 예제에서는 모델 응답의 흔들림을 없애고 하네스의 처리 차이에 집중한다.

평가 세트와 채점 기준을 분리한다

평가 세트(evaluation set)의 각 사례는 최소한 식별자, 입력, 기대 결과, 채점 기준을 가져야 한다. 식별자는 수정 전후에 같은 사례를 연결하는 이름이다. 입력은 하네스에 전달할 자료이고, 기대 결과는 요구를 만족하는 답이다. 채점 기준은 두 결과를 어떤 규칙으로 비교할지 정한다. 코드에서는 모든 사례에 순서가 있는 목록의 정확한 일치 기준을 적용한다.

각 평가 사례가 확인하는 요구
식별자입력기대 결과확인할 조건
spaces초안 작성 , 검토 요청초안 작성 → 검토 요청양쪽 공백 제거
duplicate검토, 검토, 발송검토 → 발송첫 항목을 남기는 중복 제거
order자료 확인, 초안 작성자료 확인 → 초안 작성입력 순서 유지
empty빈 문자열빈 목록빈 항목 제거

기대 결과는 구현이 반환한 값을 그대로 복사해서 만들지 않는다. 그렇게 하면 구현의 실수가 정답에도 들어간다. 먼저 요구에서 기대 결과를 적고, 다른 사람이 입력과 기대 결과의 관계를 읽어 볼 수 있게 한다. 특히 순서가 요구에 포함되어 있다면 목록을 집합으로 바꾸어 비교해서는 안 된다. 비교 과정에서 순서를 없애면 하네스가 순서를 잘못 바꿔도 통과한다.

이 장의 자동 채점은 사례당 통과 또는 실패로 계산한다. 네 사례 중 세 사례가 통과하면 75.0점이다. 이 점수는 예제 안에서 정한 계산값이며 다른 하네스의 능력을 나타내는 벤치마크 수치가 아니다. 모든 사례에 같은 비중을 주므로 실제 업무의 중요도까지 반영하지도 않는다. 순서 오류가 업무상 더 중요하다면 별도 필수 통과 조건을 둘 수 있다.

작은 평가 세트에는 정상 입력만 넣기 쉽다. 그러나 문제를 드러내는 것은 빈 입력, 반복 입력, 순서가 의미 있는 입력처럼 경계에 가까운 사례다. 처음에는 적은 수로 시작하되, 실패가 발견되면 원래 입력에서 민감한 내용을 제거한 재현 사례를 추가한다. 같은 문제가 다시 나타나는지 확인할 수 있어야 한다.

평가 사례를 고치는 일도 변경으로 취급한다. 새 요구 때문에 기대 결과가 달라졌다면 그 이유를 기록한다. 새 구현을 통과시키기 위해 기대 결과를 조용히 바꾸면 비교의 의미가 사라진다. 하네스의 변경과 평가 기준의 변경을 함께 검토해야 할 때도 두 변경의 목적은 따로 설명한다.

같은 입력과 기대 결과로 두 버전을 채점해야 개선과 회귀를 비교할 수 있다

자동 채점은 명확한 조건을 빠르게 반복하는 데 적합하다. 목록의 일치, 필수 항목의 존재, 금지 항목의 부재는 코드로 확인하기 쉽다. 반면 문서가 읽기 편한지, 표현이 업무 맥락에 맞는지, 빠뜨린 의미가 있는지는 사람 평가가 필요할 수 있다. 두 평가를 섞는다는 말은 사람의 판단을 자동 점수 속에 숨긴다는 뜻이 아니다.

예제에서는 자동 실패 사례와 순서 사례를 사람 검토 대상으로 표시한다. 사람이 아직 검토하지 않았으므로 결과는 모두 “대기”로 남긴다. 자동 통과를 사람 승인으로 바꾸어 기록하지 않는다. 실제 검토에서는 기준 충족 여부, 판단 이유, 검토자를 구분해서 남길 수 있다. 검토자 간 판단이 다르면 기준의 문장을 더 구체적으로 고칠 자료가 된다.

사람 검토 기준: 입력의 각 작업이 결과에 대응하는지 확인한다. 순서를 바꿔도 되는 업무인지 판단하고 근거를 적는다. 자동 채점과 판단이 다르면 기대 결과가 잘못되었는지, 하네스가 잘못되었는지 구분한다. 검토하지 않은 사례에는 승인 표시를 하지 않는다.

추적 기록으로 실패 지점을 찾는다

추적(trace)은 한 실행에서 거친 단계와 그 단계의 결과를 연결한 기록이다. 최종 목록만 남기면 잘못된 항목이 모델 응답에서 생겼는지, 도구 처리에서 생겼는지 알기 어렵다. 예제는 입력 수신, 가짜 모델의 계획, 도구 출력, 검증 결과를 차례로 남긴다. 각 기록에는 실행 식별자와 단계 번호가 붙는다.

두 번째 버전의 spaces 사례를 보면 가짜 모델은 “초안 작성, 검토 요청”의 순서를 유지한다. 도구 출력에서 “검토 요청, 초안 작성”으로 바뀐다. 따라서 이 실행에서는 도구의 정렬 처리가 실패를 설명하는 단서다. 기록이 있다고 해서 모든 원인이 자동으로 밝혀지는 것은 아니지만, 적어도 살펴볼 범위를 줄인다.

단계별 기록에서 확인하는 질문
단계남기는 자료확인할 질문
input사례 식별자어떤 평가 입력을 실행했는가
model선택한 도구와 항목계획 단계에서 항목이 바뀌었는가
tool도구가 반환한 목록실제 처리에서 순서나 내용이 바뀌었는가
verify통과 또는 실패어떤 기준으로 완료를 판단했는가

실행 식별자는 시간만으로 만들지 않는다. 같은 순간에 여러 실행이 시작될 수 있기 때문이다. 이 예제는 실행 용도, 버전, 사례 이름을 조합한다. 비교 실행과 자동 실행도 서로 다른 접두어를 사용한다. 단계 번호는 한 실행 안의 순서를 표현하고, 고정 시각은 예제 출력의 재현성을 위한 값이다. 실제 운영에서는 실제 시작·종료 시각과 소요 시간을 별도로 기록한다.

기록 내용은 많을수록 좋은 것이 아니다. 앞 장에서 다룬 민감한 입력은 추적 파일에도 남을 수 있다. 이 예제에는 저자가 만든 짧은 작업 이름만 들어 있다. 실제 업무에서는 비밀 값과 개인정보를 제거하고, 결과를 설명하는 데 필요한 자료만 남겨야 한다. 식별자만으로 원문을 찾아갈 수 있는 환경이라면 입력 전체를 여러 기록에 반복 저장할 필요도 없다.

기록을 생성하는 것과 기록을 활용하는 것도 다르다. 실패 사례에서 어느 단계를 먼저 볼지 정해야 한다. 입력이 올바른 사례인지 확인하고, 모델 계획과 도구 출력을 비교한 뒤, 채점 기준이 요구를 제대로 표현했는지 살펴본다. 추적은 구현을 의심하는 자료이면서 평가 자체를 검토하는 자료이기도 하다.

회귀 비교와 자동 실행의 경계를 정한다

회귀(regression)는 변경 뒤에 이전에 만족하던 요구를 더 이상 만족하지 못하는 현상이다. 두 버전을 비교할 때는 전체 점수뿐 아니라 사례별 통과 상태를 연결해야 한다. 이전 실패가 새 버전에서 통과하면 개선이고, 이전 통과가 새 버전에서 실패하면 회귀다. 계속 실패하는 사례는 여전히 해결되지 않은 문제다.

지침만 바꿔도 비교가 필요하다. 같은 도구를 사용하더라도 지침이 달라지면 모델이 선택하는 행동이나 입력이 달라질 수 있다. 도구만 바꾸어도 비교가 필요하다. 예제에서는 모델 계획을 동일하게 두고 도구 동작을 바꾸므로 차이를 읽기 쉽다. 실제 변경에서는 지침, 도구, 채점 기준 중 무엇이 바뀌었는지 구분해서 남긴다.

정해진 일정이나 파일 변경 이벤트로 실행하면 사람이 실행 버튼을 누르지 않아도 작업이 시작된다. 이때 비용 한도는 실행 후 보고할 숫자에 그치지 않는다. 다음 작업을 시작하기 전에 남은 한도로 실행할 수 있는지 판단해야 한다. 예제는 모델 단계에 1단위, 도구 단계에 2단위를 부여한다. 이는 금액이나 제품 요금이 아니라 한도 제어를 설명하기 위한 내부 작업 단위다.

예제의 자동 실행 한도는 6단위다. 첫 번째 일정 실행과 두 번째 이벤트 실행은 각각 3단위를 사용한다. 세 번째 이벤트는 시작 전에 차단된다. 첫 번째 실행은 검증에 실패하지만, 이미 수행한 모델과 도구 작업의 비용은 사용량에 남는다. 실패 비용을 빼 버리면 반복 실패가 일어나도 한도가 소진되지 않는 계산 오류가 생긴다.

실패 알림은 원인과 실행을 연결할 수 있어야 한다. “작업 실패”만 보내면 어떤 자료를 확인해야 할지 모른다. 예제는 실패 유형과 실행 식별자를 알림 목록에 남긴다. 알림 전달은 외부 서비스 없이 화면 출력으로 대신한다. 검증 실패와 비용 부족은 대응이 다르므로 서로 다른 유형으로 기록한다.

자동 실행은 시작 전에 비용을 확인하고 실행 뒤에는 검증 실패를 알린다

완성 코드는 실제 예약 실행기나 이벤트 감시기를 만들지 않는다. 고정된 세 트리거를 순서대로 처리하여 한도와 알림의 동작을 재현한다. 실제 연결에서는 같은 이벤트의 중복 전달과 겹치는 실행도 고려해야 한다. 한도를 공유하는 실행이 여러 개라면 사용량 확인과 예약을 함께 처리해야 한다. 단일 실행 예제의 계산을 그대로 병렬 실행에 옮기면 여러 작업이 같은 잔여 한도를 보고 동시에 시작할 수 있다.

수정안을 적용하기 전에는 실행과 비교, 변경 내용 확인을 함께 한다. 점수 상승만으로 배포를 결정하지 않는다. 아래 프로그램의 두 번째 버전은 개선을 보여 주면서도 순서 회귀를 남긴다. 이 결과는 수정안을 더 검토해야 한다는 근거다. AI가 제안한 코드라면 집합과 정렬을 추가한 변경 부분을 직접 읽고, 요구에 맞는지 확인한다.

완성 코드

다음 프로그램을 main.py로 저장한다. Python 3.12 이상의 표준 라이브러리만 사용한다. 파일은 임시 폴더 안의 추적 파일 하나만 만들고, 읽어 확인한 뒤 폴더와 함께 정리한다. 난수, 실제 시각, 네트워크를 사용하지 않는다. 평가용 두 버전은 비교를 위해 알려진 결함을 포함한다.

import json
import tempfile
from pathlib import Path

FIXED_TIME = "2026-10-08T09:00:00+09:00"
MODEL_COST = 1
TOOL_COST = 2
RUN_COST = MODEL_COST + TOOL_COST

CASES = [
    {
        "id": "spaces",
        "input": " 초안 작성 , 검토 요청 ",
        "expected": ["초안 작성", "검토 요청"],
        "criterion": "ordered_exact",
        "human": False,
    },
    {
        "id": "duplicate",
        "input": "검토, 검토, 발송",
        "expected": ["검토", "발송"],
        "criterion": "ordered_exact",
        "human": False,
    },
    {
        "id": "order",
        "input": "자료 확인, 초안 작성",
        "expected": ["자료 확인", "초안 작성"],
        "criterion": "ordered_exact",
        "human": True,
    },
    {
        "id": "empty",
        "input": "",
        "expected": [],
        "criterion": "ordered_exact",
        "human": False,
    },
]


def as_json(value):
    return json.dumps(value, ensure_ascii=False, sort_keys=True)


def fake_model(text):
    return {
        "tool": "clean_items",
        "items": [part.strip() for part in text.split(",")],
    }


def clean_items(items, version):
    if version == "v1":
        return list(items)
    if version == "v2":
        return sorted({item for item in items if item})
    raise ValueError("알 수 없는 버전")


def grade(actual, case):
    if case["criterion"] != "ordered_exact":
        raise ValueError("알 수 없는 채점 기준")
    return actual == case["expected"]


def run_case(case, version, run_id):
    trace = []

    def record(stage, data):
        trace.append({
            "run_id": run_id,
            "step": len(trace) + 1,
            "time": FIXED_TIME,
            "stage": stage,
            "data": data,
        })

    record("input", {"case_id": case["id"]})
    plan = fake_model(case["input"])
    record("model", plan)
    if plan["tool"] != "clean_items":
        raise ValueError("허용되지 않은 도구")
    actual = clean_items(plan["items"], version)
    record("tool", {"items": actual})
    passed = grade(actual, case)
    record("verify", {"passed": passed})
    return {
        "case_id": case["id"],
        "actual": actual,
        "passed": passed,
        "cost": RUN_COST,
        "human_status": (
            "대기" if case["human"] or not passed else "대상 아님"
        ),
        "trace": trace,
    }


def evaluate(version):
    return [
        run_case(case, version, f"eval:{version}:{case['id']}")
        for case in CASES
    ]


def show_report(version, results):
    passed = sum(result["passed"] for result in results)
    score = passed / len(results) * 100
    pending = sum(
        result["human_status"] == "대기" for result in results
    )
    print(
        f"{version}: {passed}/{len(results)} 통과, "
        f"점수 {score:.1f}, 사람 검토 대기 {pending}건"
    )
    cases_by_id = {case["id"]: case for case in CASES}
    for result in results:
        if result["passed"]:
            continue
        expected = cases_by_id[result["case_id"]]["expected"]
        print(
            f"  실패 {result['case_id']}: "
            f"기대={as_json(expected)}, "
            f"실제={as_json(result['actual'])}"
        )
        stages = " → ".join(
            f"{event['step']}:{event['stage']}"
            for event in result["trace"]
        )
        print(f"    추적 {stages}")


def compare(before, after):
    before_by_id = {result["case_id"]: result for result in before}
    improved = []
    regressed = []
    for result in after:
        old_passed = before_by_id[result["case_id"]]["passed"]
        if not old_passed and result["passed"]:
            improved.append(result["case_id"])
        if old_passed and not result["passed"]:
            regressed.append(result["case_id"])
    print("개선: " + (", ".join(improved) or "없음"))
    print("회귀: " + (", ".join(regressed) or "없음"))


def run_triggers():
    limit = 6
    used = 0
    alerts = []
    traces = []
    cases_by_id = {case["id"]: case for case in CASES}
    triggers = [
        ("daily", "일정", "spaces"),
        ("changed", "이벤트", "order"),
        ("extra", "이벤트", "duplicate"),
    ]
    print("자동 실행: 한도 6단위")
    for trigger_id, source, case_id in triggers:
        run_id = f"auto:v2:{trigger_id}"
        if used + RUN_COST > limit:
            alerts.append(f"비용 부족 {run_id}")
            print(f"  {source} {trigger_id}: 차단, 사용 {used}/{limit}")
            continue
        used += RUN_COST
        result = run_case(cases_by_id[case_id], "v2", run_id)
        traces.extend(result["trace"])
        status = "통과" if result["passed"] else "실패"
        print(
            f"  {source} {trigger_id}: {status}, 사용 {used}/{limit}"
        )
        if not result["passed"]:
            alerts.append(f"검증 실패 {run_id}")
    for alert in alerts:
        print(f"  알림: {alert}")
    return traces


def main():
    before = evaluate("v1")
    after = evaluate("v2")
    show_report("v1", before)
    show_report("v2", after)
    compare(before, after)

    order_result = next(
        result for result in after if result["case_id"] == "spaces"
    )
    print("회귀 사례 상세: eval:v2:spaces")
    for event in order_result["trace"]:
        print(
            f"  {event['step']} {event['stage']} "
            f"{as_json(event['data'])}"
        )

    auto_traces = run_triggers()
    traces = [
        event
        for result in before + after
        for event in result["trace"]
    ]
    traces.extend(auto_traces)
    with tempfile.TemporaryDirectory() as temp_dir:
        trace_path = Path(temp_dir) / "trace.jsonl"
        lines = [as_json(event) for event in traces]
        trace_path.write_text(
            "\n".join(lines) + "\n", encoding="utf-8"
        )
        restored = [
            json.loads(line)
            for line in trace_path.read_text(
                encoding="utf-8"
            ).splitlines()
        ]
        if restored != traces:
            raise RuntimeError("추적 저장 검증 실패")
        print(f"추적 저장 확인: {len(restored)}개 기록")


if __name__ == "__main__":
    main()

줄별 해설

import 문과 상수 선언. json은 추적 자료를 문자열로 바꾸고 다시 읽는 데 사용한다. tempfile은 예제 파일의 수명을 실행 범위 안으로 제한한다. Path는 파일 경로를 다룬다. FIXED_TIME은 실제 현재 시각을 읽는 대신 일정한 값을 제공한다. MODEL_COST와 TOOL_COST를 더한 RUN_COST는 한 번의 실행에 필요한 비용이다.

CASES 선언. 각 딕셔너리는 평가 사례 하나다. expected는 빈 목록도 표현할 수 있다. criterion은 채점 규칙의 이름이며, human은 자동 통과 여부와 별개로 사람 검토가 필요한 사례를 표시한다. 순서 사례에는 True를 지정한다. 이 표시는 사람이 이미 평가했다는 뜻이 아니다.

as_json 함수. ensure_ascii=False는 작업 이름을 한글로 출력한다. sort_keys=True는 딕셔너리 키의 출력 순서를 일정하게 만든다. 목록 내부의 순서는 바꾸지 않으므로 작업 순서 검증에 영향을 주지 않는다. 문자열의 출력 형태와 작업 목록의 의미를 구분해야 한다.

fake_model 함수. 입력을 쉼표로 나누고 공백을 제거한 뒤 도구 이름과 항목을 반환한다. 같은 입력에는 같은 계획을 돌려준다. 빈 문자열도 split의 결과로 빈 문자열 하나를 포함한다. 이 값을 도구가 어떻게 처리하는지가 빈 입력 사례의 평가 대상이다.

clean_items 함수. v1은 항목을 그대로 복사한다. v2는 빈 항목을 제외하고 집합으로 중복을 제거한 뒤 정렬한다. 정렬 덕분에 출력은 결정적이지만 입력 순서는 보존되지 않는다. “항상 같은 결과”라는 성질과 “요구에 맞는 결과”라는 성질은 따로 확인해야 한다.

grade 함수. 알려진 채점 기준만 받아들인다. 목록끼리 비교하므로 항목의 값과 개수, 순서가 모두 같아야 통과한다. 지원하지 않는 기준을 조용히 기본 규칙으로 처리하지 않고 예외를 발생시킨다. 평가 설정 오류를 정상적인 점수로 숨기지 않기 위해서다.

run_case 함수. record라는 내부 함수가 실행 식별자, 단계 번호, 고정 시각과 자료를 붙인다. 단계 번호는 현재 기록 수에 1을 더해 정한다. input 기록에는 사례 이름만 저장하고 모델 계획은 model 단계에 남긴다. 도구 실행 전에는 선택된 이름이 허용된 이름인지 확인한다.

결과 딕셔너리. actual과 passed를 함께 반환하여 실패 결과를 다시 볼 수 있게 한다. human_status는 자동 실패 또는 지정된 검토 사례이면 “대기”가 된다. cost는 실행이 사용한 단위이고 trace는 단계별 기록이다. 자동 통과 사례가 사람 검토 대상이 아닐 때도 “승인”이라는 표현은 사용하지 않는다.

evaluate와 show_report 함수. 두 버전 모두 CASES 전체를 순서대로 실행한다. 통과 여부는 불리언이므로 sum으로 통과 수를 셀 수 있다. 보고서는 총점 다음에 실패 사례의 기대 결과와 실제 결과를 출력한다. 사람 검토 대기 건수는 별도 계산하여 자동 점수와 분리한다.

compare 함수. 목록 위치 대신 사례 식별자로 이전 결과를 찾는다. 평가 사례의 출력 순서를 바꾸더라도 같은 사례끼리 비교하려는 의도다. 이 예제는 같은 세트를 쓰지만, 세트가 달라질 수 있는 운영 코드라면 추가·삭제된 사례를 먼저 보고해야 한다. 없는 사례를 기존 실패처럼 취급해서는 안 된다.

run_triggers 함수. 세 트리거를 고정된 순서로 처리한다. 실행 비용을 더했을 때 한도를 넘는지 먼저 확인한다. 허용된 실행은 비용을 예약한 뒤 수행하고, 검증 실패에도 사용량을 되돌리지 않는다. 이 예제는 실행 중 예외 처리와 재시도를 생략한다. 운영에서는 그런 경로도 별도 실패 기록과 한도 정책을 가져야 한다.

main 함수. 보고서와 비교 결과를 출력한 뒤, 회귀 사례의 단계 자료를 자세히 보여 준다. 평가 여덟 실행과 자동 실행 두 건의 기록을 모아 총 40개를 저장한다. 파일을 다시 읽어 원래 기록과 비교하므로 저장 과정도 확인한다. 임시 폴더 경로는 매번 달라질 수 있어 출력하지 않는다.

실행 결과

구문 검사는 소스 문자열을 메모리에서 컴파일하는 방식으로 할 수 있다. 다음 명령은 바이트코드 파일을 만들지 않으며, 경고도 오류로 취급한다. 정상이라면 화면에 아무것도 출력하지 않는다. 이어서 main.py를 실행한다. 코드 변경 뒤에는 이 실행 결과를 예상 결과와 비교하고, 바뀐 코드 부분도 검토한다.

python3 -W error -c 'from pathlib import Path; compile(Path("main.py").read_text(encoding="utf-8"), "main.py", "exec")'
python3 main.py

프로그램의 예상 출력은 다음과 같다. 사람 검토 대기 수는 평가 대상 선정 결과이며, 사람 평가를 수행한 결과가 아니다.

v1: 2/4 통과, 점수 50.0, 사람 검토 대기 3건
  실패 duplicate: 기대=["검토", "발송"], 실제=["검토", "검토", "발송"]
    추적 1:input → 2:model → 3:tool → 4:verify
  실패 empty: 기대=[], 실제=[""]
    추적 1:input → 2:model → 3:tool → 4:verify
v2: 3/4 통과, 점수 75.0, 사람 검토 대기 2건
  실패 spaces: 기대=["초안 작성", "검토 요청"], 실제=["검토 요청", "초안 작성"]
    추적 1:input → 2:model → 3:tool → 4:verify
개선: duplicate, empty
회귀: spaces
회귀 사례 상세: eval:v2:spaces
  1 input {"case_id": "spaces"}
  2 model {"items": ["초안 작성", "검토 요청"], "tool": "clean_items"}
  3 tool {"items": ["검토 요청", "초안 작성"]}
  4 verify {"passed": false}
자동 실행: 한도 6단위
  일정 daily: 실패, 사용 3/6
  이벤트 changed: 통과, 사용 6/6
  이벤트 extra: 차단, 사용 6/6
  알림: 검증 실패 auto:v2:daily
  알림: 비용 부족 auto:v2:extra
추적 저장 확인: 40개 기록

v1의 사람 검토 대상은 중복 실패, 순서 사례, 빈 입력 실패의 세 건이다. v2에서는 spaces 실패와 사람 검토 대상으로 지정된 order 사례의 두 건이 남는다. 자동 점수와 검토 대기 수 모두 개선되어 보이지만, spaces 사례에서 입력 순서 유지 요구는 여전히 만족하지 않는다. 이 때문에 회귀 목록과 상세 추적을 함께 읽어야 한다.

코드를 받은 독자는 위 출력을 단순히 믿는 대신 실행으로 확인한다. 특히 정렬된 두 작업의 순서와 사용량 6/6을 살펴본다. 도구를 수정한 뒤에는 전체 평가 세트를 다시 실행한다. 일부 실패 사례만 실행하면 수정으로 다른 사례가 깨졌는지 확인할 수 없다.

실무에서 자주 틀리는 것

순서가 중요한 결과를 집합으로 채점한다

다음 코드는 실제 결과가 뒤집혀 있어도 통과시킨다. 중복 개수도 사라지므로 목록 요구를 제대로 채점하지 못한다. 집합 비교는 순서와 중복이 요구와 무관하다고 명시된 경우에만 사용한다.

expected = ["자료 확인", "초안 작성"]
actual = ["초안 작성", "자료 확인"]
passed = set(actual) == set(expected)
assert passed

이 장의 요구에서는 목록을 직접 비교한다. 아래 확인은 순서가 바뀐 결과를 실패로 판단한다.

expected = ["자료 확인", "초안 작성"]
actual = ["초안 작성", "자료 확인"]
passed = actual == expected
assert not passed

총점이 높아졌다는 이유로 회귀가 없다고 판단한다

다음 코드의 총 통과 수는 늘지만, a 사례는 통과에서 실패로 바뀐다. 총점 비교는 개선과 회귀가 동시에 발생하는 상황을 표현하지 못한다.

before = {"a": True, "b": False, "c": False}
after = {"a": False, "b": True, "c": True}
no_regression = sum(after.values()) >= sum(before.values())
assert no_regression

사례 식별자별로 이전 통과가 새 실패로 바뀌었는지 확인한다. 전체 점수는 이 확인과 함께 사용한다.

before = {"a": True, "b": False, "c": False}
after = {"a": False, "b": True, "c": True}
regressions = [
    case_id
    for case_id in before
    if before[case_id] and not after[case_id]
]
assert regressions == ["a"]

실행을 시작한 뒤 비용 한도를 확인한다

다음 코드는 이미 비용을 늘린 다음 한도 초과 여부를 계산한다. 실제 호출을 used 증가 앞에 붙이면 초과 작업까지 수행하게 된다. 실패한 실행 비용을 빼는 구현도 같은 한도를 여러 번 사용할 수 있게 만든다.

used = 6
limit = 6
run_cost = 3
used += run_cost
allowed = used <= limit
assert used == 9
assert not allowed

실행 전 허용 여부를 판단하고, 허용할 때만 비용을 예약한다. 이 코드는 한도를 모두 쓴 상태에서 사용량을 늘리지 않는다.

used = 6
limit = 6
run_cost = 3
allowed = used + run_cost <= limit
if allowed:
    used += run_cost
assert used == 6
assert not allowed

자동 통과를 사람 승인으로 기록한다

자동 검사가 확인하는 조건과 사람이 확인하는 조건은 다를 수 있다. 다음 코드는 사람이 읽지 않은 결과를 승인으로 표시한다. 이후 보고서를 읽는 사람은 검토가 끝났다고 오해할 수 있다.

automatic_passed = True
human_status = "승인" if automatic_passed else "대기"
assert human_status == "승인"

검토 대상 여부와 검토 결과를 따로 관리한다. 아래 코드는 자동 통과여도 검토 대상이면 대기로 남긴다. 사람이 판단한 뒤에만 그 결과와 근거를 추가한다.

automatic_passed = True
needs_human = True
human_status = (
    "대기" if needs_human or not automatic_passed else "대상 아님"
)
assert human_status == "대기"

한눈에 보기

평가와 운영 기록을 함께 읽는 방법
대상남길 내용확인할 판단
평가 사례입력, 기대 결과, 채점 기준요구가 확인 가능한 형태인가
자동 채점사례별 통과와 실제 결과어떤 조건을 만족했는가
사람 평가검토 상태, 판단, 근거자동 검사 밖의 조건을 확인했는가
단계 추적실행 식별자, 단계, 자료결과가 어느 단계에서 달라졌는가
버전 비교개선과 회귀 사례이전 통과를 잃지 않았는가
자동 실행비용 예약과 실패 유형시작 가능한가, 누구에게 알릴 것인가

평가 보고서는 수정 여부를 판단할 자료다. 기대 결과가 잘못되었을 수도 있고, 도구의 구현이 잘못되었을 수도 있다. 실패를 억지로 통과시키기 전에 요구, 실행 기록, 변경 내용을 연결해서 읽는다. 다음 장에서는 이 확인 방식을 문서 정리 작업의 승인 관문과 연결한다.

연습 문제

  1. v2의 clean_items 분기를 고쳐 빈 항목과 중복을 제거하면서 입력 순서를 유지하라. 같은 평가 세트를 실행하여 점수, 회귀 목록, 사람 검토 대기 수가 어떻게 달라지는지 설명하라.
  2. 입력이 “검토, , 검토, 발송”인 사례를 추가하라. 기대 결과와 채점 기준을 먼저 적고, 수정 전 v1과 v2의 결과를 설명하라. 기존 네 사례의 기대 결과는 바꾸지 않는다.
  3. 자동 실행의 한도를 9단위로 바꾸라. 수정 전 v2를 사용한다고 할 때 세 트리거의 상태, 최종 사용량, 알림 내용, 저장되는 추적 기록 수를 계산하라.
  4. 순서 회귀 사례의 모델 기록과 도구 기록을 근거로 검토 메모를 작성하라. 자동 채점 결과와 사람이 아직 판단하지 않은 사항을 구분하고, 다음 수정에서 확인할 조건을 적으라.

정답과 해설

1. 처음 본 항목인지 확인하면서 결과 목록에 추가한다. 집합은 포함 여부를 확인하는 용도로만 사용하고 출력은 목록에 쌓는다. 다음 함수는 독립적으로 실행할 수 있다. 완성 코드의 같은 이름 함수와 교체하면 된다.

def clean_items(items, version):
    if version == "v1":
        return list(items)
    if version == "v2":
        seen = set()
        result = []
        for item in items:
            if item and item not in seen:
                seen.add(item)
                result.append(item)
        return result
    raise ValueError("알 수 없는 버전")


assert clean_items(
    ["자료 확인", "", "자료 확인", "초안 작성"], "v2"
) == ["자료 확인", "초안 작성"]

수정한 v2는 네 사례 모두 통과하여 100.0점이 된다. v1과 비교한 개선은 duplicate와 empty이며 회귀는 없다. order는 자동 통과하더라도 human이 True이므로 사람 검토 대기 한 건이 남는다. 자동 실행의 changed도 통과하지만 extra는 비용 한도 때문에 여전히 차단된다. 국소적인 assert 확인 뒤에 전체 main.py를 다시 실행해야 다른 사례의 변화를 확인할 수 있다.

2. 새 사례의 기대 결과는 “검토, 발송” 순서의 목록이고 채점 기준은 ordered_exact다. 수정 전 v1은 “검토, 빈 문자열, 검토, 발송”을 반환하여 실패한다. 수정 전 v2는 빈 항목과 중복을 제거한 “검토, 발송”을 반환하여 이 사례에서는 통과한다. 사례 이름은 mixed처럼 기존 이름과 겹치지 않게 정한다. 전체 통과 수는 v1이 2/5, 수정 전 v2가 4/5가 된다. 기존 spaces 회귀는 그대로 남는다.

3. daily는 실패하여 사용량이 3/9가 된다. changed는 통과하여 6/9가 되고, extra는 통과하여 9/9가 된다. 알림은 “검증 실패 auto:v2:daily” 한 건이다. 비용 부족 알림은 사라진다. 평가 실행에서 32개, 자동 실행 세 건에서 12개의 단계 기록이 생겨 총 44개를 저장한다. 한도를 늘려도 순서 오류는 고쳐지지 않는다.

4. 검토 메모는 다음처럼 쓸 수 있다. 실패를 확인한 근거와 추가로 판단할 사항을 한 문장에 섞지 않는다.

eval:v2:spaces는 순서가 있는 목록의 정확한 일치 검사에서 실패했다. 모델 단계의 항목은 “초안 작성, 검토 요청”으로 입력 순서와 같지만 도구 단계에서 “검토 요청, 초안 작성”으로 바뀌었다. 현재 도구의 정렬 처리가 이 실행의 차이를 설명한다. 업무상 순서를 바꿔도 되는지는 사람 검토 대기 상태다. 현재 지침은 입력 순서 유지를 요구하므로, 수정안은 처음 등장한 순서대로 중복을 제거해야 한다. 수정 후에는 전체 평가 세트를 다시 실행하고 순서 사례의 모델·도구 기록과 변경 내용을 확인한다.

댓글 0

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

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