ZPi Controller 를 만들며 디버깅한 세 가지 함정 — Modbus 포트, mDNS 캐시, Regex `$1`

ZPi Controller 만들면서 만난 세 가지 비-자명한 버그를 정리. 각자 다른 도메인이지만 공통점은 “사용자가 실수해도 망가지지 않는 시스템” 을 만드는 과정에서 드러났다는 것.

함정 1: Modbus 포트 5000 입력 → 웹 UI 통째로 죽음

증상

사용자가 시스템 페이지에서 모드버스 슬레이브 포트를 5000 으로 잘못 입력하고 저장. 웹 UI 가 안 열림. SSH 로 들어가서 보니:

zpi-controller.service: Main process exited, code=killed, status=6/ABRT
Restart=on-failure, restart counter = 27

서비스가 27회 재시작 실패 반복. 로그:

ZPi.IO08R.Plc.Modbus.ModbusTcpSlaveService[0]
  Modbus TCP slave listening on port 5000 (unit 1)
Failed to bind to address http://[::]:5000: address already in use.

Modbus 슬레이브가 5000 (HTTP 포트와 충돌) 에 먼저 바인딩 → Kestrel 이 같은 포트 못 잡고 죽음 → systemd 재시작 → 무한 반복. 사용자는 웹에 못 들어와서 설정을 고칠 수도 없다. 치명적.

첫 시도: typed-level 가드

public void Load()
{
    _opts = JsonSerializer.Deserialize<ModbusOptions>(...);
    if (_opts.TcpSlave.Port == HttpPort) {
        _opts.TcpSlave.Port = 502;   // auto-correct
        Save(_opts);
    }
}

배포. 인위적으로 port=5000 강제 주입 후 재시작 → 자동 복구 안 됨. 왜?

GET /api/modbus 로 deserialize 결과 확인:

{
  "tcpSlave": {
    "enabled": false,      사용자가 true 설정했는데 false?
    "port": 502,           5000 으로 설정했는데 default 502?
    "unitId": 1,           이건 정상
    "map": { "p": 0, "m": 4096, ... }   map 안의 값들은 정상
  }
}

enabledport 만 default 로 fallback. unitId 와 map 은 정상. 같은 객체 안의 다른 property 들이라 일관성 없는 deserialize 실패.

지금도 정확한 원인은 모르지만 (System.Text.Json + CamelCase PropertyNamingPolicy + 중첩 객체 조합의 어딘가), 결론은 typed deserialize 가 일부 필드를 silently drop 한다는 것. 그래서 _opts. TcpSlave.Port 는 항상 default 502, 우리의 typed-level 가드는 영영 트리거 안 됨.

진짜 해결: raw text 단계 사전 처리

deserialize 가 못 믿을 거면 그 전에 막자. 파일을 string 으로 읽어서 정규식으로 직접 치환:

public void Load()
{
    var raw = File.ReadAllText(_path);

    // Phase 1: raw-text pre-sanitise — bulletproof
    var patched = Regex.Replace(
        raw,
        @"(""port""\s*:\s*)5000(?!\d)",
        $"${{1}}{DefaultModbusPort}",       // ${1} 명시! $1 로 쓰면 $15 + 02 로 해석됨
        RegexOptions.IgnoreCase);
    if (patched != raw) {
        File.WriteAllText(_path, patched);
        Console.Error.WriteLine("[ModbusStore] raw JSON: \"port\": 5000 → 502 (HTTP port collision)");
    }

    // Phase 2: deserialize as before (now safe)
    _opts = JsonSerializer.Deserialize<ModbusOptions>(patched, JsonOpts);

    // Phase 3: typed-level guards (belt and braces)
    if (_opts.TcpSlave.Port == HttpPort) { ... }
}

3중 방어 (raw text + typed + UI-side validation on Replace()). 인위적 포트 충돌 주입 후 재시작 → 정상 자동 복구:

[ModbusStore] raw JSON: "port": 5000 → 502 (HTTP port collision)
Modbus TCP slave listening on port 502 (unit 1)
Now listening on: http://[::]:5000
HTTP: 302

교훈


