---
tags: [research, llm, ssot, workspace-config, hardcoding, robeing]
---

# 260315 모델 SSOT 하드코딩 분산과 workspace-config 로컬 이식 통합 리서치

## 상위 원칙

- [0_VALUE Writing Principles](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/writing-principles.md)
- [0_VALUE Infrastructure SSOT Principle](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/infrastructure-ssot-principle.md)
- [0_VALUE Coding Principles](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/coding-principles.md)
- [로빙 문서 작성 원칙](../../book/300_architecture/312_writing-principles.md)
- [로빙 인프라 설정 SSOT 원칙](../../book/300_architecture/314_infrastructure-ssot-principle.md)

## 이 리서치가 받는 문제 문서

- 현재 문제 정의와 영향 범위는 [workspace-config 루트기준 SSOT와 하드코딩 분산 문제 오픈](../troubleshooting/260316_workspace_config_루트기준_SSOT와_하드코딩_분산_문제오픈.md)에서 엽니다.

## 문제 정의

- 로빙은 `DEFAULT_LLM_MODEL` 같은 SSOT 선언이 이미 존재하지만, 실제 호출부는 `GeminiHandler`, `google.generativeai`, `gemini-*` 문자열 하드코딩에 분산되어 있습니다.
- 그 결과 모델명만 바꿔도 전체가 따라 바뀌지 않고, 서비스별 fallback과 직접 SDK 호출이 다시 살아나는 구조입니다.
- 같은 유형의 문제가 인프라 문서에서는 주소, 포트, 경로, secret의 하드코딩과 runtime SSOT 불일치 문제로 반복 기록돼 있습니다.
- 따라서 이번 이슈는 단순 모델 선택 문제가 아니라, `운영값을 한 곳에서 선언하고 실제 런타임이 그 계약만 따르게 만드는 구조`를 아직 닫지 못한 문제입니다.
- 이 구조는 로컬 전용이 아니라 서버와 로컬이 함께 공유해야 하는 공통 구조입니다.
- 지금의 핵심은 새 구조를 발명하는 것이 아니라, 이미 있는 `workspace-config` 2파일 주입 구조 위에서 남은 로컬 `.env`와 코드 우회 경로를 제거하는 것입니다.
- 또한 사용자 관점에서는 "주 모델을 `--`로 바꾸자"라고 했을 때 수정 지점이 즉시 답돼야 합니다.
- 이 리서치의 작업 목표는 주 모델 선언을 `workspace-config/runtime.env`로 올리고, 과도기에는 최소 `rb8001/.env`까지만 수정하거나 API 호출 입력에서 모델명을 명시하면 그 값으로 실제 호출 모델이 바뀌도록 만드는 것입니다.

## Facts

### 1. 상위 SSOT는 절대경로가 아니라 루트 기준값 + 상대 구조를 요구한다

- `0_VALUE/02_Governance/infrastructure-ssot-principle.md`는 환경마다 절대경로가 달라도, 루트 기준값 하나만 바꾸면 같은 디렉터리 구조와 같은 설정 해석 규칙을 따라가게 유지하라고 명시합니다.
- `0_VALUE/02_Governance/workspace-config/README.md`는 `workspace-config`의 SSOT가 특정 절대경로가 아니라, 워크스페이스 루트 기준값 아래에서 같은 상대 구조를 해석하는 방식이라고 명시합니다.

### 2. 상위 코드 원칙은 하드코딩·광범위 예외·상시 폴백으로 문제를 덮지 말라고 요구한다

- `0_VALUE/02_Governance/coding-principles.md`는 운영 IP, 포트, 시크릿, 공통 URL을 코드에 하드코딩하지 말라고 명시합니다.
- 같은 문서는 catch-all fallback, 광범위 예외 처리, 기본값 주입으로 구조적 결함을 가리지 말라고 명시합니다.

### 3. 로빙의 실제 모델/런타임 경로에는 하드코딩과 분산 참조가 남아 있다

- `DEFAULT_LLM_MODEL` 같은 선언은 존재하지만, 실제 경로에는 `GeminiHandler` 직접 생성, Gemini SDK 직접 호출, `gemini-*` fallback 문자열 배열이 섞여 있습니다.
- `workspace-config`도 상위 원칙상 상대 구조로 해석돼야 하지만, 일부 compose와 문서는 여전히 서버 절대경로와 서비스별 설정을 함께 전제합니다.

### 4. 서버도 공용 주입 구조는 이미 있으나, 모델 SSOT는 아직 완전히 올라가지 않았다

- 서버 기준 `rb8001`, `skill-slack`은 이미 공용 `runtime.env`, `secrets.env`를 읽는 구조를 사용합니다.
- 하지만 `DEFAULT_LLM_MODEL`의 실제 원천은 공용 `runtime.env`가 아니라 서비스 로컬 `.env`에 남아 있는 구간이 있습니다.
- 따라서 현재 문제는 `workspace-config` 구조 부재가 아니라, `DEFAULT_LLM_MODEL`과 호출 경로가 아직 그 구조에 끝까지 종속되지 못한 상태입니다.

### 5. 24서버 실상태는 문서 방향과 일치하지만, 과도기라는 점이 확인됐다

- 2026-03-16 기준 24서버(`robeing-i9`, `192.168.0.106`)에서 `workspace-config` 구조와 `env_file` 주입은 실제 런타임에 이미 일부 적용돼 있습니다.
- 이는 "서버도 로컬과 같은 구조로 가야 하느냐"에 대한 답이 문서상 선언이 아니라 실상태 기준으로도 `예`임을 뜻합니다.
- 다만 `DEFAULT_LLM_MODEL`의 실제 공급원은 아직 `workspace-config/runtime.env`가 아니라 `rb8001/.env`에 남아 있으므로, 24서버의 현재 평가는 "구조는 맞아가고 있고, 런타임은 아직 과도기"입니다.

