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

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

증상

Modbus TCP 포트 충돌은 사용자가 시스템 페이지에서 모드버스 슬레이브 포트를 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

교훈

  • 사용자 입력 검증은 저장 시점 + 로드 시점 둘 다 필요합니다. UI 만 검증하면 SSH 로 직접 편집하거나 옛 버전이 만든 파일에 당합니다
  • typed deserialize 만 신뢰하지 말 것. 치명적 필드는 raw string 단계에서 한 번 더 잡습니다. “이중 처리” 가 신뢰성 비용 대비 가치가 큽니다
  • 자동 복구 로그는 무조건 남깁니다 — “조용한 마법” 은 디버깅을 망칩니다

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

증상

avahi 호스트명 변경은 헬퍼 (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초 추가) 안정성 비교가 안 됩니다. 자체검증 한 줄로 “성공했는지” 확실히 알 수 있게 했습니다.

교훈

  • reload vs restart 의미는 데몬마다 다릅니다. 재시작 후 동작 검증 까지 helper 에 포함하는 게 안전합니다
  • mDNS 는 의외로 캐시 / 광고 갱신이 까다롭습니다. 호스트명 같은 핵심 속성을 바꾸면 그냥 restart 추천합니다

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

C# Regex 치환 버그는 함정 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 — 의도대로입니다.

교훈

  • Regex.Replace replacement 의 $ 패턴은 PowerShell escape 만큼 까다롭습니다
  • 숫자 literal 이 group 번호 뒤에 올 때는 반드시 ${N} 명시합니다
  • 이 함정은 다른 언어 (PowerShell, sed, perl) 에서도 마찬가지입니다

세 함정에서 얻은 공통 패턴 정리

세 함정의 공통 패턴은 아래 셋입니다. 다 “그냥 동작할 것” 같았지만 안 했습니다.

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

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

검증은 여러 layer 에, 변경 뒤에는 자체검증을, escape 가 모호한 곳은 명시적으로 — 이 셋이 세 함정의 답이었습니다.

ZPi Controller 시리즈 다음 편

문의