Devin.KR

AI 개발 · 심화

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

명세 주도 파이프라인 - 명세·계획·작업·구현을 잇기

명세를 기준 문서로 두고 계획·작업·구현이 거기서 나오게 하기, 모호한 곳을 먼저 질문하는 단계, 산출물끼리 일관성 검사(명세의 요구가 작업 목록에 모두 있나), 프로젝트 원칙 문서, 새 프로젝트와 기존 코드 모두에 적용, 완성 코드는 명세 텍스트에서 요구 목록을 뽑아 작업 목록과 대조해 빠진 요구를 보고한다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 에이전트가 사용할 맥락을 고르고 줄이는 방법을 다뤘다. 필요한 자료를 잘 골라도 자료끼리 서로 다른 약속을 담고 있으면 작업 방향이 흔들린다. 명세에는 빈 목록 처리가 있는데 작업 목록에는 없을 수 있고, 계획에는 입력 순서를 유지한다고 적었는데 구현은 제목을 정렬할 수 있다. 이번에는 정보를 얼마나 넣을지가 아니라, 어떤 문서를 기준으로 삼고 다른 산출물을 어떻게 연결할지를 다룬다.

명세 주도 파이프라인(spec-driven pipeline)은 명세를 기준 문서로 두고 계획, 작업 목록, 구현을 차례로 연결하는 절차다. 문서를 많이 만드는 것이 목적은 아니다. 요구가 어디에서 나왔고 어느 작업으로 이어지는지 확인할 수 있게 만드는 것이 목적이다. 예제에서는 작은 작업 목록 요약 기능을 만들면서 요구 하나가 작업 목록에서 빠지는 상황을 재현한다. 규칙 기반 가짜 모델이 계획과 작업을 제안하고, 하네스가 누락을 찾아 보고한다.

  • 명세, 계획, 작업 목록, 구현의 역할을 구분한다.
  • 모호한 요구를 먼저 질문하고 답을 명세에 반영한다.
  • 요구 식별자로 산출물을 연결하고 빠진 요구를 찾는다.
  • 프로젝트 원칙을 새 프로젝트와 기존 코드에 적용한다.
  • 제안된 변경을 실행 결과와 변경 비교로 확인한다.

문제 상황

문서 정리 하네스에 작업 목록을 보여 주는 기능을 추가한다고 하자. 사용자는 작업 제목을 순서대로 보여 주고, 마지막에 작업 수를 붙여 달라고 요청한다. 빈 목록에는 안내 문구가 필요하다고도 말한다. 개발자는 이를 읽고 계획을 작성한 뒤, 제목 출력과 개수 계산을 작업으로 나눈다. 두 작업을 구현하면 화면은 그럴듯해 보인다. 그러나 입력이 비어 있을 때는 아무것도 출력하지 않는다.

여기서 문제는 구현자가 빈 목록을 처리하는 문법을 몰랐다는 데 있지 않다. 명세에서 작업 목록으로 넘어가는 과정에서 요구가 사라졌다. 제목 출력과 개수 계산은 눈에 잘 띄지만, 별도의 조건으로 적힌 빈 목록 안내는 쉽게 빠진다. 같은 요청을 모델에 다시 보내면 표현이 달라져 요구가 다른 문장 속에 섞일 수도 있다. 따라서 문장을 다시 읽는 노력만으로 연결을 유지하기는 어렵다.

작업 수의 뜻도 정해야 한다. 완료된 작업까지 세는지, 아직 남은 작업만 세는지에 따라 같은 입력의 결과가 달라진다. 이 질문을 뒤로 미루면 구현자가 고른 해석이 사실상 명세가 된다. 나중에 사용자가 다른 뜻이었다고 설명하면 코드뿐 아니라 계획과 작업 목록도 고쳐야 한다. 구현 전에 답을 받아 기준 문서를 바꾸는 편이 변경 범위를 이해하기 쉽다.

이 장의 하네스는 일부러 빈 목록 안내 작업을 누락한 초안을 받는다. 먼저 누락을 보고하고, 그 요구를 포함하도록 작업 목록을 보완한다. 그 뒤 작업 목록 요약 함수를 실행해 입력 순서, 개수, 빈 목록 문구를 확인한다. 실제 모델의 판단 능력을 평가하는 실습은 아니다. 문서 사이의 연결을 코드로 검사하는 방법을 배우기 위한 결정적인 실습이다.

명세를 기준으로 산출물을 연결한다

