AI 에이전트끼리 자연어로 대화시키면 망한다: Typed Agent Contract 설계법

이경규·2026년 7월 21일
post-thumbnail

AI 에이전트끼리 자연어로 대화시키면 망한다: Typed Agent Contract 설계법

AI 코딩 에이전트를 처음 만들 때는 보통 역할부터 나눈다.

Planner
→ 작업 계획 작성

Implementer
→ 코드 수정

Reviewer
→ 변경 사항 검토

Tester
→ 테스트 실행

Reporter
→ 최종 보고서 작성

구조만 보면 그럴듯하다.

하지만 실제로 운영해보면 이상한 문제가 생긴다.

Planner는 분명 수정 파일을 세 개로 제한했다고 말한다.
Implementer는 “관련 파일도 함께 수정했다”고 보고한다.
Reviewer는 어떤 파일이 원래 범위였는지 다시 해석한다.
Tester는 실행할 명령을 찾지 못해 임의로 테스트를 선택한다.

결국 에이전트끼리 계속 같은 내용을 다시 해석한다.

Planner:
관련 ViewModel과 테스트 파일만 수정하세요.

Implementer:
관련 파일의 의미가 공통 ErrorMapper까지 포함된다고 판단했습니다.

Reviewer:
이번 작업 범위가 어디까지인지 명확하지 않습니다.

사람 사이에서는 이런 대화를 다시 확인하면 된다.

AI 에이전트 체인에서는 문제가 더 크다.

각 모델이 같은 문장을 조금씩 다르게 해석하고, 그 해석이 다음 단계로 전달되면서 작업 범위가 변한다.

이런 현상을 줄이기 위해 필요한 것이 Typed Agent Contract다.

핵심은 단순하다.

에이전트끼리 긴 자연어 보고서를 주고받지 않는다.

정해진 Schema를 만족하는 구조화된 결과만 전달한다.

Planner는 AgentPlan을 만든다.
Implementer는 ImplementationResult를 만든다.
Reviewer는 ReviewResult를 만든다.
Tester는 TestResult를 만든다.

다음 에이전트는 자연어를 다시 해석하지 않고, 타입이 보장된 필드를 읽는다.

1. 자연어 기반 에이전트 체인이 왜 불안정할까

다음과 같은 작업을 생각해보자.

로그인 실패 시 서버 에러 원문이 그대로 노출되지 않도록 수정하고,
관련 테스트를 추가해줘.

Planner가 이런 계획을 만들 수 있다.

LoginViewModel과 AuthErrorMapper를 확인합니다.
서버 원문을 사용자 친화적 메시지로 바꿉니다.
테스트를 추가합니다.

사람이 보면 대충 이해된다.

하지만 Implementer 입장에서는 여러 가지가 애매하다.

LoginViewModel은 수정 가능한가?
AuthErrorMapper도 수정 가능한가?
AuthRepository까지 건드려도 되는가?
테스트 파일은 새로 만들어도 되는가?
에러 메시지 문자열 파일을 수정해도 되는가?

자연어는 의도를 전달하기에는 좋다.

하지만 실행 경계를 강제하기에는 약하다.

특히 다음 항목은 자연어로만 두면 자주 흔들린다.

수정 가능한 파일
수정 금지 파일
신규 의존성 허용 여부
테스트 명령
위험도
사람 승인 필요 여부
완료 조건
중단 조건

그래서 에이전트 체인이 길어질수록 자연어를 줄이고 계약을 늘려야 한다.

2. Typed Agent Contract란 무엇인가

Typed Agent Contract는 각 에이전트가 받을 입력과 반환할 출력을 타입으로 정의하는 방식이다.

예를 들어 Planner의 출력은 다음처럼 고정할 수 있다.

type AgentPlan = {
  taskId: string;
  summary: string;
  riskLevel: "low" | "medium" | "high";
  filesToRead: string[];
  filesToEdit: string[];
  filesNotToEdit: string[];
  steps: PlanStep[];
  testsToRun: string[];
  requiresHumanApproval: boolean;
  stopConditions: string[];
};

이제 Planner가 단순히 다음처럼 말할 수 없다.

관련 파일을 적절히 수정합니다.

반드시 구체적인 파일 목록을 반환해야 한다.