### 6. 23서버 쪽에서는 문서 SSOT 정합성 강화가 직접 의미를 가진다

- 2026-03-16 기준 23서버 pull 결과에서 중요한 변화는 `robeing/DOCS`의 SSOT/workspace-config 문서 강화입니다.
- 이는 23 제어면에서 같은 `workspace-config` 구조와 해석 규칙을 따라야 한다는 운영 기준을 더 명확히 합니다.
- 반면 `references/openclaw` 갱신은 참고용 업스트림 변경이므로, 현재 23서버의 gateway/nginx/auth/Gitea 런타임에 즉시 반영되는 성격은 아닙니다.

## 직접 연결되는 로빙 문서

### 모델 SSOT와 모델 선택

- [LLM 모델 비교 분석](./LLM_모델_비교_분석.md)
- [로빙 LLM API, Agent API, 모델 선정, 비용 비교 리서치](./orchestration_tools/260312_로빙_LLM_API_Agent_API_모델선정_비용비교_리서치.md)
- [23서버 인프라점검 응답실패 원인확정 리서치](./260307_23서버_인프라점검_응답실패_원인확정_리서치.md)
- [콜드메일 skill-rag-file 500 및 실패은닉 원인확정 리서치](./260310_콜드메일_skill_rag_file_500_및_실패은닉_원인확정_리서치.md)

### Gemini 단일 모델 고정과 fallback 기록

- [Gemini 모델 최적화: gemini-2.5-flash-lite 전환](../troubleshooting/250906_gemini_model_optimization.md)
- [admin gemini single model and ethics visibility](../troubleshooting/251021_admin_gemini_single_model_and_ethics_visibility.md)
- [IR deck Gemini fallback 빈응답 처리](../troubleshooting/251202_ir_deck_gemini_fallback_빈응답_처리.md)

### SSOT 분산과 하드코딩 잔존

- [아침 뉴스 슬랙 포맷 SSOT 분산 및 표시 혼선](../troubleshooting/260310_아침뉴스슬랙포맷_SSOT_분산_및_표시혼선.md)
- [프롬프트 DB 응답생성 폐루프 미연결](../troubleshooting/260310_프롬프트DB_응답생성_폐루프_미연결.md)
- [프롬프트 DB 부분 도입 상태와 하드코딩 프롬프트 잔존](../troubleshooting/260311_prompt_db_partial_adoption_and_hardcoded_prompts.md)
- [하드코딩 URL 제거 작업](../troubleshooting/250915_hardcoded_url_removal.md)
- [rb8001: 하드코딩 URL/DB 기본값 환경변수화 정리](../troubleshooting/251023_admin_rb8001_URL_환경변수화_정리.md)
- [코드와 규칙 정렬 점검](../troubleshooting/251023_code_alignment_with_rules.md)
- [로빙 인프라 설정 SSOT 원칙](../../book/300_architecture/314_infrastructure-ssot-principle.md)

## 이 리서치와 함께 닫아야 할 문서 묶음

### 0. 닫힌 문서 재오픈 금지 원칙

- `완전 닫힘`, `종결`, `완료`로 표시된 문서는 다시 열지 않습니다.
- 새 회귀나 새 사실은 기존 종결 문서를 재오픈하지 않고 새 troubleshooting/research 문서로 분리합니다.
- 따라서 아래 묶음은 현재 문제와 함께 닫아야 하는 열린 문서 또는 미종결 문서만 유지합니다.

### 1. 리서치

- [LLM 모델 비교 분석](./LLM_모델_비교_분석.md)
- [로빙 LLM API, Agent API, 모델 선정, 비용 비교 리서치](./orchestration_tools/260312_로빙_LLM_API_Agent_API_모델선정_비용비교_리서치.md)
- [23서버 인프라점검 응답실패 원인확정 리서치](./260307_23서버_인프라점검_응답실패_원인확정_리서치.md)
- [콜드메일 skill-rag-file 500 및 실패은닉 원인확정 리서치](./260310_콜드메일_skill_rag_file_500_및_실패은닉_원인확정_리서치.md)
- [23서버 워크스페이스 SSOT 구조전환 리서치](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/journey/research/260309_23서버_워크스페이스_SSOT_구조전환_리서치.md)

### 2. 트러블슈팅

- [workspace-config 루트기준 SSOT와 하드코딩 분산 문제 오픈](../troubleshooting/260316_workspace_config_루트기준_SSOT와_하드코딩_분산_문제오픈.md)
- [아침 뉴스 슬랙 포맷 SSOT 분산 및 표시 혼선](../troubleshooting/260310_아침뉴스슬랙포맷_SSOT_분산_및_표시혼선.md)
- [프롬프트 DB 응답생성 폐루프 미연결](../troubleshooting/260310_프롬프트DB_응답생성_폐루프_미연결.md)
- [프롬프트 DB 부분 도입 상태와 하드코딩 프롬프트 잔존](../troubleshooting/260311_prompt_db_partial_adoption_and_hardcoded_prompts.md)
- [Gemini 모델 최적화: gemini-2.5-flash-lite 전환](../troubleshooting/250906_gemini_model_optimization.md)
- [admin gemini single model and ethics visibility](../troubleshooting/251021_admin_gemini_single_model_and_ethics_visibility.md)
- [IR deck Gemini fallback 빈응답 처리](../troubleshooting/251202_ir_deck_gemini_fallback_빈응답_처리.md)
- [하드코딩 URL 제거 작업](../troubleshooting/250915_hardcoded_url_removal.md)
- [rb8001: 하드코딩 URL/DB 기본값 환경변수화 정리](../troubleshooting/251023_admin_rb8001_URL_환경변수화_정리.md)
- [코드와 규칙 정렬 점검](../troubleshooting/251023_code_alignment_with_rules.md)
- [gateway SSOT runtime/secrets 분리 적용 및 검증](../troubleshooting/260307_gateway_SSOT_runtime_secrets_분리_적용_및_검증.md)

