# ExperionCrawler 코드 진단 보고서

> 분석일: 2026-05-14 | 기준: `.roo/rules-code/diagnosis-checklist.md` 8단계
> CLAUDE.md 작업 이력(2026-04-14 ~ 2026-05-14)을 반영하여 이미 수정된 항목은 제외

---

## 🔴 HIGH (2건)

### 1. SQL 인젝션 — CreateHypertableAsync의 RetentionPeriod/CompressionPeriod 미검증 (HIGH)

**문제**: `CreateHypertableAsync`에서 `request.RetentionPeriod`와 `request.CompressionPeriod`가 `IsValidSqlIdentifier` 검증 없이 SQL 문자열에 직접 보간됨. `TableName`/`TimeColumn`은 검증되지만, INTERVAL 인자 두 개는 검증 사각지대.
**근거**: `src/Infrastructure/Database/ExperionDbContext.cs:1736` — `INTERVAL '{request.RetentionPeriod}'`, `:1746` — `INTERVAL '{request.CompressionPeriod}'`. `IsValidSqlIdentifier` (line 1660)는 `^[a-zA-Z0-9_\-\.]+$` 패턴으로 식별자만 검증.
**영향**: 악의적 `RetentionPeriod` 값(예: `'::text; DROP TABLE history_table;--`)로 SQL 인젝션 가능.
**수정**: INTERVAL 인자에 대해 별도 검증 함수 추가 (예: `^[0-9]+[smhdw]$` 패턴) 또는 `NpgsqlParameter` 사용.

### 2. 스택 트레이스 노출 — HistoryController 에러 응답 (HIGH)

**문제**: `QueryHistoryAsync` 예외 시 `InnerException.Message` 또는 `StackTrace`가 HTTP 응답 본문에 포함됨.
**근거**: `src/Web/Controllers/ExperionControllers.cs:651` — `detail = ex.InnerException?.Message ?? ex.StackTrace`
**영향**: 내부 DB 구조, 쿼리, 파일 경로 등 민감 정보가 외부 클라이언트에 노출.
**수정**: `detail = "이력 조회 중 오류가 발생했습니다."` 등 고정 메시지. 실제 예체는 `ILogger`로 로깅.

---

## 🟠 MED (5건)

### 3. Console.WriteLine 잔존 — ExperionDbContext.cs (MED)

**문제**: `CreateHypertableAsync` 내에서 14개 이상의 `Console.WriteLine`이 `[DEBUG]`/`[ERROR]` 태그로 출력. 일부는 전체 스택 트레이스 포함.
**근거**: `src/Infrastructure/Database/ExperionDbContext.cs:1714, 1721, 1728, 1737, 1747, 1771, 1779, 1797, 1800, 1802, 1808, 1820, 1823, 1824, 1826`
**영향**: 프로덕션에서 systemd journal에 `[DEBUG]`/`[ERROR]` 출력이 ILogger 파이프라인을 우회해 출력. 스택 트레이스 노출.
**수정**: `_logger.LogInformation`/`LogDebug`/`LogError`로 교체.

### 4. Blocking .Open() — CreateHypertableAsync (MED)

**문제**: `_ctx.Database.GetDbConnection().Open()`이 동기 호출됨. async 메서드 내에서 이벤트루프 블로킹 가능.
**근거**: `src/Infrastructure/Database/ExperionDbContext.cs:1678`
**영향**: 고부하 시 ASP.NET 요청 스레드 블로킹 가능. TimescaleDB 확장이 이미 설치된 환경에서는 이 코드가 자주 실행되지 않지만, retry 시나리오에서 문제 발생 가능.
**수정**: `await _ctx.Database.OpenConnectionAsync()`로 교체, finally에 `CloseConnectionAsync()`.

### 5. Blocking .GetResult() — ExperionRealtimeService.Dispose (MED)

**문제**: `Dispose()`에서 `CleanupSessionAsync().GetAwaiter().GetResult()`로 동기 블로킹.
**근거**: `src/Infrastructure/OpcUa/ExperionRealtimeService.cs:585`
**영향**: `StopAsync()`가 먼저 호출된 정상 종료 경로에서는 문제 없음. 하지만 `StopAsync` 없이 `Dispose`가 호출되면 데드락 가능. empty catch로 예외가 삼켜짐.
**수정**: `IAsyncDisposable`의 `DisposeAsync()`에서 async로 처리하고, `Dispose()`에서는 실제 정리가 아닌 `DisposeAsync().GetAwaiter().GetResult()` 대신 `try { await this; } catch { }` 패턴 사용.

### 6. CORS AllowAnyOrigin — Program.cs (MED)

**문제**: `AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()`로 완전 개방.
**근거**: `src/Web/Program.cs:154`
**영향**: 공장 내부망에서는 위험도가 낮으나, 외부 노출 시 CSRF 등 공격 가능.
**수정**: `WithOrigins("http://factory-internal:5000")` 등 명시적 origin 설정.