명세는 사용자에게 제공할 동작을 설명한다. 계획은 그 동작을 어떤 순서와 접근으로 만들지 설명한다. 작업 목록은 실제로 수행할 수 있는 변경 단위로 나눈다. 구현은 그 변경을 실행 가능한 프로그램으로 표현한다. 이 네 가지를 같은 문장의 반복으로 만들면 문서가 늘어도 얻는 정보가 적다. 각 산출물이 다른 질문에 답하도록 구분해야 한다.

각 산출물이 답해야 할 질문
산출물주요 질문예제의 내용
명세어떤 동작이 필요한가빈 목록에는 작업 없음이라고 표시한다
계획어떤 접근으로 만들 것인가빈 입력을 일반 출력보다 먼저 분기한다
작업 목록어떤 변경을 수행할 것인가빈 목록 안내 구현
구현그 변경이 어떻게 실행되는가빈 입력에서 지정 문구를 반환한다

명세의 요구에는 식별자를 붙인다. 이 예제에서는 R1, R2, R3을 사용한다. 식별자는 중요도나 수행 순서를 뜻하지 않는다. 문장 표현이 조금 바뀌어도 같은 요구를 가리키는 주소다. 계획과 작업은 이 주소를 기록한다. 그러면 요구 문장 전체를 비교하지 않고도 어느 요구가 작업 목록에 연결되지 않았는지 찾을 수 있다.

식별자가 같다는 이유만으로 요구의 뜻까지 같다고 판단해서는 안 된다. 입력 순서 유지 요구를 제목 정렬 요구로 바꾸면 동작이 달라진다. 예제의 R2는 미정인 개수 범위를 확정하는 수정이지만, 운영 중인 프로젝트에서 이미 합의한 동작을 바꿀 때는 변경 이유도 남겨야 한다. 식별자는 추적을 돕고, 변경 비교는 내용의 변화를 드러낸다.

명세의 요구 식별자를 계획과 작업에 이어 붙여 구현의 근거를 추적한다

관계를 한 방향으로만 읽을 필요는 없다. 명세에서 출발하면 요구가 빠지지 않았는지 확인할 수 있다. 구현에서 거슬러 올라가면 이 코드가 왜 존재하는지 확인할 수 있다. 작업에 명세에 없는 요구 식별자가 적혀 있다면 오래된 문서가 섞였거나 식별자를 잘못 입력했을 가능성이 있다. 누락만 찾지 말고 이런 잘못된 연결도 함께 검사해야 한다.

요구 하나가 작업 하나와 대응해야 하는 것은 아니다. 파일 저장 요구 하나를 입력 확인, 저장 처리, 오류 안내 작업으로 나눌 수도 있다. 반대로 짧은 작업 하나가 여러 요구를 함께 다룰 수도 있다. 그래서 예제의 작업은 단일 요구 문자열 대신 요구 식별자 목록을 가진다. 검사에서는 모든 작업이 참조한 요구를 집합으로 모아 명세의 요구 집합과 대조한다.

모호한 요구는 질문으로 확정한다

질문 단계는 모델이 자유롭게 상상하는 시간이 아니다. 구현 결과를 갈라놓는 빈칸을 찾아 답을 받는 단계다. 이번에는 작업 수에 완료된 작업을 포함하는지가 빈칸이다. 색상이나 문구의 세부 표현처럼 현재 예제의 결과에 영향을 주지 않는 질문을 한꺼번에 늘리지 않는다. 먼저 판단이 필요한 지점을 좁히고, 답이 바꾸는 요구를 지정한다.

요청: R2의 작업 수에 완료된 작업도 포함하는지 확인한다. 답이 없으면 계획 생성으로 넘어가지 않는다.

질문: 완료된 작업을 포함한 전체 개수를 표시하는가, 미완료 작업만 세는가?

사용자 답: 완료된 작업을 포함한 전체 개수를 표시한다.

반영 결과: R2는 빈 목록이 아닐 때 완료 여부와 관계없이 전체 개수를 마지막 줄에 표시한다.

이 대화는 코드의 가짜 모델이 재현하는 고정된 상황이다. 실제 프로젝트에서는 답을 사용자가 제공해야 한다. 모델이 질문과 답을 모두 만들어 놓고 이를 사용자 합의로 취급하면 모호함을 해결한 것이 아니다. 예제의 고정 답은 실습을 매번 같은 결과로 실행하기 위한 입력이며, 실제 승인이나 합의를 대신하지 않는다.