### 3. 닫힘 원칙

- 이 리서치를 닫을 때는 모델 교체 가능성만 보지 않고, `workspace-config`, 프롬프트 경로, URL/엔드포인트, fallback 정책이 같은 방식으로 SSOT를 따르도록 함께 닫아야 합니다.
- 따라서 한 문서만 완료로 두고 주변 문서를 미확정으로 남기지 않습니다. `모델 원천`, `호출 경로`, `프롬프트 원천`, `운영 URL 원천`이 모두 공용 계약으로 수렴했는지 기준으로 함께 판정합니다.
- 닫힘 이후 동일 계열 문제가 다시 나타나면 이 리서치를 재오픈하지 않고 새 문서를 엽니다.

## 관련 기준 문서

- [0_VALUE Infrastructure SSOT Principle](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/infrastructure-ssot-principle.md)
- [0_VALUE workspace-config README](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/workspace-config/README.md)
- [Infrastructure Project Structure](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/02_Architecture/Infrastructure_Project_Structure.md)
- [51123 GitHub Org Repo Bootstrap Runbook](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/02_Architecture/51123_github_org_repo_bootstrap_runbook.md)
- [23서버 워크스페이스 SSOT 구조전환 리서치](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/journey/research/260309_23서버_워크스페이스_SSOT_구조전환_리서치.md)
- [23제어면 gateway workspace-config 단일화](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/journey/worklog/260309_23제어면_gateway_workspace_config_단일화.md)
- [24서버 robeing runtime workspace-config 단일화](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/journey/worklog/260310_24서버_robeing_runtime_workspace_config_단일화.md)

## 외부 사례 비교

### 1. OpenAI와 Gemini는 모두 요청 입력에서 모델명을 받는다

- OpenAI Responses API는 요청 본문에서 `model`을 직접 받습니다.
  - 예: `responses.create({ model: "gpt-5.4", input: ... })`
  - 출처: <https://developers.openai.com/api/docs/guides/latest-model>
- Gemini API도 `generateContent` 호출에서 `model`을 직접 받습니다.
  - 예: `models.generateContent({ model: "gemini-2.5-flash", ... })`
  - 출처: <https://ai.google.dev/docs>
- Anthropic Messages API도 요청 본문에서 `model`을 필수로 받습니다.
  - 예: `messages.create({ model: "claude-sonnet-4-20250514", ... })`
  - 출처: <https://docs.anthropic.com/id/api/messages>
- 즉 외부 주요 API들은 "요청 단위로 모델명을 지정할 수 있다"는 점에서는 공통입니다.

### 2. 하지만 애플리케이션 레벨에서는 단일 추상화로 감싸는 패턴이 널리 쓰인다

- LangChain은 `init_chat_model` 또는 `initChatModel`로 모델 초기화를 단일 진입점에서 처리합니다.
  - Python 예시: `init_chat_model("gpt-5.2")`, `init_chat_model("google_genai:gemini-2.5-flash-lite")`
  - JavaScript 예시: `initChatModel("gpt-5.2")`, `initChatModel("google-genai:gemini-2.5-flash-lite")`
  - 출처: <https://docs.langchain.com/oss/python/langchain/models>, <https://docs.langchain.com/oss/javascript/langchain/models>
- LangChain은 `provider:model` 형식도 허용하므로, 호출부는 공통 인터페이스를 쓰고 provider 차이는 초기화 계층에서 해석합니다.
- LiteLLM은 더 나아가 "100+ LLMs"를 하나의 인터페이스와 게이트웨이로 통합하고, provider별 endpoint 차이를 내부에서 번역한다고 설명합니다.
  - 출처: <https://docs.litellm.ai/>

### 3. 우리가 참고할 핵심 포인트

- 요청 단위 모델 지정 자체는 이상한 설계가 아닙니다. OpenAI/Gemini/Anthropic 모두 그렇게 합니다.
- 다만 서비스 코드가 provider SDK를 직접 고르는 것은 별개 문제입니다.
- 외부 사례에서 참고할 부분은 다음입니다.
  - API 입력에서는 `model` override를 허용한다.
  - 내부 구현에서는 단일 factory/gateway/initializer가 provider 차이를 해석한다.
  - 호출부는 `GeminiHandler`, `OpenAIHandler`를 직접 만들지 않고 공통 인터페이스만 사용한다.

### 4. 로빙에 적용할 권고안

- 기본 경로는 `workspace-config/runtime.env -> settings.DEFAULT_LLM_MODEL -> 단일 LLM factory/service -> handler`로 고정합니다.
- 예외적으로 API 요청에 `model`이 명시되면 그 입력값을 우선 적용할 수 있습니다.
- 이 경우에도 handler 선택은 호출부가 아니라 factory/service가 담당해야 합니다.
- 권장 해석 방식은 아래 둘 중 하나입니다.
  - `model` 문자열만 보고 factory가 provider를 판별
  - `provider` + `model`을 명시적으로 받고 factory가 둘을 함께 검증
