에이전트가 프로덕션 DB를 잘못 수정했습니다. 파일 200개를 잘못 덮어썼습니다. 되돌릴 방법이 없습니다. 이 상황을 구조적으로 막는 법을 정리했습니다.
[핵심 요약]
→ 문제: AI 에이전트는 실수를 확신에 차서 함 — 일반 버그보다 위험
→ 해결: 액션 실행 전 스냅샷, 실행 후 검증, 실패 시 자동 롤백
→ 핵심 패턴: Dry Run, 트랜잭션 래퍼, 스냅샷, 사람 확인 게이트
→ 도구: Git, DB 트랜잭션, 파일 백업, 샌드박스 실행
→ 원칙: 되돌릴 수 없는 액션은 항상 사람이 확인
왜 AI 에이전트의 실수가 더 위험한가
일반 버그 vs AI 에이전트 실수:
일반 버그:
→ 재현 가능 — 같은 입력에 같은 오류
→ 스택 트레이스 명확
→ 원인 파악 후 수정
AI 에이전트 실수:
→ 비결정적 — 재현이 어려움
→ 에이전트는 틀렸어도 확신에 차서 실행
→ 여러 파일/DB/API를 연쇄적으로 건드린 후 실패
→ 롤백 대상이 분산됨
실제 사고 시나리오:
에이전트: "레거시 코드 정리 시작"
→ 파일 47개 삭제 (사용 안 한다고 판단)
→ 실제로는 동적 임포트로 사용 중이었음
→ 프로덕션 배포 후 서비스 다운
→ 삭제된 파일 목록? 에이전트 세션 종료로 소멸
[위험도별 액션 분류]
읽기 전용 (안전):
→ 파일 읽기, DB 조회, API GET
→ 롤백 불필요
가역적 (주의):
→ 파일 수정, DB 업데이트, 이메일 초안
→ 스냅샷 후 실행, 롤백 가능
비가역적 (위험):
→ 파일 삭제, DB 삭제, 이메일 발송, 결제, 배포
→ 반드시 사람 확인 후 실행
실전 1 — Dry Run 패턴
실제 실행 전에 "무엇을 할 것인지"만 먼저 보여주고 확인을 받습니다.
from dataclasses import dataclass
from enum import Enum
from typing import Any
class RiskLevel(Enum):
SAFE = "safe" # 읽기 전용
REVERSIBLE = "reversible" # 되돌릴 수 있음
IRREVERSIBLE = "irreversible" # 되돌릴 수 없음
@dataclass
class PlannedAction:
"""실행 예정 액션 (Dry Run 결과)"""
action_id: str
tool_name: str
description: str # 사람이 읽을 수 있는 설명
args: dict
risk_level: RiskLevel
estimated_impact: str # "파일 3개 삭제", "DB 행 150개 수정"
class DryRunAgent:
"""Dry Run 지원 에이전트"""
async def plan(self, task: str) -> list[PlannedAction]:
"""실제 실행 없이 계획만 반환"""
# LLM에게 계획 수립 요청
response = await self.llm.complete(f"""
다음 작업의 실행 계획을 JSON으로 작성해주세요.
실제 실행은 하지 말고, 무엇을 할지만 설명하세요.
작업: {task}
각 액션은 다음 형식으로:
{{
"tool_name": "툴 이름",
"description": "무엇을 하는지 한국어 설명",
"args": {{}},
"risk_level": "safe|reversible|irreversible",
"estimated_impact": "영향 범위 설명"
}}
""")
return self._parse_plan(response)
async def execute_with_confirmation(
self,
task: str,
auto_approve_safe: bool = True
) -> str:
"""계획 확인 후 실행"""
# 1단계: 계획 수립
plan = await self.plan(task)
print("\n=== 실행 계획 ===")
for i, action in enumerate(plan, 1):
risk_emoji = {"safe": "✅", "reversible": "⚠️", "irreversible": "🚨"}
print(f"{i}. {risk_emoji[action.risk_level.value]} "
f"[{action.risk_level.value.upper()}] "
f"{action.description}")
print(f" 영향: {action.estimated_impact}")
# 2단계: 위험 액션 확인
dangerous = [a for a in plan
if a.risk_level == RiskLevel.IRREVERSIBLE]
if dangerous:
print(f"\n🚨 비가역적 액션 {len(dangerous)}개 발견:")
for action in dangerous:
print(f" - {action.description}")
confirm = input("\n계속 진행하시겠습니까? (yes/no): ")
if confirm.lower() != "yes":
return "사용자가 취소했습니다."
# 3단계: 실행
return await self._execute_plan(plan, auto_approve_safe)
[Dry Run 언제 쓰나]
→ 처음 실행하는 새로운 작업
→ 대규모 파일/DB 조작
→ 프로덕션 환경에서 실행 전
→ 비가역적 액션이 포함된 경우
→ 비용이 비싼 작업 (실수 시 재실행 부담)
실전 2 — 트랜잭션 래퍼 (DB 롤백)
DB 관련 에이전트 액션은 항상 트랜잭션으로 감쌉니다.
import asyncpg
from contextlib import asynccontextmanager
class TransactionalAgentDB:
"""트랜잭션 기반 에이전트 DB 래퍼"""
def __init__(self, conn_str: str):
self.conn_str = conn_str
@asynccontextmanager
async def transaction(self, savepoint: str = None):
"""
에이전트 DB 작업을 트랜잭션으로 래핑
실패 시 자동 롤백
"""
conn = await asyncpg.connect(self.conn_str)
try:
async with conn.transaction():
if savepoint:
await conn.execute(f"SAVEPOINT {savepoint}")
try:
yield conn
if savepoint:
await conn.execute(f"RELEASE SAVEPOINT {savepoint}")
except Exception as e:
if savepoint:
await conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
raise
finally:
await conn.close()
# 에이전트에서 사용
class DatabaseAgent:
def __init__(self, db: TransactionalAgentDB):
self.db = db
async def bulk_update_with_rollback(
self,
updates: list[dict],
validation_fn = None
) -> dict:
"""
대량 업데이트 — 검증 실패 시 전체 롤백
"""
results = []
async with self.db.transaction(savepoint="bulk_update") as conn:
for update in updates:
# 업데이트 실행
await conn.execute("""
UPDATE products
SET price = $1, updated_at = NOW()
WHERE id = $2
""", update["price"], update["id"])
results.append(update)
# 검증 실행 (실패 시 전체 롤백)
if validation_fn:
validation_result = await validation_fn(conn)
if not validation_result["passed"]:
raise ValueError(
f"검증 실패: {validation_result['reason']}\n"
f"전체 업데이트 롤백됨"
)
return {"updated": len(results), "status": "success"}
# 사용 예시
async def run_price_update_agent():
db_agent = DatabaseAgent(db=TransactionalAgentDB(CONN_STR))
async def validate_prices(conn):
"""가격이 0 이하인 항목 없는지 검증"""
count = await conn.fetchval(
"SELECT COUNT(*) FROM products WHERE price <= 0"
)
return {
"passed": count == 0,
"reason": f"가격이 0 이하인 항목 {count}개 발견" if count > 0 else ""
}
try:
result = await db_agent.bulk_update_with_rollback(
updates=[
{"id": 1, "price": 15000},
{"id": 2, "price": 25000},
{"id": 3, "price": -999}, # 잘못된 가격 → 롤백 트리거
],
validation_fn=validate_prices
)
except ValueError as e:
print(f"롤백 완료: {e}")
# 모든 가격이 원래대로 복원됨
실전 3 — 파일 시스템 스냅샷
파일 조작 전에 스냅샷을 찍고, 실패 시 복원합니다.
import shutil
import os
from pathlib import Path
from datetime import datetime
from contextlib import contextmanager
class FileSystemSnapshot:
"""파일 시스템 스냅샷 및 롤백"""
def __init__(self, snapshot_dir: str = ".agent_snapshots"):
self.snapshot_dir = Path(snapshot_dir)
self.snapshot_dir.mkdir(exist_ok=True)
def create(self, target_paths: list[str]) -> str:
"""지정된 경로들의 스냅샷 생성"""
snapshot_id = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
snapshot_path = self.snapshot_dir / snapshot_id
snapshot_path.mkdir()
manifest = []
for path in target_paths:
src = Path(path)
if src.exists():
if src.is_file():
dst = snapshot_path / src.name
shutil.copy2(src, dst)
manifest.append({"path": str(src), "type": "file"})
elif src.is_dir():
dst = snapshot_path / src.name
shutil.copytree(src, dst)
manifest.append({"path": str(src), "type": "dir"})
# 매니페스트 저장
import json
(snapshot_path / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False)
)
print(f"스냅샷 생성: {snapshot_id} ({len(manifest)}개 경로)")
return snapshot_id
def restore(self, snapshot_id: str) -> None:
"""스냅샷에서 복원"""
import json
snapshot_path = self.snapshot_dir / snapshot_id
if not snapshot_path.exists():
raise ValueError(f"스냅샷 없음: {snapshot_id}")
manifest = json.loads((snapshot_path / "manifest.json").read_text())
for item in manifest:
src = snapshot_path / Path(item["path"]).name
dst = Path(item["path"])
# 현재 파일 삭제
if dst.exists():
if dst.is_file():
dst.unlink()
elif dst.is_dir():
shutil.rmtree(dst)
# 스냅샷에서 복원
if item["type"] == "file":
shutil.copy2(src, dst)
elif item["type"] == "dir":
shutil.copytree(src, dst)
print(f"복원 완료: {snapshot_id}")
def cleanup(self, snapshot_id: str) -> None:
"""성공 후 스냅샷 삭제"""
shutil.rmtree(self.snapshot_dir / snapshot_id)
@contextmanager
def file_transaction(target_paths: list[str], snapshot_dir: str = ".agent_snapshots"):
"""
파일 작업을 트랜잭션처럼 처리
실패 시 자동 롤백
"""
fs = FileSystemSnapshot(snapshot_dir)
snapshot_id = fs.create(target_paths)
try:
yield
# 성공 시 스냅샷 삭제
fs.cleanup(snapshot_id)
print("파일 작업 완료 — 스냅샷 삭제")
except Exception as e:
# 실패 시 롤백
print(f"오류 발생: {e}")
print("파일 롤백 중...")
fs.restore(snapshot_id)
fs.cleanup(snapshot_id)
print("롤백 완료")
raise
# 에이전트에서 사용
async def refactor_agent(target_dir: str):
target_files = list(Path(target_dir).glob("**/*.py"))
with file_transaction([str(f) for f in target_files]):
for file in target_files:
# 파일 수정 (실패 시 전체 롤백)
content = file.read_text()
modified = apply_refactoring(content)
file.write_text(modified)
# 수정 후 검증
if not validate_all_files(target_files):
raise ValueError("검증 실패 — 롤백 트리거")
실전 4 — Git 기반 롤백 (코드 수정 에이전트)
코드 수정 에이전트는 Git을 자동 롤백 시스템으로 활용합니다.
import subprocess
from pathlib import Path
class GitBackedAgent:
"""Git을 롤백 시스템으로 사용하는 에이전트"""
def __init__(self, repo_path: str):
self.repo = Path(repo_path)
def _git(self, *args) -> str:
"""Git 명령 실행"""
result = subprocess.run(
["git", *args],
cwd=self.repo,
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(f"Git 오류: {result.stderr}")
return result.stdout.strip()
def create_checkpoint(self, message: str) -> str:
"""현재 상태를 Git 커밋으로 체크포인트 저장"""
self._git("add", "-A")
# 변경사항이 있을 때만 커밋
status = self._git("status", "--porcelain")
if not status:
return self._git("rev-parse", "HEAD") # 현재 커밋 반환
self._git("commit", "-m", f"[AGENT CHECKPOINT] {message}")
commit_hash = self._git("rev-parse", "HEAD")
print(f"체크포인트 생성: {commit_hash[:8]} — {message}")
return commit_hash
def rollback_to(self, commit_hash: str) -> None:
"""특정 커밋으로 롤백"""
print(f"롤백 중: {commit_hash[:8]}")
self._git("reset", "--hard", commit_hash)
print("롤백 완료")
def get_diff(self, commit_hash: str) -> str:
"""체크포인트 이후 변경사항 조회"""
return self._git("diff", commit_hash, "HEAD")
async def run_with_git_rollback(self, task: str) -> str:
"""Git 체크포인트로 보호된 에이전트 실행"""
# 시작 전 체크포인트
checkpoint = self.create_checkpoint(f"작업 시작: {task[:50]}")
try:
# 에이전트 실행
result = await self._execute_coding_task(task)
# 실행 후 검증
if not self._validate_code():
raise ValueError("코드 검증 실패")
# 성공 시 최종 커밋
self.create_checkpoint(f"작업 완료: {task[:50]}")
return result
except Exception as e:
print(f"오류: {e}")
# 체크포인트로 롤백
self.rollback_to(checkpoint)
return f"실패 및 롤백 완료: {str(e)}"
def _validate_code(self) -> bool:
"""코드 검증 (문법 오류, 테스트 등)"""
try:
# Python 문법 검사
result = subprocess.run(
["python", "-m", "py_compile", "."],
cwd=self.repo,
capture_output=True
)
return result.returncode == 0
except Exception:
return False
실전 5 — 사람 확인 게이트 (Human-in-the-Loop)
비가역적 액션 직전에 반드시 사람이 확인하는 게이트를 삽입합니다.
from enum import Enum
class ApprovalResult(Enum):
APPROVED = "approved"
REJECTED = "rejected"
MODIFIED = "modified"
class HumanApprovalGate:
"""사람 확인 게이트"""
def __init__(self, mode: str = "cli"):
self.mode = mode # "cli", "slack", "email"
async def request_approval(
self,
action_description: str,
impact: str,
preview: str = None,
timeout_seconds: int = 300
) -> tuple[ApprovalResult, str]:
"""
사람에게 확인 요청
Returns:
(결과, 코멘트)
"""
if self.mode == "cli":
return await self._cli_approval(
action_description, impact, preview
)
elif self.mode == "slack":
return await self._slack_approval(
action_description, impact, preview, timeout_seconds
)
async def _cli_approval(
self, description, impact, preview
) -> tuple[ApprovalResult, str]:
print("\n" + "="*50)
print("🚨 비가역적 액션 — 사람 확인 필요")
print("="*50)
print(f"액션: {description}")
print(f"영향: {impact}")
if preview:
print(f"\n미리보기:\n{preview}")
print("="*50)
while True:
choice = input("\n[a]승인 / [r]거부 / [m]수정 / [?]상세: ").lower()
if choice == "a":
comment = input("코멘트 (선택): ")
return ApprovalResult.APPROVED, comment
elif choice == "r":
reason = input("거부 이유: ")
return ApprovalResult.REJECTED, reason
elif choice == "m":
modification = input("수정 내용: ")
return ApprovalResult.MODIFIED, modification
elif choice == "?":
print(f"\n상세 정보:\n{preview or '없음'}")
async def _slack_approval(
self, description, impact, preview, timeout
) -> tuple[ApprovalResult, str]:
"""Slack으로 승인 요청 (Slack Bolt 앱 필요)"""
# Slack Block Kit으로 승인 버튼 전송
# 사용자 클릭 대기
# 응답 반환
pass # 실제 구현은 Slack Bolt SDK 참고
# 에이전트에서 사용
class SafeDeploymentAgent:
def __init__(self, approval_gate: HumanApprovalGate):
self.gate = approval_gate
async def deploy(self, service: str, version: str) -> str:
# 배포 계획 수립
plan = await self._create_deploy_plan(service, version)
# 비가역적 액션(배포)에 사람 확인 요청
result, comment = await self.gate.request_approval(
action_description=f"{service} v{version} 프로덕션 배포",
impact="서비스 다운타임 30초 예상, 이전 버전 롤백 불가",
preview=f"변경사항:\n{plan['changelog']}"
)
if result == ApprovalResult.APPROVED:
return await self._execute_deploy(plan)
elif result == ApprovalResult.REJECTED:
return f"배포 취소됨: {comment}"
elif result == ApprovalResult.MODIFIED:
# 수정사항 반영 후 재계획
return await self.deploy(service, version)
실전 6 — 통합: 전체 롤백 파이프라인
class ResilientAgent:
"""모든 롤백 전략이 통합된 에이전트"""
def __init__(
self,
db: TransactionalAgentDB,
fs_snapshot: FileSystemSnapshot,
git: GitBackedAgent,
approval_gate: HumanApprovalGate,
state_store: StateStore
):
self.db = db
self.fs = fs_snapshot
self.git = git
self.gate = approval_gate
self.state = state_store
async def execute_safely(self, task: str, session_id: str) -> str:
"""안전한 에이전트 실행 전체 파이프라인"""
# 1. 상태 체크포인트
state = AgentState(session_id=session_id, task=task,
status=AgentStatus.RUNNING)
await self.state.save(state)
# 2. Git 체크포인트
git_checkpoint = self.git.create_checkpoint(f"작업 시작: {task[:50]}")
# 3. Dry Run으로 계획 수립
plan = await self._plan(task)
dangerous_actions = [a for a in plan
if a.risk_level == RiskLevel.IRREVERSIBLE]
# 4. 위험 액션 사람 확인
if dangerous_actions:
result, _ = await self.gate.request_approval(
action_description=f"{len(dangerous_actions)}개 비가역적 액션 포함",
impact="\n".join(a.estimated_impact for a in dangerous_actions)
)
if result != ApprovalResult.APPROVED:
return "사용자 취소"
# 5. DB 트랜잭션으로 실행
try:
async with self.db.transaction(savepoint="agent_work") as conn:
result = await self._execute_plan(plan, conn)
# 6. 실행 후 검증
if not await self._validate(conn):
raise ValueError("검증 실패")
# 7. 성공 상태 저장
state.status = AgentStatus.COMPLETED
await self.state.save(state)
return result
except Exception as e:
# 8. 실패 시 모든 레이어 롤백
self.git.rollback_to(git_checkpoint) # 파일 롤백
# DB는 트랜잭션으로 자동 롤백됨
state.status = AgentStatus.FAILED
state.context["error"] = str(e)
await self.state.save(state)
return f"실패 및 전체 롤백 완료: {str(e)}"
마무리
✅ 롤백 전략 필수인 상황
→ 파일 삭제/수정이 포함된 에이전트
→ 프로덕션 DB를 건드리는 에이전트
→ 배포, 이메일 발송, 결제 등 비가역적 액션
→ 여러 시스템을 연쇄적으로 수정하는 멀티 에이전트
❌ 과한 경우
→ 읽기 전용 에이전트 (조회만 함)
→ 샌드박스 환경에서만 실행
→ 실패해도 재실행 비용이 낮은 프로토타입
[구현 우선순위]
1순위: Dry Run + 사람 확인 게이트 (즉각 효과)
2순위: DB 트랜잭션 래퍼 (DB 안전망)
3순위: Git 체크포인트 (코드 수정 안전망)
4순위: 파일 스냅샷 (파일 시스템 안전망)
5순위: 통합 파이프라인 (모든 레이어 결합)
관련 글:
AI 에이전트 상태 관리 완전 가이드 — 장기 실행 에이전트에서 상태를 잃지 않는 법
에이전트가 30분 작업 중 20분에 크래시났습니다. 처음부터 다시 시작합니다. 이 문제를 구조적으로 해결하는 법을 정리했습니다.[핵심 요약]→ 문제: LLM 컨텍스트는 세션 종료 시 사라짐 → 장기
cell-devlog.tistory.com
AI 에이전트 테스트 전략 완전 가이드 — 단위 테스트부터 통합 테스트, E2E까지
일반 소프트웨어는 같은 입력에 항상 같은 출력이 나옵니다. AI 에이전트는 그렇지 않습니다. 테스트 전략 자체가 달라야 합니다.[핵심 요약]→ 문제: AI 에이전트는 비결정적 → 기존 단위 테스
cell-devlog.tistory.com
AI 에이전트에 Shell Access 주면 안 되는 이유 — 실제 해킹 사례와 방어법
최근 CI/CD 파이프라인에서 이런 일이 있었어요.AI가 GitHub Issues를 자동으로 분류하는 워크플로우를 구축했어요. 편리하고 잘 돌아갔어요. 그런데 어느 날 공격자가 이슈에 이런 내용을 올렸어요.
cell-devlog.tistory.com
'AI Agent' 카테고리의 다른 글
| Claude Code 디버깅 완전 가이드 — 에이전트가 실패할 때 추적하는 법 (0) | 2026.04.30 |
|---|---|
| LLM-as-Judge 완전 가이드 — AI로 AI 출력을 자동 평가하는 법 (0) | 2026.04.30 |
| AI 에이전트 상태 관리 완전 가이드 — 장기 실행 에이전트에서 상태를 잃지 않는 법 (0) | 2026.04.28 |
| AI 에이전트 테스트 전략 완전 가이드 — 단위 테스트부터 통합 테스트, E2E까지 (0) | 2026.04.28 |
| MCP 9700만 설치 — Linux Foundation 오픈 거버넌스 채택, AI 에이전트 표준 인프라가 됐습니다 (0) | 2026.04.28 |