Case Study — 사례 연구
Reopt Agentic Governance 구현기: 진단에서 시스템까지
8개 AI 에이전트를 운영하는 프로덕션 SaaS에서 reopt architecture 원칙으로 거버넌스 공백을 진단하고, 범용 감사 로깅, 영속적 승인 기록, MCP 추적·속도 제한, 에이전트 제약, Admin 대시보드까지 구현한 과정.
배경 — 진단이 먼저, 구현은 그 다음
Reopt는 브랜드, 콘텐츠, 고객 운영을 하나의 워크스페이스에서 관리하는 B2B SaaS 플랫폼이다. Next.js 16, React 19, Turborepo 모노레포 위에 35개 패키지와 4개 앱이 올라가 있다. 8개의 AI 에이전트가 문서 생성, 브랜드 전략, 고객 분석, 페이지 빌딩, 워크플로 자동화를 수행한다.
reopt architecture의 8개 패턴으로 기존 시스템을 진단하자 네 가지 부채(Agentic Debt)가 선명하게 드러났다. 감사 로깅이 BrandDefinition에만 있어 도메인 간 일관성이 없고(Observability Gap), 승인 결정이 UI 상태에만 머물러 영속되지 않으며(Contract Gap), MCP 핸들러에 사용량 추적과 속도 제한이 없고(Authority Sprawl), 에이전트 실행에 구조적 제한이 없었다(Validation Gap).
이 글은 진단 결과를 바탕으로 거버넌스 시스템을 실제 구현한 과정을 다룬다.
Responsibility Partitioning — Agent Registry + Versioning
Reopt의 8개 에이전트는 각자의 책임 영역을 갖고 전역 레지스트리에 등록된다. 거버넌스 구현에서 추가된 것은 버전 관리와 실행 제약이다. 모든 에이전트가 semver 버전을 갖고, constraints 필드로 실행 범위를 제한한다.
type AgentDefinition = {
id: string;
displayName: string;
description: string;
version?: string; // semver — "1.0.0"
buildSystemPrompt: (ctx: AgentContext) => string | Promise<string>;
createTools: (params: { context: AgentContext; dataStream: ... }) => ToolSet;
activeToolNames: (ctx: AgentContext) => string[];
defaultModel: string;
allowedModels?: string[] | null;
uiFeatures: AgentUIFeatures;
constraints?: {
maxToolCallsPerTurn?: number; // 턴당 도구 호출 상한 (기본 10)
maxTokensPerSession?: number; // 세션당 토큰 상한
maxSteps?: number; // 최대 실행 단계 (기본 5)
};
};AgentDefinition — 버전과 실행 제약이 추가된 에이전트 계약
AgentContext에 agentId를 더하면서, 도구 실행부터 감사 로깅까지 에이전트 정체성이 호출 체인 전체를 관통한다. 라우트 핸들러 → 에이전트 선택 → 컨텍스트 생성 → 도구 팩토리 → 감사 로그로 이어지는 흐름에서 agentId가 끊기지 않는다.
Module Contract — Tool Registry 확장
Tool Registry를 15개에서 32개로 확장하면서 메타데이터도 손봤다. 기존 입출력 선언에 더해 실패 모드, LLM 호출 여부, 시간당 속도 제한을 계약에 담았다.
type ToolDefinition = {
id: string;
displayName: string;
category: ToolCategory;
action: ToolAction;
needsApproval: boolean;
parameters?: ToolParamDefinition[];
outputFields?: ToolParamDefinition[];
executeSummary?: string;
detailUrlPattern?: string;
// 거버넌스 구현에서 추가된 필드
involvesLLM?: boolean; // 내부적으로 Claude를 호출하는 도구
rateLimitPerHour?: number; // 도구별 시간당 호출 제한
failureModes?: Array<{
code: string;
retryable: boolean;
userMessage: string;
}>;
};ToolDefinition — 실패 모드, LLM 호출 여부, 속도 제한이 추가된 계약
involvesLLM 플래그는 비용 추적의 핵심이다. requestSuggestions, createDocument, createPostDraft 같은 도구는 내부적으로 Claude를 호출하므로, 도구 호출 비용에 더해 LLM 비용이 한 겹 더 쌓인다. 이 플래그가 있으면 비용 대시보드에서 LLM-in-LLM 패턴을 식별할 수 있다.
failureModes는 코드 곳곳에 흩어져 있던 거부 조건을 레지스트리로 끌어올린다. reopt architecture의 판단 질문 '어떤 조건에서 거부하거나 실패해야 하는가?'에 이제 선언적으로 답할 수 있다.
Human Approval — 영속적 승인 기록
기존에는 승인 결정이 UI 메시지 상태(approval-pending → approval-responded)에만 머물렀다. 채팅이 끝나면 누가 무엇을 승인했는지 추적할 길이 없었다. AiToolApprovalRecord 모델로 이 공백을 메웠다.
model AiToolApprovalRecord {
id String @id @default(cuid())
workspaceId String
chatId String // 어떤 대화에서
messageId String // 어떤 메시지에서
agentId String // 어떤 에이전트가
toolId String // 어떤 도구를 요청했고
toolArgs Json // 어떤 인자로
status String @default("pending") // pending | approved | denied
resolvedAt DateTime? // 언제 결정되었는지
createdAt DateTime @default(now())
@@index([workspaceId, status])
@@index([chatId])
}AiToolApprovalRecord — 승인 결정의 영속적 기록
승인 플로에서 도구 호출이 잡히면 레코드가 pending으로 생성되고, 사용자가 응답하면 approved나 denied로 갱신된다. 덕분에 '지난 달 cx 에이전트의 승인 거절률은?', '어떤 도구가 가장 많이 거절되는가?' 같은 질문에 답할 수 있다.
Decision Traceability — 범용 감사 로깅
이전에는 BrandDefinitionChange 테이블만 변경 이력을 남겼다. 고객 태그, 콘텐츠 퍼블리싱, CMS 속성 변경은 추적 밖에 있었다. AiToolAuditLog는 모든 데이터 변경 도구에 일관된 감사 로깅을 건다.
model AiToolAuditLog {
id String @id @default(cuid())
workspaceId String
agentId String // 어떤 에이전트가
toolId String // 어떤 도구로
userId String // 어떤 사용자의 세션에서
entityType String // 어떤 엔티티를
entityId String // 어떤 ID의
action String // create | update | delete
before Json? // 변경 전 상태
after Json? // 변경 후 상태
approvalId String? // 승인 기록 참조
durationMs Int? // 실행 소요 시간
createdAt DateTime @default(now())
@@index([workspaceId, createdAt(sort: Desc)])
@@index([agentId, createdAt(sort: Desc)])
@@index([entityType, entityId])
}AiToolAuditLog — 에이전트·도구·엔티티를 관통하는 범용 감사 로그
// logToolAction — 트랜잭션 안팎 모두에서 동작하는 감사 헬퍼
export async function logToolAction(
client: PrismaClient | PrismaTransaction,
params: {
workspaceId: string;
agentId: string;
toolId: string;
userId: string;
entityType: string;
entityId: string;
action: "create" | "update" | "delete";
before?: unknown;
after?: unknown;
approvalId?: string;
durationMs?: number;
},
) {
await client.aiToolAuditLog.create({ data: params });
}logToolAction — Prisma 트랜잭션과 호환되는 감사 헬퍼
핵심 설계 결정 두 가지. 첫째, 워크스페이스에 FK를 걸지 않는다. 워크스페이스가 삭제되어도 감사 로그는 남아야 한다(retention-first). 둘째, 로깅 실패가 도구 실행을 중단시키지 않는다(fire-and-forget). 감사는 관찰이지 차단이 아니다.
지금은 데이터 변경 도구 6개(updateCustomerTag, createCustomerTask, updatePostTags, updatePostProperties, savePostDraft, publishPost)에 걸려 있다. before/after 스냅샷으로 정확히 무엇이 바뀌었는지, approvalId로 어떤 승인 아래 실행됐는지 따라갈 수 있다.
MCP Governance — 추적, 속도 제한, 감사
이전 MCP 핸들러에는 사용량 추적도, 속도 제한도, 감사 로깅도 없었다. 외부 에이전트(Claude Code 등)의 행동이 완전한 블랙박스였다. 이 Observability Gap을 세 계층으로 메웠다.
// 1. McpToolInvocation — 호출 기록
model McpToolInvocation {
id String @id @default(cuid())
workspaceId String
clientId String // 어떤 클라이언트가
userId String
toolName String // 어떤 도구를
status String // success | error
durationMs Int?
createdAt DateTime @default(now())
// args와 result는 민감 데이터 가능성으로 의도적 제외
@@index([workspaceId, createdAt(sort: Desc)])
@@index([clientId, createdAt(sort: Desc)])
}McpToolInvocation — 외부 에이전트의 도구 호출 기록
// 2. MCP 속도 제한 — Redis 슬라이딩 윈도우
const READ_LIMIT = 100; // 분당 100회
const WRITE_LIMIT = 10; // 분당 10회
// 읽기/쓰기를 구분하여 차등 제한
const WRITE_TOOLS = new Set(["reopt_eav_record_bulk_update"]);
// Redis 장애 시 fail-open — 속도 제한 장애가 도구를 차단하지 않음
// 429 응답에 Retry-After 헤더 포함MCP Rate Limiting — 읽기/쓰기 차등 속도 제한
// 3. withAudit — 비차단 감사 래퍼
export function withAudit(handler: McpHandler): McpHandler {
return async (params) => {
const start = Date.now();
try {
const result = await handler(params);
// fire-and-forget: 감사 로깅 실패가 응답을 지연시키지 않음
void logMcpInvocation({ ...params, status: "success", durationMs: Date.now() - start });
return result;
} catch (err) {
void logMcpInvocation({ ...params, status: "error", durationMs: Date.now() - start });
throw err;
}
};
}withAudit — 감사 실패가 도구 실행을 차단하지 않는 비차단 래퍼
세 계층은 rate-limit → audit → handler 순으로 합성된다. 속도 제한을 먼저 걸어 과도한 호출을 막고, 통과한 호출에 감사를 적용한 다음 실제 핸들러를 실행한다. 각 계층은 서로 독립적이라 한 계층이 무너져도 나머지를 끌고 내려가지 않는다.
Governance Dashboard — 운영 가시성
감사 로그와 MCP 추적 데이터가 쌓여도 볼 수 없으면 거버넌스가 아니다. Admin 앱에 두 개의 대시보드를 추가했다.
- AI Audit 페이지 — 감사 로그와 승인 기록을 탭으로 분리. agentId, toolId, 상태, 날짜 필터링. before/after JSON 확장 뷰. 페이지당 50건.
- MCP Usage 페이지 — 총 호출 수, 성공률, 평균 응답 시간 요약 카드. 도구별 호출 빈도 상위 20개 차트. 최근 호출 로그. 7/30/90일 범위 선택.
Agent Usage Statistics는 AiCreditLedger와 Chat 테이블에서 일별 롤업(세션 수, 토큰, 크레딧)을 집계한다. 에이전트별 비용을 추적할 수 있는 기반이다.
이 대시보드가 있어야 reopt architecture OCLS 루프의 SHARPEN 단계로 들어갈 수 있다. 운영 데이터로 경계를 보정하려면 먼저 그 데이터를 볼 수 있어야 한다.
남은 과제
거버넌스 시스템을 구현해 Observability Gap과 Contract Gap을 상당 부분 메웠다. 그래도 남은 과제가 있다.
- 위험 등급 분류 없음 — 모든 승인 대상 도구가 동일한 수준의 승인을 요구한다. low/medium/high 매트릭스가 필요하다.
- Cross-Agent Collaboration 없음 — 에이전트 간 핸드오프와 컨텍스트 전달이 불가능하다. Stage 3(Multi-Agent Collaboration)로의 전환이 남아 있다.
- rationale 부재 — 감사 로그에 '무엇이 바뀌었는가'는 기록되지만 '왜 이 결정을 했는가'는 아직 기록되지 않는다.
- 나머지 데이터 변경 도구 — 32개 등록 도구 중 6개에만 감사 로깅이 적용되어 있다. Canvas, Document 도메인의 CUD 도구로 확장이 필요하다.
reopt architecture의 진화 단계로 보면 Reopt는 Stage 2(Responsibility Split)에서 Stage 3(Multi-Agent Collaboration)로 넘어가는 전환점에 서 있다. 거버넌스 인프라를 깔았으니, 이제 에이전트 간 협업 규칙을 설계할 토대가 생긴 셈이다.
결론 — 구조는 한 번에 완성되지 않는다
Reopt의 거버넌스 구현 과정은 두 단계로 나뉜다. 첫째, reopt architecture 패턴으로 기존 시스템을 진단하여 공백을 식별했다. 둘째, 식별된 공백을 인프라 수준에서 메웠다. 범용 감사 로그(AiToolAuditLog), 영속적 승인 기록(AiToolApprovalRecord), MCP 추적·속도 제한(McpToolInvocation + Redis rate limiter), 에이전트 제약(constraints), 관리 대시보드가 그 결과다.
핵심 설계 원칙은 세 가지였다. retention-first(로그는 원본 데이터보다 오래 산다), fire-and-forget(관찰이 실행을 막지 않는다), fail-open(인프라 장애가 기능을 무너뜨리지 않는다). 세 원칙 모두 프로덕션 SaaS에서 거버넌스가 성능과 안정성을 깎아먹어서는 안 된다는 현실적 제약에서 나왔다.
reopt architecture의 OCLS 루프가 말하듯, 거버넌스는 한 번에 완성되지 않는다. 운영 데이터를 보며 반복해서 다듬어 나간다. 이번 구현으로 첫 번째 루프를 완주했다. SHARPEN 단계에서 모이는 데이터가 다음 OWN 단계, 곧 위험 등급 분류와 에이전트 간 협업, rationale 기록을 끌어낸다.
태그