- 현재 로빙처럼 서비스 코드 곳곳에서 Gemini SDK 직접 호출, OpenAI handler 직접 생성, fallback 배열 직접 선언이 섞여 있는 구조는 외부 사례의 "요청 입력은 유연하게, 내부 선택은 단일화" 패턴과 다릅니다.

## 현재 코드 인벤토리

### 1. 기본 모델 원천과 기본 handler 생성

- [rb8001/app/core/config.py](../../../../rb8001/app/core/config.py)
  - `DEFAULT_LLM_MODEL` 기본값 문자열이 코드에 남아 있습니다.
  - 현재 설정 진입점으로는 맞지만, 최종 목표는 `workspace-config/runtime.env` 중심 해석입니다.
- [rb8001/app/services/llm/llm_service.py](../../../../rb8001/app/services/llm/llm_service.py)
  - 이미 `settings.DEFAULT_LLM_MODEL`로 기본 handler를 생성합니다.
  - 새 추상화 계층을 만들기보다 이 파일을 단일 진입점으로 정리하는 편이 최소 변경입니다.

### 2. SSOT를 깨는 직접 우회 경로

- [rb8001/app/services/llm/gemini_handler.py](../../../../rb8001/app/services/llm/gemini_handler.py)
  - `os.getenv("GEMINI_MODEL", model or settings.DEFAULT_LLM_MODEL)` 우회가 있습니다.
  - CLI 경로도 `GEMINI_MODEL`을 다시 읽습니다.
- [rb8001/app/router/llm_endpoint.py](../../../../rb8001/app/router/llm_endpoint.py)
  - 스트리밍 엔드포인트가 `GeminiHandler(model=request.model)`를 직접 생성합니다.
- [rb8001/app/services/coldmail_llm_classifier.py](../../../../rb8001/app/services/coldmail_llm_classifier.py)
  - `google.generativeai`를 직접 import하고 `genai.GenerativeModel(GEMINI_MODEL)`을 직접 생성합니다.
- [rb8001/app/services/ir_analyzer.py](../../../../rb8001/app/services/ir_analyzer.py)
  - `fallback_models = ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-2.0-flash-lite"]`가 코드에 하드코딩되어 있습니다.
  - Gemini File Search API 키도 함수 내부에서 직접 읽습니다.

### 3. 이미 상대적으로 정리된 경로

- [rb8001/app/services/gemini_file_search_client.py](../../../../rb8001/app/services/gemini_file_search_client.py)
  - 모델 인자를 받되 기본값은 `settings.DEFAULT_LLM_MODEL`을 사용합니다.
- [rb8001/app/services/skills/naverworks_briefing.py](../../../../rb8001/app/services/skills/naverworks_briefing.py)
  - `settings.DEFAULT_LLM_MODEL`을 사용하는 경로가 이미 있습니다.
- [rb8001/app/services/workflows/headlines_workflow.py](../../../../rb8001/app/services/workflows/headlines_workflow.py)
  - 기본 모델은 `settings.DEFAULT_LLM_MODEL`을 쓰지만, `or GeminiHandler(default_model)` 직접 생성이 남아 있습니다.

### 4. provider 추상화는 이미 일부 존재한다

- [rb8001/app/services/llm/openai_handler.py](../../../../rb8001/app/services/llm/openai_handler.py)
  - OpenAI handler는 이미 구현돼 있습니다.
- [rb8001/app/services/llm/models.py](../../../../rb8001/app/services/llm/models.py)
  - `LLMRequest.model` 필드가 이미 존재합니다.
- 즉 "모델 override를 받을 구조"는 이미 있고, 부족한 것은 handler 선택을 한 곳으로 모으는 일입니다.

### 5. skill-slack 쪽 하드코딩 호출

- [skill-slack/app/services/digest.py](../../../../skill-slack/app/services/digest.py)
  - `model: "gemini-2.5-flash"` 하드코딩
- [skill-slack/app/services/action_extractor.py](../../../../skill-slack/app/services/action_extractor.py)
  - `model: "gemini-2.5-flash"` 하드코딩
- [skill-slack/app/services/summarizer.py](../../../../skill-slack/app/services/summarizer.py)
  - `skill_level`에 따라 `gemini-2.5-flash` / `gemini-2.5-pro`를 직접 선택합니다.
- [skill-slack/app/core/config.py](../../../../skill-slack/app/core/config.py)
  - 현재 LLM 기본 모델 설정 자체가 없습니다.

### 6. 전수 확인으로 추가 확인된 누락 범위

- [rb8001/app/services/diary/generator.py](../../../../rb8001/app/services/diary/generator.py)
  - `GeminiHandler()`를 직접 생성합니다.
  - 기본 모델은 간접적으로 따라가더라도, 호출 진입점이 `LLMService` 단일 경로로 닫히지 않았습니다.
- [rb8001/app/services/workflows/headlines_workflow.py](../../../../rb8001/app/services/workflows/headlines_workflow.py)
  - `llm_service.handlers.get(default_model) or GeminiHandler(default_model)` 경로가 남아 있습니다.
  - 기본값은 `settings.DEFAULT_LLM_MODEL`을 보더라도 handler 직접 생성 우회가 살아 있습니다.
- [skill-news/app/services/news_summarizer.py](../../../../skill-news/app/services/news_summarizer.py)
  - `os.getenv("GEMINI_MODEL", "gemini-2.5-flash-lite")` 기본값 문자열과 직접 `genai.GenerativeModel(...)` 생성이 남아 있습니다.