답은 대화 기록에만 두지 않고 명세에 반영한다. 다음 작업자가 대화 전체를 읽지 않아도 R2의 현재 뜻을 알 수 있어야 한다. 계획은 수정된 명세에서 생성한다. 원래 명세로 계획을 만든 뒤 대화 답을 별도 메모로 붙이면 계획 작성에 어떤 해석이 쓰였는지 확인하기 어려워진다.

예제는 미정인 부분에 명시적인 표지를 사용한다. 자연어의 모든 모호함을 프로그램이 판별한다고 가정하지 않는다. 사람이 발견한 미정 항목을 표지로 남기고, 하네스가 그 표지가 남아 있는 동안 다음 단계로 진행하지 못하게 한다. 표지가 없더라도 의미가 불명확할 수 있으므로 명세 읽기와 질문은 계속 필요하다.

질문의 답에 따라 요구끼리 충돌할 수도 있다. 전체 개수를 항상 표시한다고 쓰면서 빈 목록에는 안내 문구만 표시한다고 쓰면 빈 입력의 결과가 두 가지로 읽힌다. 이 예제에서는 R2에 ‘빈 목록이 아닐 때’를 넣고, R3에는 안내 문구만 표시한다고 적어 경계를 정한다. 조건을 명세에 남겨야 구현의 분기에도 근거가 생긴다.

프로젝트 원칙과 일관성 검사의 범위를 정한다

프로젝트 원칙 문서는 개별 기능보다 오래 유지할 제약을 담는다. 예를 들어 표준 라이브러리만 사용한다는 제약은 제목 출력뿐 아니라 이후 파일 저장 기능에도 적용될 수 있다. 반면 빈 목록에 표시할 문구는 이번 기능의 요구다. 오래 유지할 제약과 기능별 동작을 구분하면 원칙 문서가 작은 변경마다 흔들리지 않는다.

프로젝트 원칙: Python 표준 라이브러리만 사용한다. 외부 네트워크를 호출하지 않는다. 예제의 파일 입출력은 임시 작업 폴더 안에서만 수행한다. 동일한 입력은 동일한 출력을 만든다. 제안된 변경은 실행 결과와 변경 비교로 확인한다.

완성 코드는 이 원칙 중 실행 환경과 관련된 항목을 딕셔너리로 표현해 임시 폴더의 principles.json에 저장한다. 사람이 읽는 원칙 문서와 프로그램이 사용하는 설정은 목적이 다르다. 설정 값이 존재한다고 해서 원칙 준수가 자동으로 증명되는 것은 아니다. 코드에 네트워크 호출이 없는지, 파일 경로가 임시 폴더에서 만들어지는지도 검토해야 한다.

새 프로젝트에서는 명세와 원칙을 먼저 정하고 작은 구현으로 연결을 시작할 수 있다. 기존 코드에서는 관찰된 동작과 앞으로 원하는 동작을 구분하는 일이 먼저다. 현재 코드가 빈 목록에서 빈 문자열을 반환한다면 그것이 유지해야 할 약속인지 수정해야 할 결함인지 확인한다. 코드를 읽어 만든 설명을 곧바로 새 명세로 확정하면 기존 동작을 무심코 승인할 수 있다.

기존 코드에 적용할 때는 요청된 변경 범위를 좁게 잡는다. 기존 함수 이름, 호출 지점, 반환 형식 등을 확인하고 변경할 작업에 해당 위치를 기록한다. 변경하지 않을 동작은 명세에 유지 조건으로 남길 수 있다. 작업 목록에 없는 구조 변경이 함께 들어왔는지는 변경 비교에서 확인한다. 명세를 도입한다는 이유로 프로젝트 전체를 다시 만들 필요는 없다.

일관성 검사는 세 층으로 생각할 수 있다. 먼저 명세에서 요구 식별자를 올바르게 읽었는지 확인한다. 다음으로 계획과 작업이 그 식별자를 참조하는지 확인한다. 마지막으로 실제 동작이 요구의 뜻과 맞는지 확인한다. 이번 하네스의 중심은 두 번째 층이다. 마지막 층은 짧은 실행 확인으로 살펴보고, 완료 기준을 일반화하는 검증 루프는 다음 장에서 다룬다.

명세의 요구 집합에서 작업이 참조한 요구 집합을 빼면 누락 요구가 드러난다

R3을 참조하는 작업을 추가하면 구조적인 누락은 사라진다. 그러나 그 작업이 실제로 빈 목록을 처리하는지는 별도로 확인해야 한다. 작업 제목을 ‘빈 목록 안내 구현’이라고 적고 아무 코드도 바꾸지 않아도 식별자 검사는 통과한다. 이 한계를 보고서에 남기면 검사를 통과했다는 사실을 기능 완료로 오해하는 일을 줄일 수 있다.

