Files
HC900-Crawler/MULTI_CONTROLLER_WORK_ORDER.md
windpacer 16fc7a2598 Initial commit: HC900 Crawler
Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL.
기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체.

- industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버)
- src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer)
- mcp-server: Python FastMCP (RAG/NL2SQL/P&ID)
- 다중 컨트롤러(N-Controller) 지원

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:28:14 +09:00

1356 lines
52 KiB
Markdown

# HC900 다중 컨트롤러 지원 — 구현 작업 지시서
## 목표
현재 단일 HC900 컨트롤러 구조를 N대 확장 가능한 구조로 변경한다.
현재 4대 운영 중. 추가 증설 고려.
---
## 현재 구조 (변경 전)
```
config/gateway-config.json → { gateway: { controllerIp, grpcPort, ... } }
Hc900GatewayProcessService → hc900_gateway 프로세스 1개 관리
Hc900GatewayClient → gRPC 채널 1개 (단일 Options 기반)
Hc900RealtimeService → 단일 gRPC 클라이언트로 폴링
realtime_table → PK: id (UNIQUE tagname)
history_table → tagname 기준
event_history_table → tagname 기준
hc900_map_master → tagname + hc900_tag
```
---
## 목표 구조 (변경 후)
```
config/gateway-config.json → { shared: {...}, controllers: [...] }
ControllerRegistry → ControllerConfig[] 관리 싱글턴
ControllerProcessManager → 컨트롤러별 GatewayProcess 관리 BackgroundService
ControllerGrpcClientPool → 컨트롤러별 GrpcChannel 풀
Hc900RealtimeService → 컨트롤러 목록 루프로 각각 폴링
realtime_table → PK: (controller_id, tagname)
history_table → controller_id 컬럼 추가
event_history_table → controller_id 컬럼 추가
hc900_map_master → controller_id 컬럼 추가
```
---
## 1단계 — Config 구조 변경
### 파일: `src/Infrastructure/Hc900/Hc900GatewayProcessService.cs`
#### 1-1. 기존 `GatewayConfig` 클래스 대체
기존:
```csharp
public class GatewayConfig
{
public string BinaryPath { get; set; } = "";
public string ControllerIp { get; set; } = "";
public int ControllerPort { get; set; } = 502;
public int PollIntervalMs { get; set; } = 1000;
public string RegisterMapPath { get; set; } = "";
public int GrpcListenPort { get; set; } = 50051;
public string LdLibraryPath { get; set; } = "";
public string LogPath { get; set; } = "/tmp/hc900_gateway.log";
}
```
변경 후 — 파일 맨 위에 새 클래스 추가:
```csharp
/// <summary>공유 설정 — 모든 컨트롤러가 공통으로 사용</summary>
public class GatewaySharedConfig
{
public string BinaryPath { get; set; } = "";
public string LdLibraryPath { get; set; } = "";
public string LogDir { get; set; } = "/tmp";
}
/// <summary>컨트롤러 1대 설정</summary>
public class ControllerConfig
{
public string Id { get; set; } = ""; // 고유 식별자 e.g. "HC1"
public string Name { get; set; } = ""; // 표시명 e.g. "반응기 1"
public string ControllerIp { get; set; } = "";
public int ControllerPort { get; set; } = 502;
public int GrpcPort { get; set; } = 50051;
public int PollIntervalMs { get; set; } = 1000;
public string RegisterMapPath { get; set; } = "";
public bool Enabled { get; set; } = true;
}
/// <summary>전체 Config 루트 (기존 GatewayConfig 대체)</summary>
public class MultiControllerConfig
{
public GatewaySharedConfig Shared { get; set; } = new();
public List<ControllerConfig> Controllers { get; set; } = new();
}
// 하위 호환: 기존 GatewayConfig는 삭제해도 됨
// SetupController 등에서 GatewayConfig를 직접 참조하는 곳은
// ControllerConfig + GatewaySharedConfig 로 교체
```
#### 1-2. `gateway-config.json` 형식 변경 예시
```json
{
"shared": {
"binaryPath": "/home/windpacer/projects/hc900_ax/industrial-comm/cpp/build/hc900_gateway",
"ldLibraryPath": "/tmp/grpc_local/lib:/tmp/absl_local/lib",
"logDir": "/tmp"
},
"controllers": [
{
"id": "HC1",
"name": "반응기 1",
"controllerIp": "192.168.0.240",
"controllerPort": 502,
"grpcPort": 50051,
"pollIntervalMs": 1000,
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map.json",
"enabled": true
},
{
"id": "HC2",
"name": "반응기 2",
"controllerIp": "192.168.0.241",
"controllerPort": 502,
"grpcPort": 50052,
"pollIntervalMs": 1000,
"registerMapPath": "/home/windpacer/projects/hc900_ax/docs/register-map-hc2.json",
"enabled": true
}
]
}
```
#### 1-3. `Hc900GatewayProcessService` → `ControllerProcessManager` 로 재작성
기존 파일에서 `Hc900GatewayProcessService` 클래스를 다음으로 교체.
`GatewayStatus` 클래스도 수정:
```csharp
public class ControllerStatus
{
public string ControllerId { get; set; } = "";
public string Name { get; set; } = "";
public bool Running { get; set; }
public int? Pid { get; set; }
public string Message { get; set; } = "";
public string ControllerIp { get; set; } = "";
public int GrpcPort { get; set; }
public DateTime? StartedAt { get; set; }
public List<string> RecentLog { get; set; } = new();
}
public class ControllerProcessManager : BackgroundService
{
private readonly ILogger<ControllerProcessManager> _logger;
// D1: Dictionary → ConcurrentDictionary (HTTP 스레드 + 감시 루프 동시 접근 안전)
private readonly ConcurrentDictionary<string, Process?> _processes = new();
private readonly ConcurrentDictionary<string, DateTime?> _startedAt = new();
// D1: StartAsync 의 "확인 후 시작" 복합연산 TOCTOU 방지 — 컨트롤러별 1개 세마포어
private readonly ConcurrentDictionary<string, SemaphoreSlim> _startLocks = new();
private MultiControllerConfig _config = new();
private readonly object _configLock = new(); // D1: _config 읽기/쓰기 보호
private static readonly string ConfigPath = FindConfigPath();
private static string FindConfigPath()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
for (int i = 0; i < 8 && dir != null; i++, dir = dir.Parent)
{
var c = Path.Combine(dir.FullName, "config", "gateway-config.json");
if (File.Exists(c)) return c;
}
return Path.Combine(AppContext.BaseDirectory,
"..", "..", "..", "..", "..", "..", "config", "gateway-config.json");
}
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public ControllerProcessManager(ILogger<ControllerProcessManager> logger)
{
_logger = logger;
ReloadConfig();
}
// D1: _config 읽기는 lock 안에서
public MultiControllerConfig Config { get { lock (_configLock) return _config; } }
public void ReloadConfig()
{
try
{
var path = Path.GetFullPath(ConfigPath);
if (!File.Exists(path)) { _logger.LogWarning("[ProcMgr] config 없음: {P}", path); return; }
var json = File.ReadAllText(path);
var loaded = JsonSerializer.Deserialize<MultiControllerConfig>(json, _jsonOpts)
?? new MultiControllerConfig();
lock (_configLock) { _config = loaded; }
_logger.LogInformation("[ProcMgr] 컨트롤러 {Count}대 로드", loaded.Controllers.Count);
}
catch (Exception ex) { _logger.LogWarning(ex, "[ProcMgr] config 로드 실패"); }
}
public static void SaveConfig(MultiControllerConfig cfg)
{
var path = Path.GetFullPath(ConfigPath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(cfg, _jsonOpts));
}
public List<ControllerStatus> GetAllStatus()
{
List<ControllerConfig> ctrls;
lock (_configLock) { ctrls = _config.Controllers.ToList(); }
return ctrls.Select(c => GetStatus(c.Id)).ToList();
}
public ControllerStatus GetStatus(string controllerId)
{
ControllerConfig? cfg;
MultiControllerConfig snapshot;
lock (_configLock) { snapshot = _config; cfg = snapshot.Controllers.FirstOrDefault(c => c.Id == controllerId); }
if (cfg == null) return new ControllerStatus { ControllerId = controllerId, Message = "설정 없음" };
// ConcurrentDictionary — 스레드 안전 읽기
_processes.TryGetValue(controllerId, out var p);
var running = p != null && !p.HasExited;
var logPath = Path.Combine(snapshot.Shared.LogDir, $"hc900_gateway_{controllerId}.log");
var log = new List<string>();
try { if (File.Exists(logPath)) log = File.ReadLines(logPath).TakeLast(20).ToList(); } catch { }
_startedAt.TryGetValue(controllerId, out var startedAt);
return new ControllerStatus
{
ControllerId = controllerId,
Name = cfg.Name,
Running = running,
Pid = running ? p!.Id : null,
Message = running ? $"실행 중 (PID {p!.Id})" : "중지됨",
ControllerIp = cfg.ControllerIp,
GrpcPort = cfg.GrpcPort,
StartedAt = startedAt,
RecentLog = log,
};
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[ProcMgr] 시작");
await Task.Delay(2000, stoppingToken);
foreach (var ctrl in _config.Controllers.Where(c => c.Enabled))
{
var (ok, msg) = await StartAsync(ctrl.Id);
_logger.LogInformation("[ProcMgr] {Id} 초기 시작: {Msg}", ctrl.Id, msg);
}
// 감시 루프
while (!stoppingToken.IsCancellationRequested)
{
try { await Task.Delay(5000, stoppingToken); } catch (OperationCanceledException) { break; }
List<ControllerConfig> enabledCtrls;
lock (_configLock) { enabledCtrls = _config.Controllers.Where(c => c.Enabled).ToList(); }
foreach (var ctrl in enabledCtrls)
{
if (_processes.TryGetValue(ctrl.Id, out var p) && p != null && p.HasExited)
{
_logger.LogWarning("[ProcMgr] {Id} 종료됨 (exit={Code}) — 재시작", ctrl.Id, p.ExitCode);
_processes.TryRemove(ctrl.Id, out _); // D1: TryRemove 사용
_startedAt.TryRemove(ctrl.Id, out _);
try { await Task.Delay(5000, stoppingToken); } catch (OperationCanceledException) { break; }
var (ok, msg) = await StartAsync(ctrl.Id);
_logger.LogInformation("[ProcMgr] {Id} 재시작: {Msg}", ctrl.Id, msg);
}
}
}
}
public async Task<(bool Ok, string Msg)> StartAsync(string controllerId)
{
// D1: 컨트롤러별 세마포어로 동시 중복 시작 방지 (TOCTOU 방지)
var sem = _startLocks.GetOrAdd(controllerId, _ => new SemaphoreSlim(1, 1));
if (!await sem.WaitAsync(0))
return (false, "이미 시작 처리 중");
try
{
MultiControllerConfig snapshot;
lock (_configLock) { snapshot = _config; }
var cfg = snapshot.Controllers.FirstOrDefault(c => c.Id == controllerId);
if (cfg == null) return (false, $"컨트롤러 {controllerId} 설정 없음");
if (_processes.TryGetValue(controllerId, out var existing) && existing != null && !existing.HasExited)
return (false, "이미 실행 중");
if (!File.Exists(snapshot.Shared.BinaryPath))
return (false, $"바이너리 없음: {snapshot.Shared.BinaryPath}");
if (string.IsNullOrEmpty(cfg.ControllerIp))
return (false, "Controller IP 미설정");
var logPath = Path.Combine(snapshot.Shared.LogDir, $"hc900_gateway_{controllerId}.log");
try { File.WriteAllText(logPath, ""); } catch { }
// D7: 인수 순서 — 기존 호환 유지하며 끝에 추가
// argv[1]=host argv[2]=map_path argv[3]=poll_ms argv[4]=grpc_port argv[5]=modbus_port
var psi = new ProcessStartInfo
{
FileName = snapshot.Shared.BinaryPath,
Arguments = $"{cfg.ControllerIp} {cfg.RegisterMapPath} {cfg.PollIntervalMs} {cfg.GrpcPort} {cfg.ControllerPort}",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
if (!string.IsNullOrEmpty(snapshot.Shared.LdLibraryPath))
psi.EnvironmentVariables["LD_LIBRARY_PATH"] = snapshot.Shared.LdLibraryPath;
var proc = new Process { StartInfo = psi };
proc.OutputDataReceived += (_, e) => { if (e.Data != null) AppendLog(logPath, e.Data); };
proc.ErrorDataReceived += (_, e) => { if (e.Data != null) AppendLog(logPath, e.Data); };
proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
_processes[controllerId] = proc;
_startedAt[controllerId] = DateTime.UtcNow;
// D6: 고정 2초 대기 → gRPC 헬스체크 폴링 (최대 10초)
// StartAsync 내에서 임시 채널 사용 (아직 풀에 등록 전이므로 불가피); 사용 후 즉시 dispose
await Task.Delay(500);
for (int i = 0; i < 10; i++)
{
if (proc.HasExited) return (false, "즉시 종료됨 — 로그 확인");
try
{
using var tempChannel = GrpcChannel.ForAddress($"http://localhost:{cfg.GrpcPort}");
var tempClient = new ModbusGateway.ModbusGatewayClient(tempChannel);
var health = await tempClient.HealthCheckAsync(
new HealthCheckRequest(), deadline: DateTime.UtcNow.AddSeconds(1));
if (health.Status == HealthCheckResponse.Types.ServingStatus.Serving)
return (true, $"시작 PID={proc.Id} (헬스체크 통과, {i + 1}회)");
}
catch { /* 아직 준비 안 됨 */ }
await Task.Delay(1000);
}
return proc.HasExited
? (false, "즉시 종료됨 — 로그 확인")
: (true, $"시작 PID={proc.Id} (헬스체크 미응답)");
}
finally { sem.Release(); }
}
public Task<(bool Ok, string Msg)> StopAsync(string controllerId)
{
if (!_processes.TryGetValue(controllerId, out var p) || p == null || p.HasExited)
{
_processes.TryRemove(controllerId, out _); // D1: TryRemove
return Task.FromResult((false, "실행 중인 프로세스 없음"));
}
try { p.Kill(entireProcessTree: true); } catch { }
p.Dispose();
_processes.TryRemove(controllerId, out _); // D1: TryRemove
_startedAt.TryRemove(controllerId, out _);
return Task.FromResult((true, $"{controllerId} 중지됨"));
}
private void AppendLog(string logPath, string line)
{
try { File.AppendAllText(logPath, line + "\n"); } catch { }
}
public override void Dispose()
{
foreach (var (_, p) in _processes)
{
try { if (p != null && !p.HasExited) p.Kill(entireProcessTree: true); p?.Dispose(); } catch { }
}
foreach (var (_, sem) in _startLocks)
try { sem.Dispose(); } catch { }
base.Dispose();
}
}
```
---
## 2단계 — C++ 게이트웨이 수정 (gRPC 포트 + Modbus 포트 인수 추가)
> **D5 주의**: `src/main.cpp`는 엔트리포인트가 아님 (내용: `auto controller = init_system();` 3줄짜리 미사용 파일).
> 실제 `main()` 함수는 **`src/gateway.cpp:390`** 에 있음. 모든 변경은 `gateway.cpp`와 `include/gateway.h`에 적용.
### 파일 1: `industrial-comm/cpp/include/gateway.h`
현재 생성자 (`gateway.h:36`):
```cpp
Hc900Gateway(const std::string& host, uint16_t port,
const std::string& map_path,
int poll_interval_ms = 1000);
// 멤버 변수: std::string grpc_listen_{"0.0.0.0:50051"}; ← 하드코딩
```
변경 후 — D4: 생성자에 grpc_port 파라미터 추가, 멤버 변수 초기화 제거:
```cpp
// 생성자 선언 변경
Hc900Gateway(const std::string& host, uint16_t port,
const std::string& map_path,
int poll_interval_ms = 1000,
int grpc_port = 50051); // ← 추가
// 멤버 변수: 하드코딩 제거 → 생성자에서 초기화
std::string grpc_listen_; // "0.0.0.0:50051" 제거
```
### 파일 2: `industrial-comm/cpp/src/gateway.cpp`
**생성자 구현 변경** (현재 `gateway.cpp:19-26` 근방):
```cpp
// 변경 전
Hc900Gateway::Hc900Gateway(const std::string& host, uint16_t port,
const std::string& map_path,
int poll_interval_ms)
: host_(host), port_(port), poll_interval_ms_(poll_interval_ms)
// 변경 후
Hc900Gateway::Hc900Gateway(const std::string& host, uint16_t port,
const std::string& map_path,
int poll_interval_ms,
int grpc_port)
: host_(host), port_(port), poll_interval_ms_(poll_interval_ms),
grpc_listen_("0.0.0.0:" + std::to_string(grpc_port))
```
**`main()` 변경** (`gateway.cpp:390`):
현재:
```cpp
// argv[1]=host, argv[2]=map_path, argv[3]=poll_ms (port는 502 하드코딩)
if (argc > 1) host = argv[1];
if (argc > 2) map_path = argv[2];
if (argc > 3) poll_ms = std::atoi(argv[3]);
Hc900Gateway gateway(host, port, map_path, poll_ms);
```
변경 후 — D7: 기존 인수 순서 유지, 끝에 추가 (하위 호환):
```cpp
// argv[1]=host argv[2]=map_path argv[3]=poll_ms
// argv[4]=grpc_port (신규, 기본 50051)
// argv[5]=modbus_port (신규, 기본 502)
std::string host = "192.168.0.240";
std::string map_path = "docs/register-map.json";
int poll_ms = 1000;
int grpc_port = 50051;
uint16_t modbus_port = 502;
if (argc > 1) host = argv[1];
if (argc > 2) map_path = argv[2];
if (argc > 3) poll_ms = std::atoi(argv[3]);
if (argc > 4) grpc_port = std::atoi(argv[4]);
if (argc > 5) modbus_port = static_cast<uint16_t>(std::atoi(argv[5]));
Hc900Gateway gateway(host, modbus_port, map_path, poll_ms, grpc_port);
```
빌드 후 `build/hc900_gateway` 재배포.
---
## 3단계 — DB 마이그레이션
### `Hc900DbContext.cs` > `InitializeAsync()` 에 추가
기존 테이블 생성 코드 **아래에** 다음 멱등 ALTER 문 추가:
```csharp
// ── Multi-controller 지원: controller_id 컬럼 추가 ────────────────
await _ctx.Database.ExecuteSqlRawAsync("""
ALTER TABLE realtime_table
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
""");
await _ctx.Database.ExecuteSqlRawAsync("""
ALTER TABLE history_table
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
""");
await _ctx.Database.ExecuteSqlRawAsync("""
ALTER TABLE event_history_table
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
""");
await _ctx.Database.ExecuteSqlRawAsync("""
ALTER TABLE hc900_map_master
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
""");
await _ctx.Database.ExecuteSqlRawAsync("""
ALTER TABLE tag_metadata
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'
""");
// UNIQUE 제약 변경: tagname 단독 → (controller_id, tagname)
// UNIQUE INDEX 이름이 다를 수 있으므로 기존 것 먼저 삭제
await _ctx.Database.ExecuteSqlRawAsync("""
DO $$
BEGIN
-- realtime_table unique
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'realtime_table'::regclass AND contype = 'u'
) THEN
ALTER TABLE realtime_table DROP CONSTRAINT IF EXISTS realtime_table_tagname_key;
ALTER TABLE realtime_table DROP CONSTRAINT IF EXISTS uq_realtime_tagname;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'realtime_table'::regclass
AND conname = 'uq_realtime_controller_tagname'
) THEN
ALTER TABLE realtime_table
ADD CONSTRAINT uq_realtime_controller_tagname UNIQUE (controller_id, tagname);
END IF;
END $$
""");
// 인덱스
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_realtime_controller
ON realtime_table(controller_id)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_history_controller
ON history_table(controller_id)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_event_history_controller
ON event_history_table(controller_id)
""");
```
### 엔티티 클래스 수정: `src/Core/Domain/Entities/Hc900Entities.cs`
다음 4개 클래스에 `controller_id` 컬럼 추가:
```csharp
// RealtimePoint 에 추가
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
// HistoryRecord 에 추가
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
// EventHistoryRecord 에 추가
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
// Hc900MapEntry 에 추가
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
// TagMetadata 에 추가
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
```
---
## 4단계 — gRPC 클라이언트 풀
### 신규 파일: `src/Infrastructure/Hc900/ControllerGrpcClientPool.cs`
```csharp
using Grpc.Net.Client;
using Hc900.Gateway;
using Microsoft.Extensions.Logging;
namespace Hc900Crawler.Infrastructure.Hc900;
/// <summary>컨트롤러별 gRPC 채널/클라이언트 풀</summary>
public class ControllerGrpcClientPool : IDisposable
{
private readonly ControllerProcessManager _procMgr;
private readonly ILogger<ControllerGrpcClientPool> _logger;
private readonly Dictionary<string, (GrpcChannel Channel, ModbusGateway.ModbusGatewayClient Client)> _clients = new();
private readonly object _lock = new();
public ControllerGrpcClientPool(ControllerProcessManager procMgr, ILogger<ControllerGrpcClientPool> logger)
{
_procMgr = procMgr;
_logger = logger;
}
public ModbusGateway.ModbusGatewayClient GetClient(string controllerId)
{
lock (_lock)
{
if (_clients.TryGetValue(controllerId, out var existing))
return existing.Client;
var ctrl = _procMgr.Config.Controllers.FirstOrDefault(c => c.Id == controllerId)
?? throw new InvalidOperationException($"컨트롤러 {controllerId} 설정 없음");
var address = $"http://localhost:{ctrl.GrpcPort}";
var channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions
{
MaxReceiveMessageSize = 64 * 1024 * 1024,
});
var client = new ModbusGateway.ModbusGatewayClient(channel);
_clients[controllerId] = (channel, client);
_logger.LogInformation("[GrpcPool] 채널 생성: {Id} → {Addr}", controllerId, address);
return client;
}
}
/// <summary>모든 활성 컨트롤러 ID 목록</summary>
public IEnumerable<string> EnabledControllerIds
=> _procMgr.Config.Controllers.Where(c => c.Enabled).Select(c => c.Id);
public void RemoveClient(string controllerId)
{
lock (_lock)
{
if (_clients.TryGetValue(controllerId, out var pair))
{
pair.Channel.ShutdownAsync().GetAwaiter().GetResult();
pair.Channel.Dispose();
_clients.Remove(controllerId);
}
}
}
public void Dispose()
{
lock (_lock)
{
foreach (var (_, pair) in _clients)
{
try { pair.Channel.ShutdownAsync().GetAwaiter().GetResult(); pair.Channel.Dispose(); } catch { }
}
_clients.Clear();
}
}
}
```
---
## 5단계 — Hc900RealtimeService 수정
### 파일: `src/Infrastructure/Hc900/Hc900RealtimeService.cs`
`Hc900GatewayClient` 의존성을 `ControllerGrpcClientPool` 로 교체.
컨트롤러별로 폴링 루프 실행.
```csharp
// 생성자 변경: Hc900GatewayClient → ControllerGrpcClientPool
public Hc900RealtimeService(
ControllerGrpcClientPool clientPool,
IOptions<Hc900Options> options, // pollIntervalMs 등 공통 옵션 여전히 사용
IConfiguration config,
ILogger<Hc900RealtimeService> logger)
// ExecuteAsync: 컨트롤러별 Task 동시 실행
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var tasks = _clientPool.EnabledControllerIds
.Select(id => RunControllerLoopAsync(id, stoppingToken))
.ToList();
await Task.WhenAll(tasks);
}
private async Task RunControllerLoopAsync(string controllerId, CancellationToken ct)
{
// 기존 단일 루프와 동일한 로직, client는 _clientPool.GetClient(controllerId)
// DB upsert 시 controller_id = controllerId 포함
// IsConnected / PollCount 는 컨트롤러별로 관리 (Dictionary 사용)
}
```
**변경 핵심:**
- `BatchUpdateRealtimeTableAsync` 의 INSERT 쿼리:
```sql
INSERT INTO realtime_table (controller_id, tagname, node_id, livevalue, timestamp)
VALUES ($1, $2, '', $3, $4)
ON CONFLICT (controller_id, tagname) DO UPDATE
SET livevalue = EXCLUDED.livevalue, timestamp = EXCLUDED.timestamp
```
- `LoadMappingAsync` 쿼리:
```sql
SELECT tagname, hc900_tag FROM hc900_map_master
WHERE is_active = TRUE AND controller_id = $1
```
- `LoadStateLabelsAsync` 쿼리:
```sql
SELECT base_tag, attribute, value FROM tag_metadata
WHERE attribute LIKE 'state%' AND controller_id = $1
```
**IsConnected / PollCount 노출:**
```csharp
// 기존 단일 값 → 컨트롤러별 딕셔너리
private readonly ConcurrentDictionary<string, bool> _connected = new();
private readonly ConcurrentDictionary<string, long> _pollCounts = new();
private readonly ConcurrentDictionary<string, DateTime?> _lastPollAt = new();
public bool IsConnected => _connected.Values.Any(v => v); // 하나라도 연결되면 true
public long PollCount => _pollCounts.Values.Sum();
public DateTime? LastPollAt => _lastPollAt.Values.Where(v => v.HasValue).Max();
// 컨트롤러별 상태 노출 (Setup UI용)
public IReadOnlyDictionary<string, bool> ControllerConnected => _connected;
```
---
## 6단계 — Hc900HistoryService 수정
### 파일: `src/Infrastructure/Hc900/Hc900HistoryService.cs`
`SnapshotToHistoryAsync` 가 `controller_id` 를 전달하도록 수정.
**`IExperionDbService` 인터페이스 / `Hc900DbService` 구현 변경:**
```csharp
// 기존 (IExperionServices.cs:31)
Task<int> SnapshotToHistoryAsync(bool includeDigital = false);
// 변경 후 — D2: includeDigital 유지, controllerId 추가
Task<int> SnapshotToHistoryAsync(string? controllerId = null, bool includeDigital = false);
// controllerId=null → 전체 컨트롤러 스냅샷
```
`Hc900HistoryService` 호출:
```csharp
var count = await db.SnapshotToHistoryAsync(controllerId: null, includeDigital: false);
```
**DB 쿼리:**
```sql
INSERT INTO history_table (controller_id, tagname, node_id, value, recorded_at)
SELECT controller_id, tagname, node_id, livevalue, NOW()
FROM realtime_table
WHERE ($1::TEXT IS NULL OR controller_id = $1)
AND ($2 = TRUE OR livevalue IS NOT NULL) -- includeDigital=false면 NULL 제외
ON CONFLICT DO NOTHING
```
---
## 6-b단계 — Hc900DigitalEventDetectorService 수정 (D3 — 지시서 원안 누락)
> **원안 누락 항목.** 이 서비스가 `event_history_table`에 기록할 때 `controller_id`를 전파하지 않으면
> HC1 이외 컨트롤러의 디지털 이벤트가 모두 `DEFAULT 'HC1'`로 저장됨.
### 파일 1: `src/Core/Application/Interfaces/IExperionServices.cs` — DigitalEventRecord
```csharp
public class DigitalEventRecord
{
public string ControllerId { get; set; } = "HC1"; // ← 추가
public string TagName { get; set; } = "";
public string NodeId { get; set; } = "";
public string? PrevValue { get; set; }
public string CurrValue { get; set; } = "";
public string EventType { get; set; } = "";
public DateTime EventTime { get; set; } = DateTime.UtcNow;
public int? DurationSeconds { get; set; }
public string? Area { get; set; }
public string? SubArea { get; set; }
public string? Metadata { get; set; }
}
```
### 파일 2: `src/Infrastructure/Hc900/Hc900DigitalEventDetectorService.cs`
`DetectAndRecordChangesAsync` 내부 — `RealtimePoint.ControllerId` 전파:
```csharp
// RealtimePoint 엔티티에 ControllerId가 추가된 후 (3단계 완료 전제)
events.Add(new DigitalEventRecord
{
ControllerId = point.ControllerId, // ← 추가 (기본값 "HC1" 대신 실제 값)
TagName = tagName,
NodeId = point.NodeId,
PrevValue = prevState.Value,
CurrValue = currValue,
EventType = eventType,
EventTime = now,
DurationSeconds = duration,
Area = area,
SubArea = subArea,
Metadata = BuildMetadata(tagName, eventType, currValue)
});
```
### 파일 3: `src/Infrastructure/Database/Hc900DbContext.cs` — BatchRecordDigitalEventsAsync
DB INSERT 쿼리에 `controller_id` 컬럼 추가:
```sql
INSERT INTO event_history_table
(controller_id, tagname, node_id, prev_value, curr_value,
event_type, event_time, duration_seconds, area, sub_area, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
```
파라미터 $1 = `record.ControllerId`
---
## 7단계 — SetupController 수정
### 파일: `src/Hc900Crawler/Controllers/SetupController.cs`
`Hc900GatewayProcessService` → `ControllerProcessManager` 로 교체.
```csharp
[ApiController]
[Route("api/setup")]
public class SetupController : ControllerBase
{
private readonly ControllerProcessManager _procMgr;
private readonly Hc900RealtimeService _realtime;
public SetupController(ControllerProcessManager procMgr, Hc900RealtimeService realtime)
{ _procMgr = procMgr; _realtime = realtime; }
/// <summary>전체 컨트롤러 상태 조회</summary>
[HttpGet("controllers")]
public IActionResult GetControllers()
{
var statuses = _procMgr.GetAllStatus();
var connected = _realtime.ControllerConnected;
return Ok(statuses.Select(s => new
{
s.ControllerId, s.Name, s.Running, s.Pid,
s.Message, s.ControllerIp, s.GrpcPort, s.StartedAt,
crawlerConnected = connected.GetValueOrDefault(s.ControllerId),
pollCount = _realtime.PollCount, // 전체 합산 또는 컨트롤러별 구현
}));
}
/// <summary>단일 컨트롤러 상태 (기존 /gateway/status 대체)</summary>
[HttpGet("gateway/status")]
public IActionResult GetStatus()
{
// 하위 호환: 첫 번째 컨트롤러 상태 반환 + 전체 합산
var statuses = _procMgr.GetAllStatus();
var first = statuses.FirstOrDefault() ?? new ControllerStatus();
return Ok(new
{
running = statuses.Any(s => s.Running),
pid = first.Pid,
message = string.Join(", ", statuses.Select(s => $"{s.ControllerId}:{s.Message}")),
controllerIp = string.Join(", ", statuses.Select(s => s.ControllerIp)),
startedAt = first.StartedAt,
recentLog = first.RecentLog,
crawlerConnected = _realtime.IsConnected,
crawlerPollCount = _realtime.PollCount,
crawlerLastPoll = _realtime.LastPollAt,
controllers = statuses, // 상세 목록 추가
});
}
/// <summary>전체 설정 조회</summary>
[HttpGet("config")]
public IActionResult GetConfig()
{
_procMgr.ReloadConfig();
return Ok(_procMgr.Config);
}
/// <summary>전체 설정 저장</summary>
[HttpPost("config")]
public IActionResult SaveConfig([FromBody] MultiControllerConfig cfg)
{
ControllerProcessManager.SaveConfig(cfg);
_procMgr.ReloadConfig();
return Ok(new { success = true });
}
/// <summary>특정 컨트롤러 시작</summary>
[HttpPost("gateway/start")]
public async Task<IActionResult> Start([FromQuery] string? id = null)
{
if (id != null)
{
var (ok, msg) = await _procMgr.StartAsync(id);
return Ok(new { success = ok, message = msg });
}
// id 없으면 전체 시작
var results = new List<object>();
foreach (var ctrl in _procMgr.Config.Controllers.Where(c => c.Enabled))
{
var (ok, msg) = await _procMgr.StartAsync(ctrl.Id);
results.Add(new { ctrl.Id, ok, msg });
}
return Ok(results);
}
/// <summary>특정 컨트롤러 중지</summary>
[HttpPost("gateway/stop")]
public async Task<IActionResult> Stop([FromQuery] string? id = null)
{
if (id != null)
{
var (ok, msg) = await _procMgr.StopAsync(id);
return Ok(new { success = ok, message = msg });
}
var results = new List<object>();
foreach (var ctrl in _procMgr.Config.Controllers)
{
var (ok, msg) = await _procMgr.StopAsync(ctrl.Id);
results.Add(new { ctrl.Id, ok, msg });
}
return Ok(results);
}
[HttpPost("gateway/restart")]
public async Task<IActionResult> Restart([FromBody] MultiControllerConfig? cfg, [FromQuery] string? id = null)
{
if (cfg != null) { ControllerProcessManager.SaveConfig(cfg); _procMgr.ReloadConfig(); }
if (id != null)
{
await _procMgr.StopAsync(id);
await Task.Delay(500);
var (ok, msg) = await _procMgr.StartAsync(id);
return Ok(new { success = ok, message = msg });
}
// 전체 재시작
foreach (var ctrl in _procMgr.Config.Controllers)
await _procMgr.StopAsync(ctrl.Id);
await Task.Delay(500);
var results = new List<object>();
foreach (var ctrl in _procMgr.Config.Controllers.Where(c => c.Enabled))
{
var (ok, msg) = await _procMgr.StartAsync(ctrl.Id);
results.Add(new { ctrl.Id, ok, msg });
}
return Ok(results);
}
[HttpGet("gateway/log")]
public IActionResult GetLog([FromQuery] string? id = null, [FromQuery] int lines = 50)
{
var target = id ?? _procMgr.Config.Controllers.FirstOrDefault()?.Id;
if (target == null) return Ok(new { log = Array.Empty<string>() });
var status = _procMgr.GetStatus(target);
return Ok(new { log = status.RecentLog.TakeLast(lines) });
}
}
```
---
## 8단계 — Program.cs 수정
### 파일: `src/Hc900Crawler/Program.cs`
기존 등록 코드 교체:
```csharp
// 제거:
// builder.Services.AddSingleton<Hc900GatewayProcessService>();
// builder.Services.AddHostedService(sp => sp.GetRequiredService<Hc900GatewayProcessService>());
// builder.Services.AddSingleton<Hc900GatewayClient>();
// builder.Services.AddSingleton<IHc900GatewayService>(...);
// 추가:
builder.Services.AddSingleton<ControllerProcessManager>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ControllerProcessManager>());
builder.Services.AddSingleton<ControllerGrpcClientPool>();
// Hc900RealtimeService 생성자 변경에 따라 DI는 자동 해결됨
builder.Services.AddSingleton<Hc900RealtimeService>();
builder.Services.AddSingleton<IHc900RealtimeService>(sp => sp.GetRequiredService<Hc900RealtimeService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<Hc900RealtimeService>());
// Hc900WriteService 도 ControllerGrpcClientPool 사용하도록 수정 필요
builder.Services.AddSingleton<Hc900WriteService>();
```
---
## 9단계 — Hc900WriteService 수정
### 파일: `src/Infrastructure/Hc900/Hc900WriteService.cs`
```csharp
// 생성자: Hc900GatewayClient → ControllerGrpcClientPool
// WriteTagAsync 에 controllerId 파라미터 추가
public async Task<(bool Success, string? Error)> WriteTagAsync(
string controllerId, string tagName, double value)
{
var client = _clientPool.GetClient(controllerId);
var resp = await client.WriteTagAsync(new WriteTagRequest { TagName = tagName, Value = value });
return (resp.Success, resp.Error);
}
```
### GatewayController write endpoint 수정:
```csharp
// WriteTagDto 에 ControllerId 추가
public class WriteTagDto
{
public string ControllerId { get; set; } = "HC1"; // 기본값 하위 호환
public string TagName { get; set; } = "";
public double Value { get; set; }
}
```
---
## 10단계 — Setup UI 수정
### 파일: `wwwroot/js/setup.js`
기존 단일 컨트롤러 상태 표시를 컨트롤러 목록 카드 형식으로 변경.
**refreshStatus 변경:**
```javascript
async function refreshStatus() {
try {
const d = await _setupApi('GET', '/gateway/status');
// d.controllers = [{controllerId, name, running, pid, controllerIp, grpcPort, crawlerConnected}, ...]
const container = document.getElementById('controllers-list');
if (!container) return;
container.innerHTML = (d.controllers || []).map(c => `
<div class="ctrl-card ${c.running ? 'running' : 'stopped'}">
<div class="ctrl-header">
<span class="ctrl-id">${esc(c.controllerId)}</span>
<span class="ctrl-name">${esc(c.name)}</span>
<span class="ctrl-badge ${c.running ? 'green' : 'red'}">${c.running ? '실행 중' : '중지됨'}</span>
</div>
<div class="ctrl-info">
IP: ${esc(c.controllerIp)} | gRPC: :${c.grpcPort} | PID: ${c.pid ?? '—'}
| Crawler: ${c.crawlerConnected ? '연결됨' : '미연결'}
</div>
<div class="ctrl-btns">
<button class="setup-btn success" onclick="startGateway('${esc(c.controllerId)}')">▶</button>
<button class="setup-btn danger" onclick="stopGateway('${esc(c.controllerId)}')">■</button>
<button class="setup-btn warning" onclick="restartGateway('${esc(c.controllerId)}')">↺</button>
</div>
</div>
`).join('');
} catch(e) { console.error('refreshStatus:', e); }
}
```
**startGateway/stopGateway/restartGateway 에 id 파라미터 추가:**
```javascript
async function startGateway(id) {
const url = id ? `/gateway/start?id=${encodeURIComponent(id)}` : '/gateway/start';
const r = await _setupApi('POST', url);
_setupMsg('action-msg', r.success ?? (r[0]?.ok), r.message ?? '완료');
setTimeout(refreshStatus, 2500);
}
```
### 파일: `wwwroot/panes/setup.html`
Gateway Status 카드 내부:
```html
<!-- 기존 단일 status 행들 → 아래로 교체 -->
<div id="controllers-list"></div>
```
Config 카드도 `multiControllerConfig` 형식으로 편집할 수 있게 변경.
(controllers 배열 추가/편집/삭제 UI 구현)
---
## 11단계 — API 쿼리 파라미터 전파
아래 API들에 `?controllerId=HC1` 쿼리 파라미터 추가 및 DB 쿼리에 반영:
| API | 변경 |
|-----|------|
| `GET /api/realtime/points` | `?controllerId=` 필터 추가 |
| `POST /api/history/query` | `HistoryQueryDto.ControllerId` 추가 |
| `POST /api/events/query` | `EventQueryDto.ControllerId` 추가 |
| `GET /api/hc900/tags` | `?controllerId=` 필터 추가 |
UI의 이력/이벤트/태그 탭에 컨트롤러 선택 드롭다운 추가.
---
## 12단계 — IHc900RealtimeService 인터페이스 수정
### 파일: `src/Core/Application/Interfaces/IExperionServices.cs` (또는 유사)
```csharp
public interface IHc900RealtimeService
{
bool IsConnected { get; }
long PollCount { get; }
DateTime? LastPollAt { get; }
IReadOnlyDictionary<string, bool> ControllerConnected { get; } // 추가
}
```
---
## 체크리스트
```
[ ] 1. C++ gateway.h: 생성자에 grpc_port 파라미터 추가, grpc_listen_ 초기화 이동 (D4)
[ ] 2. C++ gateway.cpp main(): argv[4]=grpc_port, argv[5]=modbus_port 추가 (기존 순서 유지) (D7)
[ ] 3. C++ 재빌드 → build/hc900_gateway 배포
[ ] 4. gateway-config.json: shared + controllers 형식으로 변환
[ ] 5. GatewayConfig → MultiControllerConfig / ControllerConfig 교체
[ ] 6. Hc900GatewayProcessService → ControllerProcessManager 교체
- Dictionary → ConcurrentDictionary (D1)
- SemaphoreSlim per-controller StartAsync 보호 (D1)
- _config 읽기/쓰기 lock(_configLock) 추가 (D1)
- Task.Delay(2000) → gRPC 헬스체크 폴링 with using tempChannel (D6)
- Arguments에 grpc_port + modbus_port 포함 (D7)
[ ] 7. ControllerGrpcClientPool 신규 작성
[ ] 8. DB InitializeAsync: controller_id 컬럼 ALTER + UNIQUE 재설정
[ ] 9. 엔티티 클래스 5곳 ControllerId 추가
(RealtimePoint, HistoryRecord, EventHistoryRecord, Hc900MapEntry, TagMetadata)
[ ] 10. Hc900RealtimeService: clientPool 기반 멀티 루프로 변경
[ ] 11. Hc900HistoryService: SnapshotToHistoryAsync(controllerId, includeDigital) — includeDigital 유지 (D2)
[ ] 12. Hc900DigitalEventDetectorService: DigitalEventRecord.ControllerId 추가 + 전파 (D3)
[ ] 13. Hc900WriteService: controllerId 파라미터 추가
[ ] 14. SetupController: ControllerProcessManager 사용으로 교체
[ ] 15. GatewayController: WriteTagDto.ControllerId 추가
[ ] 16. Program.cs: DI 등록 교체
[ ] 17. Setup UI: 컨트롤러 목록 카드 표시 + 개별 제어
[ ] 18. 이력/이벤트/태그 API + UI: controllerId 필터 추가
[ ] 19. dotnet build 확인
[ ] 20. 단일 컨트롤러(HC1)로 회귀 테스트
```
---
## 주의사항
- **기존 DB 데이터**: `controller_id DEFAULT 'HC1'` 로 마이그레이션 자동 처리됨
- **하위 호환**: `GET /api/setup/gateway/status` 는 기존 형식 + `controllers` 배열 추가로 호환 유지
- **gRPC 포트**: 50051~50054 방화벽 개방 필요 (서버 내부 통신이므로 loopback만 사용 시 불필요)
- **레지스터맵**: 컨트롤러마다 다를 수 있으므로 `registerMapPath` 를 개별 지정
- **Hc900Options (appsettings.json)**: 단일 `GatewayAddress` 는 더 이상 사용 안 함. config/gateway-config.json 으로 통합
---
## 진단 결과 및 반박 검수
| 항목 | 판정 | 반영 위치 |
|------|------|-----------|
| D1 동시성 | **채택 + 보완** — ConcurrentDictionary 외 SemaphoreSlim 추가 | 1-3단계 코드 |
| D2 includeDigital | **채택** — 기존 시그니처 확인 후 양쪽 파라미터 유지 | 6단계 |
| D3 DigitalEventDetector | **채택** — 원안 누락 인정, 6-b단계 신규 추가 | 6-b단계 |
| D4 gateway.h 생성자 | **채택** — 헤더 파일 변경 명시 | 2단계 |
| D5 main.cpp 혼란 | **채택 + 상향 조정** — 수정 대상이 main.cpp가 아닌 gateway.cpp임을 명시 | 2단계 |
| D6 Task.Delay | **부분 채택** — 방향 동의. 임시 채널은 `using` 으로 즉시 dispose (풀 우회 주의) | 1-3단계 코드 |
| D7 인수 순서 | **부분 채택** — 포트 추가 동의. 단 중간 삽입은 Breaking Change이므로 **끝에 추가** | 2단계 |
### D1. `ControllerProcessManager` — Dictionary 동시성 미보호 (MED)
**문제**: `_processes`(Dictionary)와 `_config`가 `ExecuteAsync`(감시루프), `StartAsync`, `StopAsync`, `GetStatus`에서 **lock 없이** 동시 접근된다.
**근거**: `MULTI_CONTROLLER_WORK_ORDER.md:154` — `private readonly Dictionary<string, Process?> _processes = new();`
**영향**: 동시 HTTP 요청 또는 감시 루프와 충돌 시 `Collection was modified` 예외, 프로세스 중복 실행, 또는 `NullReferenceException`.
**수정**: `_processes` → `ConcurrentDictionary<string, Process?>` 로 변경하고 `_config` 접근 시 `lock (_lock)` 보호:
```csharp
// 필드 선언 교체
private readonly ConcurrentDictionary<string, Process?> _processes = new();
private readonly ConcurrentDictionary<string, DateTime?> _startedAt = new();
// GetStatus 의 _processes 접근
_processes.TryGetValue(controllerId, out var p);
// ExecuteAsync 의 _processes 접근
if (_processes.TryGetValue(ctrl.Id, out var p) && p != null && p.HasExited)
{
_processes.TryRemove(ctrl.Id, out _);
// ...
}
// StartAsync 의 _processes 접근
if (_processes.TryGetValue(controllerId, out var existing) && existing != null && !existing.HasExited)
return (false, "이미 실행 중");
// ...
_processes[controllerId] = proc;
// StopAsync 의 _processes 접근
if (!_processes.TryGetValue(controllerId, out var p) || p == null || p.HasExited)
{
_processes.TryRemove(controllerId, out _);
// ...
}
// _config 도 ReloadConfig() 전역 lock 필요
private readonly object _configLock = new();
public void ReloadConfig() { lock (_configLock) { /* 기존 로직 */ } }
public MultiControllerConfig Config { get { lock (_configLock) return _config; } }
```
---
### D2. `IExperionDbService.SnapshotToHistoryAsync` — `includeDigital` 파라미터 유실 (MED)
**문제**: STEP 6이 현재 시그니처를 `SnapshotToHistoryAsync()` (파라미터 없음)으로 잘못 기술했고, 변경 후에도 `includeDigital`이 사라진다.
**근거**: `IExperionServices.cs:31` — 실제 현재 시그니처는 `Task<int> SnapshotToHistoryAsync(bool includeDigital = false)`.
**영향**: 구현 시 기존 `includeDigital` 기능이 손실됨. 디지털 태그 필터링 없이 전체 스냅샷 저장.
**수정**: STEP 6 변경 후 시그니처를 아래로 통일:
```csharp
// 변경 후: controllerId + includeDigital 동시 지원
Task<int> SnapshotToHistoryAsync(string? controllerId = null, bool includeDigital = false);
```
`Hc900HistoryService`의 호출 코드:
```csharp
// 실시간 서비스가 하나라도 연결되었는지는 컨트롤러레벨로 확인
// 각 컨트롤러별로 스냅샷 호출하거나, null로 전체 스냅샷
var count = await db.SnapshotToHistoryAsync(controllerId: null, includeDigital: false);
```
---
### D3. `Hc900DigitalEventDetectorService` — `controller_id` 미전파 (MED)
**문제**: 작업 지시서가 `Hc900DigitalEventDetectorService`의 변경을 전혀 언급하지 않는다. 이 서비스가 기록하는 `event_history_table`의 `controller_id`가 항상 `'HC1'` (DEFAULT)이 된다.
**근거**: `MULTI_CONTROLLER_WORK_ORDER.md:624` — `Hc900HistoryService`만 언급, `Hc900DigitalEventDetectorService` 누락. `Hc900DigitalEventDetectorService.cs:140-152` — `DigitalEventRecord`에 `ControllerId` 필드가 없음.
**영향**: HC1 이외 컨트롤러의 디지털 이벤트가 모두 HC1로 기록되어 이벤트 조회 시 혼란.
**수정**: (A) `DigitalEventRecord`에 `ControllerId` 추가, (B) `DetectAndRecordChangesAsync`에서 realtime_table 조회 시 controller_id를 함께 읽어 event record에 포함, (C) DB 저장 쿼리에 controller_id 컬럼 추가:
```csharp
// ── 3a. Core.Application.Interfaces/IExperionServices.cs - DigitalEventRecord ──
public class DigitalEventRecord
{
// ... 기존 필드들 ...
public string ControllerId { get; set; } = "HC1";
}
// ── 3b. DetectAndRecordChangesAsync 내부 ──
// RealtimePoint에 ControllerId가 추가되면(STEP 7) 아래와 같이 전파:
events.Add(new DigitalEventRecord
{
TagName = tagName,
ControllerId = point.ControllerId, // ← 추가
// ...
});
// ── 3c. DB batch insert 쿼리 수정 (Hc900DbService) ──
// controller_id 컬럼 포함 INSERT
```
---
### D4. C++ `grpc_listen_` 생성자 파라미터화 명시 필요 (LOW)
**문제**: STEP 2가 `argv[4]` 파싱만 언급하고 `gateway.h:99`의 `grpc_listen_` 하드코딩을 생성자 파라미터로 변경하는 코드를 명시하지 않았다. 문맥상 유추 가능하지만 실수 방지를 위해 명시 필요.
**근거**: `gateway.h:99` — `std::string grpc_listen_{"0.0.0.0:50051"};`, `gateway.cpp:19-26` — 생성자에 `grpc_port` 파라미터 없음.
**수정**: `gateway.h` 생성자 선언과 `gateway.cpp` 생성자 구현에 `grpc_port` 파라미터 추가:
```cpp
// gateway.h:36-38
Hc900Gateway(const std::string& host, uint16_t port,
const std::string& map_path, int poll_interval_ms = 1000,
int grpc_port = 50051);
// 멤버 변수:
// std::string grpc_listen_{"0.0.0.0:50051"}; ← 제거
std::string grpc_listen_; // 생성자에서 초기화
// gateway.cpp 생성자:
Hc900Gateway::Hc900Gateway(const std::string& host, uint16_t port,
const std::string& map_path, int poll_interval_ms,
int grpc_port)
: host_(host), port_(port), poll_interval_ms_(poll_interval_ms),
grpc_listen_("0.0.0.0:" + std::to_string(grpc_port))
{
LoadRegisterMap(map_path);
auto transport = std::make_unique<ModbusTCP>();
controller_ = std::make_unique<Controller>(std::move(transport));
}
```
---
### D5. `main.cpp` / `app_init.cpp` 가 CMakeLists에서 제외됨 (LOW)
**문제**: `main.cpp`(`main()` 함수 없음, 3줄)와 `app_init.cpp`가 CMakeLists의 어떤 target에도 포함되지 않았다. 실제 게이트웨이 `main()`은 `gateway.cpp:390`에 있다. `main.cpp` 파일 내부에 `// app_init.cpp` 코멘트가 있어 파일명과 내용이 불일치.
**근거**: `CMakeLists.txt:6-10,30-35` — `main.cpp`와 `app_init.cpp`가 source 목록에 없음. `main.cpp:1` — `// app_init.cpp`.
**영향**: 유지보수 시 혼란. 누군가 `main.cpp`가 엔트리포인트라고 오해할 수 있음.
**수정**: `main.cpp`의 내용을 제거하거나, 진정한 엔트리포인트로 전환. `app_init.cpp`는 `comm_core` 라이브러리에 등록:
```cmake
# CMakeLists.txt: target_sources(comm_core PRIVATE ... src/app_init.cpp ... 추가)
target_sources(comm_core
PRIVATE
src/controller.cpp
src/codec.cpp
src/app_init.cpp # ← 추가
)
```
또는 `main.cpp`를 실제 엔트리포인트로 만들어 `gateway.cpp`의 `main()`을 분리.
---
### D6. `Task.Delay(2000)` 고정 대기 — gRPC 헬스체크로 대체 권장 (LOW)
**문제**: `ControllerProcessManager.StartAsync()`에서 2초 고정 `Task.Delay`로 게이트웨이 준비를 확인한다.
**근거**: `MULTI_CONTROLLER_WORK_ORDER.md:321` — `await Task.Delay(2000);`.
**영향**: 게이트웨이 기동이 2초보다 오래 걸리면 즉시 종료됨으로 오판. 2초 이내면 불필요한 대기.
**수정**: gRPC 헬스체크 폴링 루프로 대체 (기존 `Hc900RealtimeService.WaitForGatewayAsync` 패턴 재사용):
```csharp
// StartAsync 끝부분:
await Task.Delay(1000);
// gRPC 헬스체크 폴링 (최대 10초)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
for (int i = 0; i < 10; i++)
{
try
{
var client = new ModbusGateway.ModbusGatewayClient(
GrpcChannel.ForAddress($"http://localhost:{cfg.GrpcPort}"));
var health = await client.HealthCheckAsync(new HealthCheckRequest(),
deadline: DateTime.UtcNow.AddSeconds(2));
if (health.Status == HealthCheckResponse.Types.ServingStatus.Serving)
return (true, $"시작 PID={proc.Id} (헬스체크 통과)");
}
catch { /* 아직 준비 안 됨 */ }
try { await Task.Delay(1000, cts.Token); } catch { break; }
}
return _processes.TryGetValue(controllerId, out var p) && p != null && !p.HasExited
? (true, $"시작 PID={proc.Id} (헬스체크 미응답)")
: (false, "즉시 종료됨 — 로그 확인");
```
---
### D7. C++ 게이트웨이 포트 처리 누락 (LOW)
**문제**: `Hc900GatewayProcessService.StartAsync()`는 현재 `ControllerPort`를 ProcessStartInfo.Arguments에 포함시키지 않고, `Hc900Gateway` 생성자의 두 번째 인자가 포트이므로 전달되어야 한다. 그러나 `gateway.cpp`의 `main()`에서는 `argv[1]=host`, `argv[2]=map_path`, `argv[3]=poll_ms`만 처리하고 포트는 항상 `uint16_t port = 502`로 고정이다.
**근거**: `gateway.cpp:392-399` — 포트는 고정 502, `Hc900GatewayProcessService.cs:203` — Arguments에 `ControllerPort` 누락.
**영향**: `config/gateway-config.json`에서 `controllerPort`를 변경해도 무시되고 항상 502로 연결.
**수정**: C++ `main()`에 `argv[4]=port` 추가 및 .NET 측 Arguments에 `${_config.ControllerPort}` 포함:
```cpp
// gateway.cpp main()
if (argc > 1) host = argv[1];
uint16_t port = 502;
if (argc > 2) port = static_cast<uint16_t>(std::atoi(argv[2]));
if (argc > 3) map_path = argv[3];
if (argc > 4) poll_ms = std::atoi(argv[4]);
if (argc > 5) grpc_port = std::atoi(argv[5]);
Hc900Gateway gateway(host, port, map_path, poll_ms, grpc_port);
```
```csharp
// .NET StartAsync()
Arguments = $"{cfg.ControllerIp} {cfg.ControllerPort} {cfg.RegisterMapPath} {cfg.PollIntervalMs} {cfg.GrpcPort}";
```