- [skill-news/app/services/companyx_news_summarizer.py](../../../../skill-news/app/services/companyx_news_summarizer.py)
  - `os.getenv("GEMINI_MODEL", "gemini-2.5-flash-lite")` 기본값 문자열과 직접 `genai.GenerativeModel(...)` 생성이 남아 있습니다.
- [skill-news/app/services/sea_news_filter.py](../../../../skill-news/app/services/sea_news_filter.py)
  - `GEMINI_MODEL`을 독자 SSOT처럼 요구하고 직접 `genai.GenerativeModel(...)`를 생성합니다.
- [skill-news/app/api/news_endpoints.py](../../../../skill-news/app/api/news_endpoints.py)
  - 모듈 import 시점에 `news_summarizer = NewsSummarizer()`가 즉시 생성됩니다.
  - 즉 `skill-news`는 단순 설정 조회가 아니라, 프로세스 시작 시점부터 `.env`의 `GEMINI_MODEL` 계약을 전제로 동작합니다.
- [skill-news/docker-compose.yml](../../../../skill-news/docker-compose.yml)
  - 현재 `workspace-config/runtime.env`를 읽지 않고 `${GEMINI_MODEL}`을 직접 전달합니다.
- [skill-news/README.md](../../../../skill-news/README.md)
  - 모델명 SSOT를 `.env`의 `GEMINI_MODEL`로 선언하고 있어, 현재 로빙 전체 목표 SSOT와 충돌합니다.
- [skill-news/.env.example](../../../../skill-news/.env.example)
  - `GEMINI_MODEL` 예시값을 별도 SSOT처럼 유지합니다.

### 7. compose / env_file 계층 확인 결과

- [rb8001/docker-compose.yml](../../../../rb8001/docker-compose.yml)
  - `env_file`에 `.env`, `/home/admin/workspace-config/runtime.env`, `/home/admin/workspace-config/secrets.env`를 함께 읽습니다.
  - 즉 구조는 workspace-config를 쓰지만, 서비스별 `.env` 오버라이드가 아직 실제 경로에 남아 있습니다.
- [skill-slack/docker-compose.yml](../../../../skill-slack/docker-compose.yml)
  - `env_file`에 `.env`, `/home/admin/workspace-config/runtime.env`, `/home/admin/workspace-config/secrets.env`를 함께 읽습니다.
- [skill-rag-file/docker-compose.yml](../../../../skill-rag-file/docker-compose.yml)
  - `env_file` 3중 구조와 별도 `./.env:/app/.env:ro` 마운트가 함께 있습니다.
  - 로빙 전체 SSOT 문제를 닫을 때 `.env` 오버라이드가 실제 런타임 우선순위를 어떻게 바꾸는지 별도 점검이 필요합니다.
- [skill-calendar/docker-compose.yml](../../../../skill-calendar/docker-compose.yml)
- [skill-email/docker-compose.yml](../../../../skill-email/docker-compose.yml)
- [robeing-monitor/docker-compose.yml](../../../../robeing-monitor/docker-compose.yml)
- [robeing-gateway/docker-compose.yml](../../../../robeing-gateway/docker-compose.yml)
  - 공통으로 `workspace-config`와 서비스별 `.env`를 함께 읽는 구조가 남아 있습니다.
  - 따라서 `LLM 모델 SSOT`를 넘어 `서비스별 .env 오버라이드` 문제까지 완전 종결하려면 후속 점검 범위가 더 큽니다.

## 확인 사실 요약표

| 항목 | 확인 사실 | 해석 |
|------|-----------|------|
| 기본 설정 진입점 | `rb8001/app/core/config.py`에 `DEFAULT_LLM_MODEL` 존재 | 설정 표면은 이미 있음 |
| 기본 handler 생성 | `rb8001/app/services/llm/llm_service.py`가 `settings.DEFAULT_LLM_MODEL` 사용 | 단일 진입점 후보가 이미 있음 |
| env 우회 | `rb8001/app/services/llm/gemini_handler.py`가 `GEMINI_MODEL` 재조회 | SSOT 파손 |
| router 우회 | `rb8001/app/router/llm_endpoint.py`가 직접 `GeminiHandler` 생성 | 계층 우회 |
| SDK 직결 | `rb8001/app/services/coldmail_llm_classifier.py`가 직접 `genai.GenerativeModel` 생성 | 공용 경로 우회 |
| 파일별 fallback | `rb8001/app/services/ir_analyzer.py`에 fallback 배열 존재 | 정책 분산 |
| API 입력 model | `rb8001/app/services/llm/models.py`의 `LLMRequest.model` 존재 | override 구조는 이미 있음 |
| OpenAI handler | `rb8001/app/services/llm/openai_handler.py` 존재 | provider 추상화 일부 존재 |
| skill-slack 모델 선택 | `digest.py`, `action_extractor.py`, `summarizer.py`에 모델 문자열 존재 | 설정화 또는 정책화 필요 |
| rb8001 추가 직접 생성 | `diary/generator.py`, `workflows/headlines_workflow.py`에 `GeminiHandler` 직접 생성 잔존 | 계획 누락 |
| skill-news 독자 SSOT | `skill-news`가 `.env`의 `GEMINI_MODEL`을 자체 SSOT로 유지 | 로빙 전체 SSOT와 충돌 |
| skill-news 초기화 시점 | `news_endpoints.py`가 import 시점에 `NewsSummarizer()` 생성 | 런타임 계약이 프로세스 시작 시점에 고정 |
| compose env 계층 | 여러 서비스가 `workspace-config`와 `.env`를 함께 읽음 | 구조는 있으나 오버라이드 잔존 |

