DOCS/_archive/docs/architecture/로빙_아키텍쳐_설계_경량.md
happybell80 725ad0876c fix: 문서 파일 실행 권한 제거
- 모든 .md, .html 파일 권한을 644로 정상화
- .gitignore 파일 권한도 644로 수정
- 문서 파일에 실행 권한은 불필요하고 보안상 바람직하지 않음
- deprecated 아이디어 폴더 생성 및 레벨별 UI 변경 아이디어 이동
2025-08-18 00:37:51 +09:00

20 KiB

로빙(Robing) 경량 멀티테넌트 아키텍처 설계

1. 개요

1.1 배경

  • 로빙은 스탯 기반 성장형 AI 에이전트로, 회사별로 독립적인 컨테이너로 배포됨
  • 로빙 브레인은 스킬 라우팅과 결과 판단에 집중하며, 실제 처리는 공용 스킬 서버에서 수행
  • 프롬프트용 기억은 컨테이너 외부의 공용 저장소에 보관
  • 회사가 100개 이상으로 확장되어도 효율적으로 운영 가능한 구조 필요

1.2 설계 원칙

  • 경량화: 회사별 로빙 컨테이너는 최소한의 리소스(512MB)로 운영
  • 무상태(Stateless): 컨테이너는 언제든 재시작 가능하며 상태는 외부에서 관리
  • 확장성: 스킬 서버는 독립적으로 스케일링 가능
  • 비용 효율성: LLM API는 공용 서버에서만 호출하여 비용 최적화
  • 독립성: 각 스킬은 마이크로서비스로 분리되어 독립적 배포/업데이트 가능

2. 전체 아키텍처

2.1 멀티테넌트 구조

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   회사 A 로빙    │     │   회사 B 로빙    │     │   회사 C 로빙    │
│  (가벼운 라우터)  │     │  (가벼운 라우터)  │     │  (가벼운 라우터)  │
│   (Stateless)   │     │   (Stateless)   │     │   (Stateless)   │
└────────┬────────┘     └────────┬────────┘     └────────┬────────┘
         │                       │                       │
         ├───────────────────────┼───────────────────────┤
         │                       │                       │
    ┌────▼─────┐          ┌─────▼──────┐         ┌──────▼─────┐
    │   상태    │          │ 공용 스킬   │         │auth-server │
    │  서비스   │          │   서버      │         │(인증허브)  │
    └────┬─────┘          └────────────┘         └────────────┘
         │
    ┌────▼─────┐          ┌────────────┐
    │PostgreSQL│          │   Redis    │
    │ (영구상태)│          │  (캐시)    │
    └──────────┘          └────────────┘

2.2 데이터 흐름

1. Slack Event → 회사별 로빙 컨테이너
2. 로빙이 State Service에서 사용자 상태 조회
3. 로빙 브레인이 의도 분석 (키워드 기반, LLM 없이)
4. 적절한 스킬 서버로 라우팅
5. 스킬 서버에서 LLM 처리 및 결과 생성
6. 결과를 로빙 컨테이너로 반환
7. 로빙이 Slack으로 응답 전송
8. 사용 기록을 State Service에 업데이트

3. 상태 관리 아키텍처

3.1 State Service 설계

State Service는 모든 회사와 사용자의 상태를 중앙에서 관리하는 핵심 서비스입니다.

# state-service/main.py
from fastapi import FastAPI
from sqlalchemy import create_engine

app = FastAPI()

@app.get("/company/{company_id}/state")
async def get_company_state(company_id: str):
    """회사별 전체 상태 조회 - 로빙 컨테이너 초기화용"""
    return {
        "company_id": company_id,
        "slack_tokens": {
            "bot_token": decrypt(company.slack_bot_token),
            "signing_secret": decrypt(company.slack_signing_secret),
            "app_token": decrypt(company.slack_app_token)
        },
        "users": await get_users_with_stats(company_id),
        "skills": await get_company_skills(company_id),
        "settings": await get_company_settings(company_id)
    }