{
  "taskId": "login-error-message",
  "summary": "로그인 실패 시 서버 원문 대신 사용자용 메시지를 표시한다.",
  "riskLevel": "medium",
  "filesToRead": [
    "LoginViewModel.swift",
    "AuthErrorMapper.swift",
    "LoginViewModelTests.swift"
  ],
  "filesToEdit": [
    "AuthErrorMapper.swift",
    "LoginViewModelTests.swift"
  ],
  "filesNotToEdit": [
    "AuthRepository.swift",
    "NetworkClient.swift",
    "DesignSystem/**"
  ],
  "steps": [
    {
      "id": "step-1",
      "action": "서버 에러와 사용자 메시지 매핑을 확인한다."
    },
    {
      "id": "step-2",
      "action": "AuthErrorMapper 안에서 안전한 메시지를 반환하도록 수정한다."
    },
    {
      "id": "step-3",
      "action": "실패 유형별 테스트를 추가한다."
    }
  ],
  "testsToRun": [
    "xcodebuild test -scheme MyApp -only-testing:MyAppTests/LoginViewModelTests"
  ],
  "requiresHumanApproval": false,
  "stopConditions": [
    "AuthRepository 수정이 필요할 경우 중단한다.",
    "신규 의존성이 필요할 경우 중단한다."
  ]
}

다음 에이전트는 이 계약을 그대로 따른다.

3. 폴더 구조부터 계약 중심으로 바꾼다

프로젝트 안에 계약 파일을 따로 둔다.

.ai/
├── contracts/
│   ├── task.schema.json
│   ├── plan.schema.json
│   ├── tool-request.schema.json
│   ├── implementation-result.schema.json
│   ├── review-result.schema.json
│   ├── test-result.schema.json
│   └── final-report.schema.json
│
├── tasks/
│   └── login-error-message/
│       ├── task.json
│       ├── plan.json
│       ├── implementation-result.json
│       ├── review-result.json
│       ├── test-result.json
│       └── final-report.json
│
└── runtime/
    ├── validate-contract.ts
    ├── route-result.ts
    └── migrate-contract.ts

이 구조의 핵심은 에이전트의 말보다 Artifact가 기준이 된다는 점이다.

대화:
참고 정보

Contract Artifact:
실제 실행 기준

4. Task Contract부터 고정한다

사용자 요청도 바로 에이전트에게 넘기지 않는다.

Runtime이 먼저 구조화한다.

type AgentTask = {
  id: string;
  title: string;
  description: string;
  category: "bug_fix" | "feature" | "review" | "test" | "documentation";
  riskLevel: "low" | "medium" | "high";
  expectedBehavior: string[];
  forbiddenChanges: string[];
  requiredEvidence: string[];
};

예시는 다음과 같다.

{
  "id": "login-error-message",
  "title": "로그인 실패 메시지 안전하게 변경",
  "description": "서버 에러 원문 대신 사용자 친화적 메시지를 표시하고 테스트를 추가한다.",
  "category": "bug_fix",
  "riskLevel": "medium",
  "expectedBehavior": [
    "인증 실패와 네트워크 실패가 구분된다.",
    "서버 원문은 화면에 노출되지 않는다.",
    "사용자는 다시 로그인할 수 있다."
  ],
  "forbiddenChanges": [
    "NetworkClient 수정",
    "인증 API 계약 변경",
    "DesignSystem 수정",
    "신규 라이브러리 추가"
  ],
  "requiredEvidence": [
    "수정된 파일 목록",
    "실행한 테스트 명령",
    "테스트 종료 코드",
    "미실행 테스트 사유"
  ]
}

Planner는 이 Task Contract를 입력받는다.

5. Plan Contract는 실행 가능한 수준이어야 한다

계획이 너무 추상적이면 계약을 둔 의미가 없다.

나쁜 Plan은 이렇다.

{
  "steps": [
    "코드를 확인한다.",
    "수정한다.",
    "테스트한다."
  ]
}

좋은 Plan은 각 단계의 입력과 출력을 포함한다.

type PlanStep = {
  id: string;
  action: string;
  inputFiles: string[];
  expectedOutput: string;
  validation: string;
};
{
  "steps": [
    {
      "id": "inspect-mapping",
      "action": "현재 인증 오류 매핑 로직을 확인한다.",
      "inputFiles": [
        "AuthErrorMapper.swift",
        "LoginViewModel.swift"
      ],
      "expectedOutput": "서버 원문이 화면까지 전달되는 경로를 확인한다.",
      "validation": "원문 노출 지점을 파일과 함수 이름으로 기록한다."
    },
    {
      "id": "update-mapping",
      "action": "사용자용 오류 메시지 매핑을 수정한다.",
      "inputFiles": [
        "AuthErrorMapper.swift"
      ],
      "expectedOutput": "인증 실패와 네트워크 실패에 안전한 메시지가 반환된다.",
      "validation": "public API 변경이 없어야 한다."
    }
  ]
}

