
1편에서는 AI 코딩 에이전트의 역할을 나눴다.
Planner
Implementer
Reviewer
Tester
Final Judge
2편에서는 Claude, Codex, Cursor를 중앙 Orchestrator로 연결했다.
Claude Planner
→ Codex Implementer
→ Claude Reviewer
→ Codex Fixer
→ Tester
→ Cursor Final Review
→ Human
이제 마지막 문제가 남는다.
정상적으로 흘러갈 때는 어렵지 않다.
Planner가 계획을 만들고, Implementer가 코드를 수정하고, Reviewer가 통과시키고, 테스트가 성공하면 된다.
실무는 그렇게 끝나지 않는다.
Reviewer가 Planner의 계획이 잘못됐다고 판단할 수 있다.
Codex가 구현을 끝내지 못할 수 있다.
테스트가 계속 실패할 수 있다.
Claude와 Codex가 서로 다른 해결책을 제시할 수 있다.
필요한 수정이 보호된 파일에 걸릴 수 있다.
에이전트가 위험한 명령을 실행하려 할 수 있다.
이때 많은 멀티 에이전트 시스템이 단순한 방법을 쓴다.
실패
→ 다시 시도
→ 또 실패
→ 다른 모델로 다시 시도
→ 또 실패
겉으로 보면 Self-Healing처럼 보인다.
하지만 이건 복구가 아니다.
비용을 태우면서 같은 실패를 반복하는 것이다.
진짜 Self-Healing은 실패 원인을 분류하고, 그 실패를 해결할 수 있는 역할에게 필요한 정보만 넘기는 구조다.
코드 문제
→ Fixer
테스트 해석 문제
→ Test Analyzer
실행 환경 문제
→ Human
정책 위반
→ 즉시 중단
모호한 요구사항
→ Planner 또는 Human
모델 장애
→ Fallback Provider
이번 글에서는 AI 개발 오케스트라가 실패했을 때 무한 재시도하지 않고, 적절한 담당자에게 작업을 넘기는 방법을 다룬다.
여기에 Consensus Workflow, Eval Gate, Tool Allowlist, Human Approval Gate를 붙여 실제 운영 가능한 구조를 완성한다.
Self-Healing이라는 표현을 들으면 AI가 알아서 문제를 발견하고 끝까지 고치는 모습을 떠올리기 쉽다.
테스트 실패
→ AI가 로그 분석
→ 코드 수정
→ 테스트 재실행
→ 성공
이 흐름 자체는 가능하다.
문제는 실패 원인을 구분하지 않은 채 같은 작업을 반복할 때다.
예를 들어 iOS 프로젝트에서 테스트가 실패했다고 해보자.
CheckoutViewModelTests.testRetryAfterFailure
Expected:
isSubmitting == false
Actual:
isSubmitting == true
이 실패는 구현 코드 문제일 가능성이 높다.
Codex Fixer에게 관련 파일과 최신 실패를 넘기면 된다.
하지만 다음 실패는 다르다.
xcodebuild: error:
Unable to find a destination matching the provided destination specifier
이건 구현 코드 문제가 아니다.
시뮬레이터 환경이나 테스트 명령의 문제다.
Codex에게 소스 코드를 다시 고치라고 해도 해결되지 않는다.
또 다른 실패를 보자.
PaymentAPI.swift를 수정해야 해결할 수 있지만
protected-files.yaml에서 approval_required로 지정됨
이건 기술 실패가 아니다.
정책에 의한 중단이다.
AI가 계속 우회해서 수정하려 하면 안 된다.
따라서 Self-Healing의 첫 단계는 재시도가 아니다.
실패 분류다.
오케스트라에서 발생하는 실패를 최소한 다음 정도로 나누는 것이 좋다.
CODE_FAILURE
TEST_FAILURE
ENVIRONMENT_FAILURE
POLICY_BLOCK
AMBIGUOUS_REQUIREMENT
PROVIDER_FAILURE
각 실패는 담당자가 다르다.
구현 코드 자체의 문제다.
컴파일 오류
잘못된 상태 전환
nil 처리 누락
동시성 오류
리뷰에서 발견된 기능 버그
이 경우 Codex Fixer나 Implementer 역할로 돌려보낸다.
단, 전체 작업을 처음부터 다시 시키지 않는다.
현재 diff와 발견된 문제만 전달한다.
테스트가 실패했지만 코드 문제인지 테스트 문제인지 바로 알 수 없는 경우다.
테스트 기대값이 잘못됨
Mock이 실제 동작과 다름
비동기 대기 방식이 불안정함
테스트가 상태 변경 전에 assertion을 실행함
이 경우 바로 Fixer에게 넘기지 않는다.
먼저 Test Analyzer가 실패를 분류한다.
제품 코드 버그인가
테스트 코드 버그인가
실행 순서 문제인가
flaky test인가
환경 문제인가
분석 결과가 코드 문제라면 Fixer에게 넘긴다.
테스트 자체의 문제라면 테스트 수정 계획을 새로 만든다.
코드와 직접 관계없는 실행 환경 문제다.
시뮬레이터 없음
Xcode 버전 불일치
의존성 다운로드 실패
인증서 문제
네트워크 차단
테스트 계정 없음
샌드박스 결제 환경 없음
이 경우 자동 코드 수정을 중단한다.
환경 문제를 코드 수정으로 해결하려 하면 쓸데없는 diff만 늘어난다.
에이전트가 정책상 허용되지 않은 행동을 요구하는 경우다.
보호 파일 수정 필요
신규 라이브러리 추가 필요
운영 설정 변경 필요
배포 명령 필요
secret 접근 필요
허용되지 않은 shell 명령 필요
정책 위반은 실패가 아니다.
의도적으로 멈춘 것이다.
따라서 다른 모델로 우회하면 안 된다.
Claude에서 차단
→ Codex로 다시 시도
금지
Provider를 바꿔 정책을 우회하면 Allowlist의 의미가 사라진다.
요구사항이 불명확해 더 진행할 수 없는 경우다.
실패 시 재시도 버튼을 보여줘야 하는가
자동 재시도해야 하는가
결제 완료 화면으로 언제 이동해야 하는가
기존 UX를 유지해야 하는가
API 계약 변경이 허용되는가
AI가 빈칸을 알아서 채우게 하지 않는다.
Planner에게 다시 보내거나 사람에게 질문을 올린다.
모델 또는 도구 자체가 정상 실행되지 않은 경우다.
CLI 비정상 종료
API timeout
rate limit
응답 파일 생성 실패
잘못된 JSON 반환
세션 연결 실패
이 경우에만 Fallback Provider를 고려한다.
Claude CLI 장애
→ Codex fallback
Codex 실행 장애
→ Claude fallback
잘못된 구현을 다른 모델로 덮는 것이 아니라, 실행 자체가 불가능할 때만 공급자를 바꾼다.
실패 분류도 자연어 보고서로만 남기면 다음 단계에서 다시 해석해야 한다.
JSON으로 고정한다.
{
"failure_type": "test_failure",
"severity": "medium",
"retryable": true,
"next_role": "test_analyzer",
"summary": "실패 후 isSubmitting 상태가 복구되지 않습니다.",
"evidence": {
"command": "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16'",
"failed_test": "CheckoutViewModelTests.testSubmitOrder_whenFailed_allowsRetry",
"error": "XCTAssertFalse failed: isSubmitting remained true"
},
"relevant_files": [
"CheckoutViewModel.swift",
"CheckoutViewModelTests.swift"
],
"policy_blocks": [],
"human_approval_required": false
}
Orchestrator는 failure_type과 next_role을 기준으로 다음 단계를 결정한다.
실패 분류는 규칙 기반으로 먼저 처리하고, 애매한 경우에만 AI를 사용한다.
from dataclasses import dataclass
from enum import StrEnum
class FailureType(StrEnum):
CODE_FAILURE = "code_failure"
TEST_FAILURE = "test_failure"
ENVIRONMENT_FAILURE = "environment_failure"
POLICY_BLOCK = "policy_block"
AMBIGUOUS_REQUIREMENT = "ambiguous_requirement"
PROVIDER_FAILURE = "provider_failure"
@dataclass(frozen=True)
class FailureDecision:
failure_type: FailureType
retryable: bool
next_role: str
human_approval_required: bool
reason: str
class FailureClassifier:
def classify(
self,
*,
exit_code: int,
log: str,
policy_blocked: bool,
provider_error: bool,
requirement_ambiguous: bool,
) -> FailureDecision:
normalized = log.lower()
if policy_blocked:
return FailureDecision(
failure_type=FailureType.POLICY_BLOCK,
retryable=False,
next_role="human",
human_approval_required=True,
reason="작업이 정책에 의해 차단됐습니다.",
)
if requirement_ambiguous:
return FailureDecision(
failure_type=FailureType.AMBIGUOUS_REQUIREMENT,
retryable=False,
next_role="planner",
human_approval_required=True,
reason="요구사항이 충분히 명확하지 않습니다.",
)
if provider_error:
return FailureDecision(
failure_type=FailureType.PROVIDER_FAILURE,
retryable=True,
next_role="fallback_provider",
human_approval_required=False,
reason="에이전트 공급자가 정상적으로 응답하지 않았습니다.",
)
environment_patterns = (
"unable to find a destination",
"no such module",
"certificate",
"provisioning profile",
"network is unreachable",
"connection timed out",
"simulator device failed",
)
if any(pattern in normalized for pattern in environment_patterns):
return FailureDecision(
failure_type=FailureType.ENVIRONMENT_FAILURE,
retryable=False,
next_role="human",
human_approval_required=True,
reason="실행 환경 문제로 판단됩니다.",
)
if "test failed" in normalized or "xctassert" in normalized:
return FailureDecision(
failure_type=FailureType.TEST_FAILURE,
retryable=True,
next_role="test_analyzer",
human_approval_required=False,
reason="테스트 실패 원인을 추가 분석해야 합니다.",
)
if exit_code != 0:
return FailureDecision(
failure_type=FailureType.CODE_FAILURE,
retryable=True,
next_role="fixer",
human_approval_required=False,
reason="컴파일 또는 구현 코드 문제로 판단됩니다.",
)
return FailureDecision(
failure_type=FailureType.AMBIGUOUS_REQUIREMENT,
retryable=False,
next_role="human",
human_approval_required=True,
reason="실패 유형을 자동 분류하지 못했습니다.",
)
규칙으로 명확히 분류할 수 있는 실패는 AI에게 다시 묻지 않는 편이 좋다.
에이전트가 실패할 때마다 계속 다른 모델을 호출하면 비용과 diff가 함께 커진다.
따라서 위험도별로 최대 수정 횟수를 정해야 한다.
.ai/policies/retry.yaml
version: 1
max_total_attempts: 4
risk:
low:
max_fix_rounds: 2
human_approval_required: false
medium:
max_fix_rounds: 2
human_approval_required: true
high:
max_fix_rounds: 1
human_approval_required: true
failure_routes:
code_failure:
next_role: fixer
retry: true
test_failure:
next_role: test_analyzer
retry: true
environment_failure:
next_role: human
retry: false
policy_block:
next_role: human
retry: false
ambiguous_requirement:
next_role: planner
retry: false
provider_failure:
next_role: fallback_provider
retry: true
max_provider_fallbacks: 1
핵심은 위험도가 높을수록 자동 수정 횟수를 줄이는 것이다.
Low Risk
→ 최대 2회 자동 수정
Medium Risk
→ 최대 2회
→ 최종 사람 승인
High Risk
→ 최대 1회
→ 바로 사람 검토
결제, 인증, 개인정보, 배포와 가까운 작업을 AI가 세 번, 네 번 연속으로 고치게 하면 변경 이력을 사람이 따라가기 어려워진다.
from pathlib import Path
import yaml
class RetryPolicy:
def __init__(self, policy_file: Path) -> None:
self.policy = yaml.safe_load(
policy_file.read_text(encoding="utf-8")
)
def can_retry(
self,
*,
failure_type: str,
risk: str,
current_fix_rounds: int,
total_attempts: int,
) -> bool:
if total_attempts >= int(self.policy["max_total_attempts"]):
return False
route = self.policy["failure_routes"].get(failure_type)
if route is None or not route.get("retry", False):
return False
risk_policy = self.policy["risk"][risk]
max_fix_rounds = int(risk_policy["max_fix_rounds"])
return current_fix_rounds < max_fix_rounds
def next_role(self, failure_type: str) -> str:
route = self.policy["failure_routes"].get(failure_type)
if route is None:
return "human"
return route.get("next_role", "human")
Orchestrator는 재시도 전에 정책을 확인한다.
if not retry_policy.can_retry(
failure_type=failure.failure_type.value,
risk=task_risk,
current_fix_rounds=state.fix_attempts,
total_attempts=state.total_attempts,
):
state.move_to(RunState.WAITING_HUMAN)
return
테스트가 실패했다고 이전 대화와 전체 빌드 로그를 Fixer에게 모두 전달하는 경우가 많다.
초기 요구사항
전체 소스 파일
첫 번째 계획
첫 번째 구현
첫 번째 리뷰
전체 빌드 로그
두 번째 구현
두 번째 빌드 로그
이전 모델의 설명
이렇게 넘기면 Fixer는 현재 실패보다 과거 흐름에 더 많은 영향을 받을 수 있다.
필요한 것은 Failure Context 압축이다.
무엇이 실패했는가
어떤 명령에서 실패했는가
기대 동작은 무엇인가
현재 실제 동작은 무엇인가
관련 파일은 무엇인가
수정할 수 있는 범위는 어디까지인가
{
"failure_type": "test_failure",
"task_id": "checkout-duplicate-submit",
"attempt": 2,
"command": "xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:MyAppTests/CheckoutViewModelTests",
"failed_test": "CheckoutViewModelTests.testSubmitOrder_whenFailed_allowsRetry",
"error": "XCTAssertFalse failed: isSubmitting remained true",
"expected": "주문 생성이 실패하면 isSubmitting이 false가 되고 사용자가 다시 시도할 수 있어야 합니다.",
"actual": "실패 후에도 isSubmitting이 true로 유지됩니다.",
"relevant_files": [
"CheckoutViewModel.swift",
"CheckoutViewModelTests.swift"
],
"allowed_to_edit": [
"CheckoutViewModel.swift",
"CheckoutViewModelTests.swift"
],
"do_not_edit": [
"PaymentAPI.swift",
"PaymentRepository.swift",
"NetworkClient.swift"
],
"previous_attempts_summary": [
"중복 진입 방지 guard를 추가했습니다.",
"실패 후 재시도 테스트를 추가했습니다."
]
}
Fixer에게는 이 파일과 현재 diff만 전달한다.
failure-context.json
current diff.patch
approved plan.json
review.json
과거 전체 대화는 전달하지 않는다.
import json
from pathlib import Path
from typing import Any
class FailureContextBuilder:
def build(
self,
*,
task_dir: Path,
failure: dict[str, Any],
allowed_to_edit: list[str],
do_not_edit: list[str],
previous_attempts: list[str],
) -> Path:
context = {
"failure_type": failure["failure_type"],
"task_id": task_dir.name,
"attempt": failure.get("attempt", 1),
"command": failure.get("command"),
"failed_test": failure.get("failed_test"),
"error": failure.get("error"),
"expected": failure.get("expected"),
"actual": failure.get("actual"),
"relevant_files": failure.get("relevant_files", []),
"allowed_to_edit": allowed_to_edit,
"do_not_edit": do_not_edit,
"previous_attempts_summary": previous_attempts[-3:],
}
output = task_dir / "failure-context.json"
output.write_text(
json.dumps(context, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return output
이전 시도는 최대 몇 줄만 요약해서 유지한다.
.ai/agents/fixer.md
# Fixer Agent
## Role
Fix only the issues explicitly listed in failure-context.json or review.json.
## Inputs
- approved plan.json
- current diff.patch
- failure-context.json
- review.json
- project coding rules
## Restrictions
- Do not restart the implementation.
- Do not expand the approved scope.
- Do not modify files outside allowed_to_edit.
- Do not add dependencies.
- Do not change public APIs.
- Do not refactor unrelated code.
- Do not modify tests only to make a real product bug disappear.
## Stop If
- The failure cannot be fixed inside allowed_to_edit.
- A protected file must change.
- The expected behavior is ambiguous.
- The test expectation conflicts with task.md.
## Required Output
{
"status": "fixed | blocked | failed",
"issues_addressed": [],
"files_changed": [],
"commands_run": [],
"remaining_risks": [],
"blocked_reasons": [],
"not_changed": []
}
Fixer가 전체 기능을 다시 구현하게 하면 기존 변경과 새 변경이 섞인다.
수정 대상은 최대한 좁아야 한다.
테스트가 실패했다고 제품 코드를 바로 고치면 안 된다.
테스트 자체가 잘못됐을 수도 있다.
예를 들어 이런 테스트가 있다고 하자.
func test_submitOrder_whenFailed_allowsRetry() async {
await viewModel.submitOrder()
XCTAssertFalse(viewModel.isSubmitting)
}
Mock이 실제로 비동기 실패를 반환하기 전에 assertion이 실행된다면 테스트 구조 문제일 수 있다.
Test Analyzer는 다음을 판단한다.
제품 코드 버그
테스트 코드 버그
Mock 문제
비동기 대기 문제
환경 문제
flaky test
출력은 구조화한다.
{
"verdict": "product_code_failure",
"confidence": 0.88,
"reason": "submitOrder 실패 경로에서 isSubmitting을 false로 복구하지 않습니다.",
"evidence": [
"Mock은 동기적으로 failure를 반환합니다.",
"catch 블록에서 isSubmitting이 변경되지 않습니다."
],
"next_role": "fixer",
"allowed_files": [
"CheckoutViewModel.swift",
"CheckoutViewModelTests.swift"
]
}
테스트 기대값이 잘못됐다면 이렇게 나온다.
{
"verdict": "test_code_failure",
"confidence": 0.81,
"reason": "현재 요구사항상 성공 직후 route가 설정되므로 canSubmit이 false인 것이 정상입니다.",
"next_role": "planner",
"human_approval_required": true
}
Planner와 Reviewer가 다른 판단을 내릴 수 있다.
예를 들어 현재 작업은 결제 버튼 중복 탭 방지다.
Planner는 이렇게 판단했다.
CheckoutViewModel에서 중복 진입을 막는다.
PaymentAPI는 수정하지 않는다.
Reviewer는 다른 문제를 발견했다.
ViewModel guard만으로는 네트워크 재시도나
다른 화면에서 발생하는 중복 주문을 막을 수 없다.
서버 idempotency가 필요하다.
둘 다 맞을 수 있다.
문제는 현재 작업의 범위다.
Reviewer가 발견한 위험이 유효하다고 해서 현재 PR에 Payment API 변경까지 바로 넣으면 작업이 크게 확대된다.
이럴 때 Consensus Workflow를 실행한다.
Consensus Workflow를 AI끼리 자유롭게 이야기하는 구조로 만들면 다시 컨텍스트가 길어진다.
다음 세 역할만 둔다.
Proposal Agent
→ 기존 계획의 근거 제시
Challenge Agent
→ 반대 의견과 위험 제시
Judge Agent
→ 근거 비교 후 결정
필요하면 마지막에 Human이 결정한다.
중요한 규칙은 다음과 같다.
자유 토론 금지
최대 한 라운드
코드 수정 금지
파일과 테스트 근거 필수
다수결 금지
현재 작업과 후속 이슈 분리 가능
AI 세 개가 같은 의견을 냈다고 정답이 되는 것은 아니다.
근거가 중요하다.
.ai/policies/consensus.yaml
version: 1
trigger:
- reviewer_requests_scope_change
- architecture_disagreement
- disputed_high_severity_issue
- test_expectation_disagreement
participants:
proposal_agent: planner
challenge_agent: reviewer
judge_agent: final_judge
rules:
max_rounds: 1
code_editing_allowed: false
evidence_required: true
file_references_required: true
test_references_required_when_available: true
majority_vote_allowed: false
outcomes:
- accept_original_plan
- approve_scope_change
- create_follow_up_issue
- request_human_decision
{
"position": "keep_original_scope",
"claim": "현재 사용자 중복 탭 문제는 CheckoutViewModel의 상태 제어로 해결할 수 있습니다.",
"evidence": [
{
"file": "CheckoutViewModel.swift",
"description": "결제 버튼의 isSubmitting 상태를 ViewModel이 소유합니다."
},
{
"file": "CheckoutView.swift",
"description": "결제 요청은 이 화면에서 submitOrder를 통해 시작됩니다."
}
],
"tests": [
"여러 번 호출해도 createOrder가 한 번만 실행되는 테스트"
],
"risks": [
"다른 진입점이나 네트워크 재시도는 현재 범위에서 보호하지 못합니다."
]
}
{
"position": "expand_scope",
"claim": "클라이언트 상태 제어만으로는 주문 중복 생성을 완전히 막을 수 없습니다.",
"evidence": [
{
"file": "PaymentAPI.swift",
"description": "createOrder 요청에 idempotency key가 없습니다."
},
{
"file": "CreateOrderUseCase.swift",
"description": "다른 화면에서도 같은 UseCase를 호출할 수 있습니다."
}
],
"tests": [
"동일한 주문 요청이 두 번 전달될 때 서버가 한 건만 생성하는 통합 테스트"
],
"risks": [
"API 계약 변경이 필요합니다.",
"Payment 영역은 사람 승인 대상입니다."
]
}
{
"decision": "accept_original_plan",
"reason": "현재 작업의 명시된 목표는 Checkout 화면의 중복 탭 방지입니다. 서버 idempotency는 중요하지만 API 계약과 Payment 영역 변경이 필요한 별도 작업입니다.",
"current_task_action": "ViewModel guard와 관련 테스트만 완료합니다.",
"follow_up_issue": {
"required": true,
"title": "주문 생성 API에 idempotency key 추가",
"risk": "high",
"human_review_required": true
},
"human_approval_required": false
}
현재 작업은 작게 끝내고, 발견된 더 큰 위험은 별도 이슈로 남긴다.
이게 좋은 Consensus다.
import json
from pathlib import Path
class ConsensusRunner:
def __init__(
self,
*,
task_dir: Path,
role_runner,
) -> None:
self.task_dir = task_dir
self.role_runner = role_runner
async def run(self) -> dict:
await self.role_runner(
role="proposal_agent",
output_file=self.task_dir / "proposal.json",
)
await self.role_runner(
role="challenge_agent",
output_file=self.task_dir / "challenge.json",
)
await self.role_runner(
role="judge_agent",
output_file=self.task_dir / "consensus-result.json",
)
return json.loads(
(self.task_dir / "consensus-result.json").read_text(
encoding="utf-8"
)
)
Judge Agent에게는 proposal.json, challenge.json, task.md, plan.json만 제공한다.
구현 코드를 수정할 권한은 주지 않는다.
테스트가 성공하면 작업이 끝났다고 생각하기 쉽다.
하지만 다음 변경도 테스트는 통과할 수 있다.
관련 없는 파일까지 수정
민감 정보 로그 추가
새 의존성 무단 추가
보호 파일 변경
public API 변경
구현 보고서와 실제 diff 불일치
그래서 테스트 뒤에 Eval Gate가 필요하다.
Eval Gate는 작업이 완료 조건을 만족하는지 여러 관점에서 검사한다.
Scope
Behavior
Tests
Security
Architecture
Maintainability
Evidence
.ai/evals/final-gate.yaml
version: 1
gates:
scope:
required: true
checks:
- changed_files_within_allowed_scope
- protected_files_unchanged
- not_goal_not_implemented
behavior:
required: true
checks:
- expected_behavior_satisfied
- failure_states_handled
- retry_behavior_safe
tests:
required: true
checks:
- required_tests_added
- tests_actually_executed
- failures_resolved
- skipped_tests_documented
security:
required: true
checks:
- no_sensitive_data_logged
- no_secret_file_access
- protected_domains_require_approval
architecture:
required: true
checks:
- layer_boundaries_preserved
- no_unapproved_public_api_change
maintainability:
required: true
checks:
- no_unrelated_refactoring
- no_unapproved_dependency
- diff_is_reviewable
evidence:
required: true
checks:
- implementation_report_matches_diff
- commands_have_exit_codes
- test_results_have_logs
{
"verdict": "waiting_human",
"gate_results": {
"scope": "pass",
"behavior": "pass",
"tests": "pass",
"security": "pass",
"architecture": "pass",
"maintainability": "pass",
"evidence": "pass"
},
"review_findings": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 1
},
"remaining_risks": [
"서버 측 idempotency는 별도 작업으로 남아 있습니다."
],
"human_inspection_targets": [
"결제 성공 후 route 전환 시점",
"후속 idempotency 이슈 생성 여부"
]
}
Eval Gate의 최종 상태는 다음 중 하나다.
PASS
NEEDS_CHANGES
BLOCKED
WAITING_HUMAN
PASS라고 해도 자동 머지를 의미하지는 않는다.
팀 정책에 따라 Human Approval이 필요할 수 있다.
from dataclasses import dataclass
@dataclass(frozen=True)
class GateResult:
name: str
passed: bool
reason: str
class EvalGate:
def evaluate(
self,
*,
scope_violations: list[str],
unresolved_review_issues: list[dict],
tests_passed: bool,
tests_executed: bool,
protected_files_changed: list[str],
unapproved_dependencies: list[str],
report_matches_diff: bool,
) -> dict:
gates = [
GateResult(
name="scope",
passed=not scope_violations,
reason=(
"Approved scope respected."
if not scope_violations
else f"Out-of-scope files: {scope_violations}"
),
),
GateResult(
name="review",
passed=not unresolved_review_issues,
reason=(
"No unresolved review issues."
if not unresolved_review_issues
else "Unresolved review findings remain."
),
),
GateResult(
name="tests",
passed=tests_executed and tests_passed,
reason=(
"Required tests executed and passed."
if tests_executed and tests_passed
else "Tests were not executed or did not pass."
),
),
GateResult(
name="protected_files",
passed=not protected_files_changed,
reason=(
"Protected files unchanged."
if not protected_files_changed
else f"Protected files changed: {protected_files_changed}"
),
),
GateResult(
name="dependencies",
passed=not unapproved_dependencies,
reason=(
"No unapproved dependencies."
if not unapproved_dependencies
else f"Unapproved dependencies: {unapproved_dependencies}"
),
),
GateResult(
name="evidence",
passed=report_matches_diff,
reason=(
"Implementation report matches diff."
if report_matches_diff
else "Implementation report does not match diff."
),
),
]
failed = [gate for gate in gates if not gate.passed]
return {
"verdict": "pass" if not failed else "blocked",
"gates": [
{
"name": gate.name,
"passed": gate.passed,
"reason": gate.reason,
}
for gate in gates
],
}
AI가 판단할 필요가 없는 항목은 코드로 검사한다.
에이전트가 실패를 고치려고 하다 위험한 명령을 실행할 수 있다.
rm -rf DerivedData
git reset --hard
pod update
swift package update
cat .env
printenv
curl https://example.com/install.sh | sh
git push
fastlane deploy
AI가 악의적으로 행동하지 않아도 문제는 생긴다.
“빌드가 안 되니 의존성을 업데이트해보자”는 판단이 전체 프로젝트 상태를 바꿀 수 있다.
따라서 허용된 명령만 실행하게 한다.
.ai/policies/tool-allowlist.yaml
version: 1
default: deny
allowed_patterns:
- "^git status( --short)?$"
- "^git diff( --stat| --binary| --name-only)?$"
- "^swift test$"
- "^swiftlint( --strict)?$"
- "^swiftformat --lint .+$"
- "^xcodebuild test -scheme [A-Za-z0-9_-]+ .+$"
blocked_patterns:
- "rm -rf"
- "git reset --hard"
- "git clean"
- "git push"
- "curl .+\\| sh"
- "curl .+\\| bash"
- "wget .+\\| sh"
- "cat .env"
- "printenv"
- "pod update"
- "swift package update"
- "fastlane"
- "deploy"
- "kubectl"
- "terraform apply"
기본값은 deny다.
허용되지 않은 명령은 실행하지 않는다.
프롬프트에 다음처럼 적을 수 있다.
위험한 명령을 실행하지 마.
.env 파일을 열지 마.
배포 명령을 실행하지 마.
이 규칙은 필요하다.
하지만 충분하지 않다.
에이전트가 지침을 놓치거나, 다른 문서의 악성 지시를 따르거나, 필요한 작업이라고 잘못 판단할 수 있다.
따라서 실행 직전에 코드가 검사해야 한다.
프롬프트:
행동 기준을 알려준다.
Policy Engine:
행동을 실제로 차단한다.
import re
from pathlib import Path
import yaml
class CommandGate:
def __init__(self, policy_file: Path) -> None:
self.policy = yaml.safe_load(
policy_file.read_text(encoding="utf-8")
)
def validate(self, command: str) -> tuple[bool, str]:
for pattern in self.policy.get("blocked_patterns", []):
if re.search(pattern, command):
return False, f"Blocked command pattern: {pattern}"
for pattern in self.policy.get("allowed_patterns", []):
if re.fullmatch(pattern, command):
return True, "Command explicitly allowed."
return False, "Command is not explicitly allowed."
사용 예시는 다음과 같다.
allowed, reason = gate.validate(
"xcodebuild test -scheme MyApp "
"-destination 'platform=iOS Simulator,name=iPhone 16'"
)
if not allowed:
raise PermissionError(reason)
.ai/policies/protected-files.yaml
version: 1
never_read:
- ".env"
- ".env.*"
- "**/*.p8"
- "**/Secrets.plist"
- "~/.ssh/**"
- "~/.aws/**"
read_only:
- "Sources/Auth/**"
- "Sources/Payment/**"
- "Sources/Security/**"
- ".github/workflows/**"
- "fastlane/**"
approval_required_to_edit:
- "Package.swift"
- "Podfile"
- "Tuist/**"
- "Sources/Network/**"
- "Sources/Analytics/**"
- "Database/**"
파일 접근은 네 단계로 구분하는 것이 좋다.
일반 읽기·수정 가능
읽기만 가능
사람 승인 후 수정 가능
절대 열람 금지
import fnmatch
from pathlib import Path
import yaml
class FileGate:
def __init__(self, policy_file: Path) -> None:
self.policy = yaml.safe_load(
policy_file.read_text(encoding="utf-8")
)
def can_read(self, file_path: str) -> tuple[bool, str]:
if self._matches_any(
file_path,
self.policy.get("never_read", []),
):
return False, "File is marked never_read."
return True, "Read allowed."
def can_write(
self,
file_path: str,
*,
approved: bool,
) -> tuple[bool, str]:
if self._matches_any(
file_path,
self.policy.get("never_read", []),
):
return False, "File is marked never_read."
if self._matches_any(
file_path,
self.policy.get("read_only", []),
):
return False, "File is read_only."
if self._matches_any(
file_path,
self.policy.get(
"approval_required_to_edit",
[],
),
):
if not approved:
return False, "Human approval is required."
return True, "Write allowed."
@staticmethod
def _matches_any(
file_path: str,
patterns: list[str],
) -> bool:
return any(
fnmatch.fnmatch(file_path, pattern)
for pattern in patterns
)
Claude Code Hook이나 CLI wrapper를 사용하면 도구 호출 직전에 Policy Engine을 실행할 수 있다.
개념적인 설정은 다음과 같다.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .ai/runtime/check_command.py"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python3 .ai/runtime/check_file_write.py"
}
]
}
]
}
}
AI가 실행하려는 명령이나 파일 경로를 Hook에 넘기고, 정책 위반이면 종료 코드로 차단한다.
보안과 권한은 부탁하는 것이 아니다.
강제하는 것이다.
AI 오케스트라의 목표는 사람을 완전히 제거하는 것이 아니다.
사람이 모든 코드를 처음부터 읽지 않아도 되도록 검증 결과를 압축하는 것이다.
다음 조건은 자동 완료하지 않는 것이 좋다.
결제 코드 변경
인증 흐름 변경
개인정보 처리 변경
보안·암호화 로직 변경
DB 마이그레이션
배포 설정 변경
신규 의존성 추가
public API 변경
테스트 미실행
snapshot 대량 변경
보호 파일 변경
.ai/policies/human-approval.yaml
version: 1
require_human_approval:
risk_levels:
- high
paths:
- "Sources/Payment/**"
- "Sources/Auth/**"
- "Sources/Privacy/**"
- "Sources/Security/**"
- "Database/**"
- ".github/workflows/**"
- "fastlane/**"
changes:
- public_api
- new_dependency
- database_schema
- production_config
- analytics_payload
- secret_handling
test_conditions:
- tests_not_run
- flaky_test_ignored
- snapshot_updated
- ui_tests_skipped_for_high_risk_task
{
"state": "waiting_human",
"reason": "high_risk_payment_change",
"required_reviewers": [
"ios-owner",
"payment-owner"
],
"human_inspection_targets": [
"CheckoutViewModel.swift",
"CheckoutViewModelTests.swift",
"final-report.md"
],
"unresolved_risks": [
"서버 idempotency가 아직 구현되지 않았습니다."
]
}
사람은 모든 실행 로그를 읽을 필요가 없다.
확인해야 할 파일과 위험만 보면 된다.
현재 작업을 작게 유지하면 더 큰 문제가 남을 수 있다.
결제 중복 탭은 ViewModel에서 막았지만 서버 idempotency가 없다는 문제가 대표적이다.
이 문제를 현재 PR에 억지로 넣지 않는다.
별도 후속 이슈로 남긴다.
{
"title": "주문 생성 API에 idempotency key 추가",
"reason": "클라이언트 중복 탭 방지는 네트워크 재시도와 다른 진입점의 중복 요청을 막지 못합니다.",
"risk": "high",
"suggested_owner": "payment-platform",
"evidence": [
{
"file": "PaymentAPI.swift",
"description": "createOrder 요청에 idempotency key가 없습니다."
}
],
"auto_create": false
}
auto_create의 기본값은 false가 좋다.
AI가 발견한 모든 가능성을 자동으로 이슈 트래커에 올리면 노이즈가 커진다.
사람이 검토한 뒤 GitHub Issue나 Linear에 등록한다.
import json
from pathlib import Path
class FollowUpIssueBuilder:
def create(
self,
*,
task_dir: Path,
title: str,
reason: str,
risk: str,
evidence: list[dict],
suggested_owner: str | None = None,
) -> Path:
issue = {
"title": title,
"reason": reason,
"risk": risk,
"suggested_owner": suggested_owner,
"evidence": evidence,
"auto_create": False,
}
output = task_dir / "follow-up-issue.json"
output.write_text(
json.dumps(issue, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return output
전체 흐름은 다음처럼 된다.
테스트 실패
│
▼
Failure Classifier
│
├── CODE_FAILURE
│ ▼
│ Codex Fixer
│
├── TEST_FAILURE
│ ▼
│ Claude Test Analyzer
│ ▼
│ Codex Fixer 또는 Planner
│
├── ENVIRONMENT_FAILURE
│ ▼
│ Human
│
├── POLICY_BLOCK
│ ▼
│ Human
│
├── AMBIGUOUS_REQUIREMENT
│ ▼
│ Planner / Human
│
└── PROVIDER_FAILURE
▼
Fallback Provider
수정 후에는 바로 완료하지 않는다.
Fixer
→ diff 재생성
→ Scope 검사
→ Reviewer
→ Tester
→ Eval Gate
복구 작업도 동일한 검증 과정을 다시 통과해야 한다.
class Orchestra:
async def handle_failure(
self,
*,
failure_input: dict,
task_risk: str,
) -> bool:
decision = self.failure_classifier.classify(
exit_code=failure_input["exit_code"],
log=failure_input["log"],
policy_blocked=failure_input.get(
"policy_blocked",
False,
),
provider_error=failure_input.get(
"provider_error",
False,
),
requirement_ambiguous=failure_input.get(
"requirement_ambiguous",
False,
),
)
if not self.retry_policy.can_retry(
failure_type=decision.failure_type.value,
risk=task_risk,
current_fix_rounds=self.state.fix_attempts,
total_attempts=self.state.total_attempts,
):
self.state.move_to(RunState.WAITING_HUMAN)
return False
if decision.next_role == "test_analyzer":
analysis = await self.run_test_analyzer(
failure_input
)
next_role = analysis["next_role"]
if next_role not in {"fixer", "planner"}:
self.state.move_to(RunState.WAITING_HUMAN)
return False
await self.run_role(next_role)
elif decision.next_role == "fixer":
await self.run_role("fixer")
elif decision.next_role == "fallback_provider":
await self.run_fallback_provider()
else:
self.state.move_to(RunState.WAITING_HUMAN)
return False
await self.diff_manager.capture(self.task_dir)
self.validate_scope()
review = await self.review()
if review["verdict"] != "pass":
self.state.move_to(RunState.WAITING_HUMAN)
return False
test_result = await self.test()
return test_result["status"] == "passed"
최종 보고서는 개발자가 짧은 시간 안에 판단할 수 있어야 한다.
# AI Orchestra Final Report
## Verdict
WAITING_FOR_HUMAN_APPROVAL
## Summary
CheckoutViewModel에서 결제 버튼 중복 진입을 차단하고, 실패 후 다시 시도할 수 있도록 상태를 복구했습니다.
## Files Changed
- CheckoutViewModel.swift
- CheckoutViewModelTests.swift
## Agent Chain
- Planner: Claude
- Implementer: Codex
- Reviewer: Claude
- Test Analyzer: Claude
- Fixer: Codex
- Final Review: Cursor
## Attempts
- Initial implementation: 1
- Fix rounds: 1
- Provider fallbacks: 0
## Commands Run
- xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:MyAppTests/CheckoutViewModelTests
## Tests
- Unit tests: passed
- UI tests: not run
- Build: passed
## Eval Gate
- Scope: PASS
- Behavior: PASS
- Tests: PASS
- Security: PASS
- Architecture: PASS
- Maintainability: PASS
- Evidence: PASS
## Review Findings
- Critical: 0
- High: 0
- Medium: 0
- Low: 1
## Policy Blocks
- PaymentAPI 수정은 사람 승인 대상이므로 현재 작업에서 제외했습니다.
## Remaining Risks
클라이언트 중복 탭은 차단했지만 서버 측 idempotency는 구현되지 않았습니다.
## Follow-up Issue
주문 생성 API에 idempotency key 추가
## Not Changed
- PaymentAPI
- PaymentRepository
- NetworkClient
- DesignSystem
- Analytics
- Production configuration
## Human Inspection Targets
- 결제 성공 후 route 전환 시점
- 실패 후 재시도 UX
- 서버 idempotency 후속 이슈 등록 여부
이 보고서가 있어야 사람이 전체 대화와 로그를 읽지 않고도 결정을 내릴 수 있다.
이 구조를 한 번에 모두 자동화할 필요는 없다.
Claude 계획
→ 사람이 확인
→ Codex 구현
→ Claude 리뷰
→ 테스트 실행
→ Cursor 최종 확인
실패하면 사람이 직접 원인을 분류한다.
Python script
상태 저장
역할별 산출물 전달
diff 자동 저장
Protected Files
Tool Allowlist
Stop Conditions
Schema Validation
Scope Validation
명확한 정책 위반은 코드로 차단한다.
저위험 작업만
최대 두 번
최신 실패만 전달
환경 실패는 사람에게 전달
범위 변경 의견 충돌
고위험 리뷰 이슈
요구사항 불일치
후속 이슈 제안
사람 승인 대기
GitHub Issue 이벤트
Linear 작업 이벤트
Cloud Agent 실행
완료 알림
사람 승인 대기
Cloud 자동화는 가장 마지막에 붙이는 것이 좋다.
로컬에서 통제되지 않는 워크플로를 클라우드로 옮기면 문제도 같이 확대된다.
모든 작업에 멀티 에이전트와 Self-Healing을 적용하면 안 된다.
다음 작업은 하나의 에이전트로 충분하다.
문구 수정
단순 포맷 변경
명확한 한 줄 버그
테스트 이름 변경
README 일부 수정
주석 오타 수정
오케스트라가 효과적인 작업은 다음과 같다.
여러 계층이 함께 바뀌는 작업
중간 이상 위험도를 가진 작업
AI가 자주 범위를 확대하는 작업
인증·결제·개인정보와 가까운 작업
독립 리뷰가 필요한 작업
장기 실행과 반복 수정이 필요한 작업
실패 원인 분류가 중요한 작업
AI를 많이 쓰는 것이 목표가 아니다.
검증 효과가 실행 비용보다 큰 작업에만 사용해야 한다.
실패 원인을 분류하지 않으면 같은 오류가 반복된다.
최신 실패가 과거 로그에 묻힌다.
정책 차단은 Provider fallback 대상이 아니다.
diff가 점점 커지고 처음 계획에서 멀어진다.
세 AI가 같은 의견을 냈다고 정답은 아니다.
근거와 테스트를 확인해야 한다.
독립 검토와 구현의 경계가 무너진다.
범위 위반, 보안 위험, 의존성 변경은 테스트가 잡지 못할 수 있다.
AI가 발견한 모든 가능성을 이슈로 만들면 노이즈가 커진다.
개발자가 봐야 할 파일과 위험이 정리되지 않으면 Approval Gate가 병목이 된다.
1편에서는 AI 개발팀의 역할을 나눴다.
Planner
Implementer
Reviewer
Tester
Final Judge
핵심 질문은 이것이었다.
누가 무엇을 담당할 것인가?
2편에서는 역할을 실제 파이프라인으로 연결했다.
Orchestrator
Provider Adapter
Model Router
State Store
Context Builder
Diff-first Review
Git worktree
Cursor Control Room
핵심 질문은 이것이었다.
서로 다른 AI와 작업 결과를 어떻게 이어줄 것인가?
3편에서는 실패와 위험을 통제했다.
Failure Classification
Retry Policy
Failure Context
Consensus Workflow
Eval Gate
Tool Allowlist
Human Approval
핵심 질문은 이것이다.
실패, 충돌, 보안 위험이 생겼을 때
어디까지 자동화하고 어디서 멈출 것인가?
AI 코딩 에이전트가 강해질수록 개발자의 역할은 코드를 직접 입력하는 일에서 조금씩 바뀐다.
앞으로 개발자는 다음을 설계하게 된다.
어떤 AI가 계획할 것인가
어떤 AI가 구현할 것인가
누가 독립적으로 리뷰할 것인가
어떤 파일을 수정할 수 있는가
어떤 명령을 실행할 수 있는가
실패를 몇 번까지 복구할 것인가
어떤 실패는 즉시 사람에게 넘길 것인가
AI 의견이 충돌하면 무엇을 근거로 판단할 것인가
최종적으로 어떤 증거를 남길 것인가
이건 프롬프트를 잘 쓰는 기술과 다르다.
작은 개발 조직과 실행 시스템을 설계하는 일에 가깝다.
Agent
→ 작업자
Orchestrator
→ 업무 흐름 관리자
Policy Engine
→ 권한 시스템
Artifacts
→ 작업 기록과 증거
Eval Gate
→ 품질 기준
Human Approval
→ 최종 책임
모델은 계속 바뀔 것이다.
오늘 Planner 역할에 적합한 모델과 내일 적합한 모델이 다를 수 있다.
하지만 이 구조는 쉽게 바뀌지 않는다.
Self-Healing은 AI가 실패해도 끝까지 혼자 고치게 만드는 기술이 아니다.
실패를 분류하고, 필요한 역할에게 필요한 정보만 넘기며, 일정 횟수를 넘으면 멈추는 기술이다.
Consensus는 AI끼리 긴 토론을 시키는 기술이 아니다.
서로 다른 의견을 구조화하고, 파일과 테스트 근거를 비교해 현재 작업과 후속 이슈를 분리하는 기술이다.
Eval Gate는 테스트가 통과했는지만 확인하는 단계가 아니다.
범위를 지켰는가
기대 동작을 만족했는가
테스트를 실제로 실행했는가
보안 위험이 없는가
아키텍처를 지켰는가
새 의존성이 들어오지 않았는가
보고서와 실제 diff가 일치하는가
이 모든 것을 확인하는 단계다.
결국 AI 개발 오케스트라의 핵심은 AI를 많이 붙이는 것이 아니다.
계획하는 AI
구현하는 AI
검토하는 AI
실패를 분석하는 AI가
각자의 권한 안에서만 움직이고,
검증 가능한 산출물을
다음 역할에 전달하게 만드는 것
좋은 오케스트라는 AI가 멈추지 않고 계속 일하는 시스템이 아니다.
위험하면 멈추고, 모호하면 사람에게 묻고, 실패하면 원인을 분류하고, 근거가 부족하면 승인하지 않는 시스템이다.
AI에게 일을 많이 시키는 것보다 중요한 것은 AI가 일을 못 해야 하는 순간을 정의하는 것이다.
한 줄로 정리하면 이렇다.