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