이제 Implementer는 계획을 다시 해석할 필요가 줄어든다.

6. Implementer 출력도 계약으로 제한한다

Implementer에게 자유 형식 완료 보고를 받으면 자주 이런 식으로 나온다.

로그인 오류 처리를 개선했고 테스트도 추가했습니다.
전체적으로 안전하게 동작합니다.

이 보고서만으로는 검증할 수 없다.

구조화된 결과를 받는다.

type ImplementationResult = {
  status: "completed" | "blocked" | "failed";
  changedFiles: ChangedFile[];
  executedSteps: string[];
  skippedSteps: SkippedStep[];
  commandsRequested: ToolRequest[];
  risks: string[];
  notChanged: string[];
};
type ChangedFile = {
  path: string;
  changeType: "created" | "updated" | "deleted";
  reason: string;
};

type SkippedStep = {
  stepId: string;
  reason: string;
};

예시는 다음과 같다.

{
  "status": "completed",
  "changedFiles": [
    {
      "path": "AuthErrorMapper.swift",
      "changeType": "updated",
      "reason": "서버 원문 대신 사용자용 메시지를 반환하도록 수정했다."
    },
    {
      "path": "LoginViewModelTests.swift",
      "changeType": "updated",
      "reason": "인증 실패와 네트워크 실패 테스트를 추가했다."
    }
  ],
  "executedSteps": [
    "inspect-mapping",
    "update-mapping",
    "add-tests"
  ],
  "skippedSteps": [],
  "commandsRequested": [
    {
      "tool": "run_tests",
      "command": "xcodebuild test -scheme MyApp -only-testing:MyAppTests/LoginViewModelTests",
      "reason": "변경된 에러 처리 테스트 검증",
      "riskLevel": "low"
    }
  ],
  "risks": [
    "기존 서버 오류 코드가 추가될 경우 매핑 갱신이 필요할 수 있다."
  ],
  "notChanged": [
    "AuthRepository.swift",
    "NetworkClient.swift",
    "DesignSystem/**"
  ]
}

notChanged는 특히 유용하다.

AI가 무엇을 하지 않았는지도 계약으로 남기기 때문이다.

7. Tool Request도 Schema로 고정한다

도구 호출을 자연어에서 추출하면 위험하다.

다음처럼 구조화한다.

type ToolRequest = {
  requestId: string;
  tool: "read_file" | "edit_file" | "run_tests" | "shell" | "external_api";
  targetPath?: string;
  command?: string;
  reason: string;
  riskLevel: "low" | "medium" | "high";
  requiresApproval: boolean;
};

예를 들어 테스트 실행 요청은 이렇다.

{
  "requestId": "tool-001",
  "tool": "run_tests",
  "command": "xcodebuild test -scheme MyApp -only-testing:MyAppTests/LoginViewModelTests",
  "reason": "수정된 로그인 오류 처리의 회귀 여부를 확인한다.",
  "riskLevel": "low",
  "requiresApproval": false
}

파일 수정 요청은 다르다.

{
  "requestId": "tool-002",
  "tool": "edit_file",
  "targetPath": "Sources/Auth/AuthErrorMapper.swift",
  "reason": "서버 오류를 사용자 메시지로 변환하기 위해 수정한다.",
  "riskLevel": "medium",
  "requiresApproval": true
}

Runtime은 이 구조를 Policy Engine에 전달한다.

8. JSON Schema로 실제 검증한다

TypeScript 타입만으로는 런타임 검증이 되지 않는다.

JSON Schema 또는 Zod 같은 검증 도구를 함께 사용한다.

plan.schema.json 예시는 다음과 같다.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "AgentPlan",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "taskId",
    "summary",
    "riskLevel",
    "filesToRead",
    "filesToEdit",
    "filesNotToEdit",
    "steps",
    "testsToRun",
    "requiresHumanApproval",
    "stopConditions"
  ],
  "properties": {
    "taskId": {
      "type": "string",
      "minLength": 1
    },
    "summary": {
      "type": "string",
      "minLength": 1
    },
    "riskLevel": {
      "enum": ["low", "medium", "high"]
    },
    "filesToRead": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "uniqueItems": true
    },
    "filesToEdit": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "uniqueItems": true
    },
    "filesNotToEdit": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "uniqueItems": true
    },
    "steps": {
      "type": "array",
      "minItems": 1,
      "items": {
        "$ref": "#/$defs/planStep"
      }
    },
    "testsToRun": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "requiresHumanApproval": {
      "type": "boolean"
    },
    "stopConditions": {
      "type": "array",
      "items": {
        "type": "string"
      }
    }
  },
  "$defs": {
    "planStep": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "id",
        "action",
        "inputFiles",
        "expectedOutput",
        "validation"
      ],
      "properties": {
        "id": {
          "type": "string"
        },
        "action": {
          "type": "string"
        },
        "inputFiles": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "expectedOutput": {
          "type": "string"
        },
        "validation": {
          "type": "string"
        }
      }
    }
  }
}