## 최소 변경 원칙

### 1. 새 추상화 계층을 더 만들지 않는다

- 기존 [rb8001/app/services/llm/llm_service.py](../../../../rb8001/app/services/llm/llm_service.py)를 단일 진입점으로 강화하는 편이 맞습니다.
- 새 `manager`, 새 `registry`, 새 `gateway wrapper`를 추가하는 것은 현재 문제를 닫는 데 필수가 아닙니다.

### 2. 우선순위는 추가보다 제거다

- 1순위 제거 대상은 아래입니다.
  - `GeminiHandler(...)` 직접 생성
  - `genai.GenerativeModel(...)` 직접 생성
  - 파일별 fallback 배열
  - `GEMINI_MODEL` 별도 env 우회
- 즉 핵심은 새 기능 추가가 아니라 기존 우회 경로 삭제입니다.

### 3. 기존 요청 스키마를 재사용한다

- [rb8001/app/services/llm/models.py](../../../../rb8001/app/services/llm/models.py)의 `LLMRequest.model`을 그대로 활용하는 편이 맞습니다.
- API 입력 모델 override를 위해 새 request schema를 추가하지 않아도 됩니다.

### 4. 과도기 허용 범위를 더 넓히지 않는다

- 기본값 변경은 `workspace-config/runtime.env`와 과도기 `rb8001/.env`까지만 허용합니다.
- 요청 단위 override는 API 입력 `model`만 허용합니다.
- 그 외 서비스별 별도 `.env`, 별도 `GEMINI_MODEL`, 파일별 fallback 리스트는 더 늘리지 않습니다.

## 정확히 바꿔야 할 지점

### 1. 바로 정리할 파일

- [rb8001/app/services/llm/gemini_handler.py](../../../../rb8001/app/services/llm/gemini_handler.py)
  - `GEMINI_MODEL` env 우회 제거
- [rb8001/app/services/llm/llm_service.py](../../../../rb8001/app/services/llm/llm_service.py)
  - 동적 handler 생성과 fallback 정책을 중앙화
- [rb8001/app/router/llm_endpoint.py](../../../../rb8001/app/router/llm_endpoint.py)
  - router의 직접 `GeminiHandler` 생성을 `LLMService` 경유로 변경
- [rb8001/app/services/ir_analyzer.py](../../../../rb8001/app/services/ir_analyzer.py)
  - 파일 내부 fallback 배열 제거 또는 중앙 정책으로 이동
- [rb8001/app/services/coldmail_llm_classifier.py](../../../../rb8001/app/services/coldmail_llm_classifier.py)
  - 직접 Gemini SDK 호출 제거 또는 공용 LLM 경유로 정리
- [skill-slack/app/services/digest.py](../../../../skill-slack/app/services/digest.py)
- [skill-slack/app/services/action_extractor.py](../../../../skill-slack/app/services/action_extractor.py)
- [skill-slack/app/services/summarizer.py](../../../../skill-slack/app/services/summarizer.py)
  - 하드코딩 모델 문자열을 설정값 또는 API 입력값으로 승격
- [rb8001/app/services/diary/generator.py](../../../../rb8001/app/services/diary/generator.py)
  - 직접 `GeminiHandler()` 생성 제거 검토
- [rb8001/app/services/workflows/headlines_workflow.py](../../../../rb8001/app/services/workflows/headlines_workflow.py)
  - `or GeminiHandler(default_model)` 우회 제거
- [skill-news/app/services/news_summarizer.py](../../../../skill-news/app/services/news_summarizer.py)
- [skill-news/app/services/companyx_news_summarizer.py](../../../../skill-news/app/services/companyx_news_summarizer.py)
- [skill-news/app/services/sea_news_filter.py](../../../../skill-news/app/services/sea_news_filter.py)
  - `GEMINI_MODEL` 독자 SSOT와 직접 Gemini SDK 생성을 정리
- [skill-news/app/api/news_endpoints.py](../../../../skill-news/app/api/news_endpoints.py)
  - import 시점 인스턴스 생성이 어떤 설정 계약을 고정하는지 함께 정리
- [skill-news/docker-compose.yml](../../../../skill-news/docker-compose.yml)
  - `workspace-config/runtime.env` 기준 모델 주입으로 맞출지 검토
- [skill-slack/app/core/config.py](../../../../skill-slack/app/core/config.py)
  - LLM 기본 모델 설정 경로를 둘지 여부 확정 필요

### 1-1. 현재 전수 확인 기준 최소 직접 수정 규모

- `rb8001` 9개 내외
  - `app/core/config.py`
  - `app/services/llm/llm_service.py`
  - `app/services/llm/gemini_handler.py`
  - `app/router/llm_endpoint.py`
  - `app/services/coldmail_llm_classifier.py`
  - `app/services/ir_analyzer.py`
  - `app/services/diary/generator.py`
  - `app/services/workflows/headlines_workflow.py`
  - 필요 시 `app/services/llm/models.py`
- `skill-slack` 4개 내외
  - `app/services/digest.py`
  - `app/services/action_extractor.py`
  - `app/services/summarizer.py`
  - `app/core/config.py`
