# 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 /// 공유 설정 — 모든 컨트롤러가 공통으로 사용 public class GatewaySharedConfig { public string BinaryPath { get; set; } = ""; public string LdLibraryPath { get; set; } = ""; public string LogDir { get; set; } = "/tmp"; } /// 컨트롤러 1대 설정 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; } /// 전체 Config 루트 (기존 GatewayConfig 대체) public class MultiControllerConfig { public GatewaySharedConfig Shared { get; set; } = new(); public List 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 RecentLog { get; set; } = new(); } public class ControllerProcessManager : BackgroundService { private readonly ILogger _logger; // D1: Dictionary → ConcurrentDictionary (HTTP 스레드 + 감시 루프 동시 접근 안전) private readonly ConcurrentDictionary _processes = new(); private readonly ConcurrentDictionary _startedAt = new(); // D1: StartAsync 의 "확인 후 시작" 복합연산 TOCTOU 방지 — 컨트롤러별 1개 세마포어 private readonly ConcurrentDictionary _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 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(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 GetAllStatus() { List 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(); 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 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(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; /// 컨트롤러별 gRPC 채널/클라이언트 풀 public class ControllerGrpcClientPool : IDisposable { private readonly ControllerProcessManager _procMgr; private readonly ILogger _logger; private readonly Dictionary _clients = new(); private readonly object _lock = new(); public ControllerGrpcClientPool(ControllerProcessManager procMgr, ILogger 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; } } /// 모든 활성 컨트롤러 ID 목록 public IEnumerable 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 options, // pollIntervalMs 등 공통 옵션 여전히 사용 IConfiguration config, ILogger 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 _connected = new(); private readonly ConcurrentDictionary _pollCounts = new(); private readonly ConcurrentDictionary _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 ControllerConnected => _connected; ``` --- ## 6단계 — Hc900HistoryService 수정 ### 파일: `src/Infrastructure/Hc900/Hc900HistoryService.cs` `SnapshotToHistoryAsync` 가 `controller_id` 를 전달하도록 수정. **`IExperionDbService` 인터페이스 / `Hc900DbService` 구현 변경:** ```csharp // 기존 (IExperionServices.cs:31) Task SnapshotToHistoryAsync(bool includeDigital = false); // 변경 후 — D2: includeDigital 유지, controllerId 추가 Task 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; } /// 전체 컨트롤러 상태 조회 [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, // 전체 합산 또는 컨트롤러별 구현 })); } /// 단일 컨트롤러 상태 (기존 /gateway/status 대체) [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, // 상세 목록 추가 }); } /// 전체 설정 조회 [HttpGet("config")] public IActionResult GetConfig() { _procMgr.ReloadConfig(); return Ok(_procMgr.Config); } /// 전체 설정 저장 [HttpPost("config")] public IActionResult SaveConfig([FromBody] MultiControllerConfig cfg) { ControllerProcessManager.SaveConfig(cfg); _procMgr.ReloadConfig(); return Ok(new { success = true }); } /// 특정 컨트롤러 시작 [HttpPost("gateway/start")] public async Task 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(); 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); } /// 특정 컨트롤러 중지 [HttpPost("gateway/stop")] public async Task 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(); 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 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(); 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() }); var status = _procMgr.GetStatus(target); return Ok(new { log = status.RecentLog.TakeLast(lines) }); } } ``` --- ## 8단계 — Program.cs 수정 ### 파일: `src/Hc900Crawler/Program.cs` 기존 등록 코드 교체: ```csharp // 제거: // builder.Services.AddSingleton(); // builder.Services.AddHostedService(sp => sp.GetRequiredService()); // builder.Services.AddSingleton(); // builder.Services.AddSingleton(...); // 추가: builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); // Hc900RealtimeService 생성자 변경에 따라 DI는 자동 해결됨 builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Hc900WriteService 도 ControllerGrpcClientPool 사용하도록 수정 필요 builder.Services.AddSingleton(); ``` --- ## 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 => `
${esc(c.controllerId)} ${esc(c.name)} ${c.running ? '실행 중' : '중지됨'}
IP: ${esc(c.controllerIp)} | gRPC: :${c.grpcPort} | PID: ${c.pid ?? '—'} | Crawler: ${c.crawlerConnected ? '연결됨' : '미연결'}
`).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
``` 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 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 _processes = new();` **영향**: 동시 HTTP 요청 또는 감시 루프와 충돌 시 `Collection was modified` 예외, 프로세스 중복 실행, 또는 `NullReferenceException`. **수정**: `_processes` → `ConcurrentDictionary` 로 변경하고 `_config` 접근 시 `lock (_lock)` 보호: ```csharp // 필드 선언 교체 private readonly ConcurrentDictionary _processes = new(); private readonly ConcurrentDictionary _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 SnapshotToHistoryAsync(bool includeDigital = false)`. **영향**: 구현 시 기존 `includeDigital` 기능이 손실됨. 디지털 태그 필터링 없이 전체 스냅샷 저장. **수정**: STEP 6 변경 후 시그니처를 아래로 통일: ```csharp // 변경 후: controllerId + includeDigital 동시 지원 Task 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(); controller_ = std::make_unique(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(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}"; ```