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

52 KiB

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 클래스 대체

기존:

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";
}

변경 후 — 파일 맨 위에 새 클래스 추가:

/// <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 형식 변경 예시

{
  "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. Hc900GatewayProcessServiceControllerProcessManager 로 재작성

기존 파일에서 Hc900GatewayProcessService 클래스를 다음으로 교체.
GatewayStatus 클래스도 수정:

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.cppinclude/gateway.h에 적용.

파일 1: industrial-comm/cpp/include/gateway.h

현재 생성자 (gateway.h:36):

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 파라미터 추가, 멤버 변수 초기화 제거:

// 생성자 선언 변경
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 근방):

// 변경 전
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):

현재:

// 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: 기존 인수 순서 유지, 끝에 추가 (하위 호환):

// 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 문 추가:

// ── 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 컬럼 추가:

// 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

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 로 교체.
컨트롤러별로 폴링 루프 실행.

// 생성자 변경: 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 쿼리:
    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 쿼리:
    SELECT tagname, hc900_tag FROM hc900_map_master
    WHERE is_active = TRUE AND controller_id = $1
    
  • LoadStateLabelsAsync 쿼리:
    SELECT base_tag, attribute, value FROM tag_metadata
    WHERE attribute LIKE 'state%' AND controller_id = $1
    

IsConnected / PollCount 노출:

// 기존 단일 값 → 컨트롤러별 딕셔너리
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

SnapshotToHistoryAsynccontroller_id 를 전달하도록 수정.

IExperionDbService 인터페이스 / Hc900DbService 구현 변경:

// 기존 (IExperionServices.cs:31)
Task<int> SnapshotToHistoryAsync(bool includeDigital = false);

// 변경 후 — D2: includeDigital 유지, controllerId 추가
Task<int> SnapshotToHistoryAsync(string? controllerId = null, bool includeDigital = false);
// controllerId=null → 전체 컨트롤러 스냅샷

Hc900HistoryService 호출:

var count = await db.SnapshotToHistoryAsync(controllerId: null, includeDigital: false);

DB 쿼리:

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

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 전파:

// 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 컬럼 추가:

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

Hc900GatewayProcessServiceControllerProcessManager 로 교체.

[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

기존 등록 코드 교체:

// 제거:
// 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

// 생성자: 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 수정:

// 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 변경:

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 파라미터 추가:

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 카드 내부:

<!-- 기존 단일 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 (또는 유사)

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)와 _configExecuteAsync(감시루프), StartAsync, StopAsync, GetStatus에서 lock 없이 동시 접근된다. 근거: MULTI_CONTROLLER_WORK_ORDER.md:154private readonly Dictionary<string, Process?> _processes = new(); 영향: 동시 HTTP 요청 또는 감시 루프와 충돌 시 Collection was modified 예외, 프로세스 중복 실행, 또는 NullReferenceException. 수정: _processesConcurrentDictionary<string, Process?> 로 변경하고 _config 접근 시 lock (_lock) 보호:

// 필드 선언 교체
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.SnapshotToHistoryAsyncincludeDigital 파라미터 유실 (MED)

문제: STEP 6이 현재 시그니처를 SnapshotToHistoryAsync() (파라미터 없음)으로 잘못 기술했고, 변경 후에도 includeDigital이 사라진다. 근거: IExperionServices.cs:31 — 실제 현재 시그니처는 Task<int> SnapshotToHistoryAsync(bool includeDigital = false). 영향: 구현 시 기존 includeDigital 기능이 손실됨. 디지털 태그 필터링 없이 전체 스냅샷 저장. 수정: STEP 6 변경 후 시그니처를 아래로 통일:

// 변경 후: controllerId + includeDigital 동시 지원
Task<int> SnapshotToHistoryAsync(string? controllerId = null, bool includeDigital = false);

Hc900HistoryService의 호출 코드:

// 실시간 서비스가 하나라도 연결되었는지는 컨트롤러레벨로 확인
// 각 컨트롤러별로 스냅샷 호출하거나, null로 전체 스냅샷
var count = await db.SnapshotToHistoryAsync(controllerId: null, includeDigital: false);

D3. Hc900DigitalEventDetectorServicecontroller_id 미전파 (MED)

문제: 작업 지시서가 Hc900DigitalEventDetectorService의 변경을 전혀 언급하지 않는다. 이 서비스가 기록하는 event_history_tablecontroller_id가 항상 'HC1' (DEFAULT)이 된다. 근거: MULTI_CONTROLLER_WORK_ORDER.md:624Hc900HistoryService만 언급, Hc900DigitalEventDetectorService 누락. Hc900DigitalEventDetectorService.cs:140-152DigitalEventRecordControllerId 필드가 없음. 영향: HC1 이외 컨트롤러의 디지털 이벤트가 모두 HC1로 기록되어 이벤트 조회 시 혼란. 수정: (A) DigitalEventRecordControllerId 추가, (B) DetectAndRecordChangesAsync에서 realtime_table 조회 시 controller_id를 함께 읽어 event record에 포함, (C) DB 저장 쿼리에 controller_id 컬럼 추가:

// ── 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:99grpc_listen_ 하드코딩을 생성자 파라미터로 변경하는 코드를 명시하지 않았다. 문맥상 유추 가능하지만 실수 방지를 위해 명시 필요. 근거: gateway.h:99std::string grpc_listen_{"0.0.0.0:50051"};, gateway.cpp:19-26 — 생성자에 grpc_port 파라미터 없음. 수정: gateway.h 생성자 선언과 gateway.cpp 생성자 구현에 grpc_port 파라미터 추가:

// 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-35main.cppapp_init.cpp가 source 목록에 없음. main.cpp:1// app_init.cpp. 영향: 유지보수 시 혼란. 누군가 main.cpp가 엔트리포인트라고 오해할 수 있음. 수정: main.cpp의 내용을 제거하거나, 진정한 엔트리포인트로 전환. app_init.cppcomm_core 라이브러리에 등록:

# 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.cppmain()을 분리.


D6. Task.Delay(2000) 고정 대기 — gRPC 헬스체크로 대체 권장 (LOW)

문제: ControllerProcessManager.StartAsync()에서 2초 고정 Task.Delay로 게이트웨이 준비를 확인한다. 근거: MULTI_CONTROLLER_WORK_ORDER.md:321await Task.Delay(2000);. 영향: 게이트웨이 기동이 2초보다 오래 걸리면 즉시 종료됨으로 오판. 2초 이내면 불필요한 대기. 수정: gRPC 헬스체크 폴링 루프로 대체 (기존 Hc900RealtimeService.WaitForGatewayAsync 패턴 재사용):

// 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.cppmain()에서는 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} 포함:

// 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);
// .NET StartAsync()
Arguments = $"{cfg.ControllerIp} {cfg.ControllerPort} {cfg.RegisterMapPath} {cfg.PollIntervalMs} {cfg.GrpcPort}";