- `skill-news` 6개 내외
- `skill-news`는 서비스 코드 4개 + compose 1개 + 문서/예시 2개까지 걸쳐 있어, 코드 수정만으로 끝나지 않고 계약 문서도 같이 바뀌어야 합니다.
  - `app/services/news_summarizer.py`
  - `app/services/companyx_news_summarizer.py`
  - `app/services/sea_news_filter.py`
  - `app/api/news_endpoints.py`
  - `docker-compose.yml`
  - `README.md`
  - `.env.example`
- 따라서 2026-03-16 전수 확인 기준으로, 문서/테스트 제외 최소 직접 수정 예상은 `19개 내외`로 보는 편이 맞습니다.
- 특히 `skill-news`는 `.env` 기반 독자 SSOT를 README와 compose, 서비스 초기화 코드가 함께 강화하고 있으므로, 단순 코드 치환보다 "계약 전환"으로 다루는 편이 맞습니다.

### 2. 굳이 지금 추가하지 않아도 되는 것

- 새 provider enum 파일
- 새 config loader 계층
- 새 fallback registry 파일
- 새 gateway 패키지
- 새 request model 타입 분기

위 항목들은 현재 문제를 닫는 데 필수가 아닙니다. 먼저 기존 구조를 줄이는 것이 우선입니다.

## Interpretation

### 1. workspace-config는 값 저장 폴더가 아니라 런타임 계약입니다

- `workspace-config`는 단순 폴더가 아니라 공용 런타임 값의 계약과 실제 주입 지점으로 쓰입니다.
- 상위 SSOT 문서와 인프라 구조 문서를 함께 보면, 핵심은 `값을 어딘가에 모아두는 것`이 아니라 `모든 서비스가 같은 파일 구조와 같은 키 이름을 공통 계약으로 해석하게 만드는 것`입니다.
- 인프라 문서가 반복해서 닫는 결론도 같습니다. 코드 기본값, compose fallback, 수동 명령 예시, 프로젝트별 `.env`가 각자 값을 들고 있으면 SSOT는 선언만 있고 런타임 강제력이 없습니다.

### 2. 로빙의 현재 모델 구조 문제도 같은 유형입니다

- `DEFAULT_LLM_MODEL`은 선언돼 있지만, 실제 경로는 `GeminiHandler` 직접 생성, Gemini SDK 직접 호출, `gemini-*` fallback 리스트, 서비스별 개별 모델 하드코딩이 섞여 있습니다.
- `skill-news`는 이 문제의 바깥 예외가 아니라, 별도 제품 SSOT를 이미 갖고 있는 하위 서비스입니다.
- 그래서 로빙 전체 모델 SSOT를 하나로 묶으려면 `skill-news`를 예외로 선언하거나, 아니면 README/compose/service 초기화까지 함께 계약 전환해야 합니다.
- 그래서 `주 모델 변경`이 `SSOT 1개 값 변경`으로 닫히지 않고, 여러 서비스의 코드 경로를 다시 건드려야 하는 구조가 됩니다.
- 즉 지금의 직접 원인은 `gpt-5-mini` 전환 작업이고, 근본 원인은 `모델 호출 계약의 SSOT`와 `실제 호출 구현`이 분리되지 않은 점입니다.
- 이 해석은 서버와 로컬 모두 동일합니다. 서버별 모델 차이가 필요해도 그것은 코드 차이가 아니라 `runtime.env` 값 차이로만 표현돼야 합니다.

### 3. 구조적으로 닫아야 할 기준

- 모델명, 제공자, SDK 선택은 서비스 코드 곳곳이 아니라 공용 LLM 유틸리티 또는 팩토리에서만 결정돼야 합니다.
- 서비스는 `어떤 모델을 쓸지`가 아니라 `어떤 작업을 요청할지`만 말해야 합니다.
- fallback도 각 서비스가 개별 문자열 배열을 들고 있지 말고, 공용 정책 계층에서만 관리돼야 합니다.
- 이 기준이 닫혀야 `DEFAULT_LLM_MODEL` 같은 값이 실제 SSOT가 됩니다.
- 닫혀야 할 최종 경로는 `runtime.env -> settings.DEFAULT_LLM_MODEL -> 단일 LLM factory/service -> handler`입니다.
- `GEMINI_MODEL`, 코드 기본값 문자열, 요청별 모델 직접 주입, 서비스별 fallback 배열은 모두 이 경로의 우회로로 봐야 합니다.
- `skill-news`처럼 README와 `.env.example`, compose, import 시점 초기화 코드가 함께 특정 키를 독자 SSOT로 강화하는 경우도 같은 우회로로 봐야 합니다.
- 사용자 작업 기준으로는 목표 수정 지점과 과도기 최소 수정 지점을 함께 고정해야 합니다.
- 목표 수정 지점: `workspace-config/runtime.env`
- 과도기 최소 수정 지점: `workspace-config/runtime.env`와 `rb8001/.env`
- 예외 허용 입력 경로: API 호출에서 모델명을 명시하면 그 입력값만으로 실제 호출 모델이 바뀌는 것

## Unresolved

- 각 하위 프로젝트 compose가 로컬에서 어떤 방식으로 루트 기준값을 해석하게 맞출지
- 각 하위 프로젝트 compose가 서버에서 어떤 방식으로 공용 runtime env만 기준으로 읽게 맞출지
- 서비스별 `.env`와 코드 fallback 중 무엇이 아직 실제 런타임 경로에 남아 있는지 전체 인벤토리
- 로컬 기준 공용 파일만 읽도록 바꿀 때, 프로젝트별 예외가 필요한 서비스가 있는지 여부
- 서버 기준 공용 파일만 읽도록 바꿀 때, 운영상 예외가 필요한 서비스가 있는지 여부
- `skill-news`를 로빙 전체 모델 SSOT 전환 범위에 포함할지, 아니면 별도 제품 SSOT 예외로 둘지 최종 정책 확정

