# OllamaController.cs — 재진단 보고서 (2차) **진단 대상**: `src/Web/Controllers/OllamaController.cs` (1,194줄) **진단 기준**: `diagnosis-checklist.md` 8단계 **날짜**: 2026-05-16 > ⚠️ **1차 진단 대비 주요 변경사항**: > - `LoadConfig()` case-sensitivity 버그 발견 (1차 진단에서 누락) > - 기존 static 캐시 문제 (HIGH) → 실제 배포 환경(systemd 단일 프로세스)에선 문제 없음으로 재평가 → **MED 강등** > - 기존 Headers.Append → **LOW 강등** (현재 아키텍처에선 중복 조건 없음) > - 기존 silent catch (HIGH) → 실제 낙하 경로 분석 결과 **MED**로 조정 > - 신규 발견: `VllmChatStreamWithTools` Reflection 추출, `GetModels` thundering herd > - 파일 I/O blocking 항목 → Q3/Q4 탈락으로 보고서에서 완전 제거 --- ## STEP 1 — 맥락 파악 (변경 없음) - **역할**: Ollama / vLLM LLM HTTP Proxy + MCP Tool Calling Bridge (Web/API Controller) - **주요 엔드포인트**: `GET /models`, `POST /chat`, `POST /chat/stream` (Ollama native) + `vllm/` variants + `GET/POST /config` + `GET /ping` ## STEP 2 — 구조 탐색 (변경 없음) ## STEP 3 — 코드 읽기 (전체 재확인 완료) ## STEP 4 — 호출 계층 지도 (변경 없음) --- ## STEP 5 — 패턴 매칭 + STEP 6 — 교차 검증 | # | 발견 | Q1 이미 수정? | Q2 다른 레이어? | Q3 의도적? | Q4 재현 시나리오? | 결과 | |---|------|:---:|:---:|:---:|:---:|:---:| | 1 | **LoadConfig() case-sensitive deserialization** → 저장한 설정이 유실됨 | ❌ | ❌ | ❌ | ✅ 저장 → 새로고침 → 기본값(localhost) 표시 | 🔴 HIGH | | 2 | **`VllmChatStreamWithTools` anonymous type reflection 추출** → 느리고 fragile | ❌ | ❌ | ❌ | ✅ 동시 다중 tool_calls + 부하 상황 | 🟠 MED | | 3 | **JSON 텍스트 도구 감지 실패 시 silent catch + fallthrough** | ❌ | ❌ | ❌ | ✅ LLM이 `{"tool":"run_sql","parameters":{...}}` 출력 → 파싱 예외 → 도구 무시 | 🟠 MED | | 4 | **static 캐시(`_capsCache`) 메모리 누수 — TTL 만료 항목 미제거** | ❌ | ❌ | ❌ | ✅ Ollama에 100+ 모델 등록 시 누적 | 🟠 MED | | 5 | **`GetModels()` — 모든 모델 capabilities를 동시 병렬 조회 (thundering herd)** | ❌ | ❌ | ❌ | ✅ Ollama에 20+ 모델 존재 시 /api/show 동시 20+ 요청 | 🟠 MED | | 6 | **Summarize — `LoadVllmModel()` 무시하고 환경변수만 참조** | ❌ | ❌ | ❌ | ✅ `VLLM_MODEL` 미설정 + `req.Model` null → 빈 문자열로 vLLM 요청 | 🟠 MED | | 7 | **`Response.Headers.Append()` — 이론적 중복 가능** | ❌ | ✅ 현재 미들웨어 체인에서 선행 설정 없음 | ❌ | ❌ 실제 재현 불가(LOW 조건) | 🟡 LOW | | 8 | **HttpRequestMessage/HttpResponseMessage 미처분** | ❌ | ✅ .NET HttpClient가 내부 관리 | ❌ | ❌ 실제 누수 측정 불가 | 🟡 LOW | | 9 | **ExtractFirstJsonObject 문자열 내 brace 미처리** | ❌ | ❌ | ❌ | ✅ 드물지만 LLM output에 `{` 포함 시 파싱 실패 | 🟡 LOW | --- ## STEP 7 — 상세 진단 --- ### [1]. LoadConfig() case-sensitive deserialization → 설정 저장 후 유실 (🔴 HIGH) **문제**: `SetConfig()`는 `{"host":"10.0.0.50","port":11434}`처럼 **camelCase**로 JSON 파일을 저장하지만, `LoadConfig()`는 `JsonSerializer.Deserialize(json)` (기본 옵션, `PropertyNameCaseInsensitive = false`)로 읽기 때문에 `host` → `Host` 매칭이 실패한다. `OllamaConfig` 클래스에는 `[JsonPropertyName]` 애트리뷰트가 없으므로, 항상 기본값(`localhost:11434`)이 반환된다. **근거**: 파일 쓰기 (`SetConfig()` 492-496줄) — JSON 키가 **camelCase**: ```csharp var json = JsonSerializer.Serialize(new { host = cfg.Host, // ← "host" (camelCase) port = cfg.Port // ← "port" (camelCase) }, new JsonSerializerOptions { WriteIndented = true }); System.IO.File.WriteAllText(path, json); ``` 파일 읽기 (`LoadConfig()` 47-63줄) — `PropertyNameCaseInsensitive = false` 기본값: ```csharp OllamaConfig LoadConfig() { var path = OllamaConfigPath; if (System.IO.File.Exists(path)) { var json = System.IO.File.ReadAllText(path); return JsonSerializer.Deserialize(json) // ← case-sensitive! ?? new OllamaConfig(); // ← "host" != "Host" → 기본값 반환 } return new OllamaConfig(); // localhost:11434 } ``` 대상 클래스 (`OllamaConfig` 1144-1150줄): ```csharp public class OllamaConfig { public string Host { get; set; } = "localhost"; // ← PascalCase public int Port { get; set; } = 11434; // ← PascalCase public string BaseUrl => $"http://{Host}:{Port}"; } ``` 참고 — ASP.NET Core `[FromBody]` 모델 바인딩은 `AddJsonOptions`에서 `PropertyNameCaseInsensitive = true`가 기본값으로 설정되므로, `SetConfig(OllamaConfig cfg)`에서 `{ host: "..." }` 수신은 성공한다. 하지만 `LoadConfig()`가 호출하는 `JsonSerializer.Deserialize()`는 그 옵션을 공유하지 않는다. **영향**: 사용자 시나리오: 1. 설정 화면에서 Ollama host를 `10.0.0.50`로 변경 → `POST /api/ollama/config` → `SetConfig` → 파일 저장 직후 응답은 `{"host":"10.0.0.50","port":11434}`로 정상 2. 프론트엔드 alert: "변경 사항 적용을 위해 페이지를 새로고침하세요." 3. 새로고침 → `llmLoadConfigToUI()` → `GET /api/ollama/config` → `GetConfig()` → `LoadConfig()` → `"host"` != `"Host"` → `Host = "localhost"` 반환 4. 사용자는 "왜 저장이 안 되지?" 반복 시도 → 설정 저장은 항상 실패한 것처럼 보임 5. 심지어 서버 재시작 후에는 자동으로 `localhost:11434`로 동작하므로, 원격 Ollama 서버에 연결 불가 **수정** — 두 가지 중 택일: **수정 A (권장 — `LoadConfig`에서 case-insensitive 옵션 적용):** ```csharp private static readonly JsonSerializerOptions _configJsonOptions = new() { PropertyNameCaseInsensitive = true }; OllamaConfig LoadConfig() { try { var path = OllamaConfigPath; if (System.IO.File.Exists(path)) { var json = System.IO.File.ReadAllText(path); return JsonSerializer.Deserialize(json, _configJsonOptions) ?? new OllamaConfig(); } } catch (Exception ex) { _logger.LogWarning(ex, "[OllamaController] 설정 로드 실패, 기본값 사용"); } return new OllamaConfig(); } ``` **수정 B (PascalCase로 파일 저장 — SetConfig 수정):** ```csharp // SetConfig에서 PascalCase로 저장 System.IO.File.WriteAllText(path, JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true })); ``` 수정 A가 더 안전하다. 언젠가 다른 코드 경로에서도 이 파일을 camelCase로 작성할 가능성이 있으므로, 읽는 쪽에서 대소문자를 무시하는 것이 근본 해결책이다. --- ### [2]. `VllmChatStreamWithTools` — anonymous type reflection으로 tool call 추출 (🟠 MED) **문제**: `VllmChatStreamWithTools()`(894-899줄)에서 `tcList`에 anonymous object를 담은 후, 같은 메서드 내에서 Reflection(`GetType().GetProperty(...)`)으로 값을 다시 꺼낸다. 값은 이미 변수(`tcId`, `funcName`, `funcArgs`)에 들어 있는데도 중복 추출하고 있다. **근거** (`OllamaController.cs:887-899`): ```csharp // 1. tcList에 anonymous object 저장 tcList.Add(new { id = tcId, // ← 이미 이 시점에 값이 로컬 변수에 있음 type = "function", function = new { name = funcName, arguments = funcArgs } }); // 2. 직후에 Reflection으로 다시 꺼냄 (894-899줄) foreach (var tc in tcList) { var tcId = tc.GetType().GetProperty("id")?.GetValue(tc) as string ?? ""; var func = tc.GetType().GetProperty("function")?.GetValue(tc); var funcName = func?.GetType().GetProperty("name")?.GetValue(func) as string ?? ""; var funcArgs = func?.GetType().GetProperty("arguments")?.GetValue(func) as string ?? "{}"; ``` **영향**: - Reflection은 직접 접근보다 **10~100배 느림** - 컴파일 타임 타입 안전성 없음 — anonymous type property가 rename되면 조용히 `null` 반환 - 고부하 다중 tool_calls 시 불필요한 CPU 낭비 **수정** — 이미 알고 있는 변수 직접 사용: ```csharp // messages.Add용으로만 tcList 유지 (line 887-892) messages.Add(new { role = "assistant", content = (string?)null, tool_calls = tcList }); // tool execution은 변수 직접 사용 (894-899줄 대신) // 위쪽 루프(866-884)에서 tcId, funcName, funcArgs를 이미 알고 있음 // → 해당 루프 내에서 바로 tool 실행하거나, 별도 List<(string, string, string)>에 저장 var toolCallInfos = new List<(string id, string name, string args)>(); foreach (var tc in toolCalls.EnumerateArray()) { var id = tc.GetProperty("id").GetString() ?? $"tc_{toolRound}_{Guid.NewGuid():N}"; var func = tc.GetProperty("function"); var name = func.GetProperty("name").GetString() ?? ""; var args = func.GetProperty("arguments").GetString() ?? "{}"; toolCallInfos.Add((id, name, args)); } // messages에 assistant + tool_calls 추가 (tcList는 anonymous array) messages.Add(new { role = "assistant", content = (string?)null, tool_calls = tcList }); // tool execution: toolCallInfos의 값을 직접 사용 (Reflection 불필요) foreach (var (tcId, funcName, funcArgs) in toolCallInfos) { await EmitToolStart(tcId, funcName, funcArgs); // ... 나머지 동일 ... } ``` --- ### [3]. JSON 텍스트 도구 감지 silent catch + fallthrough (🟠 MED) **문제**: `VllmChatStreamWithTools()`(944-1036줄)에서 LLM 응답 내 JSON을 파싱하여 텍스트 기반 도구 호출을 감지할 때, `catch { }`(1035줄)로 예외를 삼킨 후 코드가 아래로 낙하한다. `stopContent`가 있으면 raw text로 SSE 발송되어 사용자에게 도구 의도가 노출된다. 로깅도 없어 디버깅이 불가능하다. **근거** (`OllamaController.cs:944-1036`): ```csharp var jsonCandidate = ExtractFirstJsonObject(stopContent); if (!string.IsNullOrEmpty(jsonCandidate)) { try { // ... JSON 파싱, 도구 감지, 실행 ... if (detectedTool != null && args.Count > 0) { // ... 도구 호출 ... continue; // ← 성공만 continue } // (실패: detectedTool == null || args.Count == 0) → fallthrough! } catch { } // ← 파싱 예외도 fallthrough → 로깅 없음 } // 1038-1047: stopContent가 있으면 raw text로 SSE 발송 if (!string.IsNullOrEmpty(stopContent)) { var msgJson = JsonSerializer.Serialize(new { message = new { content = stopContent } }); await Response.WriteAsync($"event: message\ndata: {msgJson}\n\n"); // ... return; // ← 도구 대신 원본 텍스트 노출! } ``` **영향**: - LLM이 `{"tool": "run_sql", "parameters": {"sql": "..."}}`를 출력했으나 JSON 파싱 실패 → SQL이 실행되지 않고 JSON 텍스트가 그대로 사용자에게 전달됨 - 에이전트 모드 다단계 추론 중단 - `catch { }`로 디버깅 불가 **수정**: ```csharp if (!string.IsNullOrEmpty(jsonCandidate)) { bool toolExecuted = false; try { // ... 기존 파싱/도출/실행 로직 ... if (detectedTool != null && args.Count > 0) toolExecuted = true; } catch (Exception ex) { _logger.LogWarning(ex, "[OllamaController] JSON 도구 감지 파싱 실패, Text로 fallback: {Candidate}", jsonCandidate); } if (toolExecuted) continue; } ``` --- ### [4]. `_capsCache` TTL 만료 항목 미제거 — 메모리 누수 (🟠 MED) **문제**: `GetModelCapabilitiesAsync()`(329-357줄)의 `_capsCache`는 `DateTime.UtcNow.AddMinutes(5)`로 TTL을 설정하지만, 만료된 항목을 제거하는 로직이 없다. Ollama 서버에 새 모델이 계속 추가되면(실험/개발 환경) 딕셔너리가 무한히 커진다. **근거** (`OllamaController.cs:329-357`): ```csharp private static readonly Dictionary _capsCache = new(); private static readonly object _capsCacheLock = new(); private async Task GetModelCapabilitiesAsync(string baseUrl, string model) { lock (_capsCacheLock) { if (_capsCache.TryGetValue(model, out var hit) && hit.Until > DateTime.UtcNow) return hit.Caps; } // ... HTTP call ... lock (_capsCacheLock) { _capsCache[model] = (DateTime.UtcNow.AddMinutes(5), caps); } return caps; } ``` **영향**: - 10개 모델에서 200개 모델로 증가 시 캐시가 20배 확장 (무제한) - 앱 생명주기 동안 제거되지 않아 메모리 단편화 유발 **수정** — `IMemoryCache`로 교체 (TTL + 자동 pruning): ```csharp private readonly IMemoryCache _memoryCache; // DI: Program.cs에서 builder.Services.AddMemoryCache(); public OllamaController(..., IMemoryCache memoryCache) { _memoryCache = memoryCache; } private async Task GetModelCapabilitiesAsync(string baseUrl, string model) { var cacheKey = $"caps_{model}"; if (_memoryCache.TryGetValue(cacheKey, out string[] cached)) return cached; try { // ... HTTP call ... _memoryCache.Set(cacheKey, caps, TimeSpan.FromMinutes(5)); return caps; } catch { return Array.Empty(); } } ``` --- ### [5]. `GetModels()` — 모든 모델의 `/api/show`를 동시 병렬 호출 (🟠 MED) **문제**: `GetModels()`(300-305줄)는 `allModels.Select(async n => ...)` + `Task.WhenAll(tasks)`를 사용하여 모든 모델의 capabilities를 **동시에** 조회한다. Ollama 서버에 20개 모델이 있으면 20개의 `/api/show` 요청이 순간적으로 폭주한다. **근거** (`OllamaController.cs:300-305`): ```csharp var tasks = allModels.Select(async n => { var caps = await GetModelCapabilitiesAsync(cfg.BaseUrl, n); return (name: n, isChat: caps.Contains("completion")); }).ToList(); var results = await Task.WhenAll(tasks); ``` **영향**: - Ollama 서버에 부하 집중 (특히 모델이 디스크에서 로딩 중이면 응답 지연) - 초기 페이지 로드 시 `/api/ollama/models`가 수십 초 지연 가능 - 분당 수백 번 호출 시 서버 리소스 고갈 **수정** — `SemaphoreSlim`으로 동시성 제한: ```csharp private static readonly SemaphoreSlim _modelCapSemaphore = new(3); // 최대 3 concurrent var tasks = allModels.Select(async n => { await _modelCapSemaphore.WaitAsync(); try { var caps = await GetModelCapabilitiesAsync(cfg.BaseUrl, n); return (name: n, isChat: caps.Contains("completion")); } finally { _modelCapSemaphore.Release(); } }).ToList(); var results = await Task.WhenAll(tasks); ``` 또는 더 간단한 방법 — 캐시가 있으므로 `GetModels`의 `Task.WhenAll`을 제거하고 순차 조회: ```csharp // 이미 5분 TTL 캐시가 있으므로, 순차 조회해도 두 번째 요청부터는 캐시 히트 foreach (var n in allModels) { var caps = await GetModelCapabilitiesAsync(cfg.BaseUrl, n); if (caps.Contains("completion")) chatModels.Add(n); else embeddingModels.Add(n); } ``` --- ### [6]. Summarize — `LoadVllmModel()` 무시 (🟠 MED) **문제**: `Summarize()`(663줄)에서 model 기본값을 `Environment.GetEnvironmentVariable("VLLM_MODEL") ?? ""`로 설정한다. `LoadVllmModel()` 메서드가 파일→환경변수→기본값 순서로 폴백하는 것과 달리, 환경변수만 확인하고 빈 문자열로 폴백한다. **근거** (`OllamaController.cs:663`): ```csharp var model = string.IsNullOrWhiteSpace(req.Model) ? Environment.GetEnvironmentVariable("VLLM_MODEL") ?? "" // ← LoadVllmModel() 무시 : req.Model; ``` 동일 컨트롤러의 기존 메서드 (`OllamaController.cs:536-553`): ```csharp string LoadVllmModel() { // 1. llm-model.json 파일 확인 // 2. 파일 없으면 → "Qwen3.6-27B-FP8" 기본값 반환 } ``` **영향**: - `VLLM_MODEL` env 미설정 환경에서 `req.Model` 미포함 요청 시 빈 문자열 model로 vLLM 400 오류 - llm-model.json에 설정된 모델명이 무시됨 **수정**: ```csharp var model = string.IsNullOrWhiteSpace(req.Model) ? LoadVllmModel() // ← 기존 메서드 재사용 : req.Model; ``` --- ### [7]. `Response.Headers.Append()` — 이론적 중복 가능 (🟡 LOW) **문제**: SSE 스트리밍 엔드포인트(402-405, 702-705줄)에서 `Response.Headers.Append()`로 헤더를 설정한다. `IHeaderDictionary.Append()`는 기존 헤더가 이미 존재할 경우 **값을 추가**하므로(덮어쓰지 않음), nginx 등 reverse proxy에서 중복 헤더 충돌 가능성이 있다. **근거** (`OllamaController.cs:402-405`): ```csharp Response.Headers.Append("Content-Type", "text/event-stream"); Response.Headers.Append("Cache-Control", "no-cache"); Response.Headers.Append("Connection", "keep-alive"); Response.Headers.Append("X-Accel-Buffering", "no"); ``` **영향**: 현재 아키텍처(Kestrel 직접 서빙, 미들웨어 체인에 선행 헤더 세터 없음)에서는 재현 불가. nginx-proxy 도입 시 발생 가능. **수정**: ```csharp Response.Headers["Content-Type"] = "text/event-stream"; Response.Headers["Cache-Control"] = "no-cache"; Response.Headers["Connection"] = "keep-alive"; Response.Headers["X-Accel-Buffering"] = "no"; ``` --- ### [8]. HttpRequestMessage / HttpResponseMessage 미처분 (🟡 LOW) **문제**: 스트리밍 메서드들(`OllamaChatStream` 420-425줄, `VllmChatStreamSimple` 751-756줄, `VllmChatStreamWithTools` 832-836, 1060-1065줄)에서 `HttpRequestMessage`와 `HttpResponseMessage`에 `using`이 누락되었다. **근거**: `OllamaChatStream`(420-434줄): ```csharp var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{cfg.BaseUrl}/api/chat") { Content = content }; // ← using 없음 var res = await _httpClient.SendAsync(httpRequest, ...); // ← using 없음 // ... using var reader = new StreamReader(stream, Encoding.UTF8); // StreamReader만 using ``` **영향**: .NET `HttpClient`(SocketsHttpHandler)가 내부적으로 연결을 관리하고, `StreamReader` disposal이 스트림을 닫으므로 실질적 누수는 미미하다. 하지만 코드 분석 툴에서 경고를 발생시키고, 극단적 부하에서 GC 압박 유발 가능. **수정**: 일관성 있는 `using var` 적용: ```csharp using var httpRequest = new HttpRequestMessage(HttpMethod.Post, ...) { Content = content }; using var res = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); ``` --- ### [9]. `ExtractFirstJsonObject` — 문자열 내 `{` brace 미처리 (🟡 LOW) **문제**: `ExtractFirstJsonObject`(1130-1141줄)가 escaping과 문자열 상태를 고려하지 않고 depth만 센다. LLM 응답 문자열 리터럴에 `{`가 포함되면 depth 계산이 틀어진다. **영향**: LLM이 응답 중간에 JSON 형식을 출력하면(예: `설명: 매개변수 {a, b, c}`) brace depth가 깨져서 잘못된 범위 추출. 발생 빈도 낮음. **수정**: 1차 진단과 동일 (문자열 상태 추적 로직 추가). --- ## STEP 8 — 자가 검증 - [x] 각 지적 사항을 현재 파일의 특정 줄 번호로 직접 가리킴 - [x] HIGH 항목(1건)은 재현 가능한 시나리오를 한 문장으로 서술 가능 - [x] 교차 검증 4개 질문 모두 통과 - [x] 실측 없는 성능 수치 배제 - [x] 기존 보고서 누락 항목(`LoadConfig` case-sensitivity) 추가 완료 --- ## 최종 요약 | 심각도 | 수 | 항목 | |--------|---|------| | 🔴 HIGH | 1 | [1] `LoadConfig()` case-sensitive deserialization → 설정 저장 후 유실 | | 🟠 MED | 5 | [2] Reflection tool call 추출, [3] JSON 감지 silent catch + fallthrough, [4] 캐시 메모리 누수, [5] Thundering herd, [6] Summarize 기본값 누락 | | 🟡 LOW | 3 | [7] Headers.Append 중복, [8] HttpMessage disposal 누락, [9] ExtractFirstJsonObject brace | **우선 수정 순서**: [1] → [2] → [3] → [5] → [4] → [6] → [9] → [7] → [8] - **[1]은 서비스 장애** — 설정을 저장할 수 없어 원격 Ollama 연결 불가 - **[2]는 기능 버그** — Reflection 깨지면 tool_calling 무시 - **[3]은 디버깅 불가** — 도구 호출 누락 시 silent - **[5]는 성능** — 모델 20개 이상 시 페이지 로드 수초 지연 - **[4][6][9]는 안정성/일관성** - **[7][8]은 Best practice** ### 1차 보고서와의 비교 | 항목 | 1차 (등급) | 2차 (등급) | 사유 | |------|:---------:|:---------:|------| | static 캐시 | 🔴 HIGH | 🟠 MED | systemd 단일 프로세스 환경에선 문제 아님. 메모리 누수만 재평가 | | JSON silent catch | 🔴 HIGH | 🟠 MED | fallthrough 후에도 stopContent는 SSE 발송됨 (crash는 아님) | | Headers.Append | 🟠 MED | 🟡 LOW | 선행 미들웨어 없어 실제 재현 불가 | | HttpMessage disposal | 🟠 MED | 🟡 LOW | .NET 런타임이 내부 관리 | | LoadConfig 캐싱 | 🟡 LOW | **🔴 HIGH** | **case-sensitivity 버그 발견** (1차 누락) | | Reflection tool call | — | 🟠 MED | 1차 누락 | | Thundering herd | — | 🟠 MED | 1차 누락 | | Summarize 기본값 | 🟠 MED | 🟠 MED | 유지 | | ExtractFirstJsonObject | 🟡 LOW | 🟡 LOW | 유지 |