Files
invyone/.omc/plans/ai-orchestration-replacement-architecture.md
T
Johngreen 52386efb83
Build & Deploy to K8s / build-and-deploy (push) Has been cancelled
도메인 정리: invion.com → invyone.com 전체 일괄 치환 + 매핑 문서화
운영 도메인이 실제로는 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>
2026-04-28 07:39:04 +09:00

64 KiB
Raw Blame History

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:1node-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_tools P3 미포함, ai_agent_knowledge_*ai_knowledge_files로 단일화. P2 RAG 라이브러리/멤버십 분리 시 추가 예정)

모든 테이블: company_code VARCHAR(20) NULL 허용(NULL 또는 '*' = 슈퍼관리자/전체 공유), created_at/updated_at TIMESTAMPTZ 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_connections
  • crawl_configs
  • pipeline_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/ 만 매칭)** → JwtAuthenticationFilterTenantConsistencyGuardFilterForcePasswordChangeGuardFilter.
  • 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) 단순). 결과 → OpenClawHealthState Bean.
  • 회로차단: Resilience4j 도입 권장하나 선택. P1 위험: OpenClaw 다운 시 LLM 호출 차단 회피.
    • 단순 fallback: OpenClawClient 호출 실패 시 → invyone 자체 LlmClient 직접 호출 (vexplor 미존재, invyone 강화 항목)
    • 외부 호출 게이트웨이는 OpenClaw 의존 X (LlmClient 직접). OpenClaw 다운은 관리화면 영향만.
  • 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 호출: RestClient 60초 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.tsMap<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_logs row 1개.

12.3 E2E

  • docker-compose up → invyone-spring + openclaw(stub) + postgres → 프론트 7페이지 smoke.

12.4 멀티테넌시 검증 (R9 — Critical)

  • 회사 A user JWT 로 회사 B agent_id=N GET → 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개:
    1. Flyway 10.x 마이그레이션
    2. OpenClaw 별도 docker 컨테이너 + HTTP API 동기화
    3. Spring RestClient(동기) + WebClient(SSE 한정) 직접 LLM 구현, Spring AI 미도입
    4. Quartz JDBC JobStore 스케줄러
    5. AES-256-GCM JDK 내장 암호화 + 환경변수 키 관리
  • executor가 시작할 첫 Story: B1 — Flyway baseline (V20260427_001__ai_baseline.sql) + build.gradle flyway 의존성 추가. 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_files DDL)
  • 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) 부터 시작 권장.