운영 도메인이 실제로는 v1.invyone.com / solution.invyone.com / api.invyone.com 인데 코드/문서 곳곳에 v1.invion.com / api.invion.com 등 미존재 도메인이 박혀 있어 정리. 변경 파일 (21): - frontend lib/api/client.ts, lib/utils/apiUrl.ts: hostname 체크 endsWith(\".invyone.com\") 일반화 - frontend lib/api/dashboard.ts, file.ts, flow.ts, FileViewerModal*2.tsx: 도메인 치환 - frontend invion-layout-v5.html: 시안 내 placeholder 도메인 정리 - backend-spring SecurityConfig.java: CORS 주석 예시 정리 - docker/deploy/docker-compose.yml, k8s/traefik-dynamic.yaml: traefik Host 라벨 정리 - scripts/prod/deploy.sh: 안내 메시지 정리 - .cursor/rules/api-client-usage.mdc, project-conventions.mdc: AI 가이드 정리 - docs/* 4개: 아키텍처/플로우 문서 도메인 정리 - notes/gbpark/* 3개: 과거 메모 정리 신규: - docs/DOMAIN_MAPPING.md: 운영/개발/폐기 도메인 영구 기록. AI 에이전트와 신규 개발자가 헷갈리지 않도록 단일 진실 출처. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
64 KiB
AI 오케스트레이션 교체 — 상세 아키텍처 설계서 (Architect 산출물)
작성: architect (BMAD 2단계) 일자: 2026-04-27 입력:
ai-orchestration-replacement-prd.md(planner) + vexplor 9 서비스 코드 + invyone Spring/Security 코드 산출물 위치: 본 응답 텍스트 (architect는 read-only — 사용자가 동일 내용으로C:\Dev\projects\invyone\.omc\plans\ai-orchestration-replacement-architecture.md저장 권장) 다음 단계: executor
0. Summary (핵심 결정 5개)
| # | 결정 영역 | 선택 | 근거 |
|---|---|---|---|
| K1 | 마이그레이션 도구 | Flyway 10.x (org.flywaydb:flyway-core + flyway-database-postgresql) 신규 도입 |
invyone 현재 마이그레이션 도구 부재(grep 확인). Spring Boot 3.3.5 starter 자동연동 우수, 멱등 보장, MyBatis와 직교. Liquibase는 XML/YAML 학습비용 큼 |
| K2 | OpenClaw 통합 | 별도 Docker 컨테이너 + HTTP API 동기화 (공유 볼륨 X) | 컨테이너 격리 → 보안/배포 단순. 공유볼륨은 K8s 운영 시 권한 충돌 빈발. OPENCLAW_ENABLED=false 토글로 단계도입 |
| K3 | LLM 클라이언트 | Spring RestClient(동기) + WebClient(스트리밍 SSE) 직접 구현. Spring AI는 Phase 2로 보류 |
이미 AiAssistantProxyService.java:30이 RestTemplate 사용 → RestClient(동기 후속)는 invyone 표준 라인. Spring AI 1.0 GA는 의존성·BOM 큼, 5개 프로바이더 직접 매핑이 vexplor 동작과 1:1 매칭 더 정확 |
| K4 | 스케줄러 | Quartz JDBC JobStore (spring-boot-starter-quartz) |
vexplor aiSchedulerService.ts:1이 node-cron 인메모리 사용 — 단일 노드 가정. invyone은 Jenkins 배포 + 추후 멀티 인스턴스 가능성. Quartz JDBC가 클러스터 안전. @Scheduled는 cron 표현식 동적 등록/해제 어려움(고정 표현식 한계) |
| K5 | API 키 인증 필터 | OncePerRequestFilter 상속 AiApiKeyAuthFilter, SubdomainResolverFilter 뒤 + JwtAuthenticationFilter 앞에 삽입 |
테넌트 컨텍스트 먼저 결정(현재 invyone Phase 2 계약 유지) → API 키 검증 → JWT는 키 없을 때만 처리. SecurityConfig.java:69-71의 기존 필터 체인 보존 |
테이블 13개 컬럼 수: 평균 14컬럼, 합산 ≈ 184컬럼 (아래 §3 DDL).
executor가 시작할 첫 Story: B1 — Flyway V20260427_001__ai_baseline.sql 작성 (Story C1 공통 인프라보다 먼저, 모든 후속 매퍼/엔티티의 contract).
1. 시스템 다이어그램
┌──────────────────────────────────────────────────────────────┐
│ 외부 시스템 (B2B) 브라우저 (사내/관리자) │
│ Authorization: Bearer Authorization: Bearer │
│ sk-pipe-{64hex} {JWT} │
└────────────┬──────────────────────────┬──────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ Spring Security FilterChain │
│ │
│ CorsFilter → SubdomainResolverFilter (tenant ctx 결정) │
│ → AiApiKeyAuthFilter ★신규 │
│ · /api/ai/v1/**만 매칭 │
│ · sk-pipe-* 발견 시 SHA256 → ai_agent_api_keys │
│ · last_used_at, usage_count 갱신 │
│ · 월간 토큰 한도 체크 → 429 │
│ → JwtAuthenticationFilter (기존) │
│ → TenantConsistencyGuardFilter │
│ → ForcePasswordChangeGuardFilter │
└────────────┬─────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Controller Layer (com.erp.ai.controller) │
│ │
│ /api/ai-agents/** ─ JWT, COMPANY_ADMIN+ │
│ /api/ai-agent-groups/** ─ JWT, COMPANY_ADMIN+ │
│ /api/ai/v1/** ─ ApiKey OR JWT (이중 허용) │
│ /api/ai-agent-schedules/** ─ JWT, COMPANY_ADMIN+ │
│ /api/ai-knowledge/** ─ JWT, COMPANY_ADMIN+ │
└────────────┬─────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Service Layer (com.erp.ai.service) │
│ │
│ ┌────────────────────┐ ┌─────────────────────────────┐ │
│ │ MultiAgentExecution│───▶│ LlmClient (interface) │ │
│ │ Engine │ │ ├─ AnthropicLlmClient │ │
│ │ (CompletableFuture │ │ ├─ OpenAiLlmClient │ │
│ │ + 가상스레드) │ │ ├─ GeminiLlmClient │ │
│ │ │ │ ├─ DeepSeekLlmClient │ │
│ │ │ │ └─ OllamaLlmClient │ │
│ │ │ └────────┬────────────────────┘ │
│ │ │ │ HTTPS │
│ │ │ ▼ │
│ │ │ External LLM Providers │
│ │ │ │
│ │ │ ┌─────────────────────────────┐ │
│ │ │───▶│ Connector executors │ │
│ │ │ │ (DB / REST / PLC / file) │ │
│ └─────────┬──────────┘ └─────────────────────────────┘ │
│ │ │
│ ├─▶ AiAgentService (CRUD) │
│ ├─▶ AiAgentGroupService (CRUD + 멤버) │
│ ├─▶ AiAgentApiKeyService (sk-pipe 발급/검증) │
│ ├─▶ AiAgentProviderService (AES-GCM 암복호) │
│ ├─▶ AiAgentConversationSvc (대화 적재) │
│ ├─▶ AiAgentUsageService (토큰/비용 누적) │
│ ├─▶ AiAnalysisLogService (이력/정확도) │
│ ├─▶ AiKnowledgeService (RAG 파일) │
│ ├─▶ AiSchedulerService (Quartz JDBC) │
│ └─▶ OpenClawSyncService (HTTP 동기화) │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ OpenClawClient │ │
│ │ (RestClient → :18789)│ │
│ └─────────┬───────────┘ │
└────────────┬─────────────────┼─────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌─────────────────────────────────┐
│ MyBatis Mapper Layer │ │ openclaw 컨테이너 (별도 docker) │
│ (com.erp.ai.mapper) │ │ · port 18789 │
└──────────┬───────────┘ │ · authProfiles + agents JSON │
│ │ · /v1/sync 엔드포인트(가정) │
▼ └─────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ PostgreSQL 16 (invyone @ 183.99.177.40) │
│ schema: invyone │
│ ai_agents · ai_agent_groups · ai_agent_group_members │
│ ai_agent_api_keys · ai_llm_providers · ai_agent_messages │
│ ai_agent_conversations · ai_agent_usage_logs │
│ ai_agent_schedules · ai_analysis_logs · ai_knowledge_files │
│ QRTZ_* (Quartz 11 테이블) │
└──────────────────────────────────────────────────────────────┘
데이터 저장 흐름 (그룹 실행): 외부 호출 → ApiKey 검증(ai_agent_api_keys UPDATE) → MultiAgentExecutionEngine.execute → 멤버별 LlmClient 호출 → ai_agent_conversations INSERT + ai_agent_messages INSERT × N + ai_analysis_logs INSERT + ai_agent_usage_logs INSERT.
2. 패키지 구조
com.erp.ai
├── controller/
│ ├── AiAgentController.java // /api/ai-agents — JWT
│ ├── AiAgentGroupController.java // /api/ai-agent-groups (+ /:id/members, /connectors)
│ ├── AiAgentApiKeyController.java // /api/ai-agents/keys — JWT
│ ├── AiAgentProviderController.java // /api/ai-agents/providers — SUPER_ADMIN
│ ├── AiAgentConversationController.java // /api/ai-agents/conversations
│ ├── AiAgentUsageController.java // /api/ai-agents/usage
│ ├── AiAgentScheduleController.java // /api/ai-agent-schedules
│ ├── AiKnowledgeController.java // /api/ai-knowledge
│ └── AiV1GatewayController.java // /api/ai/v1/* — ApiKey OR JWT (외부 호환)
├── service/
│ ├── AiAgentService.java // 에이전트 CRUD/검색/필터
│ ├── AiAgentGroupService.java // 그룹 + 멤버 + 가용 커넥터
│ ├── AiAgentApiKeyService.java // sk-pipe 발급/검증/한도
│ ├── AiAgentProviderService.java // 프로바이더 + AES-GCM
│ ├── AiAgentConversationService.java // 대화 + 메시지
│ ├── AiAgentUsageService.java // 사용량/요약/일별
│ ├── AiAnalysisLogService.java // 분석이력/정확도
│ ├── AiKnowledgeService.java // RAG 지식 파일
│ ├── AiSchedulerService.java // Quartz Job 등록/해제
│ ├── OpenClawSyncService.java // 프로바이더/에이전트 → OpenClaw
│ └── MultiAgentExecutionEngine.java // 동시성 핵심 (자세한 §7)
├── client/
│ ├── LlmClient.java // 추상 인터페이스
│ ├── llm/
│ │ ├── AnthropicLlmClient.java // /v1/messages
│ │ ├── OpenAiLlmClient.java // /v1/chat/completions
│ │ ├── GeminiLlmClient.java // /v1beta/models/.../generateContent
│ │ ├── DeepSeekLlmClient.java // OpenAI compat
│ │ └── OllamaLlmClient.java // /api/chat
│ ├── LlmClientRouter.java // model 명으로 구현체 선택
│ └── OpenClawClient.java // RestClient → :18789
├── connector/
│ ├── Connector.java // sealed interface
│ ├── DatabaseConnector.java
│ ├── RestApiConnector.java
│ ├── FileConnector.java
│ ├── CrawlerConnector.java
│ ├── PlcConnector.java
│ └── ConnectorExecutor.java // type → 구현체 라우팅
├── scheduler/
│ ├── AiAgentScheduledJob.java // org.quartz.Job 구현체
│ └── QuartzSchedulerConfig.java
├── security/
│ ├── AiApiKeyAuthFilter.java // OncePerRequestFilter
│ ├── ApiKeyAuthenticationToken.java // Authentication 구현체
│ └── ApiKeyPrincipal.java // 인증 주체
├── config/
│ ├── AiSecurityConfig.java // SecurityFilterChain @Order(2) — /api/ai/v1/**
│ ├── OpenClawProperties.java // @ConfigurationProperties("openclaw")
│ ├── LlmHttpClientConfig.java // RestClient/WebClient bean
│ ├── AiExecutorConfig.java // 가상스레드 ExecutorService bean
│ └── FlywayConfig.java // 신규 마이그레이션 위치 등록 (필요시)
├── crypto/
│ ├── EncryptUtil.java // AES-256-GCM (12B IV + 16B tag)
│ └── EncryptionProperties.java // @ConfigurationProperties("ai.encryption")
├── tenant/
│ └── CompanyCodeFilter.java // company_code 강제 주입 헬퍼 (정적 메서드)
├── mapper/ // MyBatis (interface)
│ ├── AiAgentMapper.java
│ ├── AiAgentGroupMapper.java
│ ├── AiAgentGroupMemberMapper.java
│ ├── AiAgentApiKeyMapper.java
│ ├── AiLlmProviderMapper.java
│ ├── AiAgentConversationMapper.java
│ ├── AiAgentMessageMapper.java
│ ├── AiAgentUsageLogMapper.java
│ ├── AiAgentScheduleMapper.java
│ ├── AiAnalysisLogMapper.java
│ └── AiKnowledgeFileMapper.java
├── domain/ // immutable record (Lombok @Builder)
│ ├── AiAgent.java
│ ├── AiAgentGroup.java
│ ├── AiAgentGroupMember.java
│ ├── AiAgentApiKey.java
│ ├── AiLlmProvider.java
│ ├── AiAgentConversation.java
│ ├── AiAgentMessage.java
│ ├── AiAgentUsageLog.java
│ ├── AiAgentSchedule.java
│ ├── AiAnalysisLog.java
│ ├── AiKnowledgeFile.java
│ ├── ConnectorRef.java // type / connection_id / config_id / path / name
│ └── ExecutionResult.java
├── dto/
│ ├── request/ // CreateAgentRequest, CreateApiKeyRequest …
│ └── response/ // AgentDto, GroupDto, UsageSummaryDto …
└── exception/
├── AiBusinessException.java // 422
├── AiNotFoundException.java // 404
├── AiUnauthorizedException.java // 401
├── AiQuotaExceededException.java // 429
└── AiRestExceptionHandler.java // @RestControllerAdvice
리소스:
backend-spring/src/main/resources/
├── mapper/ai/ // MyBatis XML 11개
└── db/migration/ // Flyway
├── V20260427_001__ai_baseline.sql
├── V20260427_002__ai_indexes.sql
├── V20260427_003__ai_seed_providers.sql
├── V20260427_004__quartz_tables.sql // org.quartz tables-postgres.sql 복사
└── V20260427_005__drop_ai_assistant_db.sql // (해당 DB 별도 호스트면 main-spring 미적용 가능)
3. DB 스키마 (DDL — PostgreSQL 16)
11개 핵심 테이블 + Quartz 11개 테이블. (planner가 13개 추정 → 코드 역공학 결과 11개로 정확화:
ai_agent_toolsP3 미포함,ai_agent_knowledge_*은ai_knowledge_files로 단일화. P2 RAG 라이브러리/멤버십 분리 시 추가 예정)모든 테이블:
company_code VARCHAR(20)NULL 허용(NULL또는'*'= 슈퍼관리자/전체 공유),created_at/updated_atTIMESTAMPTZ DEFAULT NOW(), FK는RESTRICT기본, 명시 시CASCADE.
3.1 ai_agents (출처: aiAgentService.ts:43-58, types/aiAgent.ts:3-17)
CREATE TABLE ai_agents (
id BIGSERIAL PRIMARY KEY,
agent_id VARCHAR(64) NOT NULL UNIQUE, -- aiAgentService.ts:38 unique check
name VARCHAR(200) NOT NULL,
description TEXT,
model VARCHAR(100) NOT NULL DEFAULT 'claude-sonnet-4-20250514',
system_prompt TEXT,
tools JSONB NOT NULL DEFAULT '[]'::jsonb, -- aiAgentService.ts:53
config JSONB NOT NULL DEFAULT '{}'::jsonb, -- knowledge_files, max_tokens, temperature
status VARCHAR(20) NOT NULL DEFAULT 'active' -- active/inactive/archived
CHECK (status IN ('active','inactive','archived')),
company_code VARCHAR(20), -- NULL 또는 '*' = 글로벌
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ai_agents_company ON ai_agents (company_code);
CREATE INDEX idx_ai_agents_status ON ai_agents (status);
CREATE INDEX idx_ai_agents_created ON ai_agents (created_at DESC);
3.2 ai_agent_groups (출처: aiAgentGroupService.ts:71-79)
CREATE TABLE ai_agent_groups (
id BIGSERIAL PRIMARY KEY,
group_id VARCHAR(64) NOT NULL UNIQUE, -- 'group-{base36}'
name VARCHAR(200) NOT NULL,
description TEXT,
execution_mode VARCHAR(20) NOT NULL DEFAULT 'mixed' -- parallel/sequential/mixed
CHECK (execution_mode IN ('parallel','sequential','mixed')),
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','inactive','archived')),
company_code VARCHAR(20),
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ai_groups_company ON ai_agent_groups (company_code);
CREATE INDEX idx_ai_groups_status ON ai_agent_groups (status);
3.3 ai_agent_group_members (출처: aiAgentGroupService.ts:117-119, multiAgentExecutionEngine.ts:60-65)
CREATE TABLE ai_agent_group_members (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES ai_agent_groups(id) ON DELETE CASCADE,
agent_id BIGINT NOT NULL REFERENCES ai_agents(id) ON DELETE RESTRICT,
role_name VARCHAR(100) NOT NULL, -- "재고 분석가" 등
connectors JSONB NOT NULL DEFAULT '[]'::jsonb, -- ConnectorRef[]
execution_order INTEGER NOT NULL DEFAULT 1, -- mixed 모드 핵심
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_group_members_group ON ai_agent_group_members (group_id, execution_order);
3.4 ai_agent_api_keys (출처: aiAgentApiKeyService.ts:30-46, types/aiAgent.ts:40-57)
CREATE TABLE ai_agent_api_keys (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
key_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 hex
key_prefix VARCHAR(16) NOT NULL, -- 'sk-pipe-{8hex}' 표시용
user_id VARCHAR(64) NOT NULL,
company_code VARCHAR(20),
agent_id BIGINT REFERENCES ai_agents(id) ON DELETE SET NULL,
permissions JSONB NOT NULL DEFAULT '["chat"]'::jsonb,
rate_limit INTEGER NOT NULL DEFAULT 60, -- req/min
monthly_token_limit BIGINT NOT NULL DEFAULT 1000000,
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','revoked')),
last_used_at TIMESTAMPTZ,
usage_count BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_api_keys_hash ON ai_agent_api_keys (key_hash);
CREATE INDEX idx_api_keys_user ON ai_agent_api_keys (user_id);
CREATE INDEX idx_api_keys_company ON ai_agent_api_keys (company_code);
CREATE INDEX idx_api_keys_status ON ai_agent_api_keys (status);
결정 (open-question Q7):
status='revoked'컬럼은 두되, vexplor의 hard-delete(aiAgentApiKeyService.ts:54) 동작은 그대로 옮긴다. 이유: ai_agent_usage_logs.api_key_id FK 무결성 + 추후 감사로그 옵션 확보. 초기 구현은DELETE, 나중에 정책 변경 시 컬럼만 활용.
3.5 ai_llm_providers (출처: aiAgentProviderService.ts:28-42, types/aiAgent.ts:113-129)
CREATE TABLE ai_llm_providers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE, -- anthropic/openai/google/deepseek/ollama
display_name VARCHAR(200) NOT NULL,
api_key_encrypted TEXT NOT NULL, -- AES-256-GCM base64(IV||CT||TAG)
model_name VARCHAR(100) NOT NULL,
endpoint VARCHAR(500),
priority INTEGER NOT NULL DEFAULT 1,
max_tokens INTEGER NOT NULL DEFAULT 4096,
temperature NUMERIC(3,2) NOT NULL DEFAULT 0.70,
cost_per_1k_input NUMERIC(10,6) NOT NULL DEFAULT 0,
cost_per_1k_output NUMERIC(10,6) NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_providers_priority ON ai_llm_providers (priority);
CREATE INDEX idx_providers_active ON ai_llm_providers (is_active);
3.6 ai_agent_conversations (출처: aiAgentConversationService.ts:56-58, multiAgentExecutionEngine.ts:99-104)
CREATE TABLE ai_agent_conversations (
id BIGSERIAL PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL UNIQUE, -- 'conv-{uuid}'
agent_id BIGINT REFERENCES ai_agents(id) ON DELETE SET NULL,
user_id VARCHAR(64),
api_key_id BIGINT REFERENCES ai_agent_api_keys(id) ON DELETE SET NULL,
title VARCHAR(500),
message_count INTEGER NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'active',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, -- group_id, group_name, execution_mode
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_conv_agent ON ai_agent_conversations (agent_id);
CREATE INDEX idx_conv_user ON ai_agent_conversations (user_id);
CREATE INDEX idx_conv_apikey ON ai_agent_conversations (api_key_id);
CREATE INDEX idx_conv_updated ON ai_agent_conversations (updated_at DESC);
3.7 ai_agent_messages (출처: aiAgentConversationService.ts:67-69)
CREATE TABLE ai_agent_messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES ai_agent_conversations(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('system','user','assistant','tool')),
content TEXT NOT NULL,
tool_calls JSONB,
token_count INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, -- multiAgentExecutionEngine.ts:115 (role_name, agent_name, ...)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_msg_conv ON ai_agent_messages (conversation_id, created_at);
3.8 ai_agent_usage_logs (출처: aiAgentUsageService.ts:8-9, types/aiAgent.ts:93-111)
CREATE TABLE ai_agent_usage_logs (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(64),
api_key_id BIGINT REFERENCES ai_agent_api_keys(id) ON DELETE SET NULL,
agent_id BIGINT REFERENCES ai_agents(id) ON DELETE SET NULL,
conversation_id BIGINT REFERENCES ai_agent_conversations(id) ON DELETE SET NULL,
provider_name VARCHAR(50),
model_name VARCHAR(100),
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd NUMERIC(12,6) NOT NULL DEFAULT 0,
response_time_ms INTEGER,
success BOOLEAN NOT NULL DEFAULT TRUE,
error_message TEXT,
request_path VARCHAR(500),
ip_address INET,
company_code VARCHAR(20), -- NEW: tenancy 추적 (vexplor 미존재, invyone 강화)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_usage_created ON ai_agent_usage_logs (created_at DESC);
CREATE INDEX idx_usage_apikey ON ai_agent_usage_logs (api_key_id, created_at DESC);
CREATE INDEX idx_usage_agent ON ai_agent_usage_logs (agent_id, created_at DESC);
CREATE INDEX idx_usage_company ON ai_agent_usage_logs (company_code, created_at DESC);
3.9 ai_agent_schedules (출처: aiSchedulerService.ts:40-42, 102)
CREATE TABLE ai_agent_schedules (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
group_id BIGINT NOT NULL REFERENCES ai_agent_groups(id) ON DELETE CASCADE,
cron_expression VARCHAR(100) NOT NULL, -- Quartz 6-7필드 또는 5필드
timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Seoul', -- open-question 12 결정
input_message TEXT NOT NULL,
notification JSONB NOT NULL DEFAULT '{}'::jsonb, -- {system_notice, webhook, email[]}
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
run_count BIGINT NOT NULL DEFAULT 0,
company_code VARCHAR(20),
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sched_active ON ai_agent_schedules (is_active);
CREATE INDEX idx_sched_company ON ai_agent_schedules (company_code);
3.10 ai_analysis_logs (출처: aiAnalysisLogService.ts:21-30, 44, 57, 67)
CREATE TABLE ai_analysis_logs (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT REFERENCES ai_agent_groups(id) ON DELETE SET NULL,
agent_id BIGINT REFERENCES ai_agents(id) ON DELETE SET NULL,
schedule_id BIGINT REFERENCES ai_agent_schedules(id) ON DELETE SET NULL,
execution_type VARCHAR(20) NOT NULL -- manual/api/schedule
CHECK (execution_type IN ('manual','api','schedule')),
input_message TEXT NOT NULL,
analysis_result TEXT NOT NULL,
prediction JSONB,
actual_result JSONB,
accuracy_score NUMERIC(5,2), -- 0~100, 백분율
tokens_used INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
company_code VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_analysis_group_created ON ai_analysis_logs (group_id, created_at DESC);
CREATE INDEX idx_analysis_company ON ai_analysis_logs (company_code, created_at DESC);
3.11 ai_knowledge_files (출처: knowledgeRoutes.ts:15, 55)
CREATE TABLE ai_knowledge_files (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(300) NOT NULL,
file_name VARCHAR(300),
category VARCHAR(100),
description TEXT,
content TEXT NOT NULL, -- 파싱된 본문 (마크다운/텍스트)
file_size BIGINT NOT NULL DEFAULT 0,
mime_type VARCHAR(100),
company_code VARCHAR(20),
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_knowledge_category ON ai_knowledge_files (category);
CREATE INDEX idx_knowledge_company ON ai_knowledge_files (company_code);
3.12 Quartz 영구 테이블 (Story C10용)
org.quartz:quartz 의존성에 포함된 tables_postgres.sql 스크립트 11개 테이블(QRTZ_JOB_DETAILS, QRTZ_TRIGGERS, QRTZ_CRON_TRIGGERS …)을 별도 마이그레이션 파일 V20260427_004__quartz_tables.sql 로 적용. invyone의 멀티 인스턴스 배포(클러스터링) 안전.
3.13 invyone 의 vexplor 커넥터 테이블 — 부재 확인 (open-question 6)
검색 결과(grep CREATE TABLE.*external_db_connections) invyone 본 DB에 다음 테이블 없음:
external_db_connections(단,ExternalRestApiConnectionService.java발견 — 동일 의도 신규 테이블 존재 가능성)external_rest_api_connectionscrawl_configspipeline_device_connections/pipeline_tag_mappings
결정: Story C3 "AiAgentGroupService.getAvailableConnectors" 는 try/catch + empty fallback 으로 구현(vexplor aiAgentGroupService.ts:158, 163, 168, 176 의 .catch(() => []) 패턴 그대로 옮김). 테이블 미존재 시 빈 배열만 반환되고 화면은 "사용 가능한 커넥터 없음" 표시. 추후 ExternalRestApi*/PLC 도입 시 mapping 추가. 이 결정으로 P0 차단 없음.
4. API 명세
모든 응답:
{ success: boolean, data?: <T>, message?: string, error?: string }(invyone 표준).
4.1 관리 (JWT, COMPANY_ADMIN+)
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|---|---|---|---|---|
| GET | /api/ai-agents |
?status&company_code&search |
AgentDto[] |
company_code 자동 주입(JWT claim) |
| GET | /api/ai-agents/:id |
— | AgentDto |
|
| POST | /api/ai-agents |
CreateAgentRequest |
AgentDto |
agent_id unique |
| PUT | /api/ai-agents/:id |
UpdateAgentRequest |
AgentDto |
|
| DELETE | /api/ai-agents/:id |
— | {success:true} |
soft (status=archived) |
| GET | /api/ai-agent-groups |
— | GroupDto[] (with member_count) |
|
| GET | /api/ai-agent-groups/:id |
— | GroupDto (with members JOIN) |
|
| POST | /api/ai-agent-groups |
{name, description, execution_mode} |
GroupDto |
|
| PUT/DELETE | /api/ai-agent-groups/:id |
|||
| POST | /api/ai-agent-groups/:id/members |
AddMemberRequest |
MemberDto |
|
| PUT/DELETE | /api/ai-agent-groups/members/:memberId |
|||
| GET | /api/ai-agent-groups/connectors |
— | ConnectorDto[] |
invyone에 테이블 없으면 [] |
| GET | /api/ai-agents/keys |
— | ApiKeyDto[] |
admin: 전체, user: 자기 |
| POST | /api/ai-agents/keys |
CreateApiKeyRequest |
{key:ApiKeyDto, plainKey} |
plainKey 1회만 |
| DELETE | /api/ai-agents/keys/:id |
— | ||
| GET/POST/PUT/DELETE | /api/ai-agents/providers/* |
SUPER_ADMIN only | ||
| GET | /api/ai-agents/conversations |
?page&limit&agentId |
{conversations, total} |
|
| GET | /api/ai-agents/conversations/:id |
— | {conversation, messages} |
|
| DELETE | /api/ai-agents/conversations/:id |
— | ||
| GET | /api/ai-agents/usage/summary |
— | UsageSummaryDto |
|
| GET | /api/ai-agents/usage/logs |
?page&limit |
{logs,total} |
|
| GET | /api/ai-agents/usage/daily |
?days=30 |
DailyUsageDto[] |
|
| CRUD | /api/ai-agent-schedules/* |
cron 검증 | ||
| CRUD | /api/ai-knowledge/* |
카테고리 필터 |
4.2 외부 게이트웨이 (ApiKey OR JWT, OpenAI 호환)
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | /api/ai/v1/chat/completions |
OpenAI 표준 (model, messages, stream, max_tokens, temperature) |
OpenAI 응답 + stream=true 시 SSE (text/event-stream) |
| POST | /api/ai/v1/groups/:groupId |
{message} |
GroupExecutionResultDto |
| GET | /api/ai/v1/groups |
— | GroupDto[] (active만) |
| GET | /api/ai/v1/models |
— | OpenAI 모델 형식 ({data:[{id,object,...}]}) |
| GET | /api/ai/v1/health |
— | {status:'ok', providers:N, openclaw:bool} |
429: 월간 토큰 한도 초과. 401: 키 없음/만료. 403: permissions에 chat 미포함. 503: OpenClaw 다운(외부 호출만).
5. 인증/보안 설계
5.1 필터 체인 통합
// SecurityConfig.java (수정)
http.addFilterBefore(new AiApiKeyAuthFilter(aiAgentApiKeyService),
JwtAuthenticationFilter.class)
- 기존 6개 필터 사이 위치:
SubdomainResolverFilter(tenant ctx) →AiApiKeyAuthFilter(신규, /api/ai/v1/ 만 매칭)** →JwtAuthenticationFilter→TenantConsistencyGuardFilter→ForcePasswordChangeGuardFilter. AiApiKeyAuthFilter가 sk-pipe-* 발견 시 SecurityContext 채우고 통과; 못 찾으면 그대로 다음 필터로 위임 (이중 인증 = JWT 도 OK).
5.2 ApiKey 검증 흐름
1. Authorization: Bearer <token>
2. token.startsWith("sk-pipe-") 검사 → 미일치면 chain.doFilter (JWT 처리)
3. SHA-256(token) 계산 → ai_agent_api_keys.key_hash 조회
4. status='active' & expires_at>NOW 확인 (실패 시 401)
5. permissions JSONB 에 'chat' 포함 검사 (없으면 403)
6. 월간 토큰 누적 vs monthly_token_limit 검사 (초과 시 429)
7. UPDATE last_used_at, usage_count++
8. SecurityContext = ApiKeyAuthenticationToken(ApiKeyPrincipal{keyId, userId, companyCode, agentId})
9. chain.doFilter
→ 컨트롤러는 @AuthenticationPrincipal ApiKeyPrincipal 또는 request.getAttribute("api_key_id") 로 식별.
5.3 LLM 프로바이더 키 암호화
선택: AES-256-GCM (Bouncy Castle) vs Jasypt → AES-GCM 직접 구현.
- 근거: Jasypt는 PBE/AES-CBC 위주, GCM 지원 약함. AES-GCM은 인증 태그가 평문 변조 검출 가능 → 키 위변조 방지에 강함.
- JDK 21 내장
javax.crypto.Cipher+GCMParameterSpec만으로 구현 가능 (Bouncy Castle 의존성 없이도 됨). - 형식:
base64( IV(12B) || CIPHERTEXT || TAG(16B) ) - 키 관리(open-question 8): 환경변수
AI_PROVIDER_ENCRYPTION_KEY(Base64 32B). KMS는 Phase 2.
// EncryptUtil.java 핵심 — 의사코드
public static String encrypt(String plain) {
byte[] iv = SecureRandom.getInstanceStrong().generateSeed(12);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(ENCRYPT_MODE, keyFromEnv(), new GCMParameterSpec(128, iv));
byte[] ct = c.doFinal(plain.getBytes(UTF_8));
return Base64.encode(concat(iv, ct));
}
5.4 멀티테넌시 강제
선택: 명시적 BaseService 패턴 (AOP 어노테이션은 invyone에 선례 없음, 학습 비용↑).
public abstract class CompanyScopedService {
protected String currentCompanyCode() {
var attr = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest().getAttribute("company_code");
if (attr == null) throw new AiBusinessException("company_code 누락");
return (String) attr;
}
}
모든 list/get/update/delete 매퍼 호출 시 companyCode 인자 강제. SQL: WHERE (company_code = #{companyCode} OR company_code IS NULL OR company_code = '*'). SUPER_ADMIN 만 companyCode='*' 그대로 통과 가능.
6. OpenClaw 통합 방식
6.1 배포 (open-question 2)
선택: docker-compose 별도 컨테이너, 이미지 출처는 사용자 확인 필요.
# docker-compose.backend.win.yml 추가 부분
openclaw:
image: ${OPENCLAW_IMAGE:-openclaw/openclaw:latest} # ← 사용자 확인 필요 (Q1)
container_name: invyone-openclaw
ports:
- "18789:18789"
environment:
- OPENCLAW_PORT=18789
- OPENCLAW_LOG_LEVEL=info
volumes:
- openclaw-config:/root/.openclaw # config 영속
restart: unless-stopped
profiles: ["full"] # OPENCLAW_ENABLED=false 시 제외
volumes:
openclaw-config:
사용자 확인 필요 (Q-A): OpenClaw이 (1) 오픈소스 npm 패키지인가? (2) Docker Hub 공식 이미지가 있는가? (3) vexplor 사내 빌드 이미지인가? Architect는 (3)로 가정하고 설계, 사용자가 다르면 Story E1에서 실제 이미지 경로/Dockerfile 확정.
6.2 HTTP 클라이언트 (open-question)
선택: Spring RestClient (spring-boot-starter-web 자동 포함, JDK 21 친화).
근거: invyone 기존 AiAssistantProxyService.java:30 가 RestTemplate 사용 → RestClient는 RestTemplate의 후속이며 같은 동기 모델, 코드 패턴 일관성. WebClient는 Reactor 도입으로 의존성·인지비용 큼 → SSE 스트리밍에만 한정 사용.
@Configuration
public class LlmHttpClientConfig {
@Bean
public RestClient openClawRestClient(OpenClawProperties props) {
return RestClient.builder()
.baseUrl(props.getBaseUrl()) // http://openclaw:18789
.requestFactory(new JdkClientHttpRequestFactory()) // HTTP/2
.defaultHeader("X-Pipeline-Source", "invyone")
.build();
}
}
6.3 Config 동기화 방식 (open-question 3)
선택: HTTP API. 공유 볼륨 X.
- 근거: 공유 볼륨은 K8s 운영 시
ReadWriteMany필수 → NFS/CephFS 의존성, 권한 충돌 잦음. HTTP API는 stateless, K8s 어디서든 작동. - 가정: OpenClaw에
POST /v1/sync/auth-profiles,POST /v1/sync/agents엔드포인트 존재 또는 OpenClaw 측에 어댑터 추가. - 만약 OpenClaw이 HTTP sync 미지원이면 Plan B: Spring 서비스가 mounted 볼륨에 JSON 파일 직접 작성 (vexplor 동일 동작). 이는 Story E4에서 OpenClaw API 스펙 확인 후 결정.
6.4 헬스체크/회로차단
- 헬스체크: 30초마다
GET /v1/health(Spring@Scheduled(fixedRate=30000)단순). 결과 →OpenClawHealthStateBean. - 회로차단: Resilience4j 도입 권장하나 선택. P1 위험: OpenClaw 다운 시 LLM 호출 차단 회피.
- 단순 fallback:
OpenClawClient호출 실패 시 → invyone 자체LlmClient직접 호출 (vexplor 미존재, invyone 강화 항목) - 외부 호출 게이트웨이는 OpenClaw 의존 X (LlmClient 직접). OpenClaw 다운은 관리화면 영향만.
- 단순 fallback:
- 503: OpenClawSyncService.sync 호출 시 OpenClaw 다운이면 큐 → 다음 health up 시 재시도 (Phase 2).
7. 멀티 에이전트 실행 엔진 설계
7.1 동시성 모델
선택: JDK 21 가상 스레드 + CompletableFuture.
@Configuration
public class AiExecutorConfig {
@Bean("aiAgentExecutor")
public ExecutorService aiAgentExecutor() {
// 가상 스레드 = 메모리 cheap, 수천개 OK. LLM 호출은 IO-bound라 정확한 매칭
return Executors.newVirtualThreadPerTaskExecutor();
}
}
7.2 핵심 로직 (vexplor multiAgentExecutionEngine.ts:41-154 Java 등가)
public CompletableFuture<GroupExecutionResult> execute(long groupId, String userMessage, Options opts) {
var group = groupService.getById(groupId).orElseThrow(NotFoundException::new);
if (group.members().isEmpty()) throw new BusinessException("그룹에 에이전트 없음");
// 1. 과거 분석 이력 컨텍스트 — try/catch 무시 (vexplor 70라인 동일)
String historyContext = analysisLogService.buildHistoryContext(groupId);
String enriched = userMessage + (historyContext.isEmpty() ? "" : "\n\n" + historyContext);
// 2. 모드별 분기 — Future 반환
return switch (group.executionMode()) {
case "parallel" -> executeParallel(group.members(), enriched, "");
case "sequential" -> executeSequential(group.members(), enriched);
default -> executeMixed(group.members(), enriched);
}.thenCompose(results -> {
// 3. 사이드 이펙트 — REQUIRES_NEW 트랜잭션 (대화/이력/사용량 적재)
return CompletableFuture.supplyAsync(() ->
persistResults(group, userMessage, results, opts), aiAgentExecutor);
});
}
private CompletableFuture<List<ExecutionResult>> executeParallel(List<Member> members, String msg, String prev) {
var futures = members.stream()
.map(m -> CompletableFuture.supplyAsync(() -> executeSingleAgent(m, msg, prev), aiAgentExecutor))
.toList();
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList());
}
private CompletableFuture<List<ExecutionResult>> executeMixed(List<Member> members, String msg) {
// execution_order 로 그룹핑 → order 정렬 순회 → 각 order 내부는 parallel
var byOrder = members.stream()
.collect(Collectors.groupingBy(Member::executionOrder, TreeMap::new, Collectors.toList()));
CompletableFuture<List<ExecutionResult>> chain = CompletableFuture.completedFuture(List.of());
StringBuilder previousCtx = new StringBuilder();
for (var entry : byOrder.entrySet()) {
var order = entry.getValue();
chain = chain.thenCompose(prev -> {
String ctx = previousCtx.toString();
CompletableFuture<List<ExecutionResult>> stage = order.size() == 1
? CompletableFuture.supplyAsync(() -> List.of(executeSingleAgent(order.get(0), msg, ctx)), aiAgentExecutor)
: executeParallel(order, msg, ctx);
return stage.thenApply(stageResults -> {
stageResults.forEach(r -> previousCtx.append(format(r)));
var combined = new ArrayList<>(prev); combined.addAll(stageResults); return combined;
});
});
}
return chain;
}
7.3 트랜잭션 경계
execute()자체는 트랜잭션 없음 (LLM 호출 60초 → connection lease 위험)persistResults만@Transactional(propagation = REQUIRES_NEW, timeout = 30)addMessage루프는 단일 트랜잭션으로 묶음 (메시지 N개 atomic) — 부분 실패 방지
7.4 커넥터 추상화
public sealed interface Connector permits DatabaseConnector, RestApiConnector, FileConnector, CrawlerConnector, PlcConnector {
Map<String,Object> execute(ConnectorRef ref);
String type();
}
@Component
public class ConnectorExecutor {
private final Map<String, Connector> registry; // Spring 주입 (type → impl)
public Map<String,Object> execute(ConnectorRef ref) {
var c = registry.get(ref.type());
if (c == null) return Map.of("error", "지원하지 않는 커넥터 타입");
try { return c.execute(ref); }
catch (Exception e) { return Map.of("error", e.getMessage()); }
}
}
각 커넥터 구현은 vexplor executeConnector (multiAgentExecutionEngine.ts:356-466) 분기 그대로 옮김. invyone에 테이블 없으면 Map.of("info", "준비됨") 반환 (vexplor의 info: "커넥터 연결 준비됨" 동작과 동일).
7.5 결과 합성 + 토큰 집계
buildFinalSummary 동일. totalTokens 는 stream sum. 사용량 적재는 AiAgentUsageService.log 단일 row (vexplor:134 와 동일). API 키 누적은 addTokenUsage 별도 호출 (UPDATE ai_agent_api_keys SET total_tokens = total_tokens + ?) — PostgreSQL row-level lock 으로 원자성 보장 (Redis 불필요).
7.6 타임아웃 / 부분 실패
- LLM 호출:
RestClient60초 read timeout. 스트리밍은 무제한. executeSingleAgent내부 try/catch (vexplor:309-350 동일) → 실패 시[실행 실패] {error}응답으로 step에 기록, 그룹 전체 실패 X.executeMixed의 한 order 가 부분 실패 시 다음 order는 계속 진행 (현 vexplor 동작 보존).
8. 스케줄러 설계
8.1 선택: Quartz JDBC JobStore (open-question 5)
근거:
| 항목 | @Scheduled |
Quartz JDBC |
|---|---|---|
| cron 동적 등록/해제 | 어려움 (TaskScheduler 직접 다뤄야) | 자연스러움 |
| 클러스터 안전 | 없음 (각 노드가 따로 실행) | org.quartz.jobStore.isClustered=true |
| 영속 (재기동 후 부활) | 매번 DB 조회 + @PostConstruct 재등록 |
자동 |
| 학습 비용 | 낮음 | 중간 |
| invyone 멀티 인스턴스 가능성 | — | ★유리 |
vexplor aiSchedulerService.ts 의 Map<number, ScheduleJob> activeJobs 는 인메모리 → 노드 죽으면 소실. invyone Jenkins 배포 + 멀티 노드 가능성에서 위험.
8.2 동기화 전략
@Component
public class AiAgentScheduledJob implements Job {
@Override
public void execute(JobExecutionContext ctx) {
long scheduleId = ctx.getMergedJobDataMap().getLong("scheduleId");
var schedule = scheduleService.getById(scheduleId).orElseThrow();
var result = executionEngine.execute(schedule.groupId(), schedule.inputMessage(),
new Options(schedule.createdBy(), null)).join();
scheduleService.markRun(scheduleId);
notificationService.dispatch(schedule, result);
}
}
AiSchedulerService.create/update/delete 는 DB 쓰기 + Scheduler.scheduleJob/unscheduleJob 호출. 서버 시작 시 @PostConstruct 로 활성 스케줄 fetch → Quartz job 등록 (vexplor initializeSchedules 동일).
8.3 알림 (vexplor 동작 보존)
- 시스템 공지:
INSERT INTO system_notice(invyone 기존 테이블 — Story C10 사전 확인 필요) - 웹훅:
RestClient.post(webhookUrl, {text:...})timeout 10초 - 이메일: invyone 기존
MailSendService재사용 (PRD §3.8 참고)
8.4 Cron 타임존 (open-question 12)
선택: Asia/Seoul (스케줄 row의 timezone 컬럼). Quartz CronTrigger.timeZone 에 주입. UTC 저장은 운영자 cron 작성 헷갈림.
9. LLM 클라이언트 추상화
9.1 선택: Spring AI 미도입, 직접 구현 (open-question 4)
근거:
| Spring AI | 직접 구현 | |
|---|---|---|
| 의존성 크기 | 큼 (Reactor, AOT support) | 작음 (RestClient만) |
| 5개 프로바이더 정확 매칭 | Anthropic/OpenAI는 OK, DeepSeek/Ollama는 직접 작성 필요 | 모두 1:1 |
| 스트리밍 | Flux (Reactor) | SSE 직접 처리 |
vexplor LlmClient.chatCompletion 1:1 포팅 |
어댑터 변환 필요 | 직접 |
| 1.0 GA 안정성 | 2024-08 GA, 빠른 변경 | 안정 |
→ Phase 1: 직접 구현. Phase 2: Spring AI MCP 도입 검토 (인터페이스 추상화로 교체 비용 감소).
9.2 인터페이스
public interface LlmClient {
LlmResponse chat(LlmRequest request);
Flux<LlmStreamChunk> stream(LlmRequest request); // SSE만 WebFlux/Flux
boolean supports(String model); // "claude-*", "gpt-*", "gemini-*"
}
@Component
public class AnthropicLlmClient implements LlmClient {
private final RestClient client; // baseUrl=https://api.anthropic.com
public boolean supports(String model) { return model.startsWith("claude-"); }
public LlmResponse chat(LlmRequest req) {
var body = Map.of(
"model", req.model(),
"messages", req.messages().stream().filter(m -> !m.role().equals("system")).toList(),
"system", req.systemPrompt(),
"max_tokens", req.maxTokens()
);
var resp = client.post().uri("/v1/messages")
.header("x-api-key", decryptedKey)
.header("anthropic-version", "2023-06-01")
.body(body).retrieve().body(AnthropicResponse.class);
return LlmResponse.fromAnthropic(resp);
}
}
9.3 라우팅
@Component
public class LlmClientRouter {
private final List<LlmClient> clients;
public LlmClient pick(String model) {
return clients.stream().filter(c -> c.supports(model))
.findFirst().orElseThrow(() -> new BusinessException("지원하지 않는 모델: " + model));
}
}
9.4 스트리밍 (open-question 9)
선택: Spring MVC SSE (MediaType.TEXT_EVENT_STREAM) + 내부 WebClient 로 LLM 스트림 수신 → SseEmitter 로 클라이언트 전송.
근거: WebFlux 전체 도입은 invyone 기존 MVC 패러다임과 충돌 (95개 컨트롤러). MVC + SseEmitter 조합으로 단일 엔드포인트만 reactive 처리 가능.
@PostMapping(value="/chat/completions", produces=MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestBody LlmRequest req) {
var emitter = new SseEmitter(0L); // 무제한
aiExecutor.submit(() -> {
try {
llmRouter.pick(req.model()).stream(req)
.doOnNext(chunk -> emitter.send(SseEmitter.event().data(chunk)))
.doOnError(emitter::completeWithError)
.doOnComplete(emitter::complete)
.blockLast();
} catch (Exception e) { emitter.completeWithError(e); }
});
return emitter;
}
10. 동시성/성능
| 항목 | 결정 | 근거 |
|---|---|---|
| 에이전트 실행 풀 | newVirtualThreadPerTaskExecutor() |
IO-bound, JDK 21 |
| 큐 백프레셔 | 가상 스레드 자체로 OOM 위험 — 논리적 limit Semaphore(200) |
동시 그룹 실행 200개 상한 |
| 토큰 누적 원자성 | PostgreSQL UPDATE … SET total_tokens = total_tokens + ? |
row-level lock 충분, Redis 불필요 |
| Rate limit (open-question 10) | Bucket4j 인메모리 Phase 1, Redis Bucket4j Phase 2 | NFR 분당 100 req 미만 가정 (open-questions.md 33) → 인메모리 충분 |
| HTTP keep-alive | JdkClientHttpRequestFactory 기본 풀 |
LLM 호출 connection reuse |
| MyBatis 풀 | 현재 invyone 설정 그대로 (수동 조정 X) | 가상 스레드 + HikariCP 호환 (Spring Boot 3.3+ 정상) |
11. 마이그레이션 전략
11.1 도구: Flyway 10.x (open-question 1)
// backend-spring/build.gradle 추가
implementation 'org.flywaydb:flyway-core:10.20.1'
runtimeOnly 'org.flywaydb:flyway-database-postgresql:10.20.1'
# application.yml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true # 기존 invyone 운영 DB는 baseline 처리
baseline-version: 0
schemas: invyone
placeholders:
ai_company_default: '*'
11.2 마이그레이션 파일
| 파일 | 내용 |
|---|---|
V20260427_001__ai_baseline.sql |
§3.1~3.11 11개 테이블 CREATE TABLE |
V20260427_002__ai_indexes.sql |
인덱스 추가 (별도 파일로 롤백 용이) |
V20260427_003__ai_seed_providers.sql |
placeholder providers 5개 row (is_active=false, 사용자가 콘솔에서 활성) |
V20260427_004__quartz_tables.sql |
Quartz 11개 테이블 |
V20260427_005__cleanup_ai_assistant.sql |
(조건부) DROP DATABASE IF EXISTS ai_assistant_db — invyone DB 호스트와 별개면 SQL 미적용, 별도 운영 스크립트로 |
11.3 롤백
- Flyway는 자체 down 스크립트 미제공 →
U20260427_001__rollback.sql수동 작성 (DROP TABLE 11개 + DROP INDEX). dev/staging만 사용. - 운영은 baseline 시점 DB 백업 → 복구.
12. 테스트 전략 (open-question 11)
12.1 단위 테스트
- Testcontainers PostgreSQL 16 + MyBatis SqlSessionFactoryBean: 매퍼 SQL 정합성.
- WireMock: LLM 응답 스텁 (anthropic 200 OK + JSON, openai 429 등).
- 서비스 클래스: Mockito + DI 주입, MyBatis 매퍼 mock.
- 핵심 시나리오: ApiKey SHA-256 일치, AES-GCM round-trip, MultiAgentExecutionEngine sequential/parallel/mixed.
12.2 통합 테스트
@SpringBootTest(webEnvironment = RANDOM_PORT)+ Testcontainers- 시나리오: sk-pipe 발급 →
/api/ai/v1/chat/completions(WireMock anthropic) →ai_agent_usage_logsrow 1개.
12.3 E2E
- docker-compose up → invyone-spring + openclaw(stub) + postgres → 프론트 7페이지 smoke.
12.4 멀티테넌시 검증 (R9 — Critical)
- 회사 A user JWT 로 회사 B
agent_id=NGET → 404 또는 빈 결과 보장 테스트. - 회사 A 가 발급한 sk-pipe 로 회사 B group 호출 → 403.
13. 배포/운영
13.1 docker-compose
# docker-compose.backend.win.yml — backend 추가 환경변수
services:
backend:
environment:
- OPENCLAW_BASE_URL=http://openclaw:18789
- OPENCLAW_ENABLED=${OPENCLAW_ENABLED:-true}
- AI_PROVIDER_ENCRYPTION_KEY=${AI_PROVIDER_ENCRYPTION_KEY} # 32B base64
- AI_QUARTZ_CLUSTERED=${AI_QUARTZ_CLUSTERED:-false}
- AI_DEFAULT_TIMEZONE=Asia/Seoul
13.2 Jenkinsfile
현재 단일 Build stage → 추가 변경 불필요. -Pspring.profiles.active=prod 만 확인. helm-charts 측은 사용자 확인 (open-question Q1) 별도 PR.
13.3 환경변수 매트릭스
| 변수 | 기본 | 설명 |
|---|---|---|
OPENCLAW_BASE_URL |
http://openclaw:18789 |
OpenClaw 컨테이너 주소 |
OPENCLAW_ENABLED |
true |
false면 sync skip |
AI_PROVIDER_ENCRYPTION_KEY |
(필수) | AES-256 키, base64 32B |
AI_QUARTZ_CLUSTERED |
false |
true면 멀티 노드 안전 |
AI_DEFAULT_TIMEZONE |
Asia/Seoul |
cron 표현식 기준 |
AI_RATE_LIMIT_PER_MINUTE |
60 |
키별 분당 호출 |
LLM_HTTP_TIMEOUT_SEC |
60 |
RestClient read timeout |
14. 프론트엔드 적응 전략
14.1 라우트 위치
선택: 기존 /admin/aiAssistant/* 폴더 통째 교체.
- 근거: PRD A5 가 이미 기존 8페이지 제거 명시 → 새 폴더 만들기보다 같은 위치에 vexplor 7페이지 복사가 운영 가이드 일관. 메뉴 URL 변경 최소화.
- 백엔드 API 베이스:
/api(invyone 표준) + vexplor 라우트 그대로 (/ai-agents,/ai-agent-groups…).
14.2 API 클라이언트
frontend/lib/api/aiAssistant/(구) 삭제 →frontend/lib/api/aiAgent.ts신규 (vexplor 그대로 복사 + base URL 조정).- vexplor 의 axios 인스턴스 → invyone 의
apiClient로 교체. Authorization 헤더는 invyone 의 JWT cookie/localStorage 패턴 따름.
14.3 권한/메뉴 통합 (직전 커밋 2c57dc8c)
- Story G1: 7개 메뉴 신규 row (
menu_master또는 동등 테이블). - 메뉴 URL:
/admin/aiAssistant,/admin/aiAssistant/agents,/admin/aiAssistant/workspace,/admin/aiAssistant/providers(SUPER),/admin/aiAssistant/api-keys-manage,/admin/aiAssistant/conversations,/admin/aiAssistant/knowledge. - 권한 그룹 매핑:
menu_permission테이블에 SUPER_ADMIN/COMPANY_ADMIN/일반사용자 별 row insert (직전 커밋의MenuPermissionMapper패턴 따름).
14.4 의존성
@dnd-kit/*(workspace drag-drop) → invyone 패키지 미존재 시 추가sonner(toast) → invyone 기존 toast 라이브러리 확인 후 통합- shadcn/ui 버전 mismatch 위험 → F5 점검 단계에서 prop signature 검증
15. open-questions.md 12개 답변
| # | 질문 | 결정 | 근거 (요약) |
|---|---|---|---|
| 1 | 마이그레이션 도구 | Flyway 10.x | invyone 부재, Spring Boot 통합 우수 |
| 2 | OpenClaw 배포 | 별도 docker 컨테이너 + 사용자 이미지 출처 확인 | 격리, 토글 가능 |
| 3 | OpenClaw config 동기화 | HTTP API (Plan B: 볼륨 파일) | 클러스터 안전, 단순성 |
| 4 | Spring AI 도입 | 미도입 (Phase 1 직접 구현) | 5개 프로바이더 1:1 매칭 정확도 |
| 5 | 스케줄러 | Quartz JDBC JobStore | 동적 cron + 클러스터링 |
| 6 | 커넥터 테이블 존재 | 부재 확인 → try/catch 빈 배열 fallback | invyone에 4개 테이블 미존재, P0 차단 안 됨 |
| 7 | API 키 폐기 | hard delete 유지 + status 컬럼 보존 | vexplor 동작 보존, 향후 변경 가능 |
| 8 | AES 키 관리 | 환경변수 Phase 1, KMS Phase 2 | 단순, 도커 시크릿 호환 |
| 9 | 스트리밍 | MVC SSE (SseEmitter) + 내부 WebClient |
95 컨트롤러 MVC 일관성 |
| 10 | Rate limit | Bucket4j 인메모리 Phase 1 | 분당 100 req 가정 |
| 11 | 테스트 전략 | Testcontainers PG + WireMock LLM | 표준, CI 호환 |
| 12 | cron 타임존 | Asia/Seoul (스케줄 row별 timezone 컬럼) |
운영자 인지 단순성 |
16. 사용자 추가 확인 필요
| ID | 항목 | 영향 |
|---|---|---|
| Q-A | OpenClaw 이미지 출처 — 오픈소스 npm? Docker Hub? vexplor 사내 빌드? | Story E1 시작 가능 여부 |
| Q-B | invyone helm-charts (values_invyone.yaml)에 ai-assistant 잔재 PR 필요 여부 |
Story A4 종결 |
| Q-C | invyone에 system_notice, MailSendService 정확한 인터페이스 존재 확인 |
Story C10 알림 발송 |
| Q-D | ai_assistant_db @ localhost drop은 본 운영 DB 호스트(183.99.177.40)와 별도 호스트라 V20260427_005 마이그레이션은 적용 안 함이 맞나? (사용자 답변 = 별도 DB confirmed) |
Story A7 SQL 위치 |
| Q-E | 스케줄러 페이지 (PRD F7) 신규 작성 여부 | Frontend Story 추가 |
17. Executor에게 넘기는 산출물
17.1 Story 권장 순서
[1] B1: Flyway baseline DDL (V20260427_001) ← ★ 첫 시작
[2] C1: 공통 인프라 (EncryptUtil, RestClient bean, MyBatis Mappers 인터페이스 11개, DTO 패키지)
[3] C2~C8: 단일 도메인 서비스 8개 (병렬 가능)
C2 AiAgent, C3 Group, C4 ApiKey, C5 Provider, C6 Conv, C7 Usage, C8 Analysis, +KnowledgeService
[4] C9: MultiAgentExecutionEngine + Connector 5개 + LlmClient 5개
[5] C10: Quartz 도입 + AiSchedulerService
[6] C11: OpenClawSyncService + OpenClawClient
[7] D1~D3: ApiKey Filter + SecurityConfig 통합
[8] C12~C16: 9개 컨트롤러 (병렬)
[9] A1~A6: 기존 ai-assistant 제거 (B/C/D 안정화 후 — 회귀 위험 최소화)
[10] A7: ai-assistant DB drop (별도 호스트, 분리됨)
[11] E1~E5: docker-compose openclaw 추가
[12] F1~F7: 프론트엔드 이식
[13] G1~G4: 메뉴/권한 등록
[14] H1~H8: 검증/배포
17.2 첫 Story B1 — 산출 파일 목록
backend-spring/build.gradle (수정: flyway 의존성)
backend-spring/src/main/resources/application.yml (수정: spring.flyway.*)
backend-spring/src/main/resources/db/migration/
V20260427_001__ai_baseline.sql (신규, §3.1~3.11)
V20260427_002__ai_indexes.sql (신규, 인덱스만)
DoD:
./gradlew bootRun시 Flyway가 11개 테이블 생성, 멱등 (재실행 시 OK)\dt invyone.ai_*로 모두 보임- 인덱스 11개 이상 생성됨 (
\di idx_ai_*)
17.3 Story 별 파일 생성 목록 (C2 예시)
[C2] AiAgentService 구현
- backend-spring/src/main/java/com/erp/ai/domain/AiAgent.java (record + Builder)
- backend-spring/src/main/java/com/erp/ai/dto/request/CreateAgentRequest.java
- backend-spring/src/main/java/com/erp/ai/dto/request/UpdateAgentRequest.java
- backend-spring/src/main/java/com/erp/ai/dto/response/AgentDto.java
- backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMapper.java (interface)
- backend-spring/src/main/resources/mapper/ai/AiAgentMapper.xml (SELECT/INSERT/UPDATE)
- backend-spring/src/main/java/com/erp/ai/service/AiAgentService.java (구현)
- backend-spring/src/test/java/com/erp/ai/service/AiAgentServiceTest.java (Testcontainers)
(다른 서비스 동일 패턴: Domain + DTO 2 + Mapper interface + Mapper XML + Service + Test)
핵심 결정 요약 (사용자 보고용)
- 테이블 수: 11개 핵심 + Quartz 11개 = 22개 (planner 13개 추정 → 정확화 11개, RAG 라이브러리/멤버십 분리는 P2 보류)
- 컬럼 합계: 약 184개 (테이블당 평균 14컬럼)
- 핵심 기술 스택 결정 5개:
- Flyway 10.x 마이그레이션
- OpenClaw 별도 docker 컨테이너 + HTTP API 동기화
- Spring RestClient(동기) + WebClient(SSE 한정) 직접 LLM 구현, Spring AI 미도입
- Quartz JDBC JobStore 스케줄러
- AES-256-GCM JDK 내장 암호화 + 환경변수 키 관리
- executor가 시작할 첫 Story: B1 — Flyway baseline (
V20260427_001__ai_baseline.sql) +build.gradleflyway 의존성 추가. DDL은 본 문서 §3.1~3.11 그대로 사용. - 차단 미해결 (사용자 확인 필요): OpenClaw 이미지 출처(Q-A), invyone
system_notice/MailSendService시그니처(Q-C), 스케줄러 페이지 신규작성 여부(Q-E).
관련 파일 (절대 경로)
C:\Dev\projects\invyone\.omc\plans\ai-orchestration-replacement-prd.md(planner 결과)C:\Dev\projects\invyone\.omc\plans\open-questions.md(12개 미정 → 본 문서 §15에서 모두 답)C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\security\SecurityConfig.java(필터 체인 통합 대상)C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\security\JwtAuthenticationFilter.java(필터 체인 참조)C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\security\TenantConsistencyGuardFilter.java(테넌시 컨텍스트 참조)C:\Dev\projects\invyone\backend-spring\build.gradle(의존성 추가 대상: flyway, quartz, bucket4j)C:\Dev\projects\invyone\backend-spring\src\main\java\com\erp\service\AiAssistantProxyService.java(제거 대상, RestTemplate 패턴 참고)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\multiAgentExecutionEngine.ts(Story C9 1:1 포팅)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentService.ts(Story C2)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentGroupService.ts(Story C3)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentApiKeyService.ts(Story C4)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentProviderService.ts(Story C5)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentConversationService.ts(Story C6)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAgentUsageService.ts(Story C7)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiAnalysisLogService.ts(Story C8 — DDL 역공학 핵심)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\aiSchedulerService.ts(Story C10)C:\Dev\projects\vexplor_pipeline\backend-node\src\services\openClawSyncService.ts(Story C11)C:\Dev\projects\vexplor_pipeline\backend-node\src\routes\knowledgeRoutes.ts(Story C8 보조 —ai_knowledge_filesDDL)C:\Dev\projects\vexplor_pipeline\backend-node\src\types\aiAgent.ts(DTO 스펙 원본)
architect 노트: 본 응답이 architect 단계의 최종 산출물입니다. Write 권한이 차단되어 .md 파일을 생성하지 않았습니다 — 사용자가 본 응답 내용을 그대로
C:\Dev\projects\invyone\.omc\plans\ai-orchestration-replacement-architecture.md로 저장하면 됩니다. 다음 단계는 executor 호출 → 첫 Story B1 (Flyway baseline) 부터 시작 권장.