additionalProperties: false가 중요하다.

모델이 임의 필드를 덧붙이는 것을 막는다.

  1. Zod로 Runtime 검증하기

TypeScript에서는 Zod로 간단히 구현할 수 있다.

import { z } from "zod";

const PlanStepSchema = z.object({
  id: z.string().min(1),
  action: z.string().min(1),
  inputFiles: z.array(z.string()),
  expectedOutput: z.string().min(1),
  validation: z.string().min(1)
}).strict();

const AgentPlanSchema = z.object({
  taskId: z.string().min(1),
  summary: z.string().min(1),
  riskLevel: z.enum(["low", "medium", "high"]),
  filesToRead: z.array(z.string()),
  filesToEdit: z.array(z.string()),
  filesNotToEdit: z.array(z.string()),
  steps: z.array(PlanStepSchema).min(1),
  testsToRun: z.array(z.string()),
  requiresHumanApproval: z.boolean(),
  stopConditions: z.array(z.string())
}).strict();

type AgentPlan = z.infer<typeof AgentPlanSchema>;

모델 출력은 반드시 파싱한다.

function parseAgentPlan(raw: unknown): AgentPlan {
  const result = AgentPlanSchema.safeParse(raw);

  if (!result.success) {
    throw new Error(
      `Invalid AgentPlan: ${result.error.message}`
    );
  }

  return result.data;
}

유효하지 않은 출력은 다음 에이전트에게 넘기지 않는다.

10. Schema Retry를 무한 반복하면 안 된다

모델이 Schema를 만족하지 못하면 다시 요청할 수 있다.

하지만 무한 재시도는 금지해야 한다.

async function generateValidPlan(
  generate: () => Promise<unknown>,
  maxAttempts = 2
): Promise<AgentPlan> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const output = await generate();
    const result = AgentPlanSchema.safeParse(output);

    if (result.success) {
      return result.data;
    }

    lastError = result.error;
  }

  throw new Error(
    `Agent failed to produce valid plan: ${String(lastError)}`
  );
}

두 번 정도 실패하면 사람이나 상위 Planner에게 넘긴다.

Schema 실패 1회
→ 오류 필드만 알려주고 재요청

Schema 실패 2회
→ 작업 중단

무한 재시도
→ 금지

11. 형식 검증만으로는 부족하다

Schema를 통과했다고 계획이 좋은 것은 아니다.

다음 계획은 형식상 유효하다.

{
  "filesToEdit": [
    "NetworkClient.swift",
    "DesignSystemButton.swift"
  ]
}

하지만 Task Contract의 금지 조건을 위반할 수 있다.

따라서 두 단계 검증이 필요하다.

Schema Validation
→ 형태가 올바른가

Policy Validation
→ 내용이 정책을 지키는가

12. Contract Policy Validator 만들기

type ContractViolation = {
  code: string;
  message: string;
  field: string;
};

function validatePlanAgainstTask(
  task: AgentTask,
  plan: AgentPlan
): ContractViolation[] {
  const violations: ContractViolation[] = [];

  const forbidden = new Set(task.forbiddenChanges);

  for (const file of plan.filesToEdit) {
    if (forbidden.has(file)) {
      violations.push({
        code: "FORBIDDEN_FILE",
        message: `Forbidden file included: ${file}`,
        field: "filesToEdit"
      });
    }
  }

  if (
    task.riskLevel === "high" &&
    !plan.requiresHumanApproval
  ) {
    violations.push({
      code: "MISSING_APPROVAL",
      message: "High-risk tasks require human approval.",
      field: "requiresHumanApproval"
    });
  }

  if (plan.testsToRun.length === 0) {
    violations.push({
      code: "MISSING_TEST_PLAN",
      message: "At least one validation command is required.",
      field: "testsToRun"
    });
  }

  return violations;
}