@app.get("/user/{user_id}/stats")
async def get_user_stats(user_id: str):
    """프론트엔드용 사용자 상태 API"""
    return {
        "user_id": user_id,
        "stats": {
            "연산": 15,
            "기억": 20,
            "공감": 10,
            "통솔": 5,
            "반응": 12
        },
        "skills": {
            "slack_summary": {"level": 3, "usage": 67},
            "email_parser": {"level": 2, "usage": 34}
        },
        "level": 12,
        "exp": 3450
    }

@app.post("/user/{user_id}/levelup")
async def level_up_user(user_id: str, stat: str, points: int):
    """사용자 레벨업 처리"""
    # DB에 직접 업데이트
    await update_user_stats(user_id, stat, points)
    
    # 캐시 무효화
    await redis.delete(f"user:{user_id}:stats")
    
    # WebSocket으로 실시간 알림
    await notify_user(user_id, {"type": "LEVEL_UP", "stat": stat})
    
    return {"success": True}

3.2 데이터베이스 스키마

-- PostgreSQL 스키마
CREATE TABLE companies (
    id UUID PRIMARY KEY,
    name VARCHAR(255),
    slack_workspace_id VARCHAR(50) UNIQUE,
    slack_bot_token TEXT ENCRYPTED,
    slack_signing_secret TEXT ENCRYPTED,
    slack_app_token TEXT ENCRYPTED,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE users (
    id UUID PRIMARY KEY,
    company_id UUID REFERENCES companies(id),
    slack_user_id VARCHAR(50),
    stats JSONB DEFAULT '{"연산": 0, "기억": 0, "공감": 0, "통솔": 0, "반응": 0}',
    level INTEGER DEFAULT 1,
    exp INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(company_id, slack_user_id)
);

CREATE TABLE user_skills (
    user_id UUID REFERENCES users(id),
    skill_id VARCHAR(50),
    level INTEGER DEFAULT 1,
    usage_count INTEGER DEFAULT 0,
    unlocked_at TIMESTAMP,
    last_used_at TIMESTAMP,
    PRIMARY KEY (user_id, skill_id)
);

CREATE TABLE skill_growth_logs (
    id UUID PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    skill_id VARCHAR(50),
    old_level INTEGER,
    new_level INTEGER,
    triggered_by VARCHAR(50),
    created_at TIMESTAMP DEFAULT NOW()
);

4. 로빙 컨테이너 설계 (Stateless)

4.1 핵심 역할

  • Slack 이벤트 게이트웨이: 회사별 Slack 이벤트 수신 및 ACK 응답
  • 상태 조회: State Service에서 필요한 정보 조회
  • 간단한 의도 분석: 키워드 기반 빠른 분류 (LLM 없이)
  • 스킬 라우팅: 적절한 스킬 서버로 요청 전달
  • 학습 욕구 기록: 처리하지 못한 요청 로컬 기록

4.2 초기화 및 상태 관리

# app/services/robing_brain.py
class RobeingBrain:
    def __init__(self, company_id: str):
        self.company_id = company_id
        self.state_service = StateServiceClient()
        self.skill_registry = {
            "요약": "http://skill-summary:8001",
            "이메일": "http://skill-email:8002",
            "일정": "http://skill-calendar:8003"
        }
        self._state_cache = {}  # 메모리 캐시 (TTL 있음)
        
    async def initialize(self):
        """컨테이너 시작 시 상태 로드"""
        # 외부 서비스에서 상태 가져오기
        company_state = await self.state_service.get_company_state(
            self.company_id
        )
        
        # Slack 토큰 설정
        self.slack_token = company_state["slack_tokens"]["bot_token"]
        self.slack_signing_secret = company_state["slack_tokens"]["signing_secret"]
        self.slack_client = SlackClient(token=self.slack_token)
        
        # 회사 설정 로드
        self.settings = company_state["settings"]
        
        print(f"로빙 초기화 완료: {self.company_id}")
        
    async def get_user_stats(self, user_id: str):
        """사용자 스탯 조회 (캐시 활용)"""
        cache_key = f"user:{user_id}:stats"
        
        # 메모리 캐시 확인
        if cache_key in self._state_cache:
            if not self._is_cache_expired(cache_key):
                return self._state_cache[cache_key]["data"]
                
        # 외부 서비스에서 조회
        stats = await self.state_service.get_user_stats(user_id)
        
        # 캐시 저장 (5분 TTL)
        self._state_cache[cache_key] = {
            "data": stats,
            "expires_at": time.time() + 300
        }
        
        return stats

4.3 컨테이너 시작 흐름

# main.py
async def startup():
    """컨테이너 시작 시 실행"""
    # 1. 환경변수에서 회사 ID 가져오기
    company_id = os.getenv("COMPANY_ID")
    if not company_id:
        raise ValueError("COMPANY_ID is required")
    
    # 2. 로빙 브레인 초기화
    brain = RobeingBrain(company_id)
    await brain.initialize()
    
    # 3. FastAPI 앱에 brain 주입
    app.state.brain = brain
    
    # 4. 헬스체크 활성화
    app.state.healthy = True

app = FastAPI()
app.add_event_handler("startup", startup)

5. 슬랙 요약 스킬 마이크로서비스

5.1 스킬 레벨 시스템

class SkillLevels:
    """스킬 레벨별 기능 정의"""
    
    LEVEL_1 = {
        "name": "기본 요약",
        "required_stats": {"연산": 5, "기억": 5},
        "features": ["최근 1시간 대화 요약", "핵심 키워드 추출"]
    }
    
    LEVEL_2 = {
        "name": "구조화 요약",
        "required_stats": {"연산": 10, "기억": 10},
        "features": ["주제별 그룹화", "중요도 판단", "결정사항 하이라이트"]
    }
    
    LEVEL_3 = {
        "name": "맥락 인식 요약",
        "required_stats": {"연산": 15, "기억": 15, "공감": 5},
        "features": ["이전 회의와 연결", "진행률 추적", "분위기 분석"]
    }
    
    LEVEL_4 = {
        "name": "액션 중심 요약",
        "required_stats": {"연산": 20, "기억": 20, "통솔": 10},
        "features": ["액션아이템 자동 추출", "담당자 자동 배정", "우선순위 설정"]
    }
    
    LEVEL_5 = {
        "name": "예측적 요약",
        "required_stats": {"연산": 25, "기억": 30, "공감": 15, "통솔": 15},
        "features": ["패턴 학습", "리스크 예측", "선제적 제안"]
    }

5.2 스킬 서버 구조

skill-slack-summary/
├── Dockerfile
├── requirements.txt
├── src/
│   ├── api/
│   │   └── endpoints.py     # REST API
│   ├── core/
│   │   ├── config.py
│   │   └── security.py      # API 키 검증
│   ├── services/
│   │   ├── slack_client.py  # Slack API 통신
│   │   ├── summarizer.py    # LLM 요약 로직
│   │   └── memory.py        # ChromaDB 인터페이스
│   ├── models/
│   │   └── summary.py       # 데이터 모델
│   └── utils/
│       ├── cache.py         # Redis 캐싱
│       └── rate_limiter.py  # API 제한 관리
└── main.py

6. 통신 프로토콜

6.1 로빙 → 스킬 서비스 요청

{
    "company_id": "company_a",
    "user_id": "U123456",
    "skill": "slack_summary",
    "skill_level": 3,
    "auth_token": "jwt_token",
    "payload": {
        "channel_id": "C789012",
        "time_range": "2h",
        "thread_ts": "1234567890.123456"
    },
    "context": {
        "user_stats": {"memory": 15, "compute": 10},
        "workspace_id": "T123456"
    }
}

6.2 스킬 서비스 → 로빙 응답

{
    "status": "success",
    "data": {
        "summary": "오늘 회의에서는 다음 분기 제품 로드맵에 대해 논의했습니다...",
        "key_points": [
            "Q2 신제품 출시 일정 확정",
            "마케팅 예산 20% 증액 승인"
        ],
        "action_items": [
            {
                "assignee": "@김철수",
                "task": "프로토타입 데모 준비",
                "due_date": "2025-07-15"
            }
        ]
    },
    "metadata": {
        "processing_time": 2.5,
        "tokens_used": 1500,
        "cache_hit": false
    }
}

7. LLM 처리 및 비용 최적화

7.1 LLM 관리 전략

class LLMManager:
    """중앙 집중식 LLM 관리"""
    def __init__(self):
        self.gemini_client = GeminiClient()
        self.openai_client = OpenAIClient()
        self.cache = Redis()
        self.rate_limiter = RateLimiter()
        self.api_key_pool = APIKeyPool()
        
    async def summarize(self, messages: List[str], company_id: str):
        # 1. 캐시 확인
        cache_key = f"summary:{company_id}:{hash(messages)}"
        if cached := await self.cache.get(cache_key):
            return cached
            
        # 2. Rate limiting
        await self.rate_limiter.check(company_id)
        
        # 3. API 키 로테이션
        api_key = self.api_key_pool.get_available_key()
        
        # 4. LLM 호출
        summary = await self.gemini_client.summarize(
            messages, 
            api_key=api_key
        )
        
        # 5. 캐시 저장
        await self.cache.set(cache_key, summary, ttl=3600)
        
        return summary

7.2 Rate Limit 대응

  1. 다중 API 키 풀: 여러 API 키를 로테이션하여 사용
  2. 큐 기반 처리: 우선순위 큐로 유료 고객 우선 처리
  3. 배치 처리: 여러 요청을 하나의 프롬프트로 묶어 처리
  4. 계층적 처리: 부하에 따라 다른 모델 사용 (Pro vs Flash)
  5. 스마트 캐싱: 유사한 요청에 대한 캐시 재사용

8. 메모리 저장 구조

8.1 ChromaDB Collection

{
    "collection": "meeting_summaries",
    "documents": [
        {
            "id": "summary_company_a_20250701_123456",
            "content": "오늘 회의에서는 다음 분기 계획을 논의했습니다...",
            "metadata": {
                "company_id": "company_a",
                "user_id": "U123456",
                "channel_id": "C789012",
                "timestamp": "2025-07-01T14:30:00",
                "participants": ["user1", "user2", "user3"],
                "key_points": ["포인트1", "포인트2"],
                "action_items": ["할일1", "할일2"],
                "skill_version": "1.0.0",
                "skill_level": 3
            }
        }
    ]
}

9. 배포 구성

9.1 Docker Compose - 공용 서비스

# docker-compose.shared.yml
version: '3.8'

services:
  # 상태 관리 서비스
  state-service:
    build: ./state-service
    environment:
      - DATABASE_URL=postgresql://postgres:password@postgres:5432/robbing
      - REDIS_URL=redis://redis:6379
      - ENCRYPTION_KEY=${MASTER_ENCRYPTION_KEY}
      - JWT_SECRET=${JWT_SECRET}
    ports:
      - "8000:8000"
    depends_on:
      - postgres
      - redis
      
  # 스킬 서버들
  skill-slack-summary:
    build: ./skills/slack-summary
    environment:
      - GEMINI_API_KEY=${GEMINI_API_KEY}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - REDIS_URL=redis://redis:6379
      - CHROMADB_HOST=chromadb
      - MAX_REQUESTS_PER_MINUTE=60
    deploy:
      replicas: 3
      resources:
        limits:
          memory: 2G
          
  # 데이터베이스
  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=robbing
      - POSTGRES_PASSWORD=password
    volumes:
      - postgres_data:/var/lib/postgresql/data
      
  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
      
  chromadb:
    image: chromadb/chroma
    volumes:
      - chroma_data:/chroma/chroma

volumes:
  postgres_data:
  redis_data:
  chroma_data:

9.2 Docker Compose - 회사별 로빙

# docker-compose.company-a.yml
version: '3.8'

services:
  robbing-company-a:
    image: robbing/core:latest
    environment:
      - COMPANY_ID=company_a
      - STATE_SERVICE_URL=http://state-service:8000
      - SKILL_SUMMARY_URL=http://skill-slack-summary:8001
      - SKILL_EMAIL_URL=http://skill-email:8002
    ports:
      - "10001:8000"
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.5'
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    restart: unless-stopped

10.1 인증/인가

  • 로빙-State Service 간: 서비스 계정 + mTLS
  • 로빙-스킬 서버 간: JWT 토큰 기반 인증
  • 회사별 데이터 격리: company_id 기반 접근 제어
  • API 키 관리: 환경 변수로 관리, 로빙 컨테이너에는 노출하지 않음

10.2 데이터 보호

  • 전송 중 암호화: HTTPS/TLS 사용
  • 저장 시 암호화: Slack 토큰 등 민감한 데이터는 암호화하여 저장
  • 로그 마스킹: 개인정보가 로그에 노출되지 않도록 처리
  • 접근 로깅: 모든 API 호출 감사 로그 기록

11. 모니터링 및 운영

11.1 메트릭 수집

# 모니터링 스택
services:
  prometheus:
    image: prometheus/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      
  grafana:
    image: grafana/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
    ports:
      - "3000:3000"

11.2 주요 모니터링 지표

  • 회사별 API 사용량
  • 스킬별 응답 시간
  • LLM API 사용량 및 비용
  • 에러율 및 가용성
  • 메모리/CPU 사용률

12. 상태 관리 FAQ

Q1: 로빙 레벨업 시 컨테이너가 꺼졌다 켜지는데 상태값을 변화시킬 수 있는가?

A: 네, 가능합니다. 로빙 컨테이너는 시작할 때마다 외부 상태 관리 서비스(State Service)에서 최신 상태를 불러옵니다. 레벨업이나 스킬 해금 같은 상태 변경은 State Service의 API를 통해 직접 데이터베이스에 반영되므로, 컨테이너가 꺼져 있는 동안에도 상태 변경이 가능합니다. 컨테이너가 다시 시작되면 await brain.initialize()에서 변경된 상태를 자동으로 로드하여 적용합니다.

Q2: 회사별 로빙의 상태는 프론트엔드에서 어떻게 조회하는가?

A: 프론트엔드는 State Service의 공개 API(GET /user/{user_id}/stats)를 호출하여 사용자 상태를 조회합니다. 로빙 컨테이너를 거치지 않고 직접 State Service와 통신하므로, 로빙 컨테이너가 꺼져 있어도 정상적으로 데이터를 볼 수 있습니다. State Service는 WebSocket도 제공하여 레벨업 같은 실시간 이벤트도 프론트엔드로 직접 전달할 수 있습니다.

Q3: Slack 인증 정보는 어디서 관리하고 어떻게 불러오는가?

A: auth-server가 멀티테넌트 인증 허브 역할을 담당합니다. 회사별 Slack 봇 토큰과 OAuth 정보를 중앙 관리하며, 각 로빙 컨테이너는 필요시 auth-server를 통해 인증 정보를 조회합니다. 상세 구조는 auth-server 데이터베이스 스키마 참조.

13. 성능 최적화

13.1 캐싱 전략

  • Redis를 통한 LLM 응답 캐싱
  • 자주 요청되는 요약에 대한 사전 처리
  • 유사 요청 감지 및 재사용

13.2 비동기 처리

  • 모든 I/O 작업은 비동기로 처리
  • 백그라운드 태스크로 무거운 작업 분리
  • 웹훅 방식으로 결과 전달 옵션

14. 향후 로드맵

Phase 1 (현재)

  • 기본 슬랙 요약 기능 구현
  • State Service 구축
  • 단일 스킬 서버 운영

Phase 2

  • 다국어 지원 (한국어/영어)
  • 실시간 요약 스트리밍
  • 더 많은 스킬 추가

Phase 3

  • AI 기반 요약 품질 개선
  • 사용자별 요약 스타일 학습
  • 스킬 마켓플레이스 구축

15. 결론

이 아키텍처는 다음과 같은 이점을 제공합니다:

  1. 확장성: 회사가 증가해도 효율적으로 대응 가능
  2. 비용 효율성: LLM API 사용을 최적화하여 비용 절감
  3. 독립성: 각 컴포넌트가 독립적으로 배포/업데이트 가능
  4. 안정성: 장애 격리로 전체 시스템 안정성 향상
  5. 성능: 캐싱과 배치 처리로 응답 속도 향상
  6. 무상태: 컨테이너 재시작에 영향 받지 않는 안정적 서비스

이 설계를 통해 로빙은 수백 개의 회사를 효율적으로 서비스할 수 있는 확장 가능한 플랫폼으로 성장할 수 있습니다.