## workspace-config 로컬 이식 기준

### 1. 기준 문서

- 상위부터 확인할 문서는 다음 순서입니다.
- [0_VALUE Infrastructure SSOT Principle](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/infrastructure-ssot-principle.md)
- [0_VALUE workspace-config README](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/workspace-config/README.md)
- [Infrastructure Project Structure](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/02_Architecture/Infrastructure_Project_Structure.md)
- [51123 GitHub Org Repo Bootstrap Runbook](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/02_Architecture/51123_github_org_repo_bootstrap_runbook.md)
- [23서버 워크스페이스 SSOT 구조전환 리서치](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/journey/research/260309_23서버_워크스페이스_SSOT_구조전환_리서치.md)
- [23제어면 gateway workspace-config 단일화](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/journey/worklog/260309_23제어면_gateway_workspace_config_단일화.md)
- [24서버 robeing runtime workspace-config 단일화](https://git.ro-being.com/ivada-infra/DOCS/src/branch/main/journey/worklog/260310_24서버_robeing_runtime_workspace_config_단일화.md)

핵심은 `workspace-config`가 값 저장소가 아니라 `공용 런타임 설정의 구조와 계약 SSOT`라는 점입니다. 비민감값은 `runtime.env`, 민감값은 `secrets.env`, 서비스별 `.env`는 임시 오버라이드만 허용합니다.

### 2. 로컬 이식 원칙

- 로컬 이식은 서버 값을 그대로 복사하는 게 아니라 구조를 복제하면 됩니다.
- 예를 들어 로컬 워크스페이스 루트에 `~/workspace-config/runtime.env`와 `~/workspace-config/secrets.env`를 만들면 됩니다.
- `runtime.env`에는 비민감 운영값만 둡니다.
- 예시 키:
  - `WORKSPACE_ROOT`
  - `HOST_51123`
  - `HOST_51124`
  - `NAS_HOST`
  - `NAS_SHARE`
  - `NAS_MOUNT_PATH`
  - `GITEA_BASE_URL`
  - `ROBEING_DEFAULT_HOST`
  - `MONITOR_URL`
  - `REDIS_HOST`
  - `ROBEING_URLS`
  - `SKILL_URLS`
- `secrets.env`에는 인증정보와 DB 연결값만 둡니다.
- 예시 키:
  - `DATABASE_URL`
  - `JWT_SECRET_KEY`
  - `GITEA_PAT`
  - `NAS_USERNAME`
  - `NAS_PASSWORD`
  - `ROBEING_MONITOR_DATABASE_URL`
- 중요한 점은 서버 값을 로컬에 복제하는 것이 아니라, 키 이름과 역할만 동일하게 유지하고 값은 로컬 환경에 맞게 새로 만드는 것입니다.

### 3. 따라야 하는 운영 방식

- 각 프로젝트 `docker-compose.yml`이나 실행 스크립트는 공통 `env_file`로 이 두 파일만 읽어야 합니다.
- 코드 기본값이나 compose `environment:`에 운영값을 하드코딩하지 않아야 합니다.
- 서비스별 `.env`는 정말 필요한 로컬 실험 오버라이드에만 써야 합니다.
- 주소, 포트, 시크릿이 바뀌면 `workspace-config`의 두 파일만 수정한 뒤 재기동해서 반영 여부를 검증해야 합니다.
- 같은 기준을 서버에도 그대로 적용합니다. 서버별 차이는 파일 위치가 아니라 값만 달라야 합니다.
- 특히 주 LLM 모델은 서비스 로컬 `.env`가 아니라 공용 `runtime.env`에서만 선언되고, 코드는 `settings.DEFAULT_LLM_MODEL`만 소비해야 합니다.

로컬 SSOT의 닫힘 조건은 아래 3가지입니다.

1. 값을 바꿀 때 수정 지점이 `workspace-config` 2개 파일로 끝나는가
2. 모든 서비스가 그 파일만 바라보는가
3. 실제 실행 결과가 그 값으로 해석되는가

서버와 로컬을 함께 보는 닫힘 조건은 아래 3가지입니다.

1. 서버와 로컬 모두 주 모델 원천이 각자 `runtime.env` 1곳인가
2. 서버와 로컬 모두 코드가 `settings.DEFAULT_LLM_MODEL`만 소비하는가
3. 서버와 로컬 모두 실제 호출이 단일 factory/service 경로만 타는가

## 결론

- 로빙의 현재 LLM 주모델 교체 난이도는 `모델 선정` 자체보다 `모델 호출 구조가 SSOT를 강제하지 못하는 설계`에서 발생합니다.
- 인프라 쪽 `workspace-config`가 해결한 방식은 값 자체가 아니라 `계약과 주입 지점`을 단일화한 것입니다.
- 로빙도 같은 방식으로 `모델명`, `제공자`, `fallback`, `SDK 선택`을 공용 유틸리티 또는 팩토리 한 곳으로 승격해야, 이후 모델 교체가 SSOT 변경 1회로 닫힙니다.
- 따라서 이 작업은 로컬만 정리해서 끝나는 문제가 아니라, 서버와 로컬이 함께 같은 계약을 쓰도록 맞추는 공통 정리 작업입니다.
- 더 정확히 말하면, 현재 단계의 본질은 `workspace-config` 구조 도입이 아니라 `남아 있는 .env 원천과 코드 우회 경로 청산`입니다.