함정 2: mDNS — Pi 호스트명 바꾸면 getent 가 빈값 반환

증상

호스트명 변경 헬퍼 (zpi-set-hostname.sh) 가 hostnamectl + /etc/hosts 업데이트 + systemctl reload-or-restart avahi-daemon 까지 다 하는데:

sudo /usr/local/sbin/zpi-set-hostname.sh going-zpi-XXX
hostname changed: going-zpi going-zpi-XXX

getent hosts going-zpi-XXX.local
 빈값!

getent hosts going-zpi.local
192.168.0.x going-zpi.local 이름은 아직 살아있음

같은 LAN 의 PC 에서 ping 도 안 됨. 호스트명 바꿨는데 새 이름으로 mDNS 광고가 안 됨.

원인

Debian Trixie 의 avahi-daemon 은 systemctl reload-or-restart 의 “reload” 경로에서 호스트명 변경을 picked up 안 함. reload 가 성공 리턴해도 실제로는 stale 광고를 그대로 유지. fully restart 가 필요.

해결

# 헬퍼 마지막 부분
systemctl restart avahi-daemon 2>/dev/null || \
    systemctl reload-or-restart avahi-daemon 2>/dev/null || true

# 자체검증
sleep 1
if getent hosts "${NEW}.local" > /dev/null 2>&1; then
    echo "hostname changed: $OLD -> $NEW (mDNS verified)"
else
    echo "warning: ${NEW}.local not yet resolving — avahi may need another second"
fi

restart 가 살짝 더 무겁지만 (1초 추가) 안정성 비교가 안 됨. 자체검증 한 줄로 “성공했는지” 확실히 알 수 있게.

교훈


함정 3: C# Regex.Replace 의 $1$15 로 해석됨

함정 1 의 부산물. raw text 정규식으로 "port": 5000"port": 502 로 치환했는데 결과 JSON 이 깨졌다:

{
  "tcpSlave": {
    "port": $1502,     무엇??
    ...
  }
}

코드:

var patched = Regex.Replace(
    raw,
    @"(""port""\s*:\s*)5000(?!\d)",
    $"$1{DefaultModbusPort}",       // $1 + 502 = ???
    RegexOptions.IgnoreCase);

C# 의 $"..." (interpolated string) 안에서 $1 은 그냥 문자열 $1 그대로. {DefaultModbusPort} 는 보간되어 502. 결과 replacement string:

"$1502"

이걸 Regex.Replace 가 받으면 $1, $15, $150, $1502 중 어느 것이 capture group 번호인지 모름. 문서상:

“Substitutions that use a value greater than the highest number of capturing groups are interpreted as literal text.”

근데 실제로는 $1502 가 group 1502 로 시도 → 없으니 literal? group 15 로 시도 → 없으니 literal? 정확한 동작이 모호하고, 우리 케이스에서는 $1 가 group 1 으로 매치 + 502 가 literal 이 되지 않고 그냥 통째로 literal 처리됨.

해결: ${1} 명시

C# Regex 의 documented 방법:

$"${{1}}{DefaultModbusPort}"
//   ↑ ${1} 명시적 group reference
//   ↑↑↑ C# 의 `{{` → `{` literal
//        결과 replacement string: "${1}502"

이제 ${1} 가 capture group 1, 502 가 literal — 의도대로.

교훈


정리

세 함정 다 “그냥 동작할 것” 같았지만 안 했다. 공통 패턴:

  1. 검증을 여러 layer 에서: Modbus 포트는 UI 저장 시점 / 디스크 raw text 단계 / typed deserialize 후 — 3중 방어
  2. 재시작 후 자체검증: mDNS 변경 같은 비-즉시 효과 동작은 helper 가 직접 확인까지 책임
  3. escape 가 모호한 곳은 명시적 표기: ${1} 같은 명시형은 길어 보여도 디버깅 시간 아낌

다 만들고 나면 “당연한 것” 인데, 만들기 전에는 모름.

다음

댓글

로그인 없이 이름만 적고 남기실 수 있어요. 남긴 댓글은 바로 게시됩니다.

댓글을 불러오는 중…