190 lines
7.8 KiB
Markdown
190 lines
7.8 KiB
Markdown
---
|
|
name: fastTable/fastRecord 구현 검증 결과
|
|
description: roo-fasttable-implementation.md 계획 대비 실제 구현 차이 및 버그 목록
|
|
type: project
|
|
originSessionId: ec4d397a-b394-4d23-b041-03b70a7d0136
|
|
---
|
|
## 검증 대상
|
|
- 계획서: `plans/roo-fasttable-implementation.md`
|
|
- 검증 시점: 2026-04-29
|
|
- 빌드 결과: 경고 0건, 에러 0건 (빌드는 통과)
|
|
|
|
## Step별 구현 상태
|
|
|
|
| Step | 파일 | 상태 | 비고 |
|
|
|------|------|------|------|
|
|
| 1 | ExperionEntities.cs | ✅ 완료 | |
|
|
| 2 | IExperionServices.cs | ✅ 완료 | |
|
|
| 3 | IExperionServices.cs | ✅ 완료 | |
|
|
| 4 | ExperionDbContext.cs | ✅ 완료 | |
|
|
| 5 | ExperionDbContext.cs (DDL) | ⚠️ 차이 | `tag_list JSONB` (계획: TEXT). EnsureCreatedAsync가 먼저 실행되어 실제로는 text 타입이 됨 → 런타임 문제 없음 |
|
|
| 6 | ExperionDbContext.cs (메서드) | ⚠️ 차이 | GetFastSessionsAsync 정렬 역전, UpdateFastSessionRowCountAsync 구현 방식 다름 |
|
|
| 7 | ExperionDbContext.cs (메서드) | ❌ 버그 | CSV Export 헤더 오류 |
|
|
| 8~11 | ExperionFastService.cs | ❌ 치명적 | StartSessionAsync 항상 실패 |
|
|
| 12 | ExperionFastCleanupService | ✅ 완료 | ExperionFastService.cs 파일에 같이 위치 (문제없음) |
|
|
| 13 | ExperionControllers.cs | ✅ 완료 | FastPinRequest → PinRequest로 이름 다름 (동작 동일) |
|
|
| 14 | Program.cs | ✅ 완료 | DI 패턴 3줄 패턴 정확히 구현 |
|
|
| 15 | appsettings.json | ✅ 완료 | Fast 섹션 추가됨 |
|
|
| 16 | index.html | ✅ 완료 | Bootstrap 방식으로 구현 (계획과 스타일 다르나 기능 동일) |
|
|
| 17 | app.js | ⚠️ 차이 | 태그 목록 로딩 방식 다름 (전역변수 의존) |
|
|
| 18 | style.css | ⚠️ 생략 | Bootstrap 사용하여 별도 CSS 불필요 |
|
|
|
|
---
|
|
|
|
## 버그 목록
|
|
|
|
### Bug 1 — StartSessionAsync 항상 실패 (치명적)
|
|
**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs:99-116`
|
|
|
|
현재 코드:
|
|
```csharp
|
|
var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig()); // 빈 설정!
|
|
if (string.IsNullOrEmpty(cfg?.ServerConfiguration?.BaseAddresses?.Count > 0 ? cfg.ServerConfiguration.BaseAddresses[0] : null))
|
|
throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다.");
|
|
|
|
if (!await _opcClient.IsConnectedAsync(cfg))
|
|
throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다.");
|
|
|
|
// 노드 유효성 사전 검증
|
|
foreach (var tagName in request.TagList)
|
|
{
|
|
var nodeId = await db.GetNodeIdByTagNameAsync(tagName);
|
|
if (string.IsNullOrEmpty(nodeId))
|
|
throw new ArgumentException($"태그 '{tagName}'의 nodeId를 찾을 수 없습니다.");
|
|
|
|
var readResult = await _opcClient.ReadTagAsync(new ExperionServerConfig(), nodeId); // 빈 설정!
|
|
if (!readResult.Success)
|
|
throw new ArgumentException($"태그 '{tagName}' 읽기 실패: {readResult.ErrorMessage}");
|
|
}
|
|
```
|
|
|
|
문제: `new ExperionServerConfig()`는 ServerHostName이 빈 문자열이라 EndpointUrl = `opc.tcp://:4840`. ApplicationConfiguration의 BaseAddresses가 비어 있어 "서버 엔드포인트 URL이 설정되어 있지 않습니다." 항상 throw.
|
|
|
|
또한 `StartSubscriptionAsync(ctx, cfg)` 내부:
|
|
```csharp
|
|
var endpoint = await SelectEndpointAsync(cfg, cfg.ServerConfiguration?.BaseAddresses?[0] ?? string.Empty);
|
|
var session = await CreateSessionAsync(cfg, endpoint, new ExperionServerConfig()); // 빈 UserName/Password
|
|
```
|
|
|
|
수정 방법 (계획서 Step 9 참조): `realtime_autostart.json`에서 ExperionServerConfig를 읽어야 함.
|
|
```csharp
|
|
private static readonly string RealtimeFlagPath = Path.GetFullPath("realtime_autostart.json");
|
|
|
|
private static async Task<ExperionServerConfig?> ReadServerConfigAsync()
|
|
{
|
|
if (!File.Exists(RealtimeFlagPath)) return null;
|
|
try
|
|
{
|
|
var json = await File.ReadAllTextAsync(RealtimeFlagPath);
|
|
return JsonSerializer.Deserialize<ExperionServerConfig>(json);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
```
|
|
|
|
그리고 `StartSessionAsync` 시작 부분을:
|
|
```csharp
|
|
var serverCfg = await ReadServerConfigAsync();
|
|
if (serverCfg == null)
|
|
throw new InvalidOperationException("OPC UA 서버 설정을 찾을 수 없습니다. 실시간 구독을 먼저 시작하세요.");
|
|
|
|
var appConfig = await _configProvider.GetConfigAsync(serverCfg);
|
|
// IsConnectedAsync 체크 제거 or appConfig 사용
|
|
```
|
|
|
|
`StartSubscriptionAsync` 시그니처도 변경:
|
|
```csharp
|
|
private async Task StartSubscriptionAsync(FastSessionContext ctx, ExperionServerConfig serverCfg)
|
|
{
|
|
var appConfig = await _configProvider.GetConfigAsync(serverCfg);
|
|
var endpoint = await SelectEndpointAsync(appConfig, serverCfg.EndpointUrl);
|
|
var identity = new UserIdentity(serverCfg.UserName, System.Text.Encoding.UTF8.GetBytes(serverCfg.Password));
|
|
// ...
|
|
}
|
|
```
|
|
|
|
`IExperionOpcClient` 생성자 주입도 제거 가능 (사전 검증 로직 삭제). Program.cs의 DI 등록은 이미 정상.
|
|
|
|
---
|
|
|
|
### Bug 2 — CSV Export 헤더 오류
|
|
**파일**: `src/Infrastructure/Database/ExperionDbContext.cs:828-829`
|
|
|
|
현재 코드 (잘못됨):
|
|
```csharp
|
|
csv.WriteRecord(new { RecordedAt = "recorded_at", TagNames = tagNames.Select((t, i) => $"tag{i+1}") });
|
|
await writer.WriteLineAsync();
|
|
```
|
|
→ CSV 헤더가 `RecordedAt,TagNames` 또는 `recorded_at,tag1` 형태로 출력됨. 태그명이 아님.
|
|
|
|
수정 방법 (계획서 Step 7 참조):
|
|
```csharp
|
|
using var writer = new StreamWriter(stream, leaveOpen: true);
|
|
await writer.WriteLineAsync("recorded_at," + string.Join(",", tagNames));
|
|
|
|
foreach (var g in records.GroupBy(x => x.RecordedAt).OrderBy(g => g.Key))
|
|
{
|
|
var values = g.ToDictionary(r => r.TagName, r => r.Value);
|
|
var row = g.Key.ToString("o") + "," +
|
|
string.Join(",", tagNames.Select(t => values.TryGetValue(t, out var v) ? $"\"{v}\"" : ""));
|
|
await writer.WriteLineAsync(row);
|
|
}
|
|
await writer.FlushAsync();
|
|
```
|
|
CsvHelper 의존성 제거. `CsvHelper` using도 제거 필요.
|
|
|
|
---
|
|
|
|
### Bug 3 — 세션 목록 정렬 역전 (경미)
|
|
**파일**: `src/Infrastructure/Database/ExperionDbContext.cs:760`
|
|
|
|
현재:
|
|
```csharp
|
|
.OrderBy(x => x.StartedAt) // 오래된 것이 위
|
|
```
|
|
계획:
|
|
```csharp
|
|
.OrderByDescending(x => x.StartedAt) // 최신이 위
|
|
```
|
|
|
|
---
|
|
|
|
### Bug 4 — 신규 세션 모달에서 태그 목록이 비어 있을 수 있음 (경미)
|
|
**파일**: `src/Web/wwwroot/js/app.js`, `btn-fast-new` 이벤트 핸들러
|
|
|
|
현재 코드:
|
|
```javascript
|
|
(typeof tagNames !== 'undefined' ? tagNames : []).forEach(name => { ... });
|
|
```
|
|
`tagNames` 전역 변수가 실시간 탭 방문 전에는 비어 있음 → 태그 선택 불가.
|
|
|
|
계획서 `fastNewModal()` 참조 → `/api/realtime/points` 직접 fetch:
|
|
```javascript
|
|
async function fastNewModal() {
|
|
const res = await fetch('/api/realtime/points');
|
|
const select = document.getElementById('fast-tag-select');
|
|
select.innerHTML = '';
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
(data.items || []).forEach(p => {
|
|
const opt = document.createElement('option');
|
|
opt.value = p.tagName || p.TagName;
|
|
opt.textContent = p.tagName || p.TagName;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
// ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 수정 우선순위
|
|
1. **Bug 1** (치명적): StartSessionAsync — realtime_autostart.json 기반 서버 설정 읽기로 전면 수정
|
|
2. **Bug 2** (중요): ExportFastRecordsToCsvAsync — CsvHelper 제거, 수동 CSV 작성으로 교체
|
|
3. **Bug 3** (경미): GetFastSessionsAsync 정렬 방향 수정
|
|
4. **Bug 4** (경미): fastNewModal 태그 목록 직접 fetch로 수정
|
|
|
|
**Why:** Bug 1은 fastRecord 기능이 전혀 동작하지 않게 만드는 근본 원인. realtime_autostart.json에 OPC UA 서버 접속 정보가 있음 (실시간 구독 시작 시 저장됨).
|
|
**How to apply:** Roo에게 위 4개 버그를 순서대로 수정 지시. 각 수정 후 `dotnet build` 확인.
|