완성 코드

다음 프로그램을 main.py로 저장한다. 사용자에게 질문할 상황, 그에 대한 고정 답, 일부 요구가 빠진 작업 초안이 모두 포함되어 있다. 가짜 모델은 전달받은 요구 목록을 기준으로 계획을 만들되, 누락 보고를 관찰할 수 있도록 작업 초안에서는 마지막 요구를 제외한다. 외부 모델이나 네트워크를 호출하지 않는다.

명세는 줄 시작에 ‘- R숫자:’가 있는 형식으로 제한한다. 줄 시작이 그 형식처럼 보이지만 문법이 맞지 않으면 오류를 낸다. 자유로운 한국어에서 요구를 모두 이해하는 추출기가 아니라, 합의된 문서 형식을 읽는 추출기다. 저장한 명세와 작업 목록을 다시 읽어 실제 파일 내용으로 대조한다.

import difflib
import json
import re
from pathlib import Path
from tempfile import TemporaryDirectory


SPEC_DRAFT = """작업 목록 요약 기능
- R1: 작업 제목을 입력 순서대로 번호와 함께 표시한다.
- R2: 빈 목록이 아닐 때 [미정: 개수 범위]를 마지막 줄에 표시한다.
- R3: 빈 목록에는 작업 없음이라는 안내 문구만 표시한다.
"""

POLICY = {
    "standard_library_only": True,
    "network_allowed": False,
    "file_scope": "temporary_directory",
    "deterministic_output": True,
}

REQUIREMENT_LINE = re.compile(r"- (R[1-9][0-9]*): (.+)")


def extract_requirements(spec):
    requirements = {}
    for line in spec.splitlines():
        line = line.strip()
        if not line.startswith("- R"):
            continue
        match = REQUIREMENT_LINE.fullmatch(line)
        if match is None:
            raise ValueError(f"요구 형식 오류: {line}")
        requirement_id, description = match.groups()
        if requirement_id in requirements:
            raise ValueError(f"중복 요구: {requirement_id}")
        requirements[requirement_id] = description
    if not requirements:
        raise ValueError("요구가 없는 명세")
    return requirements


def fake_model(stage, data):
    if stage == "clarify":
        if "[미정: 개수 범위]" in data["spec"]:
            return {
                "question": "완료된 작업도 개수에 포함하는가?",
                "answer": "완료된 작업을 포함한 전체 개수",
            }
        return None

    requirements = data["requirements"]
    if stage == "plan":
        return [
            {"requirement": key, "approach": f"동작 구현: {value}"}
            for key, value in requirements.items()
        ]
    if stage == "tasks":
        return [
            {
                "id": f"T{index}",
                "requirements": [item["requirement"]],
                "title": "일반 목록 출력 구현",
            }
            for index, item in enumerate(data["plan"][:-1], start=1)
        ]
    if stage == "repair":
        return [
            {
                "id": f"T{data['start'] + index}",
                "requirements": [key],
                "title": "빈 목록 안내 구현",
            }
            for index, key in enumerate(data["missing"])
        ]
    raise ValueError(f"지원하지 않는 단계: {stage}")


def compare_requirements(requirements, tasks):
    known = set(requirements)
    referenced = {
        key
        for task in tasks
        for key in task["requirements"]
    }
    missing = sorted(known - referenced)
    unknown = sorted(referenced - known)
    return missing, unknown


def task_lines(tasks):
    return [
        f"{task['id']} | {','.join(task['requirements'])} | {task['title']}\n"
        for task in tasks
    ]


def render_tasks(items):
    if not items:
        return "작업 없음"
    lines = [
        f"{index}. {item['title']}"
        for index, item in enumerate(items, start=1)
    ]
    lines.append(f"총 {len(items)}개")
    return "\n".join(lines)