계약은 문법뿐 아니라 의미도 검증해야 한다.

13. Reviewer도 자유 형식으로 두지 않는다

Reviewer가 긴 리뷰 문장을 반환하면 Fixer가 다시 해석해야 한다.

구조화된 결과를 받는다.

type ReviewResult = {
  verdict: "pass" | "needs_changes" | "blocked";
  issues: ReviewIssue[];
  scopeCheck: ScopeCheck;
  testCheck: TestCheck;
  securityCheck: SecurityCheck;
  requiredFixes: RequiredFix[];
};
type ReviewIssue = {
  id: string;
  severity: "low" | "medium" | "high" | "critical";
  category: "scope" | "behavior" | "test" | "security" | "architecture";
  file: string;
  line?: number;
  description: string;
  evidence: string;
};

예시는 다음과 같다.

{
  "verdict": "needs_changes",
  "issues": [
    {
      "id": "review-001",
      "severity": "medium",
      "category": "behavior",
      "file": "AuthErrorMapper.swift",
      "line": 42,
      "description": "네트워크 타임아웃과 인증 실패가 같은 메시지로 처리됩니다.",
      "evidence": "두 오류 케이스가 default 분기로 들어갑니다."
    }
  ],
  "scopeCheck": {
    "passed": true,
    "unexpectedFiles": []
  },
  "testCheck": {
    "passed": false,
    "missingCases": [
      "timeout 오류 메시지 테스트"
    ]
  },
  "securityCheck": {
    "passed": true,
    "findings": []
  },
  "requiredFixes": [
    {
      "issueId": "review-001",
      "allowedFiles": [
        "AuthErrorMapper.swift",
        "LoginViewModelTests.swift"
      ],
      "expectedChange": "timeout과 인증 실패 메시지를 분리하고 테스트를 추가한다."
    }
  ]
}

Fixer는 requiredFixes만 처리한다.

14. Reviewer가 코드까지 수정하면 안 되는 이유

Reviewer에게 수정 권한까지 주면 계약 구조가 무너진다.

Reviewer
→ 문제 발견
→ 직접 수정
→ 자기 수정 결과를 다시 판단

독립 검증이 사라진다.

좋은 구조는 이렇다.

Reviewer
→ ReviewResult 생성

Runtime
→ requiredFixes 검증

Fixer
→ 지정된 파일만 수정

Reviewer
→ 새 diff 재검토

에이전트 역할뿐 아니라 출력 계약도 분리해야 한다.

15. Tester 결과도 증거 중심으로 만든다

Tester가 이렇게 말하는 것은 부족하다.

테스트가 정상적으로 통과했습니다.

구조화된 결과를 받는다.

type TestResult = {
  status: "passed" | "failed" | "not_run";
  commands: TestCommandResult[];
  failedTests: FailedTest[];
  environmentIssues: string[];
  artifacts: string[];
};
type TestCommandResult = {
  command: string;
  exitCode: number | null;
  durationMs: number | null;
  stdoutArtifact?: string;
  stderrArtifact?: string;
};

예시는 다음과 같다.

{
  "status": "passed",
  "commands": [
    {
      "command": "xcodebuild test -scheme MyApp -only-testing:MyAppTests/LoginViewModelTests",
      "exitCode": 0,
      "durationMs": 48211,
      "stdoutArtifact": "artifacts/login-tests.log"
    }
  ],
  "failedTests": [],
  "environmentIssues": [],
  "artifacts": [
    "artifacts/login-tests.log",
    "artifacts/test-summary.json"
  ]
}

테스트를 실행하지 못했다면 다음처럼 나와야 한다.

{
  "status": "not_run",
  "commands": [
    {
      "command": "xcodebuild test -scheme MyApp",
      "exitCode": null,
      "durationMs": null,
      "stderrArtifact": "artifacts/test-environment-error.log"
    }
  ],
  "failedTests": [],
  "environmentIssues": [
    "요청한 iOS Simulator가 설치되어 있지 않습니다."
  ],
  "artifacts": [
    "artifacts/test-environment-error.log"
  ]
}

not_run과 passed는 완전히 다른 상태다.

16. 에이전트 간 Handoff도 타입으로 만든다

멀티 에이전트 시스템에서 가장 자주 깨지는 부분이 Handoff다.

다음 에이전트에게 넘기는 정보를 고정한다.

type AgentHandoff<TPayload> = {
  handoffId: string;
  fromAgent: string;
  toAgent: string;
  taskId: string;
  contractVersion: string;
  payloadType: string;
  payload: TPayload;
  artifacts: string[];
  createdAt: string;
};