### 7. Brute-force 미방어 — KB 로그인 (MED)

**문제**: `/api/kb/auth/login`에 rate limiting, 계정 잠금, attempt 카운팅 없음.
**근거**: `src/Web/Controllers/KbAuthController.cs:23-34` — 예외적 로그인 시도 로깅만 있으며, 제한 로직 없음.
**영향**: 내부망에서는 위험도가 낮으나, Argon2id 해시 검증이 계산 집약적이므로 DoS로 서비스 감속 가능.
**수정**: ASP.NET Rate Limiting 미들웨어 적용 또는 `IDistributedRateLimiter` 기반 throttling.

---

## 🟡 LOW (4건)

### 8. Console.WriteLine — AssetLoader.cs (LOW)

**문제**: 7개의 `Console.WriteLine` (이모지 포함). ILogger 미사용.
**근거**: `src/Infrastructure/Csv/AssetLoader.cs:22, 26, 32, 41, 74, 78, 82`
**영향**: AssetLoader는 일회성 CSV 가져오기 유틸리티. 프로덕션 영향은 낮으나, journal 로그 오염.
**수정**: `ILogger` 의존성 주입 후 교체.

### 9. Process.Dispose 누락 — McpServerHostedService (LOW)

**문제**: `Process` 인스턴스가 `Kill()` 후 `Dispose()`되지 않음.
**근거**: `src/Infrastructure/Mcp/McpServerHostedService.cs:43` — `_process = new Process`, `:93` — `_process.Kill(entireProcessTree: true)`, Dispose 없음.
**영향**: 언매니지드 핸들 누수. 하지만 앱 종료 시 프로세스가 함께 종료되므로 실제 영향은 미미.
**수정**: `using var process = new Process { ... }` 또는 `StopAsync` 끝에서 `_process?.Dispose()`.

### 10. HttpClient 직접 생성 — McpClient (LOW)

**문제**: `new HttpClient { Timeout = TimeSpan.FromSeconds(1800) }`로 직접 생성 (IHttpClientFactory 경로의 fallback).
**근거**: `src/Infrastructure/Mcp/McpClient.cs:23-27`
**영향**: DI 주입 경로에서는 `null`이 전달되어 fallback 경로가 활성화됨 (Program.cs:92 확인 필요). 소켓 고갈 가능성.
**수정**: Program.cs에서 `AddHttpClient("McpClient")` 등록 후 `McpClient` 생성자에 주입.

### 11. McpClient Timeout 30분 (LOW)

**문제**: 기본 타임아웃 1800초 (30분).
**근거**: `src/Infrastructure/Mcp/McpClient.cs:26`
**영향**: MCP 서버 응답 지연 시 30분간 연결 유지. 하지만 개별 `CallToolAsync`는 `CancellationToken`을 받으므로 컨트롤러 레이어에서 취소 가능.
**수정**: 기본값을 60~120초로 낮추고, LLM 호출용 별도 HttpClient 등록.

---

## 📊 요약

| 등급 | 건수 | 항목 |
|------|------|------|
| 🔴 HIGH | 2 | SQL 인젝션 (#1), 스택 트레이스 노출 (#2) |
| 🟠 MED | 5 | Console.WriteLine (#3), blocking async (#4, #5), CORS (#6), brute-force (#7) |
| 🟡 LOW | 4 | Console.WriteLine (#8), Process dispose (#9), HttpClient (#10, #11) |

## 교차 검증 결과 — 제외된 항목

| 항목 | 제외 사유 | 교차 검증 질문 |
|------|-----------|---------------|
| `_restarting` 레이스 컨디션 | 실제로 `volatile` 선언됨. UI 단일 사용자 환경에서 동시 호출 시나리오 없음 | Q4 (재현 시나리오 부재) |
| `ctx.Cancel` non-volatile | `ConcurrentDictionary`에서 세션 제거 후 확인 → 최대 1회 stale read만 발생 | Q2 (다른 레이어 처리) |
| `_flushTask` 미대기 | `StopAsync`에서 `Task.WhenAll`로 적절히 처리됨 | Q2 (다른 레이어 처리) |
| 정적 캐시 (`_digitalTagCache`, `_plantContextCached`) | `lock`으로 동기화됨. TTL 기반 무효화 적용 | Q3 (의도적 설계) |

## 즉시 수정 우선순위

1. **#1 (SQL 인젝션)** — `RetentionPeriod`/`CompressionPeriod` 검증 추가
2. **#2 (스택 트레이스 노출)** — 고정 에러 메시지로 교체
3. **#3 (Console.WriteLine DbContext)** — ILogger로 교체
4. **#4 (blocking .Open())** — `OpenAsync()`로 교체