def write_json(path, value):
    path.write_text(
        json.dumps(value, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def main():
    with TemporaryDirectory(prefix="spec_pipeline_") as folder:
        root = Path(folder)
        write_json(root / "principles.json", POLICY)

        clarification = fake_model("clarify", {"spec": SPEC_DRAFT})
        spec = SPEC_DRAFT
        if clarification is not None:
            print(f"질문: {clarification['question']}")
            print(f"고정 답: {clarification['answer']}")
            spec = spec.replace(
                "[미정: 개수 범위]",
                clarification["answer"],
            )
        if "[미정:" in spec:
            raise ValueError("미정 항목을 먼저 확정해야 한다")

        spec_path = root / "spec.txt"
        spec_path.write_text(spec, encoding="utf-8")
        requirements = extract_requirements(
            spec_path.read_text(encoding="utf-8")
        )
        print(f"명세 요구: {', '.join(requirements)}")

        plan = fake_model("plan", {"requirements": requirements})
        plan_ids = {item["requirement"] for item in plan}
        if plan_ids != set(requirements):
            raise ValueError("계획의 요구 연결이 명세와 다르다")
        write_json(root / "plan.json", plan)

        tasks = fake_model(
            "tasks",
            {"requirements": requirements, "plan": plan},
        )
        tasks_path = root / "tasks.json"
        write_json(tasks_path, tasks)
        tasks = json.loads(tasks_path.read_text(encoding="utf-8"))

        missing, unknown = compare_requirements(requirements, tasks)
        if unknown:
            raise ValueError(f"알 수 없는 요구: {', '.join(unknown)}")
        print(f"초안 누락: {', '.join(missing) or '없음'}")
        for key in missing:
            print(f"누락 내용: {key} - {requirements[key]}")

        before = task_lines(tasks)
        tasks.extend(
            fake_model(
                "repair",
                {
                    "requirements": requirements,
                    "missing": missing,
                    "start": len(tasks) + 1,
                },
            )
        )
        write_json(tasks_path, tasks)
        tasks = json.loads(tasks_path.read_text(encoding="utf-8"))
        missing, unknown = compare_requirements(requirements, tasks)
        if missing or unknown:
            raise ValueError("작업 목록의 요구 연결을 다시 확인해야 한다")

        print("작업 목록 변경:")
        changes = difflib.unified_diff(before, task_lines(tasks))
        for line in changes:
            if line.startswith("+") and not line.startswith("+++"):
                print(line.rstrip("\n"))
        print("보완 후 누락: 없음")

        sample = [
            {"title": "명세 읽기", "done": True},
            {"title": "작업 나누기", "done": False},
        ]
        result = render_tasks(sample)
        if result != "1. 명세 읽기\n2. 작업 나누기\n총 2개":
            raise AssertionError("입력 순서 또는 전체 개수 불일치")
        if render_tasks([]) != "작업 없음":
            raise AssertionError("빈 목록 안내 불일치")

        probe = [{"requirements": ["R1", "R9"]}]
        if compare_requirements(requirements, probe) != (
            ["R2", "R3"], ["R9"]
        ):
            raise AssertionError("일관성 검사 결과 불일치")

        print("실행 확인: 일반 목록, 빈 목록, 누락·미등록 요구 통과")
        print("일반 목록:")
        print(result)
        print("빈 목록:")
        print(render_tasks([]))


if __name__ == "__main__":
    main()

줄별 해설

가져오기와 상수 정의에서 difflib는 작업 목록의 변경을 비교하고, json은 산출물을 저장하고 다시 읽는다. re는 명세의 요구 줄을 읽는다. Path는 임시 폴더 안의 경로를 만들고 TemporaryDirectory는 실행이 끝날 때 그 폴더를 정리한다. 출력에는 임시 폴더 이름을 넣지 않는다. 실행마다 달라지는 경로가 예상 출력에 섞이지 않게 하기 위해서다.

SPEC_DRAFT에는 세 요구가 있다. R1은 입력 순서와 번호, R2는 개수, R3은 빈 목록을 담당한다. R2의 미정 표지가 질문 단계의 입력이 된다. POLICY는 기능 요구와 별도로 저장되는 프로젝트 제약이다. 이 프로그램은 해당 제약을 만족하도록 작성되어 있지만, 모든 제약을 자동 검사하는 범용 정책 엔진을 구현하지는 않는다.

REQUIREMENT_LINE과 extract_requirements()는 문서 형식을 검사한다. 정규식은 R 뒤에 양의 정수가 오는 식별자와 비어 있지 않은 설명을 받는다. fullmatch()를 사용하므로 줄의 일부만 맞는 경우는 허용하지 않는다. 각 줄의 앞뒤 공백을 없앤 뒤 요구처럼 시작하는 줄을 검사한다. 같은 식별자가 두 번 등장하면 뒤의 문장으로 조용히 덮어쓰지 않고 오류를 낸다.

반환값은 식별자를 키로, 설명을 값으로 가지는 딕셔너리다. 파일에 적힌 요구 순서가 딕셔너리에도 유지되므로 명세 요구 출력은 R1, R2, R3 순서다. 이 추출기는 ‘- 요구:’처럼 다른 형식으로 적힌 문장을 요구로 인식하지 않는다. 팀이 문서 형식을 지키는 것이 전제이며, 형식 밖에 중요한 조건을 숨기지 않도록 사람이 검토해야 한다.

fake_model()은 단계 이름에 따라 정해진 자료를 반환한다. clarify는 표지를 발견하면 질문과 고정 답을 반환한다. plan은 추출된 모든 요구를 계획 항목으로 옮긴다. tasks는 계획의 마지막 항목을 제외해 누락을 만든다. 이 생략은 실습용 결함이다. 실제 작업 생성기에서 마지막 항목을 제외하는 규칙을 사용해서는 안 된다.

repair는 누락된 식별자를 참조하는 작업을 만든다. 이 예제에서 누락은 R3으로 정해져 있으므로 제목도 빈 목록 안내에 맞춰져 있다. 임의의 명세에 재사용하려면 요구 설명에 맞는 작업 제목과 구현 범위를 따로 결정해야 한다. 함수 이름이 모델이라고 해서 자연어를 폭넓게 이해하는 것은 아니다. 입력과 출력의 연결을 드러내기 위한 대역이다.

compare_requirements()는 명세의 요구와 작업이 참조한 요구를 각각 집합으로 만든다. 명세에 있지만 참조되지 않은 식별자는 missing에 들어간다. 참조되었지만 명세에 없는 식별자는 unknown에 들어간다. 두 결과를 정렬해 반환하므로 작업 순서가 달라도 보고 순서는 일정하다. 이 함수는 작업 제목이나 구현 내용이 요구와 맞는지 판단하지 않는다.

task_lines()는 변경 비교에 사용할 짧은 표현을 만든다. 작업 식별자, 요구 연결, 제목을 한 줄에 넣는다. 이번 변경은 작업 하나를 추가하는 것이므로 추가 줄만 출력한다. 실제 코드 검토에서는 삭제 줄과 문맥도 함께 읽어야 한다. 이 화면은 작은 실습의 변화 지점을 보여 주는 요약이다.

render_tasks()는 명세에 따른 실제 구현이다. 빈 입력을 먼저 처리해 R3의 문구만 반환한다. 일반 입력은 주어진 순서대로 순회해 R1을 구현하고, 마지막 줄에 전체 길이를 붙여 R2를 구현한다. done 값으로 항목을 걸러내지 않는 이유는 질문 단계에서 완료된 작업을 포함하기로 정했기 때문이다. 반환값을 문자열로 모으면 함수의 결과를 출력과 분리해 확인하기 쉽다.

main()의 질문과 저장 부분은 초안에서 답을 반영한 명세를 만든다. 아직 미정 표지가 남아 있으면 예외를 내고 끝낸다. 확정된 명세만 파일로 저장한 뒤 다시 읽어 요구를 추출한다. 계획 생성 뒤에는 계획 식별자 집합이 명세와 같은지 확인한다. 다만 집합 비교는 같은 계획 항목이 반복되는 것까지 잡지 않는다. 이번 생성기는 요구별로 한 번씩 계획을 만들며, 더 넓은 입력을 받는다면 중복 검사도 추가해야 한다.

main()의 대조와 보완 부분은 작업 초안을 저장하고 다시 읽는다. 미등록 요구는 잘못된 연결로 보고 중단하며, 누락 요구는 설명까지 출력한다. 보완된 목록을 저장한 뒤 같은 검사를 다시 수행한다. 수정 전 목록은 문자열 목록으로 따로 보관했으므로 tasks를 확장해도 비교 기준이 함께 바뀌지 않는다.

마지막 실행 확인은 완료된 작업과 미완료 작업을 하나씩 넣어 순서와 전체 개수를 살펴본다. 빈 입력의 결과도 확인한다. 별도의 작은 입력은 검사 함수가 누락과 미등록 요구를 동시에 찾는지 확인한다. 이는 이번 예제의 핵심 경로를 확인하는 장치다. 식별자 대조를 통과했다는 이유로 구현 확인을 생략하지 않는 태도를 코드에 담았다.

실행 결과

저장한 파일은 다음 명령으로 실행한다. 명령 자체도 실제로 실행 가능한 코드다.

python3 main.py

예상 표준 출력은 다음과 같다. 누락 설명은 명세에서 읽은 R3의 문장이고, 추가된 T3는 그 요구를 참조한다. 임시 파일은 실행이 끝나면 정리된다.

질문: 완료된 작업도 개수에 포함하는가?
고정 답: 완료된 작업을 포함한 전체 개수
명세 요구: R1, R2, R3
초안 누락: R3
누락 내용: R3 - 빈 목록에는 작업 없음이라는 안내 문구만 표시한다.
작업 목록 변경:
+T3 | R3 | 빈 목록 안내 구현
보완 후 누락: 없음
실행 확인: 일반 목록, 빈 목록, 누락·미등록 요구 통과
일반 목록:
1. 명세 읽기
2. 작업 나누기
총 2개
빈 목록:
작업 없음

문법 컴파일도 별도로 확인할 수 있다. 다음 명령은 소스 문자열을 컴파일하며 파일을 만들지 않는다. 성공하면 아무것도 출력하지 않는다. 이 원고에서는 도구 실행을 하지 않았으므로 실행 결과는 코드에 따른 예상 출력이다. 실제 사용에서는 두 명령을 실행하고, 변경한 코드의 차이도 확인한다.

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

실행 확인 메시지가 나와도 범위를 정확히 읽어야 한다. 일반 목록과 빈 목록의 예시, 그리고 식별자 검사에 대한 확인이 통과한 것이다. 입력 항목에 title이 없거나 문자열이 아닌 경우까지 처리하도록 명세를 정한 것은 아니다. 그런 입력을 지원하려면 요구를 추가하고 그 요구를 계획과 작업으로 연결해야 한다.

실무에서 자주 틀리는 것

요구 개수와 작업 개수만 비교한다

요구가 세 개이고 작업도 세 개이면 모두 연결되었다고 생각하기 쉽다. 그러나 두 작업이 R1을 참조하고 R3을 아무도 참조하지 않을 수 있다. 다음 잘못된 코드는 그런 상황을 완료로 판단한다.

requirements = {"R1": "순서", "R2": "개수", "R3": "빈 목록"}
references = ["R1", "R1", "R2"]
complete = len(requirements) == len(references)
assert complete is True

개수가 아니라 어떤 식별자가 포함되어 있는지 비교한다. 중복 참조는 다른 요구의 누락을 메우지 못한다. 작업 하나가 여러 요구를 담당하는 구조에도 같은 방식이 적용된다.

requirements = {"R1": "순서", "R2": "개수", "R3": "빈 목록"}
references = ["R1", "R1", "R2"]
missing = sorted(set(requirements) - set(references))
assert missing == ["R3"]

질문의 답을 별도 메모에만 남긴다

답을 저장했지만 계획 생성에는 수정 전 명세를 넘기면 구현 단계가 다시 해석을 고르게 된다. 다음 코드는 답이 있는데도 계획 입력에 미정 항목을 남긴다.

spec = "- R2: [미정: 개수 범위]를 표시한다."
answer = "완료된 작업을 포함한 전체 개수"
plan_input = spec
assert "[미정:" in plan_input

답을 요구에 반영하고 미정 표지가 없는지 확인한 뒤 계획 입력을 만든다. 실제 프로젝트에서는 치환된 문장 전체를 읽어 조건이나 예외가 어색해지지 않았는지도 검토한다.

spec = "- R2: [미정: 개수 범위]를 표시한다."
answer = "완료된 작업을 포함한 전체 개수"
spec = spec.replace("[미정: 개수 범위]", answer)
if "[미정:" in spec:
    raise ValueError("답이 필요한 항목이 남아 있다")
plan_input = spec
assert answer in plan_input

보완할 때 잘못된 요구 연결을 무시한다

누락 집합이 비었다고 해서 모든 참조가 올바른 것은 아니다. 존재하지 않는 R9가 추가되어 있어도 필요한 요구가 모두 들어 있으면 누락 검사는 통과한다.

known = {"R1", "R2"}
referenced = {"R1", "R2", "R9"}
missing = known - referenced
assert not missing

반대 방향의 차집합도 확인한다. 미등록 요구를 자동으로 명세에 추가하지 않는다. 잘못 입력한 식별자인지, 명세 변경이 빠진 것인지 먼저 확인해야 한다.

known = {"R1", "R2"}
referenced = {"R1", "R2", "R9"}
missing = sorted(known - referenced)
unknown = sorted(referenced - known)
assert missing == []
assert unknown == ["R9"]

완료된 작업을 임의로 제외한다

함수 이름이 작업 목록 요약이라고 해서 남은 작업만 세어도 되는 것은 아니다. 다음 구현은 완료 여부를 이용해 개수를 줄인다. 사용자가 전체 개수를 선택한 명세와 다르다.

items = [{"done": True}, {"done": False}]
count = sum(1 for item in items if not item["done"])
assert count == 1

확정된 요구가 전체 개수라면 입력 목록의 길이를 사용한다. 이후 남은 작업 수도 필요해지면 별도 요구로 추가한다. 기존 개수의 뜻을 구현 내부에서 바꾸지 않는다.

items = [{"done": True}, {"done": False}]
count = len(items)
assert count == 2

한눈에 보기

파이프라인에서 남길 근거와 확인할 한계
단계남길 근거확인할 내용검사의 한계
질문질문, 답, 수정된 요구미정 항목이 해결되었는가표지 없는 모호함은 읽어야 한다
계획요구 식별자와 접근명세의 요구를 참조하는가접근의 적절성은 별도 검토다
작업작업 식별자와 요구 목록누락과 미등록 요구가 있는가참조가 구현을 증명하지 않는다
구현코드 변경과 실행 결과요구한 동작을 수행하는가확인하지 않은 입력은 남는다

명세를 기준으로 삼는다는 말은 다른 산출물의 변화를 금지한다는 뜻이 아니다. 구현 중 새로운 제약을 발견하면 명세를 수정할 수 있다. 다만 코드만 먼저 바꾸고 문서를 뒤늦게 맞추는 대신, 요구의 변화와 그 영향을 함께 남긴다. 요구가 바뀌면 연결된 계획과 작업을 다시 살펴보고 실행 확인도 갱신한다.

연습 문제

  1. 명세에 R4로 ‘일반 목록의 마지막 줄에 표시하는 개수 앞에는 총이라는 글자를 붙인다’를 추가한다고 하자. 현재 가짜 모델의 작업 초안에서 어떤 요구가 누락되는지 설명하고, repair가 만드는 작업 제목에 어떤 문제가 생기는지 설명한다.
  2. 작업 하나가 R1과 R2를 함께 참조하고 다른 작업이 R3을 참조하도록 목록을 바꾼다. 누락 검사 결과와 작업 개수를 예측한다. 작업 개수를 요구 개수와 맞출 필요가 없는 이유도 설명한다.
  3. R1을 두 번 적은 명세를 extract_requirements()에 전달하면 어떤 결과가 나오는지 설명한다. 오류를 내지 않고 딕셔너리에 넣기만 할 때 생기는 문제도 설명한다.
  4. 기존 구현이 입력 제목을 정렬해서 출력한다고 하자. 입력 순서 유지 요구를 적용하기 전에 확인할 사항 두 가지와, 구현 수정 뒤 실행으로 확인할 예시 입력을 제안한다.

정답과 해설

  1. 요구 순서가 R1, R2, R3, R4라면 작업 초안은 마지막 계획 항목을 제외하므로 R4가 누락된다. repair는 R4를 참조하는 작업을 추가하지만 제목을 빈 목록 안내 구현으로 고정했기 때문에 요구 내용과 맞지 않는다. 식별자 검사는 통과해도 의미상 연결은 어긋난다. 요구 설명에 맞게 작업 제목과 범위를 결정하도록 수정해야 한다. 또한 R4는 기존 R2의 일부 내용을 더 구체화하므로 별도 요구가 필요한지, R2의 설명을 명확하게 고칠지 먼저 판단할 수 있다.

  2. 두 작업이 참조하는 식별자의 합집합은 R1, R2, R3이므로 누락과 미등록 요구가 모두 없다. 작업 개수는 두 개다. 요구는 제공할 동작의 단위이고 작업은 변경을 수행하는 단위이므로 개수가 같을 이유는 없다. 함께 처리하는 편이 자연스러운 요구를 한 작업으로 묶어도 요구 연결을 기록하면 추적할 수 있다.

  3. 두 번째 R1을 읽을 때 ValueError가 발생하며 메시지는 중복 요구: R1이다. 중복 확인 없이 같은 키에 값을 넣으면 뒤의 설명이 앞의 설명을 덮어쓴다. 그 결과 요구 하나가 사라졌는데도 겉으로는 유효한 딕셔너리가 만들어진다. 추출 단계에서 오류를 내야 작성자가 명세를 수정할 수 있다.

  4. 먼저 기존 호출자가 정렬된 결과에 의존하는지 확인하고, 요청자가 입력 순서 유지로 동작을 바꾸려는 것인지 확인한다. 변경이 합의되면 관련 요구와 작업에 영향을 기록한다. 실행 입력은 ‘자료 정리’, ‘계획 확인’처럼 제목 정렬과 입력 순서가 다른 두 항목으로 고른다. 결과가 입력 순서대로 나오는지 확인하고, 변경 비교에서는 정렬 제거와 관련 없는 수정이 섞이지 않았는지 살핀다.

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

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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