Planner에서 Implementer로 넘기는 Handoff는 다음과 같다.

{
  "handoffId": "handoff-001",
  "fromAgent": "planner",
  "toAgent": "implementer",
  "taskId": "login-error-message",
  "contractVersion": "1.0.0",
  "payloadType": "AgentPlan",
  "payload": {
    "taskId": "login-error-message",
    "summary": "로그인 오류 메시지를 안전하게 변경한다.",
    "riskLevel": "medium",
    "filesToRead": [
      "LoginViewModel.swift",
      "AuthErrorMapper.swift"
    ],
    "filesToEdit": [
      "AuthErrorMapper.swift",
      "LoginViewModelTests.swift"
    ],
    "filesNotToEdit": [
      "NetworkClient.swift"
    ],
    "steps": [],
    "testsToRun": [],
    "requiresHumanApproval": false,
    "stopConditions": []
  },
  "artifacts": [
    "task.json",
    "plan.json"
  ],
  "createdAt": "2026-07-20T10:00:00+09:00"
}

다음 에이전트는 payloadType과 contractVersion을 확인한 뒤 작업한다.

17. Contract Versioning이 꼭 필요하다

계약 구조는 시간이 지나면서 바뀐다.

처음에는 filesToEdit만 있었는데 나중에 allowedOperations가 추가될 수 있다.

v1:
filesToEdit

v2:
filesToEdit
allowedOperations
approvalPolicy

버전을 관리하지 않으면 오래된 에이전트가 새 계약을 잘못 해석한다.

type ContractEnvelope<T> = {
  schema: string;
  version: string;
  payload: T;
};
{
  "schema": "AgentPlan",
  "version": "2.0.0",
  "payload": {}
}

18. Schema 변경은 API 변경처럼 다룬다

다음 변경은 호환 가능하다.

선택 필드 추가
새 enum 값을 지원하도록 모든 소비자 갱신
설명 필드 보강

다음 변경은 호환성이 깨질 수 있다.

필수 필드 추가
필드명 변경
기존 enum 값 제거
타입 변경
중첩 구조 변경

따라서 계약 변경도 리뷰와 테스트가 필요하다.

Schema 수정
→ Contract fixture 테스트
→ 모든 Agent Adapter 테스트
→ Migration 적용
→ 배포

19. Contract Migration 만들기

type AgentPlanV1 = {
  taskId: string;
  filesToEdit: string[];
};

type AgentPlanV2 = {
  taskId: string;
  filesToEdit: string[];
  filesNotToEdit: string[];
  requiresHumanApproval: boolean;
};

function migratePlanV1ToV2(
  oldPlan: AgentPlanV1
): AgentPlanV2 {
  return {
    taskId: oldPlan.taskId,
    filesToEdit: oldPlan.filesToEdit,
    filesNotToEdit: [],
    requiresHumanApproval: false
  };
}

오래 실행되는 에이전트 작업에서는 중간에 Runtime이 업데이트될 수 있다.

그래서 Migration이 중요하다.

20. Contract Fixture로 회귀 테스트한다

에이전트 출력 품질을 검증하려면 실제 예제를 저장한다.

.ai/contract-fixtures/
├── valid/
│   ├── low-risk-plan.json
│   ├── medium-risk-plan.json
│   └── test-result-passed.json
│
└── invalid/
    ├── missing-files-to-edit.json
    ├── unknown-risk-level.json
    ├── forbidden-extra-property.json
    └── passed-without-exit-code.json

테스트는 다음처럼 작성할 수 있다.

import { describe, expect, it } from "vitest";
import validPlan from "./fixtures/valid/medium-risk-plan.json";
import invalidPlan from "./fixtures/invalid/unknown-risk-level.json";

describe("AgentPlan contract", () => {
  it("accepts valid plan", () => {
    const result = AgentPlanSchema.safeParse(validPlan);
    expect(result.success).toBe(true);
  });

  it("rejects unknown risk level", () => {
    const result = AgentPlanSchema.safeParse(invalidPlan);
    expect(result.success).toBe(false);
  });
});

에이전트 Prompt를 바꾸거나 모델을 교체해도 같은 Fixture를 통과해야 한다.

21. Final Report도 자유 형식으로 두지 않는다

최종 보고서는 사람이 읽기 좋아야 하지만, Runtime도 분석할 수 있어야 한다.

구조화된 JSON과 Markdown을 함께 만든다.

type FinalReport = {
  verdict: "completed" | "blocked" | "failed";
  summary: string;
  changedFiles: string[];
  validation: {
    testsExecuted: boolean;
    passed: boolean;
    commands: string[];
  };
  review: {
    unresolvedCritical: number;
    unresolvedHigh: number;
  };
  risks: string[];
  notChanged: string[];
  humanReviewTargets: string[];
};

이 JSON을 기반으로 Markdown을 렌더링한다.

# Final Report

## Verdict

Completed

## Summary

로그인 실패 메시지를 사용자 친화적 문구로 변경하고 관련 테스트를 추가했습니다.

## Files Changed

- AuthErrorMapper.swift
- LoginViewModelTests.swift

## Validation

- 테스트 실행: 완료
- 결과: 통과

## Remaining Risks

- 새로운 서버 오류 코드가 추가되면 매핑 갱신이 필요합니다.

## Not Changed

- NetworkClient.swift
- AuthRepository.swift
- DesignSystem/**

사람용 문서와 기계용 계약을 분리하지 않고 같은 원본에서 생성하는 것이 좋다.

22. Swift 프로젝트에도 같은 원칙을 적용할 수 있다

Swift에서도 Codable 타입으로 에이전트 계약을 정의할 수 있다.

enum AgentRiskLevel: String, Codable {
    case low
    case medium
    case high
}

struct AgentPlan: Codable {
    let taskID: String
    let summary: String
    let riskLevel: AgentRiskLevel
    let filesToRead: [String]
    let filesToEdit: [String]
    let filesNotToEdit: [String]
    let steps: [PlanStep]
    let testsToRun: [String]
    let requiresHumanApproval: Bool
    let stopConditions: [String]
}

struct PlanStep: Codable {
    let id: String
    let action: String
    let inputFiles: [String]
    let expectedOutput: String
    let validation: String
}

JSON을 디코딩한다.

func decodePlan(from data: Data) throws -> AgentPlan {
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase

    return try decoder.decode(
        AgentPlan.self,
        from: data
    )
}

다만 Codable 디코딩 성공만으로 정책이 검증되는 것은 아니다.

별도 의미 검증을 둔다.

enum PlanValidationError: Error {
    case forbiddenFile(String)
    case missingTestPlan
    case approvalRequired
}

func validate(
    plan: AgentPlan,
    forbiddenFiles: Set<String>
) throws {
    for file in plan.filesToEdit where forbiddenFiles.contains(file) {
        throw PlanValidationError.forbiddenFile(file)
    }

    guard !plan.testsToRun.isEmpty else {
        throw PlanValidationError.missingTestPlan
    }

    if plan.riskLevel == .high && !plan.requiresHumanApproval {
        throw PlanValidationError.approvalRequired
    }
}

23. 어떤 항목을 반드시 Contract로 만들어야 할까

모든 에이전트 메시지를 타입으로 만들 필요는 없다.

다음 항목부터 시작하면 된다.

Task
Plan
Tool Request
Implementation Result
Review Result
Test Result
Approval Request
Final Report

반대로 다음은 자유 텍스트로 남겨도 된다.

사용자에게 보여줄 설명
PR 본문 초안
문서 초안
리뷰의 보충 설명

기준은 간단하다.

다음 행동을 결정하는 정보
→ Contract

사람이 읽기 위한 설명
→ Natural Language

24. Contract가 너무 복잡해지는 것도 문제다

Schema가 지나치게 크면 모델이 필드를 빠뜨리거나 잘못 채울 가능성이 높아진다.

나쁜 Contract는 이렇다.

필드 80개
중첩 7단계
enum 값 40개
선택 필드와 조건부 필드가 너무 많음

좋은 Contract는 역할별로 작다.

Planner
→ 계획에 필요한 필드만

Reviewer
→ 이슈와 판정에 필요한 필드만

Tester
→ 명령과 결과에 필요한 필드만

하나의 거대한 AgentEverythingResult를 만들면 안 된다.

25. Optional 필드를 남발하지 않는다

모든 필드를 optional로 만들면 검증 효과가 사라진다.

type WeakPlan = {
  filesToEdit?: string[];
  testsToRun?: string[];
  requiresHumanApproval?: boolean;
};

이 구조에서는 빈 객체도 통과할 수 있다.

{}

반드시 필요한 정보는 required로 둔다.

값이 없으면 빈 배열이나 명시적인 상태를 사용한다.

{
  "testsToRun": [],
  "requiresHumanApproval": false
}

없음과 누락을 구분해야 한다.

26. 에러도 Contract로 반환한다

에이전트 실행 실패를 예외 문자열 하나로 끝내지 않는다.

type AgentError = {
  code:
    | "INVALID_CONTRACT"
    | "POLICY_VIOLATION"
    | "TOOL_DENIED"
    | "ENVIRONMENT_FAILURE"
    | "MODEL_FAILURE";
  message: string;
  retryable: boolean;
  failedField?: string;
  artifact?: string;
  nextAction: "retry" | "human" | "planner" | "stop";
};
{
  "code": "POLICY_VIOLATION",
  "message": "NetworkClient.swift는 수정 금지 파일입니다.",
  "retryable": false,
  "failedField": "filesToEdit",
  "nextAction": "planner"
}

이제 Runtime이 에러 메시지를 다시 해석하지 않고 바로 라우팅할 수 있다.

27. 전체 실행 흐름

Typed Agent Contract를 적용한 전체 흐름은 다음과 같다.

사용자 요청
→ Task Contract 생성
→ Schema Validation
→ Planner
→ Plan Contract
→ Schema Validation
→ Policy Validation
→ Implementer
→ Implementation Result
→ Tool Request Validation
→ Reviewer
→ Review Result
→ Fixer
→ Tester
→ Test Result
→ Eval Gate
→ Final Report

각 단계에서 잘못된 Contract는 다음 단계로 넘어가지 않는다.

28. 이 구조가 실제로 줄여주는 문제

작업 범위가 덜 흔들린다

filesToEdit과 filesNotToEdit가 명확하다.

에이전트 간 해석 비용이 줄어든다

다음 역할은 정해진 필드만 읽는다.

자동 라우팅이 쉬워진다

verdict, status, nextAction으로 다음 단계를 고를 수 있다.

테스트 실행 여부를 속이기 어렵다

명령, 종료 코드, 로그 Artifact가 필요하다.

모델 교체가 쉬워진다

모델이 달라도 같은 Contract를 반환하면 된다.

감사와 분석이 쉬워진다

모든 결과가 구조화되어 저장된다.

29. 개발자가 왜 이걸 알아야 할까

AI 에이전트가 한 번만 대답한다면 자연어로 충분하다.

하지만 여러 에이전트가 작업을 이어가면 상황이 달라진다.

Planner가 계획한다.
Implementer가 수정한다.
Reviewer가 검토한다.
Fixer가 다시 고친다.
Tester가 검증한다.

이 구조에서 자연어는 계속 재해석된다.

에이전트 수가 많아질수록 해석 차이도 누적된다.

그래서 앞으로 개발자는 Prompt뿐 아니라 Contract를 설계해야 한다.

어떤 필드를 필수로 받을 것인가
어떤 값만 허용할 것인가
어떤 상태에서 다음 단계로 갈 것인가
어떤 오류는 재시도할 것인가
계약 버전이 바뀌면 어떻게 마이그레이션할 것인가

이건 AI 전용 기술이라기보다 익숙한 소프트웨어 설계다.

API Contract
DTO
Schema Validation
Versioning
Migration
Compatibility Test

우리가 백엔드 API에서 이미 하던 일을 에이전트 사이에도 적용하는 것이다.

30. 마무리

AI 에이전트끼리 자연어로 자유롭게 대화시키면 처음에는 유연해 보인다.

하지만 작업이 길어질수록 문제가 생긴다.

범위가 달라진다.
위험도가 바뀐다.
테스트 기준이 사라진다.
완료 조건이 흔들린다.

좋은 멀티 에이전트 시스템은 에이전트가 말을 잘하는 시스템이 아니다.

각 역할이 정해진 계약을 지키는 시스템이다.

Task Contract
Plan Contract
Tool Contract
Review Contract
Test Contract
Final Report Contract

한 줄로 정리하면 이렇다.

에이전트의 자연어는 설명에 사용하고,
에이전트의 행동은 Schema로 통제해야 한다.

모델은 계속 바뀔 것이다.

Planner는 Claude가 될 수도 있고, Codex가 될 수도 있고, 다른 모델이 될 수도 있다.

하지만 Contract가 유지되면 Runtime은 흔들리지 않는다.

앞으로 AI 에이전트 개발에서 중요한 것은 더 긴 프롬프트가 아니다.

모델이 바뀌어도 깨지지 않는 입력·출력 계약을 만드는 능력이다.

profile
iOS 앱 개발자

0개의 댓글